feat(frontend): analytics shows minutes online by hour of week and by day, sessions with start and end, says when last seen is hidden, chat status is live and in russian

This commit is contained in:
hh committed 2026-10-19 02:41:39 +00:00
1 parent f44597b2dc
commit 73b8903de1
11 files changed
+1275 -373

No files matched your search

@@ -1,193 +1,295 @@
<script lang="ts">
import {
getCurrentPresence,
getMessageVolume,
getPresenceHistory,
getPresenceHourly,
getPresenceStats,
getResponseStats,
} from "#lib/api/endpoints.js";
import type {
PresenceHourly,
PresenceSample,
PresenceStats,
ResponseStats,
VolumeBucket,
} from "#lib/api/types.js";
import OnlineDays from "#lib/components/presence/OnlineDays.svelte";
import PresenceHeatmap from "#lib/components/presence/PresenceHeatmap.svelte";
import PresenceSessions from "#lib/components/presence/PresenceSessions.svelte";
import VolumeChart from "#lib/components/presence/VolumeChart.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { formatPresence } from "#lib/format/presence.js";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { browserTimeZone, timeZoneLabel } from "#lib/format/datetime.js";
import {
formatClock,
formatLongSpan,
formatPresence,
formatSpan,
isOnline,
} from "#lib/format/presence.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state";
const HISTORY_LIMIT = 200;
const SECONDS_IN_MINUTE = 60;
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;
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const MS_IN_SECOND = 1000;
const tz = browserTimeZone();
const zone = timeZoneLabel();
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const isDm = $derived(chatId !== null && chatId > 0);
let current = $state<PresenceSample | null>(null);
let hourly = $state<PresenceHourly[]>([]);
let history = $state<PresenceSample[]>([]);
let volume = $state<VolumeBucket[]>([]);
let days = $state(DEFAULT_PERIOD);
let stats = $state<PresenceStats | null>(null);
let volume = $state<VolumeBucket[] | null>(null);
let response = $state<ResponseStats | null>(null);
let loading = $state(false);
let shownDays = $state(DEFAULT_PERIOD);
let failed = $state(false);
let shownChat: number | null = null;
let token = 0;
const changes = $derived.by(() => {
const result: PresenceSample[] = [];
let last: string | null = null;
for (const sample of history) {
if (sample.status !== last) {
result.push(sample);
last = sample.status;
}
const sample = $derived(
presence.sample?.peer_id === chatId
? presence.sample
: (stats?.current ?? null)
);
const online = $derived(isOnline(sample, presence.now));
const tracked = $derived(
stats !== null &&
(stats.current !== null || stats.summary.session_count > 0)
);
const quiet = $derived(
volume?.every((bucket) => bucket.total === 0) ?? false
);
const rows = $derived.by(() => {
if (stats === null) {
return [];
}
const { summary } = stats;
const result: { label: string; value: string }[] = [];
if (summary.daily_seconds !== null) {
result.push({
label: TEXT.average,
value: formatSpan(summary.daily_seconds),
});
}
if (summary.median_session_seconds !== null) {
result.push({
label: TEXT.median,
value: formatSpan(summary.median_session_seconds),
});
}
if (summary.first_online !== null) {
result.push({
label: TEXT.firstOnline,
value: formatClock(summary.first_online),
});
}
if (summary.last_offline !== null) {
result.push({
label: TEXT.lastOffline,
value: formatClock(summary.last_offline),
});
}
const period =
(new Date(stats.until).getTime() - new Date(stats.since).getTime()) /
MS_IN_SECOND;
const missing = period - summary.covered_seconds;
if (missing >= SECONDS_IN_HOUR) {
result.push({ label: TEXT.missing, value: formatLongSpan(missing) });
}
return result;
});
function formatDuration(seconds: number | null): string {
if (seconds === null) {
return "—";
function reply(seconds: number | null): string {
return seconds === null ? TEXT.noData : formatSpan(seconds);
}
async function load(id: number, period: number) {
token += 1;
const current = token;
failed = false;
if (shownChat !== id) {
shownChat = id;
stats = null;
volume = null;
response = null;
}
if (seconds < SECONDS_IN_MINUTE) {
return `${Math.round(seconds)} с`;
try {
const [nextStats, nextVolume, nextResponse] = await Promise.all([
id > 0
? getPresenceStats(id, period, tz, { limit: SESSION_PAGE })
: Promise.resolve(null),
getMessageVolume(id, period, tz),
getResponseStats(id),
]);
if (current === token) {
stats = nextStats;
volume = nextVolume;
response = nextResponse;
shownDays = period;
}
} catch {
if (current === token) {
failed = true;
}
}
if (seconds < SECONDS_IN_HOUR) {
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
}
if (seconds < SECONDS_IN_DAY) {
return `${Math.round(seconds / SECONDS_IN_HOUR)} ч`;
}
return `${Math.round(seconds / SECONDS_IN_DAY)} д`;
}
$effect(() => {
if (accounts.selectedId === null || chatId === null) {
return;
if (accounts.selectedId !== null && chatId !== null) {
load(chatId, days);
}
const id = chatId;
let active = true;
loading = true;
current = null;
hourly = [];
history = [];
volume = [];
response = null;
Promise.all([getMessageVolume(id), getResponseStats(id)])
.then(([vol, resp]) => {
if (active) {
volume = vol;
response = resp;
}
})
.catch(() => undefined)
.finally(() => {
if (active) {
loading = false;
}
});
if (id > 0) {
Promise.all([
getCurrentPresence(id),
getPresenceHourly(id),
getPresenceHistory(id, { limit: HISTORY_LIMIT }),
])
.then(([cur, hrs, hist]) => {
if (active) {
current = cur;
hourly = hrs;
history = hist;
}
})
.catch(() => undefined);
}
const unsub = events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === id && event.sample) {
current = event.sample;
}
});
return () => {
active = false;
unsub();
};
});
</script>
{#if chatId === null}
<EmptyState description="Откройте чат" title="Аналитика" />
<EmptyState description={TEXT.openChat} title={TEXT.title} />
{:else}
<div class="analytics">
<section>
<h3>Объём сообщений (90 дней)</h3>
{#if volume.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
{:else}
<VolumeChart {volume} />
{/if}
</section>
<div class="periods">
{#each PERIODS as period (period)}
<button
aria-pressed={days === period}
class={["period", { on: days === period }]}
onclick={() => {
days = period;
}}
type="button"
>
{TEXT.period(period)}
</button>
{/each}
</div>
<section>
<h3>Время ответа</h3>
{#if response}
<div class="stats">
<div class="stat">
<span class="value"
>{formatDuration(response.mine_median_seconds)}</span
>
<span class="caption">я отвечаю ({response.mine_count})</span>
</div>
<div class="stat">
<span class="value"
>{formatDuration(response.their_median_seconds)}</span
>
<span class="caption">мне отвечают ({response.their_count})</span>
</div>
</div>
{:else}
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
{/if}
</section>
{#if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button
onclick={() => chatId !== null && load(chatId, days)}
smaller
variant="text"
>
{TEXT.retry}
</Button>
</div>
{:else if volume === null || (isDm && stats === null)}
<div class="state"><Spinner /></div>
{:else}
{#if isDm && stats}
<section class="hero">
{#if tracked && !stats.hidden}
<div class="total-line">
<span class="total"
>{formatSpan(stats.summary.online_seconds)}</span
>
<span class="count">{TEXT.total(shownDays)}</span>
</div>
{/if}
{#if sample}
<p class={["status", { online }]}>
{formatPresence(sample, presence.now)}
</p>
{/if}
{#if stats.hidden}
<p class="note">{TEXT.hidden}</p>
{:else if !tracked}
<p class="note">{TEXT.noStatus}</p>
{/if}
</section>
{#if isDm}
<section>
<div class="status" class:online={current?.status === "online"}>
{current ? formatPresence(current) : "нет данных"}
</div>
</section>
{#if tracked && !stats.hidden}
{#if rows.length > 0}
<section>
<dl class="rows">
{#each rows as row (row.label)}
<div class="row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
{/each}
</dl>
</section>
{/if}
<section>
<div class="section-title">
<h3>{TEXT.byHour}</h3>
<span>{zone}</span>
</div>
<PresenceHeatmap week={stats.week} />
</section>
<section>
<div class="section-title"><h3>{TEXT.byDay}</h3></div>
<OnlineDays days={stats.days} />
</section>
{/if}
{/if}
<section>
<h3>Когда в сети</h3>
{#if hourly.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
<div class="section-title"><h3>{TEXT.messages}</h3></div>
{#if quiet}
<p class="state">{TEXT.noMessages}</p>
{:else}
<PresenceHeatmap {hourly} />
<VolumeChart {volume} />
{/if}
{#if response && response.mine_count + response.their_count > 0}
<dl class="rows replies">
<div class="row">
<dt>{TEXT.mine}</dt>
<dd>{reply(response.mine_median_seconds)}</dd>
</div>
<div class="row">
<dt>{TEXT.theirs}</dt>
<dd>{reply(response.their_median_seconds)}</dd>
</div>
</dl>
{/if}
</section>
<section>
<h3>История статусов</h3>
{#if changes.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет записей"}</p>
{:else}
<ul class="changes">
{#each changes as sample (sample.ts)}
<li>
<span
class="dot"
class:online={sample.status === "online"}
></span>
<span class="label">{formatPresence(sample)}</span>
<span class="time">{formatFull(sample.ts)}</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if isDm && stats && tracked && !stats.hidden}
<section>
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
{#key `${chatId}:${shownDays}:${stats.until}`}
<PresenceSessions
days={shownDays}
first={stats.sessions}
pageSize={SESSION_PAGE}
peerId={stats.peer_id}
{tz}
/>
{/key}
</section>
{/if}
{/if}
</div>
{/if}
@@ -196,48 +298,89 @@
.analytics {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
padding-bottom: 0.5rem;
}
.periods {
display: flex;
gap: 0.25rem;
margin: 0.75rem 1rem 0.25rem;
padding: 0.125rem;
border-radius: 0.625rem;
background-color: var(--color-background-secondary);
}
.period {
cursor: pointer;
flex: 1;
height: 2rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
white-space: nowrap;
background: none;
transition:
background-color 0.15s ease-out,
color 0.15s ease-out;
&.on {
color: var(--color-text);
background-color: var(--color-background);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (pointer: coarse) {
height: 2.75rem;
}
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0;
& + section {
border-top: 1px solid var(--color-borders);
}
}
h3 {
margin: 0;
font-size: 0.75rem;
.hero {
padding: 0.75rem 1rem 0.875rem;
}
.total-line {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
}
.stats {
display: flex;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.value {
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.caption {
font-size: 0.75rem;
.count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.status {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
@@ -246,49 +389,80 @@
}
}
.muted {
margin: 0;
font-size: 0.8125rem;
.note {
margin: 0.5rem 0 0;
font-size: 0.875rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.changes {
.section-title {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.375rem 1rem 0.625rem;
.changes li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
.dot {
width: 0.5rem;
height: 0.5rem;
flex-shrink: 0;
border-radius: 50%;
background-color: var(--color-text-secondary);
&.online {
background-color: var(--color-primary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
white-space: nowrap;
}
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
.rows {
margin: 0;
}
.replies {
margin-top: 0.5rem;
}
.row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
}
dt {
color: var(--color-text);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
white-space: nowrap;
}
.time {
flex-shrink: 0;
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
</style>
@@ -0,0 +1,144 @@
<script lang="ts">
import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: PresenceDay[];
}
const { days }: Props = $props();
const SECONDS_IN_HOUR = 3600;
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 first = $derived(days[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) {
return 0;
}
return Math.max(0, SECONDS_IN_DAY - day.covered_seconds);
}
</script>
<div class="days">
<div class="chart">
<span class="peak">{formatSpan(peak)}</span>
<div class={["bars", { dense: days.length > 45 }]}>
{#each days as day, index (day.day)}
<div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar(
label(day.day),
day.online_seconds,
absent(day, index)
)}
>
<span
class={["bar", { zero: day.online_seconds === 0 }]}
style:height="{(day.online_seconds / peak) * 100}%"
></span>
</div>
{/each}
</div>
</div>
{#if first}
<div class="axis">
<span>{label(first.day)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
</div>
<style lang="scss">
.days {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars {
display: flex;
gap: 3px;
align-items: flex-end;
height: 6rem;
&.dense {
gap: 1px;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
&.empty {
border-radius: 2px 2px 0 0;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.35) 0 1px,
transparent 1px 4px
);
}
}
.bar {
width: 100%;
min-height: 2px;
border-radius: 2px 2px 0 0;
background-color: var(--color-primary);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
.empty & {
min-height: 0;
}
}
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -1,116 +1,217 @@
<script lang="ts">
import type { PresenceHourly } from "#lib/api/types.js";
import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
hourly: PresenceHourly[];
week: PresenceHour[];
}
const { hourly }: Props = $props();
const { week }: Props = $props();
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
const HOUR_TICKS = new Set([0, 6, 12, 18]);
interface Cell {
online: number;
total: number;
}
const grid = $derived.by(() => {
const cells: Cell[][] = DAYS.map(() =>
HOURS.map(() => ({ online: 0, total: 0 }))
);
for (const bucket of hourly) {
const date = new Date(bucket.bucket);
const day = (date.getDay() + 6) % 7;
const cell = cells[day]?.[date.getHours()];
if (cell) {
cell.online += bucket.online_samples;
cell.total += bucket.samples;
const TEXT = {
cell: (day: string, cell: PresenceHour) => {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
}
return cells;
});
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
function intensity(cell: Cell): number {
return cell.total > 0 ? cell.online / cell.total : 0;
const HOURS_IN_DAY = 24;
const HOUR_TICKS = [0, 6, 12, 18];
const MIN_SHADE = 0.18;
const rows = $derived(
TEXT.days.map((_, weekday) =>
week
.filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour)
)
);
const peak = $derived(
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
);
const hasGaps = $derived(week.some(missing));
function missing(cell: PresenceHour): boolean {
return cell.hours > 0 && cell.online_minutes === null;
}
function cellTitle(day: number, hour: number, cell: Cell): string {
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) {
return 0;
}
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
}
</script>
<div class="heatmap">
{#each grid as row, day (day)}
<div class="heat-row">
<span class="day-label">{DAYS[day]}</span>
{#each rows as cells, weekday (weekday)}
<div class="row">
<span class="day">{TEXT.days[weekday]}</span>
<div class="cells">
{#each row as cell, hour (hour)}
{#each cells as cell (cell.hour)}
<span
class="cell"
title={cellTitle(day, hour, cell)}
style:--i={intensity(cell)}
class:empty={cell.total === 0}
class={[
"cell",
{
empty: missing(cell),
outside: cell.hours === 0,
zero: cell.online_minutes === 0,
},
]}
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%"
></span>
{/each}
</div>
</div>
{/each}
<div class="heat-row axis">
<span class="day-label"></span>
<div class="cells">
{#each HOURS as hour (hour)}
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
<div class="row">
<span class="day"></span>
<div class="ticks">
{#each HOUR_TICKS as hour (hour)}
<span style:left="{(hour / HOURS_IN_DAY) * 100}%">{hour}</span>
{/each}
</div>
</div>
<div class="legend">
<span class="key"><span class="swatch zero"></span>0</span>
{#if peak > 0}
<span class="key">
<span class="scale"></span>
{TEXT.max(peak)}
</span>
{/if}
{#if hasGaps}
<span class="key">
<span class="swatch empty"></span>
{TEXT.empty}
</span>
{/if}
</div>
</div>
<style lang="scss">
.heatmap {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
padding: 0 1rem;
}
.heat-row {
.row {
display: flex;
align-items: center;
gap: 0.5rem;
align-items: center;
}
.day-label {
width: 1.5rem;
.day {
flex-shrink: 0;
font-size: 0.6875rem;
width: 1.375rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-align: right;
}
.cells {
display: grid;
flex: 1;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.cell {
aspect-ratio: 1;
border-radius: 2px;
background-color: var(--color-primary);
opacity: calc(0.12 + 0.88 * var(--i));
background-color: color-mix(
in srgb,
var(--color-primary) var(--shade),
transparent
);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.outside {
background: none;
}
&.empty {
background-color: var(--color-text-secondary);
opacity: 0.08;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
.tick {
font-size: 0.625rem;
.ticks {
position: relative;
flex: 1;
height: 1rem;
span {
position: absolute;
top: 0.125rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
align-items: center;
margin: 0.375rem 0 0 1.875rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
text-align: left;
}
.key {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.scale {
width: 2.5rem;
height: 0.625rem;
border-radius: 2px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-primary) 18%, transparent),
var(--color-primary)
);
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.empty {
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
</style>
@@ -0,0 +1,205 @@
<script lang="ts">
import { getPresenceSessions } from "#lib/api/endpoints.js";
import type { PresenceSession } from "#lib/api/types.js";
import Button from "#lib/components/ui/Button.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: number;
first: PresenceSession[];
pageSize: number;
peerId: number;
tz: string;
}
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;
let extra = $state<PresenceSession[]>([]);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
const sessions = $derived([...first, ...extra]);
const exhausted = $derived(done || first.length < pageSize);
const groups = $derived.by(() => {
const result: { label: string; sessions: PresenceSession[] }[] = [];
for (const session of sessions) {
const label = dayLabel(new Date(session.started));
const last = result.at(-1);
if (last?.label === label) {
last.sessions.push(session);
} else {
result.push({ label, sessions: [session] });
}
}
return result;
});
function dayLabel(date: Date): string {
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return TEXT.today;
}
if (
date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString()
) {
return TEXT.yesterday;
}
const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1);
}
async function loadMore() {
if (loading) {
return;
}
loading = true;
failed = false;
try {
const batch = await getPresenceSessions(peerId, days, tz, {
limit: pageSize,
offset: sessions.length,
});
const seen = new Set(sessions.map((session) => session.started));
extra = [
...extra,
...batch.filter((session) => !seen.has(session.started)),
];
done = batch.length < pageSize;
} catch {
failed = true;
} finally {
loading = false;
}
}
</script>
{#if sessions.length === 0}
<p class="state">{TEXT.none}</p>
{:else}
{#each groups as group (group.label)}
<h4 class="day">{group.label}</h4>
<ul>
{#each group.sessions as session (session.started)}
<li>
<span>
{session.ongoing
? TEXT.since(formatTime(session.started))
: TEXT.span(
formatTime(session.started),
formatTime(session.ended)
)}
</span>
<span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
</span>
</li>
{/each}
</ul>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
</div>
{:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
{/if}
{/if}
<style lang="scss">
.day {
margin: 0;
padding: 0.625rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
}
.length {
font-size: 0.875rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 1.5rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
interface Props {
volume: VolumeBucket[];
@@ -7,47 +8,104 @@
const { volume }: Props = $props();
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
function barTitle(bucket: VolumeBucket): string {
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const first = $derived(volume[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
</script>
<div class="volume">
<div class="bars">
{#each volume as bucket (bucket.bucket)}
<div
class="bar"
title={barTitle(bucket)}
style:height={`${(bucket.total / max) * 100}%`}
>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
</div>
{/each}
<div class="chart">
<span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}>
{#each volume as bucket (bucket.bucket)}
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
<div
class={["bar", { zero: bucket.total === 0 }]}
style:height="{(bucket.total / peak) * 100}%"
>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
</div>
</div>
{/each}
</div>
</div>
{#if first}
<div class="axis">
<span>{label(first.bucket)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
<div class="legend">
<span class="key"><span class="swatch out"></span>исходящие</span>
<span class="key"><span class="swatch in"></span>входящие</span>
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
</div>
</div>
<style lang="scss">
.volume {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars {
display: flex;
gap: 3px;
align-items: flex-end;
gap: 1px;
height: 7.5rem;
height: 6rem;
&.dense {
gap: 1px;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
}
.bar {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
min-height: 2px;
overflow: hidden;
border-radius: 2px 2px 0 0;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
}
.seg.out {
@@ -55,8 +113,18 @@
}
.seg.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
background-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
}
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.legend {
@@ -69,8 +137,8 @@
.key {
display: flex;
align-items: center;
gap: 0.375rem;
align-items: center;
}
.swatch {
@@ -83,8 +151,11 @@
}
&.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
background-color: color-mix(
in srgb,
var(--color-primary) 40%,
transparent
);
}
}
</style>