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;