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,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>