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
+1275
-373
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user