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:
1 parent
f44597b2dc
commit
73b8903de1
11 files changed
+1259
-357
No files matched your search
@@ -28,8 +28,9 @@ import type {
|
||||
PolicyRecord,
|
||||
PolicyRule,
|
||||
PolicyScope,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
PresenceSession,
|
||||
PresenceStats,
|
||||
ReactionView,
|
||||
ReadInfo,
|
||||
ResponseStats,
|
||||
@@ -206,30 +207,38 @@ export function getCurrentPresence(
|
||||
});
|
||||
}
|
||||
|
||||
export function getPresenceHistory(
|
||||
export function getPresenceStats(
|
||||
peerId: number,
|
||||
days: number,
|
||||
tz: string,
|
||||
page: Page = {}
|
||||
): Promise<PresenceSample[]> {
|
||||
return request<PresenceSample[]>("/presence", {
|
||||
): Promise<PresenceStats> {
|
||||
return request<PresenceStats>("/presence/stats", {
|
||||
account: true,
|
||||
query: { peer_id: peerId, ...page },
|
||||
query: { days, peer_id: peerId, tz, ...page },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
|
||||
return request<PresenceHourly[]>("/presence/hourly", {
|
||||
export function getPresenceSessions(
|
||||
peerId: number,
|
||||
days: number,
|
||||
tz: string,
|
||||
page: Page = {}
|
||||
): Promise<PresenceSession[]> {
|
||||
return request<PresenceSession[]>("/presence/sessions", {
|
||||
account: true,
|
||||
query: { peer_id: peerId },
|
||||
query: { days, peer_id: peerId, tz, ...page },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessageVolume(
|
||||
chatId: number,
|
||||
days = 90
|
||||
days: number,
|
||||
tz: string
|
||||
): Promise<VolumeBucket[]> {
|
||||
return request<VolumeBucket[]>("/analytics/volume", {
|
||||
account: true,
|
||||
query: { chat_id: chatId, days },
|
||||
query: { chat_id: chatId, days, tz },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -336,12 +336,57 @@ export interface PresenceSample {
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface PresenceHourly {
|
||||
bucket: string;
|
||||
export interface PresenceSession {
|
||||
ended: string;
|
||||
ongoing: boolean;
|
||||
online_seconds: number;
|
||||
seconds: number;
|
||||
started: string;
|
||||
}
|
||||
|
||||
export interface PresenceHour {
|
||||
covered_hours: number;
|
||||
hour: number;
|
||||
hours: number;
|
||||
online_minutes: number | null;
|
||||
weekday: number;
|
||||
}
|
||||
|
||||
export interface PresenceDay {
|
||||
covered_seconds: number;
|
||||
day: string;
|
||||
online_seconds: number;
|
||||
}
|
||||
|
||||
export interface CoverageGap {
|
||||
ended: string;
|
||||
started: string;
|
||||
}
|
||||
|
||||
export interface PresenceSummary {
|
||||
covered_seconds: number;
|
||||
daily_seconds: number | null;
|
||||
first_online: number | null;
|
||||
last_offline: number | null;
|
||||
last_seen: string | null;
|
||||
online_samples: number;
|
||||
median_session_seconds: number | null;
|
||||
online_seconds: number;
|
||||
session_count: number;
|
||||
}
|
||||
|
||||
export interface PresenceStats {
|
||||
current: PresenceSample | null;
|
||||
days: PresenceDay[];
|
||||
gaps: CoverageGap[];
|
||||
hidden: boolean;
|
||||
online: boolean;
|
||||
peer_id: number;
|
||||
samples: number;
|
||||
sessions: PresenceSession[];
|
||||
since: string;
|
||||
summary: PresenceSummary;
|
||||
tz: string;
|
||||
until: string;
|
||||
week: PresenceHour[];
|
||||
}
|
||||
|
||||
export interface VolumeBucket {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
enqueueBackfill,
|
||||
getCurrentPresence,
|
||||
getPeer,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type { PeerView, PresenceSample } from "#lib/api/types.js";
|
||||
import { enqueueBackfill, getPeer } from "#lib/api/endpoints.js";
|
||||
import type { PeerView } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||
@@ -16,6 +12,7 @@
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { discover } from "#lib/stores/discover.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { presence } from "#lib/stores/presence.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -26,13 +23,10 @@
|
||||
|
||||
let { chatId }: Props = $props();
|
||||
|
||||
const PRESENCE_INTERVAL = 30_000;
|
||||
|
||||
const isDm = $derived(chatId > 0);
|
||||
const chat = $derived(chats.byId(chatId));
|
||||
const discovered = $derived(discover.get(chatId));
|
||||
let peer = $state<PeerView | null>(null);
|
||||
let presence = $state<PresenceSample | null>(null);
|
||||
let backfilling = $state(false);
|
||||
|
||||
async function backfill() {
|
||||
@@ -81,34 +75,8 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null || !isDm) {
|
||||
presence = null;
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
presence = null;
|
||||
const refresh = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
getCurrentPresence(chatId)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
presence = result;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
presence = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const timer = setInterval(refresh, PRESENCE_INTERVAL);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
presence.follow(accounts.selectedId, isDm ? chatId : null);
|
||||
return () => presence.follow(null, null);
|
||||
});
|
||||
|
||||
const fallbackTitle = $derived(
|
||||
@@ -119,8 +87,8 @@
|
||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||
const subtitle = $derived.by(() => {
|
||||
if (isDm) {
|
||||
if (presence) {
|
||||
return formatPresence(presence);
|
||||
if (presence.sample) {
|
||||
return formatPresence(presence.sample, presence.now);
|
||||
}
|
||||
if (peer?.username) {
|
||||
return `@${peer.username}`;
|
||||
@@ -176,7 +144,12 @@
|
||||
/>
|
||||
<div class="info">
|
||||
<h2 class="title">{title}</h2>
|
||||
<span class="subtitle" class:online={isDm && isOnline(presence)}>
|
||||
<span
|
||||
class={[
|
||||
"subtitle",
|
||||
{ online: isDm && isOnline(presence.sample, presence.now) },
|
||||
]}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
if (seconds < SECONDS_IN_MINUTE) {
|
||||
return `${Math.round(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_HOUR) {
|
||||
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
|
||||
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_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">
|
||||
<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>
|
||||
|
||||
{#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 tracked && !stats.hidden}
|
||||
{#if rows.length > 0}
|
||||
<section>
|
||||
<h3>Объём сообщений (90 дней)</h3>
|
||||
{#if volume.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
|
||||
<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>
|
||||
<div class="section-title"><h3>{TEXT.messages}</h3></div>
|
||||
{#if quiet}
|
||||
<p class="state">{TEXT.noMessages}</p>
|
||||
{:else}
|
||||
<VolumeChart {volume} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{#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="stat">
|
||||
<span class="value"
|
||||
>{formatDuration(response.their_median_seconds)}</span
|
||||
>
|
||||
<span class="caption">мне отвечают ({response.their_count})</span>
|
||||
<div class="row">
|
||||
<dt>{TEXT.theirs}</dt>
|
||||
<dd>{reply(response.their_median_seconds)}</dd>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if isDm}
|
||||
{#if isDm && stats && tracked && !stats.hidden}
|
||||
<section>
|
||||
<div class="status" class:online={current?.status === "online"}>
|
||||
{current ? formatPresence(current) : "нет данных"}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<section>
|
||||
<h3>Когда в сети</h3>
|
||||
{#if hourly.length === 0}
|
||||
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
|
||||
{:else}
|
||||
<PresenceHeatmap {hourly} />
|
||||
{/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}
|
||||
</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 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 grid = $derived.by(() => {
|
||||
const cells: Cell[][] = DAYS.map(() =>
|
||||
HOURS.map(() => ({ online: 0, 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)
|
||||
)
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
const peak = $derived(
|
||||
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
|
||||
);
|
||||
const hasGaps = $derived(week.some(missing));
|
||||
|
||||
function intensity(cell: Cell): number {
|
||||
return cell.total > 0 ? cell.online / cell.total : 0;
|
||||
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);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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">
|
||||
<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"
|
||||
title={barTitle(bucket)}
|
||||
style:height={`${(bucket.total / max) * 100}%`}
|
||||
class={["bar", { zero: bucket.total === 0 }]}
|
||||
style:height="{(bucket.total / peak) * 100}%"
|
||||
>
|
||||
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
|
||||
<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;
|
||||
height: 6rem;
|
||||
|
||||
&.dense {
|
||||
gap: 1px;
|
||||
height: 7.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -9,19 +9,25 @@ const OPTIONS = {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
longDay: { day: "numeric", month: "long", weekday: "long" },
|
||||
offset: { timeZoneName: "shortOffset" },
|
||||
time: { hour: "2-digit", minute: "2-digit" },
|
||||
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
||||
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function format(kind: keyof typeof OPTIONS, date: Date): string {
|
||||
function formatter(kind: keyof typeof OPTIONS): Intl.DateTimeFormat {
|
||||
const key = `${locale.tag}:${kind}`;
|
||||
let formatter = formatters.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
||||
formatters.set(key, formatter);
|
||||
let cached = formatters.get(key);
|
||||
if (!cached) {
|
||||
cached = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
||||
formatters.set(key, cached);
|
||||
}
|
||||
return formatter.format(date);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function format(kind: keyof typeof OPTIONS, date: Date): string {
|
||||
return formatter(kind).format(date);
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
@@ -47,3 +53,24 @@ export function formatListDate(iso: string | null): string {
|
||||
}
|
||||
return format("day", date);
|
||||
}
|
||||
|
||||
export function formatLongDay(date: Date): string {
|
||||
return format("longDay", date);
|
||||
}
|
||||
|
||||
export function parseDay(value: string): Date {
|
||||
const [year, month, date] = value.split("-").map(Number);
|
||||
return new Date(year ?? 0, (month ?? 1) - 1, date ?? 1);
|
||||
}
|
||||
|
||||
export function browserTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
export function timeZoneLabel(): string {
|
||||
return (
|
||||
formatter("offset")
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,117 @@
|
||||
import type { PresenceSample } from "#lib/api/types.js";
|
||||
import { formatListDate } from "#lib/format/datetime.js";
|
||||
import { formatDay, formatTime } from "#lib/format/datetime.js";
|
||||
import { plural } from "#lib/format/shares.js";
|
||||
|
||||
export function isOnline(sample: PresenceSample | null): boolean {
|
||||
if (sample === null || sample.status !== "online") {
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
const MINUTES_IN_HOUR = 60;
|
||||
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 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<string, string> = {
|
||||
last_month: TEXT.lastMonth,
|
||||
last_week: TEXT.lastWeek,
|
||||
long_time_ago: TEXT.longAgo,
|
||||
recently: TEXT.recently,
|
||||
};
|
||||
|
||||
export function isOnline(
|
||||
sample: PresenceSample | null,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
if (sample?.status !== "online" || sample.next_offline_date === null) {
|
||||
return false;
|
||||
}
|
||||
if (sample.next_offline_date === null) {
|
||||
return false;
|
||||
}
|
||||
return new Date(sample.next_offline_date).getTime() > Date.now();
|
||||
return new Date(sample.next_offline_date).getTime() > now;
|
||||
}
|
||||
|
||||
export function formatPresence(sample: PresenceSample): string {
|
||||
switch (sample.status) {
|
||||
case "online":
|
||||
return isOnline(sample)
|
||||
? "online"
|
||||
: lastSeen(sample.last_online_date ?? sample.ts);
|
||||
case "recently":
|
||||
return "last seen recently";
|
||||
case "last_week":
|
||||
return "last seen within a week";
|
||||
case "last_month":
|
||||
return "last seen within a month";
|
||||
case "long_time_ago":
|
||||
return "last seen a long time ago";
|
||||
default:
|
||||
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;
|
||||
}
|
||||
if (minutes < MINUTES_IN_HOUR) {
|
||||
return TEXT.ago(minutes);
|
||||
}
|
||||
const time = formatTime(iso);
|
||||
const today = new Date(now);
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return TEXT.today(time);
|
||||
}
|
||||
if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
|
||||
return TEXT.yesterday(time);
|
||||
}
|
||||
return TEXT.on(formatDay(iso), time);
|
||||
}
|
||||
|
||||
export function formatPresence(
|
||||
sample: PresenceSample,
|
||||
now = Date.now()
|
||||
): string {
|
||||
if (sample.status === "online") {
|
||||
return isOnline(sample, now)
|
||||
? TEXT.online
|
||||
: lastSeen(sample.next_offline_date ?? sample.ts, now);
|
||||
}
|
||||
const fuzzy = FUZZY[sample.status];
|
||||
if (fuzzy) {
|
||||
return fuzzy;
|
||||
}
|
||||
return sample.last_online_date
|
||||
? lastSeen(sample.last_online_date)
|
||||
: "offline";
|
||||
}
|
||||
? lastSeen(sample.last_online_date, now)
|
||||
: TEXT.offline;
|
||||
}
|
||||
|
||||
function lastSeen(date: string): string {
|
||||
return `last seen ${formatListDate(date)}`;
|
||||
export function formatSpan(seconds: number): string {
|
||||
if (seconds < SECONDS_IN_MINUTE) {
|
||||
return TEXT.seconds(Math.round(seconds));
|
||||
}
|
||||
const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
|
||||
if (minutes < MINUTES_IN_HOUR) {
|
||||
return TEXT.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);
|
||||
}
|
||||
|
||||
export function formatClock(secondsOfDay: number): string {
|
||||
const hours = Math.floor(secondsOfDay / SECONDS_IN_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(secondsOfDay % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE
|
||||
);
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatLongSpan(seconds: number): string {
|
||||
if (seconds < 2 * SECONDS_IN_DAY) {
|
||||
return formatSpan(seconds);
|
||||
}
|
||||
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)}`;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getCurrentPresence } from "#lib/api/endpoints.js";
|
||||
import type { PresenceSample } from "#lib/api/types.js";
|
||||
import { events } from "#lib/stores/events.svelte.js";
|
||||
|
||||
const CLOCK_INTERVAL = 30_000;
|
||||
|
||||
function createPresence() {
|
||||
let peerId: number | null = null;
|
||||
let key: string | null = null;
|
||||
let sample = $state<PresenceSample | null>(null);
|
||||
let now = $state(Date.now());
|
||||
let clock: ReturnType<typeof setInterval> | undefined;
|
||||
let token = 0;
|
||||
|
||||
async function refresh() {
|
||||
const id = peerId;
|
||||
token += 1;
|
||||
const current = token;
|
||||
if (id === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await getCurrentPresence(id);
|
||||
if (current === token) {
|
||||
sample = next;
|
||||
now = Date.now();
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
sample = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.subscribe((event) => {
|
||||
if (event.type === "presence" && event.peer_id === peerId) {
|
||||
token += 1;
|
||||
({ sample } = event);
|
||||
now = Date.now();
|
||||
}
|
||||
});
|
||||
events.onReconnect(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
return {
|
||||
follow(accountId: number | null, id: number | null) {
|
||||
const next =
|
||||
accountId === null || id === null ? null : `${accountId}:${id}`;
|
||||
if (next === key) {
|
||||
return;
|
||||
}
|
||||
key = next;
|
||||
peerId = next === null ? null : id;
|
||||
sample = null;
|
||||
clearInterval(clock);
|
||||
if (peerId !== null) {
|
||||
clock = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, CLOCK_INTERVAL);
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
get now(): number {
|
||||
return now;
|
||||
},
|
||||
get sample(): PresenceSample | null {
|
||||
return sample;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const presence = createPresence();
|
||||
Reference in new issue
Block a user