diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index ab933f9..39cde84 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -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 { - return request("/presence", { +): Promise { + return request("/presence/stats", { account: true, - query: { peer_id: peerId, ...page }, + query: { days, peer_id: peerId, tz, ...page }, }); } -export function getPresenceHourly(peerId: number): Promise { - return request("/presence/hourly", { +export function getPresenceSessions( + peerId: number, + days: number, + tz: string, + page: Page = {} +): Promise { + return request("/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 { return request("/analytics/volume", { account: true, - query: { chat_id: chatId, days }, + query: { chat_id: chatId, days, tz }, }); } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 85948a3..62d56d3 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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 { diff --git a/frontend/src/lib/components/ChatHeader.svelte b/frontend/src/lib/components/ChatHeader.svelte index cc80f53..512345e 100644 --- a/frontend/src/lib/components/ChatHeader.svelte +++ b/frontend/src/lib/components/ChatHeader.svelte @@ -1,10 +1,6 @@ {#if chatId === null} - + {:else}
-
-

Объём сообщений (90 дней)

- {#if volume.length === 0} -

{loading ? "Загрузка…" : "Нет сообщений"}

- {:else} - - {/if} -
+
+ {#each PERIODS as period (period)} + + {/each} +
-
-

Время ответа

- {#if response} -
-
- {formatDuration(response.mine_median_seconds)} - я отвечаю ({response.mine_count}) -
-
- {formatDuration(response.their_median_seconds)} - мне отвечают ({response.their_count}) -
-
- {:else} -

{loading ? "Загрузка…" : "Нет данных"}

- {/if} -
+ {#if failed} +
+

{TEXT.failed}

+ +
+ {:else if volume === null || (isDm && stats === null)} +
+ {:else} + {#if isDm && stats} +
+ {#if tracked && !stats.hidden} +
+ {formatSpan(stats.summary.online_seconds)} + {TEXT.total(shownDays)} +
+ {/if} + {#if sample} +

+ {formatPresence(sample, presence.now)} +

+ {/if} + {#if stats.hidden} +

{TEXT.hidden}

+ {:else if !tracked} +

{TEXT.noStatus}

+ {/if} +
- {#if isDm} -
-
- {current ? formatPresence(current) : "нет данных"} -
-
+ {#if tracked && !stats.hidden} + {#if rows.length > 0} +
+
+ {#each rows as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {/each} +
+
+ {/if} + +
+
+

{TEXT.byHour}

+ {zone} +
+ +
+ +
+

{TEXT.byDay}

+ +
+ {/if} + {/if}
-

Когда в сети

- {#if hourly.length === 0} -

{loading ? "Загрузка…" : "Пока нет данных"}

+

{TEXT.messages}

+ {#if quiet} +

{TEXT.noMessages}

{:else} - + + {/if} + {#if response && response.mine_count + response.their_count > 0} +
+
+
{TEXT.mine}
+
{reply(response.mine_median_seconds)}
+
+
+
{TEXT.theirs}
+
{reply(response.their_median_seconds)}
+
+
{/if}
-
-

История статусов

- {#if changes.length === 0} -

{loading ? "Загрузка…" : "Нет записей"}

- {:else} -
    - {#each changes as sample (sample.ts)} -
  • - - {formatPresence(sample)} - {formatFull(sample.ts)} -
  • - {/each} -
- {/if} -
+ {#if isDm && stats && tracked && !stats.hidden} +
+

{TEXT.sessions}

+ {#key `${chatId}:${shownDays}:${stats.until}`} + + {/key} +
+ {/if} {/if}
{/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; + } } diff --git a/frontend/src/lib/components/presence/OnlineDays.svelte b/frontend/src/lib/components/presence/OnlineDays.svelte new file mode 100644 index 0000000..47596fd --- /dev/null +++ b/frontend/src/lib/components/presence/OnlineDays.svelte @@ -0,0 +1,144 @@ + + +
+
+ {formatSpan(peak)} +
45 }]}> + {#each days as day, index (day.day)} +
+ +
+ {/each} +
+
+ {#if first} +
+ {label(first.day)} + {TEXT.today} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/presence/PresenceHeatmap.svelte b/frontend/src/lib/components/presence/PresenceHeatmap.svelte index ea03dff..22b98a4 100644 --- a/frontend/src/lib/components/presence/PresenceHeatmap.svelte +++ b/frontend/src/lib/components/presence/PresenceHeatmap.svelte @@ -1,116 +1,217 @@
- {#each grid as row, day (day)} -
- {DAYS[day]} + {#each rows as cells, weekday (weekday)} +
+ {TEXT.days[weekday]}
- {#each row as cell, hour (hour)} + {#each cells as cell (cell.hour)} {/each}
{/each} -
- -
- {#each HOURS as hour (hour)} - {HOUR_TICKS.has(hour) ? hour : ""} +
+ +
+ {#each HOUR_TICKS as hour (hour)} + {hour} {/each}
+
+ 0 + {#if peak > 0} + + + {TEXT.max(peak)} + + {/if} + {#if hasGaps} + + + {TEXT.empty} + + {/if} +
diff --git a/frontend/src/lib/components/presence/PresenceSessions.svelte b/frontend/src/lib/components/presence/PresenceSessions.svelte new file mode 100644 index 0000000..4632efb --- /dev/null +++ b/frontend/src/lib/components/presence/PresenceSessions.svelte @@ -0,0 +1,205 @@ + + +{#if sessions.length === 0} +

{TEXT.none}

+{:else} + {#each groups as group (group.label)} +

{group.label}

+
    + {#each group.sessions as session (session.started)} +
  • + + {session.ongoing + ? TEXT.since(formatTime(session.started)) + : TEXT.span( + formatTime(session.started), + formatTime(session.ended) + )} + + + {session.ongoing ? TEXT.now : formatSpan(session.seconds)} + +
  • + {/each} +
+ {/each} + {#if loading} +
+ {:else if failed} +
+

{TEXT.failed}

+ +
+ {:else if !exhausted} + + {/if} +{/if} + + diff --git a/frontend/src/lib/components/presence/VolumeChart.svelte b/frontend/src/lib/components/presence/VolumeChart.svelte index 7110e36..ab7cf13 100644 --- a/frontend/src/lib/components/presence/VolumeChart.svelte +++ b/frontend/src/lib/components/presence/VolumeChart.svelte @@ -1,5 +1,6 @@
-
- {#each volume as bucket (bucket.bucket)} -
-
-
-
- {/each} +
+ {peak} +
45 }]}> + {#each volume as bucket (bucket.bucket)} +
+
+
+
+
+
+ {/each} +
+ {#if first} +
+ {label(first.bucket)} + {TEXT.today} +
+ {/if}
- исходящие - входящие + {TEXT.incoming} + {TEXT.outgoing}
diff --git a/frontend/src/lib/format/datetime.ts b/frontend/src/lib/format/datetime.ts index a5c1551..ba5cb9f 100644 --- a/frontend/src/lib/format/datetime.ts +++ b/frontend/src/lib/format/datetime.ts @@ -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; const formatters = new Map(); -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() + ); +} diff --git a/frontend/src/lib/format/presence.ts b/frontend/src/lib/format/presence.ts index 8f28586..b36d7b0 100644 --- a/frontend/src/lib/format/presence.ts +++ b/frontend/src/lib/format/presence.ts @@ -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 = { + 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: - return sample.last_online_date - ? lastSeen(sample.last_online_date) - : "offline"; +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); } -function lastSeen(date: string): string { - return `last seen ${formatListDate(date)}`; +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, now) + : TEXT.offline; +} + +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)}`; } diff --git a/frontend/src/lib/stores/presence.svelte.ts b/frontend/src/lib/stores/presence.svelte.ts new file mode 100644 index 0000000..b8fb888 --- /dev/null +++ b/frontend/src/lib/stores/presence.svelte.ts @@ -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(null); + let now = $state(Date.now()); + let clock: ReturnType | 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();