feat: web UI chat render, panels, presence + analytics
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user