feat: web UI chat render, panels, presence + analytics

This commit is contained in:
hh committed 2026-05-31 19:41:37 +02:00
1 parent 75425d1bee
commit ed469ba8dd
83 files changed
+6034 -136

No files matched your search

@@ -0,0 +1,294 @@
<script lang="ts">
import { page } from "$app/state";
import {
getCurrentPresence,
getMessageVolume,
getPresenceHistory,
getPresenceHourly,
getResponseStats,
} from "$lib/api/endpoints";
import type {
PresenceHourly,
PresenceSample,
ResponseStats,
VolumeBucket,
} from "$lib/api/types";
import PresenceHeatmap from "$lib/components/presence/PresenceHeatmap.svelte";
import VolumeChart from "$lib/components/presence/VolumeChart.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import { formatFull } from "$lib/format/datetime";
import { formatPresence } from "$lib/format/presence";
import { accounts } from "$lib/stores/accounts.svelte";
import { events } from "$lib/stores/events.svelte";
const HISTORY_LIMIT = 200;
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
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 response = $state<ResponseStats | null>(null);
let loading = $state(false);
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;
}
}
return result;
});
function formatDuration(seconds: number | null): string {
if (seconds === null) {
return "—";
}
if (seconds < SECONDS_IN_MINUTE) {
return `${Math.round(seconds)} с`;
}
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;
}
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 title="Аналитика" description="Откройте чат" />
{:else}
<div class="analytics">
<section>
<h3>Объём сообщений (90 дней)</h3>
{#if volume.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</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>
</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 isDm}
<section>
<div class="status" class:online={current?.status === "online"}>
{current ? formatPresence(current) : "нет данных"}
</div>
</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}
<style lang="scss">
.analytics {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.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;
color: var(--color-text-secondary);
}
.status {
font-size: 0.9375rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
.muted {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.changes {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.changes li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.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);
}
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex-shrink: 0;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { PresenceHourly } from "$lib/api/types";
interface Props {
hourly: PresenceHourly[];
}
const { hourly }: 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;
}
}
return cells;
});
function intensity(cell: Cell): number {
return cell.total > 0 ? cell.online / cell.total : 0;
}
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`;
}
</script>
<div class="heatmap">
{#each grid as row, day (day)}
<div class="heat-row">
<span class="day-label">{DAYS[day]}</span>
<div class="cells">
{#each row as cell, hour (hour)}
<span
class="cell"
class:empty={cell.total === 0}
style:--i={intensity(cell)}
title={cellTitle(day, hour, cell)}
></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>
{/each}
</div>
</div>
</div>
<style lang="scss">
.heatmap {
display: flex;
flex-direction: column;
gap: 3px;
}
.heat-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.day-label {
width: 1.5rem;
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--color-text-secondary);
text-align: right;
}
.cells {
display: grid;
flex: 1;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.cell {
aspect-ratio: 1;
border-radius: 2px;
background-color: var(--color-primary);
opacity: calc(0.12 + 0.88 * var(--i));
&.empty {
background-color: var(--color-text-secondary);
opacity: 0.08;
}
}
.tick {
font-size: 0.625rem;
color: var(--color-text-secondary);
text-align: left;
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { VolumeBucket } from "$lib/api/types";
interface Props {
volume: VolumeBucket[];
}
const { volume }: Props = $props();
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
function barTitle(bucket: VolumeBucket): string {
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
}
</script>
<div class="volume">
<div class="bars">
{#each volume as bucket (bucket.bucket)}
<div
class="bar"
style:height={`${(bucket.total / max) * 100}%`}
title={barTitle(bucket)}
>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
</div>
{/each}
</div>
<div class="legend">
<span class="key"><span class="swatch out"></span>исходящие</span>
<span class="key"><span class="swatch in"></span>входящие</span>
</div>
</div>
<style lang="scss">
.bars {
display: flex;
align-items: flex-end;
gap: 1px;
height: 7.5rem;
}
.bar {
display: flex;
flex: 1;
flex-direction: column;
min-height: 2px;
overflow: hidden;
border-radius: 2px 2px 0 0;
}
.seg.out {
background-color: var(--color-primary);
}
.seg.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
}
.legend {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.key {
display: flex;
align-items: center;
gap: 0.375rem;
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.out {
background-color: var(--color-primary);
}
&.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
}
}
</style>