feat(frontend): analytics panel and online statuses speak russian and english with real plurals
This commit is contained in:
1 parent
543e6eaa50
commit
cff493f5dd
9 files changed
+286
-190
No files matched your search
@@ -24,35 +24,11 @@
|
|||||||
formatSpan,
|
formatSpan,
|
||||||
isOnline,
|
isOnline,
|
||||||
} from "#lib/format/presence.js";
|
} from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { presence } from "#lib/stores/presence.svelte.js";
|
import { presence } from "#lib/stores/presence.svelte.js";
|
||||||
import { page } from "$app/state";
|
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 PERIODS = [7, 30, 90];
|
||||||
const DEFAULT_PERIOD = 30;
|
const DEFAULT_PERIOD = 30;
|
||||||
const SESSION_PAGE = 30;
|
const SESSION_PAGE = 30;
|
||||||
@@ -60,7 +36,7 @@
|
|||||||
const MS_IN_SECOND = 1000;
|
const MS_IN_SECOND = 1000;
|
||||||
|
|
||||||
const tz = browserTimeZone();
|
const tz = browserTimeZone();
|
||||||
const zone = timeZoneLabel();
|
const zone = $derived(timeZoneLabel());
|
||||||
|
|
||||||
const chatId = $derived(
|
const chatId = $derived(
|
||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
@@ -97,25 +73,25 @@
|
|||||||
const result: { label: string; value: string }[] = [];
|
const result: { label: string; value: string }[] = [];
|
||||||
if (summary.daily_seconds !== null) {
|
if (summary.daily_seconds !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.average,
|
label: t("presence.average"),
|
||||||
value: formatSpan(summary.daily_seconds),
|
value: formatSpan(summary.daily_seconds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.median_session_seconds !== null) {
|
if (summary.median_session_seconds !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.median,
|
label: t("presence.median"),
|
||||||
value: formatSpan(summary.median_session_seconds),
|
value: formatSpan(summary.median_session_seconds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.first_online !== null) {
|
if (summary.first_online !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.firstOnline,
|
label: t("presence.firstOnline"),
|
||||||
value: formatClock(summary.first_online),
|
value: formatClock(summary.first_online),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.last_offline !== null) {
|
if (summary.last_offline !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.lastOffline,
|
label: t("presence.lastOffline"),
|
||||||
value: formatClock(summary.last_offline),
|
value: formatClock(summary.last_offline),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,13 +100,16 @@
|
|||||||
MS_IN_SECOND;
|
MS_IN_SECOND;
|
||||||
const missing = period - summary.covered_seconds;
|
const missing = period - summary.covered_seconds;
|
||||||
if (missing >= SECONDS_IN_HOUR) {
|
if (missing >= SECONDS_IN_HOUR) {
|
||||||
result.push({ label: TEXT.missing, value: formatLongSpan(missing) });
|
result.push({
|
||||||
|
label: t("presence.missing"),
|
||||||
|
value: formatLongSpan(missing),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
function reply(seconds: number | null): string {
|
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) {
|
async function load(id: number, period: number) {
|
||||||
@@ -172,7 +151,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null}
|
{#if chatId === null}
|
||||||
<EmptyState description={TEXT.openChat} title={TEXT.title} />
|
<EmptyState description={t("profile.empty")} title={t("profile.analytics")} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="analytics">
|
<div class="analytics">
|
||||||
<div class="periods">
|
<div class="periods">
|
||||||
@@ -185,20 +164,20 @@
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{TEXT.period(period)}
|
{t("presence.days", { count: period })}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if failed}
|
{#if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>{TEXT.failed}</p>
|
<p>{t("presence.failed")}</p>
|
||||||
<Button
|
<Button
|
||||||
onclick={() => chatId !== null && load(chatId, days)}
|
onclick={() => chatId !== null && load(chatId, days)}
|
||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
{TEXT.retry}
|
{t("ui.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if volume === null || (isDm && stats === null)}
|
{:else if volume === null || (isDm && stats === null)}
|
||||||
@@ -211,7 +190,9 @@
|
|||||||
<span class="total"
|
<span class="total"
|
||||||
>{formatSpan(stats.summary.online_seconds)}</span
|
>{formatSpan(stats.summary.online_seconds)}</span
|
||||||
>
|
>
|
||||||
<span class="count">{TEXT.total(shownDays)}</span>
|
<span class="count"
|
||||||
|
>{t("presence.total", { count: shownDays })}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if sample}
|
{#if sample}
|
||||||
@@ -220,9 +201,9 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if stats.hidden}
|
{#if stats.hidden}
|
||||||
<p class="note">{TEXT.hidden}</p>
|
<p class="note">{t("presence.hidden")}</p>
|
||||||
{:else if !tracked}
|
{:else if !tracked}
|
||||||
<p class="note">{TEXT.noStatus}</p>
|
<p class="note">{t("presence.noStatus")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -242,34 +223,34 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>{TEXT.byHour}</h3>
|
<h3>{t("presence.byHour")}</h3>
|
||||||
<span>{zone}</span>
|
<span>{zone}</span>
|
||||||
</div>
|
</div>
|
||||||
<PresenceHeatmap week={stats.week} />
|
<PresenceHeatmap week={stats.week} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>{TEXT.byDay}</h3></div>
|
<div class="section-title"><h3>{t("presence.byDay")}</h3></div>
|
||||||
<OnlineDays days={stats.days} />
|
<OnlineDays days={stats.days} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>{TEXT.messages}</h3></div>
|
<div class="section-title"><h3>{t("presence.messages")}</h3></div>
|
||||||
{#if quiet}
|
{#if quiet}
|
||||||
<p class="state">{TEXT.noMessages}</p>
|
<p class="state">{t("presence.noMessages")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<VolumeChart {volume} />
|
<VolumeChart {volume} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if response && response.mine_count + response.their_count > 0}
|
{#if response && response.mine_count + response.their_count > 0}
|
||||||
<dl class="rows replies">
|
<dl class="rows replies">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<dt>{TEXT.mine}</dt>
|
<dt>{t("presence.mine")}</dt>
|
||||||
<dd>{reply(response.mine_median_seconds)}</dd>
|
<dd>{reply(response.mine_median_seconds)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<dt>{TEXT.theirs}</dt>
|
<dt>{t("presence.theirs")}</dt>
|
||||||
<dd>{reply(response.their_median_seconds)}</dd>
|
<dd>{reply(response.their_median_seconds)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -278,7 +259,7 @@
|
|||||||
|
|
||||||
{#if isDm && stats && tracked && !stats.hidden}
|
{#if isDm && stats && tracked && !stats.hidden}
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
|
<div class="section-title"><h3>{t("presence.sessions")}</h3></div>
|
||||||
{#key `${chatId}:${shownDays}:${stats.until}`}
|
{#key `${chatId}:${shownDays}:${stats.until}`}
|
||||||
<PresenceSessions
|
<PresenceSessions
|
||||||
days={shownDays}
|
days={shownDays}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { PresenceDay } from "#lib/api/types.js";
|
import type { PresenceDay } from "#lib/api/types.js";
|
||||||
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
||||||
import { formatSpan } from "#lib/format/presence.js";
|
import { formatSpan } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
days: PresenceDay[];
|
days: PresenceDay[];
|
||||||
@@ -12,18 +13,6 @@
|
|||||||
const SECONDS_IN_HOUR = 3600;
|
const SECONDS_IN_HOUR = 3600;
|
||||||
const SECONDS_IN_DAY = 86_400;
|
const SECONDS_IN_DAY = 86_400;
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
bar: (day: string, online: number, missing: number) => {
|
|
||||||
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 peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
|
||||||
const first = $derived(days[0]);
|
const first = $derived(days[0]);
|
||||||
|
|
||||||
@@ -31,6 +20,23 @@
|
|||||||
return formatDay(parseDay(day).toISOString());
|
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 {
|
function absent(day: PresenceDay, index: number): number {
|
||||||
if (index === days.length - 1) {
|
if (index === days.length - 1) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -46,11 +52,7 @@
|
|||||||
{#each days as day, index (day.day)}
|
{#each days as day, index (day.day)}
|
||||||
<div
|
<div
|
||||||
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
|
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
|
||||||
title={TEXT.bar(
|
title={title(day, index)}
|
||||||
label(day.day),
|
|
||||||
day.online_seconds,
|
|
||||||
absent(day, index)
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={["bar", { zero: day.online_seconds === 0 }]}
|
class={["bar", { zero: day.online_seconds === 0 }]}
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
{#if first}
|
{#if first}
|
||||||
<div class="axis">
|
<div class="axis">
|
||||||
<span>{label(first.day)}</span>
|
<span>{label(first.day)}</span>
|
||||||
<span>{TEXT.today}</span>
|
<span>{t("presence.axisToday")}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PresenceHour } from "#lib/api/types.js";
|
import type { PresenceHour } from "#lib/api/types.js";
|
||||||
import { formatSpan } from "#lib/format/presence.js";
|
import { formatSpan, weekdayLabels } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
week: PresenceHour[];
|
week: PresenceHour[];
|
||||||
@@ -8,28 +9,14 @@
|
|||||||
|
|
||||||
const { week }: Props = $props();
|
const { week }: Props = $props();
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
cell: (day: string, cell: PresenceHour) => {
|
|
||||||
const slot = `${day}, ${cell.hour}:00`;
|
|
||||||
if (cell.hours === 0) {
|
|
||||||
return slot;
|
|
||||||
}
|
|
||||||
if (cell.online_minutes === null) {
|
|
||||||
return `${slot}, нет данных`;
|
|
||||||
}
|
|
||||||
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
|
|
||||||
},
|
|
||||||
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
|
|
||||||
empty: "нет данных",
|
|
||||||
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const HOURS_IN_DAY = 24;
|
const HOURS_IN_DAY = 24;
|
||||||
|
const SECONDS_IN_MINUTE = 60;
|
||||||
const HOUR_TICKS = [0, 6, 12, 18];
|
const HOUR_TICKS = [0, 6, 12, 18];
|
||||||
const MIN_SHADE = 0.18;
|
const MIN_SHADE = 0.18;
|
||||||
|
|
||||||
|
const weekdays = $derived(weekdayLabels());
|
||||||
const rows = $derived(
|
const rows = $derived(
|
||||||
TEXT.days.map((_, weekday) =>
|
weekdays.map((_, weekday) =>
|
||||||
week
|
week
|
||||||
.filter((cell) => cell.weekday === weekday)
|
.filter((cell) => cell.weekday === weekday)
|
||||||
.sort((a, b) => a.hour - b.hour)
|
.sort((a, b) => a.hour - b.hour)
|
||||||
@@ -44,6 +31,20 @@
|
|||||||
return cell.hours > 0 && cell.online_minutes === null;
|
return cell.hours > 0 && cell.online_minutes === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function title(day: string, cell: PresenceHour): string {
|
||||||
|
const slot = `${day}, ${cell.hour}:00`;
|
||||||
|
if (cell.hours === 0) {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
if (cell.online_minutes === null) {
|
||||||
|
return t("presence.cellNoData", { slot });
|
||||||
|
}
|
||||||
|
return t("presence.cellAverage", {
|
||||||
|
slot,
|
||||||
|
span: formatSpan(cell.online_minutes * SECONDS_IN_MINUTE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function shade(minutes: number | null): number {
|
function shade(minutes: number | null): number {
|
||||||
if (minutes === null || minutes === 0 || peak === 0) {
|
if (minutes === null || minutes === 0 || peak === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
<div class="heatmap">
|
<div class="heatmap">
|
||||||
{#each rows as cells, weekday (weekday)}
|
{#each rows as cells, weekday (weekday)}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="day">{TEXT.days[weekday]}</span>
|
<span class="day">{weekdays[weekday]}</span>
|
||||||
<div class="cells">
|
<div class="cells">
|
||||||
{#each cells as cell (cell.hour)}
|
{#each cells as cell (cell.hour)}
|
||||||
<span
|
<span
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
zero: cell.online_minutes === 0,
|
zero: cell.online_minutes === 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)}
|
title={title(weekdays[weekday] ?? "", cell)}
|
||||||
style:--shade="{shade(cell.online_minutes) * 100}%"
|
style:--shade="{shade(cell.online_minutes) * 100}%"
|
||||||
></span>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -87,13 +88,13 @@
|
|||||||
{#if peak > 0}
|
{#if peak > 0}
|
||||||
<span class="key">
|
<span class="key">
|
||||||
<span class="scale"></span>
|
<span class="scale"></span>
|
||||||
{TEXT.max(peak)}
|
{t("presence.max", { span: formatSpan(peak * SECONDS_IN_MINUTE) })}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasGaps}
|
{#if hasGaps}
|
||||||
<span class="key">
|
<span class="key">
|
||||||
<span class="swatch empty"></span>
|
<span class="swatch empty"></span>
|
||||||
{TEXT.empty}
|
{t("presence.noData")}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
|
|
||||||
.day {
|
.day {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 1.375rem;
|
width: 1.75rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
gap: 0.25rem 0.75rem;
|
gap: 0.25rem 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin: 0.375rem 0 0 1.875rem;
|
margin: 0.375rem 0 0 2.25rem;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
|
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
|
||||||
import { formatSpan } from "#lib/format/presence.js";
|
import { formatSpan } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
days: number;
|
days: number;
|
||||||
@@ -16,18 +17,6 @@
|
|||||||
|
|
||||||
const { days, first, pageSize, peerId, tz }: Props = $props();
|
const { days, first, pageSize, peerId, tz }: Props = $props();
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
failed: "Не удалось загрузить сессии.",
|
|
||||||
more: "Показать ещё",
|
|
||||||
none: "За этот период в сети не был(а).",
|
|
||||||
now: "в сети",
|
|
||||||
retry: "Повторить",
|
|
||||||
since: (time: string) => `с ${time}`,
|
|
||||||
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
|
|
||||||
today: "Сегодня",
|
|
||||||
yesterday: "Вчера",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MS_IN_DAY = 86_400_000;
|
const MS_IN_DAY = 86_400_000;
|
||||||
|
|
||||||
let extra = $state<PresenceSession[]>([]);
|
let extra = $state<PresenceSession[]>([]);
|
||||||
@@ -54,18 +43,22 @@
|
|||||||
function dayLabel(date: Date): string {
|
function dayLabel(date: Date): string {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
if (date.toDateString() === today.toDateString()) {
|
if (date.toDateString() === today.toDateString()) {
|
||||||
return TEXT.today;
|
return t("presence.today");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
date.toDateString() ===
|
date.toDateString() ===
|
||||||
new Date(today.getTime() - MS_IN_DAY).toDateString()
|
new Date(today.getTime() - MS_IN_DAY).toDateString()
|
||||||
) {
|
) {
|
||||||
return TEXT.yesterday;
|
return t("presence.yesterday");
|
||||||
}
|
}
|
||||||
const text = formatLongDay(date);
|
const text = formatLongDay(date);
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function span(from: string, to: string): string {
|
||||||
|
return from === to ? from : `${from}–${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return;
|
return;
|
||||||
@@ -92,7 +85,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<p class="state">{TEXT.none}</p>
|
<p class="state">{t("presence.noSessions")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groups as group (group.label)}
|
{#each groups as group (group.label)}
|
||||||
<h4 class="day">{group.label}</h4>
|
<h4 class="day">{group.label}</h4>
|
||||||
@@ -101,14 +94,13 @@
|
|||||||
<li>
|
<li>
|
||||||
<span>
|
<span>
|
||||||
{session.ongoing
|
{session.ongoing
|
||||||
? TEXT.since(formatTime(session.started))
|
? t("presence.since", { time: formatTime(session.started) })
|
||||||
: TEXT.span(
|
: span(formatTime(session.started), formatTime(session.ended))}
|
||||||
formatTime(session.started),
|
|
||||||
formatTime(session.ended)
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span class={["length", { online: session.ongoing }]}>
|
<span class={["length", { online: session.ongoing }]}>
|
||||||
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
|
{session.ongoing
|
||||||
|
? t("presence.online")
|
||||||
|
: formatSpan(session.seconds)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -118,11 +110,13 @@
|
|||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>{TEXT.failed}</p>
|
<p>{t("presence.sessionsFailed")}</p>
|
||||||
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
|
<Button onclick={loadMore} smaller variant="text">{t("ui.retry")}</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if !exhausted}
|
{:else if !exhausted}
|
||||||
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
|
<button class="more" onclick={loadMore} type="button">
|
||||||
|
{t("ui.showMore")}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { VolumeBucket } from "#lib/api/types.js";
|
import type { VolumeBucket } from "#lib/api/types.js";
|
||||||
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
volume: VolumeBucket[];
|
volume: VolumeBucket[];
|
||||||
@@ -8,22 +9,25 @@
|
|||||||
|
|
||||||
const { volume }: Props = $props();
|
const { volume }: Props = $props();
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
bar: (day: string, bucket: VolumeBucket) =>
|
|
||||||
bucket.total === 0
|
|
||||||
? `${day}, нет сообщений`
|
|
||||||
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
|
|
||||||
incoming: "входящие",
|
|
||||||
outgoing: "мои",
|
|
||||||
today: "сегодня",
|
|
||||||
};
|
|
||||||
|
|
||||||
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
||||||
const first = $derived(volume[0]);
|
const first = $derived(volume[0]);
|
||||||
|
|
||||||
function label(day: string): string {
|
function label(day: string): string {
|
||||||
return formatDay(parseDay(day).toISOString());
|
return formatDay(parseDay(day).toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function title(bucket: VolumeBucket): string {
|
||||||
|
const day = label(bucket.bucket);
|
||||||
|
if (bucket.total === 0) {
|
||||||
|
return t("presence.volumeEmpty", { day });
|
||||||
|
}
|
||||||
|
return t("presence.volume", {
|
||||||
|
day,
|
||||||
|
incoming: bucket.incoming,
|
||||||
|
outgoing: bucket.outgoing,
|
||||||
|
total: bucket.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="volume">
|
<div class="volume">
|
||||||
@@ -31,7 +35,7 @@
|
|||||||
<span class="peak">{peak}</span>
|
<span class="peak">{peak}</span>
|
||||||
<div class={["bars", { dense: volume.length > 45 }]}>
|
<div class={["bars", { dense: volume.length > 45 }]}>
|
||||||
{#each volume as bucket (bucket.bucket)}
|
{#each volume as bucket (bucket.bucket)}
|
||||||
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
|
<div class="slot" title={title(bucket)}>
|
||||||
<div
|
<div
|
||||||
class={["bar", { zero: bucket.total === 0 }]}
|
class={["bar", { zero: bucket.total === 0 }]}
|
||||||
style:height="{(bucket.total / peak) * 100}%"
|
style:height="{(bucket.total / peak) * 100}%"
|
||||||
@@ -46,12 +50,16 @@
|
|||||||
{#if first}
|
{#if first}
|
||||||
<div class="axis">
|
<div class="axis">
|
||||||
<span>{label(first.bucket)}</span>
|
<span>{label(first.bucket)}</span>
|
||||||
<span>{TEXT.today}</span>
|
<span>{t("presence.axisToday")}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
|
<span class="key"
|
||||||
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
|
><span class="swatch in"></span>{t("presence.volumeIncoming")}</span
|
||||||
|
>
|
||||||
|
<span class="key"
|
||||||
|
><span class="swatch out"></span>{t("presence.volumeOutgoing")}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PresenceSample } from "#lib/api/types.js";
|
import type { PresenceSample } from "#lib/api/types.js";
|
||||||
import { formatDay, formatTime } from "#lib/format/datetime.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 SECONDS_IN_MINUTE = 60;
|
||||||
const MINUTES_IN_HOUR = 60;
|
const MINUTES_IN_HOUR = 60;
|
||||||
@@ -8,32 +9,14 @@ const SECONDS_IN_HOUR = 3600;
|
|||||||
const MS_IN_MINUTE = 60_000;
|
const MS_IN_MINUTE = 60_000;
|
||||||
const SECONDS_IN_DAY = 86_400;
|
const SECONDS_IN_DAY = 86_400;
|
||||||
const MS_IN_DAY = 86_400_000;
|
const MS_IN_DAY = 86_400_000;
|
||||||
|
const DAYS_IN_WEEK = 7;
|
||||||
|
const MONDAY = Date.UTC(2024, 0, 1);
|
||||||
|
|
||||||
const TEXT = {
|
const FUZZY: Record<string, MessageKey> = {
|
||||||
ago: (minutes: number) =>
|
last_month: "presence.lastMonth",
|
||||||
`был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`,
|
last_week: "presence.lastWeek",
|
||||||
days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`,
|
long_time_ago: "presence.longAgo",
|
||||||
hours: (hours: number) => `${hours} ч`,
|
recently: "presence.recently",
|
||||||
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<string, string> = {
|
|
||||||
last_month: TEXT.lastMonth,
|
|
||||||
last_week: TEXT.lastWeek,
|
|
||||||
long_time_ago: TEXT.longAgo,
|
|
||||||
recently: TEXT.recently,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isOnline(
|
export function isOnline(
|
||||||
@@ -50,20 +33,20 @@ function lastSeen(iso: string, now: number): string {
|
|||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
|
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
|
||||||
if (minutes < 1) {
|
if (minutes < 1) {
|
||||||
return TEXT.justNow;
|
return t("presence.justNow");
|
||||||
}
|
}
|
||||||
if (minutes < MINUTES_IN_HOUR) {
|
if (minutes < MINUTES_IN_HOUR) {
|
||||||
return TEXT.ago(minutes);
|
return t("presence.ago", { count: minutes });
|
||||||
}
|
}
|
||||||
const time = formatTime(iso);
|
const time = formatTime(iso);
|
||||||
const today = new Date(now);
|
const today = new Date(now);
|
||||||
if (date.toDateString() === today.toDateString()) {
|
if (date.toDateString() === today.toDateString()) {
|
||||||
return TEXT.today(time);
|
return t("presence.todayAt", { time });
|
||||||
}
|
}
|
||||||
if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
|
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(
|
export function formatPresence(
|
||||||
@@ -72,29 +55,31 @@ export function formatPresence(
|
|||||||
): string {
|
): string {
|
||||||
if (sample.status === "online") {
|
if (sample.status === "online") {
|
||||||
return isOnline(sample, now)
|
return isOnline(sample, now)
|
||||||
? TEXT.online
|
? t("presence.online")
|
||||||
: lastSeen(sample.next_offline_date ?? sample.ts, now);
|
: lastSeen(sample.next_offline_date ?? sample.ts, now);
|
||||||
}
|
}
|
||||||
const fuzzy = FUZZY[sample.status];
|
const fuzzy = FUZZY[sample.status];
|
||||||
if (fuzzy) {
|
if (fuzzy) {
|
||||||
return fuzzy;
|
return t(fuzzy);
|
||||||
}
|
}
|
||||||
return sample.last_online_date
|
return sample.last_online_date
|
||||||
? lastSeen(sample.last_online_date, now)
|
? lastSeen(sample.last_online_date, now)
|
||||||
: TEXT.offline;
|
: t("presence.offline");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSpan(seconds: number): string {
|
export function formatSpan(seconds: number): string {
|
||||||
if (seconds < SECONDS_IN_MINUTE) {
|
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);
|
const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
|
||||||
if (minutes < MINUTES_IN_HOUR) {
|
if (minutes < MINUTES_IN_HOUR) {
|
||||||
return TEXT.minutes(minutes);
|
return t("presence.minutes", { minutes });
|
||||||
}
|
}
|
||||||
const hours = Math.floor(minutes / MINUTES_IN_HOUR);
|
const hours = Math.floor(minutes / MINUTES_IN_HOUR);
|
||||||
const rest = 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 {
|
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 days = Math.floor(seconds / SECONDS_IN_DAY);
|
||||||
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
|
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
|
||||||
return hours === 0
|
const text = t("presence.days", { count: days });
|
||||||
? TEXT.days(days)
|
return hours === 0 ? text : `${text} ${t("presence.hours", { hours })}`;
|
||||||
: `${TEXT.days(days)} ${TEXT.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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -2,30 +2,6 @@ import type { FileShare } from "#lib/api/types.js";
|
|||||||
import { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.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 {
|
export function formatDownloads(share: FileShare): string {
|
||||||
if (share.max_downloads === null) {
|
if (share.max_downloads === null) {
|
||||||
return t("shares.downloads", { count: share.download_count });
|
return t("shares.downloads", { count: share.download_count });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { media } from "#lib/i18n/messages/media.js";
|
|||||||
import { menu } from "#lib/i18n/messages/menu.js";
|
import { menu } from "#lib/i18n/messages/menu.js";
|
||||||
import { notes } from "#lib/i18n/messages/notes.js";
|
import { notes } from "#lib/i18n/messages/notes.js";
|
||||||
import { policy } from "#lib/i18n/messages/policy.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 { profile } from "#lib/i18n/messages/profile.js";
|
||||||
import { read } from "#lib/i18n/messages/read.js";
|
import { read } from "#lib/i18n/messages/read.js";
|
||||||
import { search } from "#lib/i18n/messages/search.js";
|
import { search } from "#lib/i18n/messages/search.js";
|
||||||
@@ -38,6 +39,7 @@ export const messages = {
|
|||||||
...menu,
|
...menu,
|
||||||
...notes,
|
...notes,
|
||||||
...policy,
|
...policy,
|
||||||
|
...presence,
|
||||||
...profile,
|
...profile,
|
||||||
...read,
|
...read,
|
||||||
...search,
|
...search,
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user