diff --git a/frontend/src/lib/components/presence/AnalyticsPanel.svelte b/frontend/src/lib/components/presence/AnalyticsPanel.svelte index 03e002e..be1c579 100644 --- a/frontend/src/lib/components/presence/AnalyticsPanel.svelte +++ b/frontend/src/lib/components/presence/AnalyticsPanel.svelte @@ -24,35 +24,11 @@ formatSpan, isOnline, } from "#lib/format/presence.js"; + import { t } from "#lib/i18n/locale.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js"; import { page } from "$app/state"; - const TEXT = { - average: "В среднем за день", - byDay: "По дням", - byHour: "По часам недели", - failed: "Не удалось загрузить аналитику.", - firstOnline: "Обычно появляется", - hidden: - "Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.", - lastOffline: "Обычно уходит", - median: "Обычная сессия", - messages: "Сообщения", - mine: "Я отвечаю", - missing: "Нет данных", - noData: "нет данных", - noMessages: "За этот период сообщений нет.", - noStatus: "Статусов пока нет.", - openChat: "Откройте чат", - period: (count: number) => `${count} дней`, - retry: "Повторить", - sessions: "Сессии", - theirs: "Мне отвечают", - title: "Аналитика", - total: (count: number) => `в сети за ${count} дней`, - }; - const PERIODS = [7, 30, 90]; const DEFAULT_PERIOD = 30; const SESSION_PAGE = 30; @@ -60,7 +36,7 @@ const MS_IN_SECOND = 1000; const tz = browserTimeZone(); - const zone = timeZoneLabel(); + const zone = $derived(timeZoneLabel()); const chatId = $derived( page.params.chatId ? Number(page.params.chatId) : null @@ -97,25 +73,25 @@ const result: { label: string; value: string }[] = []; if (summary.daily_seconds !== null) { result.push({ - label: TEXT.average, + label: t("presence.average"), value: formatSpan(summary.daily_seconds), }); } if (summary.median_session_seconds !== null) { result.push({ - label: TEXT.median, + label: t("presence.median"), value: formatSpan(summary.median_session_seconds), }); } if (summary.first_online !== null) { result.push({ - label: TEXT.firstOnline, + label: t("presence.firstOnline"), value: formatClock(summary.first_online), }); } if (summary.last_offline !== null) { result.push({ - label: TEXT.lastOffline, + label: t("presence.lastOffline"), value: formatClock(summary.last_offline), }); } @@ -124,13 +100,16 @@ MS_IN_SECOND; const missing = period - summary.covered_seconds; if (missing >= SECONDS_IN_HOUR) { - result.push({ label: TEXT.missing, value: formatLongSpan(missing) }); + result.push({ + label: t("presence.missing"), + value: formatLongSpan(missing), + }); } return result; }); function reply(seconds: number | null): string { - return seconds === null ? TEXT.noData : formatSpan(seconds); + return seconds === null ? t("presence.noData") : formatSpan(seconds); } async function load(id: number, period: number) { @@ -172,7 +151,7 @@ {#if chatId === null} - + {:else}
@@ -185,20 +164,20 @@ }} type="button" > - {TEXT.period(period)} + {t("presence.days", { count: period })} {/each}
{#if failed}
-

{TEXT.failed}

+

{t("presence.failed")}

{:else if volume === null || (isDm && stats === null)} @@ -211,7 +190,9 @@ {formatSpan(stats.summary.online_seconds)} - {TEXT.total(shownDays)} + {t("presence.total", { count: shownDays })}
{/if} {#if sample} @@ -220,9 +201,9 @@

{/if} {#if stats.hidden} -

{TEXT.hidden}

+

{t("presence.hidden")}

{:else if !tracked} -

{TEXT.noStatus}

+

{t("presence.noStatus")}

{/if} @@ -242,34 +223,34 @@
-

{TEXT.byHour}

+

{t("presence.byHour")}

{zone}
-

{TEXT.byDay}

+

{t("presence.byDay")}

{/if} {/if}
-

{TEXT.messages}

+

{t("presence.messages")}

{#if quiet} -

{TEXT.noMessages}

+

{t("presence.noMessages")}

{:else} {/if} {#if response && response.mine_count + response.their_count > 0}
-
{TEXT.mine}
+
{t("presence.mine")}
{reply(response.mine_median_seconds)}
-
{TEXT.theirs}
+
{t("presence.theirs")}
{reply(response.their_median_seconds)}
@@ -278,7 +259,7 @@ {#if isDm && stats && tracked && !stats.hidden}
-

{TEXT.sessions}

+

{t("presence.sessions")}

{#key `${chatId}:${shownDays}:${stats.until}`} { - if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) { - return `${day}, нет данных`; - } - const gap = - missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : ""; - return `${day}, ${formatSpan(online)} в сети${gap}`; - }, - today: "сегодня", - }; - const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds))); const first = $derived(days[0]); @@ -31,6 +20,23 @@ return formatDay(parseDay(day).toISOString()); } + function title(day: PresenceDay, index: number): string { + const date = label(day.day); + const missing = absent(day, index); + if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) { + return t("presence.dayNoData", { day: date }); + } + const span = formatSpan(day.online_seconds); + if (missing >= SECONDS_IN_HOUR) { + return t("presence.dayGap", { + day: date, + gap: formatSpan(missing), + span, + }); + } + return t("presence.day", { day: date, span }); + } + function absent(day: PresenceDay, index: number): number { if (index === days.length - 1) { return 0; @@ -46,11 +52,7 @@ {#each days as day, index (day.day)}
{label(first.day)} - {TEXT.today} + {t("presence.axisToday")}
{/if} diff --git a/frontend/src/lib/components/presence/PresenceHeatmap.svelte b/frontend/src/lib/components/presence/PresenceHeatmap.svelte index 22b98a4..5f45d1f 100644 --- a/frontend/src/lib/components/presence/PresenceHeatmap.svelte +++ b/frontend/src/lib/components/presence/PresenceHeatmap.svelte @@ -1,6 +1,7 @@ {#if sessions.length === 0} -

{TEXT.none}

+

{t("presence.noSessions")}

{:else} {#each groups as group (group.label)}

{group.label}

@@ -101,14 +94,13 @@
  • {session.ongoing - ? TEXT.since(formatTime(session.started)) - : TEXT.span( - formatTime(session.started), - formatTime(session.ended) - )} + ? t("presence.since", { time: formatTime(session.started) }) + : span(formatTime(session.started), formatTime(session.ended))} - {session.ongoing ? TEXT.now : formatSpan(session.seconds)} + {session.ongoing + ? t("presence.online") + : formatSpan(session.seconds)}
  • {/each} @@ -118,11 +110,13 @@
    {:else if failed}
    -

    {TEXT.failed}

    - +

    {t("presence.sessionsFailed")}

    +
    {:else if !exhausted} - + {/if} {/if} diff --git a/frontend/src/lib/components/presence/VolumeChart.svelte b/frontend/src/lib/components/presence/VolumeChart.svelte index ab7cf13..4b216c7 100644 --- a/frontend/src/lib/components/presence/VolumeChart.svelte +++ b/frontend/src/lib/components/presence/VolumeChart.svelte @@ -1,6 +1,7 @@
    @@ -31,7 +35,7 @@ {peak}
    45 }]}> {#each volume as bucket (bucket.bucket)} -
    +
    {label(first.bucket)} - {TEXT.today} + {t("presence.axisToday")}
    {/if}
    - {TEXT.incoming} - {TEXT.outgoing} + {t("presence.volumeIncoming")} + {t("presence.volumeOutgoing")}
    diff --git a/frontend/src/lib/format/presence.ts b/frontend/src/lib/format/presence.ts index b36d7b0..0dea447 100644 --- a/frontend/src/lib/format/presence.ts +++ b/frontend/src/lib/format/presence.ts @@ -1,6 +1,7 @@ import type { PresenceSample } from "#lib/api/types.js"; import { formatDay, formatTime } from "#lib/format/datetime.js"; -import { plural } from "#lib/format/shares.js"; +import { locale, t } from "#lib/i18n/locale.svelte.js"; +import type { MessageKey } from "#lib/i18n/messages.js"; const SECONDS_IN_MINUTE = 60; const MINUTES_IN_HOUR = 60; @@ -8,32 +9,14 @@ const SECONDS_IN_HOUR = 3600; const MS_IN_MINUTE = 60_000; const SECONDS_IN_DAY = 86_400; const MS_IN_DAY = 86_400_000; +const DAYS_IN_WEEK = 7; +const MONDAY = Date.UTC(2024, 0, 1); -const TEXT = { - ago: (minutes: number) => - `был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`, - days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`, - hours: (hours: number) => `${hours} ч`, - hoursMinutes: (hours: number, minutes: number) => `${hours} ч ${minutes} мин`, - justNow: "был(а) только что", - lastMonth: "был(а) в этом месяце", - lastWeek: "был(а) на этой неделе", - longAgo: "был(а) давно", - minutes: (minutes: number) => `${minutes} мин`, - offline: "не в сети", - on: (day: string, time: string) => `был(а) ${day} в ${time}`, - online: "в сети", - recently: "был(а) недавно", - seconds: (seconds: number) => `${seconds} с`, - today: (time: string) => `был(а) сегодня в ${time}`, - yesterday: (time: string) => `был(а) вчера в ${time}`, -}; - -const FUZZY: Record = { - last_month: TEXT.lastMonth, - last_week: TEXT.lastWeek, - long_time_ago: TEXT.longAgo, - recently: TEXT.recently, +const FUZZY: Record = { + last_month: "presence.lastMonth", + last_week: "presence.lastWeek", + long_time_ago: "presence.longAgo", + recently: "presence.recently", }; export function isOnline( @@ -50,20 +33,20 @@ function lastSeen(iso: string, now: number): string { const date = new Date(iso); const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE); if (minutes < 1) { - return TEXT.justNow; + return t("presence.justNow"); } if (minutes < MINUTES_IN_HOUR) { - return TEXT.ago(minutes); + return t("presence.ago", { count: minutes }); } const time = formatTime(iso); const today = new Date(now); if (date.toDateString() === today.toDateString()) { - return TEXT.today(time); + return t("presence.todayAt", { time }); } if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) { - return TEXT.yesterday(time); + return t("presence.yesterdayAt", { time }); } - return TEXT.on(formatDay(iso), time); + return t("presence.on", { day: formatDay(iso), time }); } export function formatPresence( @@ -72,29 +55,31 @@ export function formatPresence( ): string { if (sample.status === "online") { return isOnline(sample, now) - ? TEXT.online + ? t("presence.online") : lastSeen(sample.next_offline_date ?? sample.ts, now); } const fuzzy = FUZZY[sample.status]; if (fuzzy) { - return fuzzy; + return t(fuzzy); } return sample.last_online_date ? lastSeen(sample.last_online_date, now) - : TEXT.offline; + : t("presence.offline"); } export function formatSpan(seconds: number): string { if (seconds < SECONDS_IN_MINUTE) { - return TEXT.seconds(Math.round(seconds)); + return t("presence.seconds", { seconds: Math.round(seconds) }); } const minutes = Math.round(seconds / SECONDS_IN_MINUTE); if (minutes < MINUTES_IN_HOUR) { - return TEXT.minutes(minutes); + return t("presence.minutes", { minutes }); } const hours = Math.floor(minutes / MINUTES_IN_HOUR); const rest = minutes % MINUTES_IN_HOUR; - return rest === 0 ? TEXT.hours(hours) : TEXT.hoursMinutes(hours, rest); + return rest === 0 + ? t("presence.hours", { hours }) + : t("presence.hoursMinutes", { hours, minutes: rest }); } export function formatClock(secondsOfDay: number): string { @@ -111,7 +96,20 @@ export function formatLongSpan(seconds: number): string { } const days = Math.floor(seconds / SECONDS_IN_DAY); const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR); - return hours === 0 - ? TEXT.days(days) - : `${TEXT.days(days)} ${TEXT.hours(hours)}`; + const text = t("presence.days", { count: days }); + return hours === 0 ? text : `${text} ${t("presence.hours", { hours })}`; +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +export function weekdayLabels(): string[] { + const format = new Intl.DateTimeFormat(locale.tag, { + timeZone: "UTC", + weekday: "short", + }); + return Array.from({ length: DAYS_IN_WEEK }, (_, day) => + capitalize(format.format(MONDAY + day * MS_IN_DAY)) + ); } diff --git a/frontend/src/lib/format/shares.ts b/frontend/src/lib/format/shares.ts index c3bc64a..e8190b2 100644 --- a/frontend/src/lib/format/shares.ts +++ b/frontend/src/lib/format/shares.ts @@ -2,30 +2,6 @@ import type { FileShare } from "#lib/api/types.js"; import { formatFull } from "#lib/format/datetime.js"; import { t } from "#lib/i18n/locale.svelte.js"; -const TEEN_START = 11; -const TEEN_END = 14; -const FEW_END = 4; - -export function plural( - count: number, - one: string, - few: string, - many: string -): string { - const tail = count % 100; - if (tail >= TEEN_START && tail <= TEEN_END) { - return many; - } - const last = count % 10; - if (last === 1) { - return one; - } - if (last >= 2 && last <= FEW_END) { - return few; - } - return many; -} - export function formatDownloads(share: FileShare): string { if (share.max_downloads === null) { return t("shares.downloads", { count: share.download_count }); diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index dae4238..0bcc1c3 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -11,6 +11,7 @@ import { media } from "#lib/i18n/messages/media.js"; import { menu } from "#lib/i18n/messages/menu.js"; import { notes } from "#lib/i18n/messages/notes.js"; import { policy } from "#lib/i18n/messages/policy.js"; +import { presence } from "#lib/i18n/messages/presence.js"; import { profile } from "#lib/i18n/messages/profile.js"; import { read } from "#lib/i18n/messages/read.js"; import { search } from "#lib/i18n/messages/search.js"; @@ -38,6 +39,7 @@ export const messages = { ...menu, ...notes, ...policy, + ...presence, ...profile, ...read, ...search, diff --git a/frontend/src/lib/i18n/messages/presence.ts b/frontend/src/lib/i18n/messages/presence.ts new file mode 100644 index 0000000..2f0826d --- /dev/null +++ b/frontend/src/lib/i18n/messages/presence.ts @@ -0,0 +1,134 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const presence = { + "presence.ago": { + en: "last seen {count} min ago", + ru: { + few: "был(а) {count} минуты назад", + many: "был(а) {count} минут назад", + one: "был(а) {count} минуту назад", + other: "был(а) {count} минуты назад", + }, + }, + "presence.average": { en: "Daily average", ru: "В среднем за день" }, + "presence.axisToday": { en: "today", ru: "сегодня" }, + "presence.byDay": { en: "By day", ru: "По дням" }, + "presence.byHour": { en: "By hour of the week", ru: "По часам недели" }, + "presence.cellAverage": { + en: "{slot}, {span} online on average", + ru: "{slot}, в среднем {span} в сети", + }, + "presence.cellNoData": { en: "{slot}, no data", ru: "{slot}, нет данных" }, + "presence.day": { en: "{day}, {span} online", ru: "{day}, {span} в сети" }, + "presence.dayGap": { + en: "{day}, {span} online, no data for {gap}", + ru: "{day}, {span} в сети, нет данных {gap}", + }, + "presence.dayNoData": { en: "{day}, no data", ru: "{day}, нет данных" }, + "presence.days": { + en: { one: "{count} day", other: "{count} days" }, + ru: { + few: "{count} дня", + many: "{count} дней", + one: "{count} день", + other: "{count} дня", + }, + }, + "presence.failed": { + en: "Couldn't load analytics.", + ru: "Не удалось загрузить аналитику.", + }, + "presence.firstOnline": { + en: "Usually comes online", + ru: "Обычно появляется", + }, + "presence.hidden": { + en: "Hides their last seen time. Telegram only shows “last seen recently”, so time online can't be counted.", + ru: "Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.", + }, + "presence.hours": { en: "{hours} h", ru: "{hours} ч" }, + "presence.hoursMinutes": { + en: "{hours} h {minutes} min", + ru: "{hours} ч {minutes} мин", + }, + "presence.justNow": { en: "last seen just now", ru: "был(а) только что" }, + "presence.lastMonth": { + en: "last seen within a month", + ru: "был(а) в этом месяце", + }, + "presence.lastOffline": { en: "Usually goes offline", ru: "Обычно уходит" }, + "presence.lastWeek": { + en: "last seen within a week", + ru: "был(а) на этой неделе", + }, + "presence.longAgo": { + en: "last seen a long time ago", + ru: "был(а) давно", + }, + "presence.max": { en: "up to {span}", ru: "до {span}" }, + "presence.median": { en: "Typical session", ru: "Обычная сессия" }, + "presence.messages": { en: "Messages", ru: "Сообщения" }, + "presence.mine": { en: "I reply in", ru: "Я отвечаю" }, + "presence.minutes": { en: "{minutes} min", ru: "{minutes} мин" }, + "presence.missing": { en: "No data", ru: "Нет данных" }, + "presence.noData": { en: "no data", ru: "нет данных" }, + "presence.noMessages": { + en: "No messages in this period.", + ru: "За этот период сообщений нет.", + }, + "presence.noSessions": { + en: "Not online in this period.", + ru: "За этот период в сети не был(а).", + }, + "presence.noStatus": { + en: "No online statuses yet.", + ru: "Статусов пока нет.", + }, + "presence.offline": { en: "offline", ru: "не в сети" }, + "presence.on": { + en: "last seen {day} at {time}", + ru: "был(а) {day} в {time}", + }, + "presence.online": { en: "online", ru: "в сети" }, + "presence.recently": { en: "last seen recently", ru: "был(а) недавно" }, + "presence.seconds": { en: "{seconds} sec", ru: "{seconds} с" }, + "presence.sessions": { en: "Sessions", ru: "Сессии" }, + "presence.sessionsFailed": { + en: "Couldn't load sessions.", + ru: "Не удалось загрузить сессии.", + }, + "presence.since": { en: "since {time}", ru: "с {time}" }, + "presence.theirs": { en: "They reply in", ru: "Мне отвечают" }, + "presence.today": { en: "Today", ru: "Сегодня" }, + "presence.todayAt": { + en: "last seen today at {time}", + ru: "был(а) сегодня в {time}", + }, + "presence.total": { + en: { + one: "online in the last {count} day", + other: "online in the last {count} days", + }, + ru: { + few: "в сети за {count} дня", + many: "в сети за {count} дней", + one: "в сети за {count} день", + other: "в сети за {count} дня", + }, + }, + "presence.volume": { + en: "{day}, {total} in total: {incoming} received, {outgoing} sent", + ru: "{day}, всего {total}: входящих {incoming}, моих {outgoing}", + }, + "presence.volumeEmpty": { + en: "{day}, no messages", + ru: "{day}, нет сообщений", + }, + "presence.volumeIncoming": { en: "received", ru: "входящие" }, + "presence.volumeOutgoing": { en: "sent", ru: "мои" }, + "presence.yesterday": { en: "Yesterday", ru: "Вчера" }, + "presence.yesterdayAt": { + en: "last seen yesterday at {time}", + ru: "был(а) вчера в {time}", + }, +} satisfies Messages;