From 1b496e74d46a30ca7e0659a0fbf87dbefae6aa3a Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Wed, 23 Sep 2026 15:38:02 +0200 Subject: [PATCH] feat(library): show view count on published clips' cards The counts were already mirrored onto every clip row, so no per-page fetch: the card reads publisherViews off the row it already has. What was missing was freshness, since the sync only ran at boot and on the Publisher screen. The library now asks on open and on window focus, throttled in main to one request per ten minutes shared with every other caller, and patches the rows on screen rather than refetching. Null and zero both show nothing on a card: a card cannot tell "never opened" from "nothing was counting yet". Closes #63 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/actions/SyncPublisherStatsAction.ts | 74 +++++++++++++++---- src/main/routes/stats.ts | 11 ++- src/main/services/publisherStatsFreshness.ts | 20 +++++ .../src/components/Library/ClipCard.vue | 2 + .../src/components/Library/ClipMetadata.vue | 22 +++++- .../library/useLibraryViewCounts.ts | 73 ++++++++++++++++++ src/renderer/src/services/clips.ts | 16 ++-- src/renderer/src/stores/clips.ts | 18 +++++ src/renderer/src/utils/viewCount.ts | 20 +++++ src/renderer/src/views/ClipsPage.vue | 3 + .../unit/main/publisherStatsFreshness.spec.ts | 28 +++++++ tests/unit/renderer/viewCount.spec.ts | 33 +++++++++ 12 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 src/main/services/publisherStatsFreshness.ts create mode 100644 src/renderer/src/composables/library/useLibraryViewCounts.ts create mode 100644 src/renderer/src/utils/viewCount.ts create mode 100644 tests/unit/main/publisherStatsFreshness.spec.ts create mode 100644 tests/unit/renderer/viewCount.spec.ts diff --git a/src/main/actions/SyncPublisherStatsAction.ts b/src/main/actions/SyncPublisherStatsAction.ts index 1f149af6..9d5231e6 100644 --- a/src/main/actions/SyncPublisherStatsAction.ts +++ b/src/main/actions/SyncPublisherStatsAction.ts @@ -5,6 +5,7 @@ import { AppDataSource } from '../data-source.js'; import { Clip } from '../entity/Clip.js'; import { publisherAuthHeaders, publisherBaseUrl } from '../services/publisherConfig.js'; import { ClipDTO } from '@shared/index.js'; +import { statsAreStale } from '../services/publisherStatsFreshness.js'; export interface PublisherClipStat { filename: string; @@ -45,16 +46,38 @@ export interface SyncPublisherStatsOutput { * state never appeared. Null when the publisher answered. */ problem: PublisherStatsProblem | null; + /** + * True when nothing was asked because the last attempt is recent enough. + * + * The rest of the answer is then empty and means nothing; a caller patching + * rows on screen must leave them alone rather than read "no clips". + */ + skipped: boolean; } +export interface SyncPublisherStatsInput { + /** + * Ask only if the last attempt is older than this. Omitted means ask now, + * which is what boot and the Publisher screen want. + */ + maxAgeMs?: number; +} + +/** When the publisher was last asked, by anyone. See `statsAreStale`. */ +let lastAttemptAt: number | null = null; +/** One request at a time: a second caller waits for the one already out. */ +let inFlight: Promise | null = null; + /** * Bring the publisher's view counts back onto the rows. * - * **Pulled, not polled.** On app start and when the Publisher screen opens. A - * background timer refreshing a number that only matters while somebody is - * looking at it is cost with no reader, and every refresh is a request over - * somebody's home uplink to their own server. + * **Pulled, not polled.** On app start, when the Publisher screen opens, and + * when the library is looked at, throttled by `maxAgeMs` so the library's + * cards stay current without asking on every alt-tab. A background timer + * refreshing a number that only matters while somebody is looking at it is + * cost with no reader, and every refresh is a request over somebody's home + * uplink to their own server. * * Mirrored onto `clip` rather than fetched per tile for the same reason * `suggestedCount` is: the library is one query per page, and a remote lookup @@ -65,15 +88,27 @@ export interface SyncPublisherStatsOutput { * are "no numbers today" rather than an error, because nothing the user asked * for has failed. */ -export class SyncPublisherStatsAction extends BaseAction { - async execute(): Promise { - const empty: SyncPublisherStatsOutput = { - clips: [], - updated: 0, - totals: { clips: 0, bytes: 0, views: 0 }, - countingSince: null, - problem: null, - }; +export class SyncPublisherStatsAction extends BaseAction< + SyncPublisherStatsInput | void, + SyncPublisherStatsOutput +> { + async execute(input: SyncPublisherStatsInput | void): Promise { + if (inFlight) return inFlight; + + const maxAgeMs = input?.maxAgeMs; + if (maxAgeMs != null && !statsAreStale(lastAttemptAt, Date.now(), maxAgeMs)) { + return { ...emptyOutput(), skipped: true }; + } + + lastAttemptAt = Date.now(); + inFlight = this.sync().finally(() => { + inFlight = null; + }); + return inFlight; + } + + private async sync(): Promise { + const empty = emptyOutput(); const baseUrl = publisherBaseUrl(); if (!baseUrl) return empty; @@ -122,6 +157,7 @@ export class SyncPublisherStatsAction extends BaseAction { - const result = await new SyncPublisherStatsAction().execute(); +statsRouter.post('/publisher/sync', asyncHandler(async (req, res) => { + // The library passes `maxAgeMs` and is answered from the last sync when it + // is recent; the Publisher screen passes nothing and always asks. + const raw = Number(req.body?.maxAgeMs); + const maxAgeMs = Number.isFinite(raw) && raw > 0 ? raw : undefined; + const result = await new SyncPublisherStatsAction().execute({ maxAgeMs }); res.json(result); })); diff --git a/src/main/services/publisherStatsFreshness.ts b/src/main/services/publisherStatsFreshness.ts new file mode 100644 index 00000000..32bc18f0 --- /dev/null +++ b/src/main/services/publisherStatsFreshness.ts @@ -0,0 +1,20 @@ +/** + * Whether the mirrored view counts are old enough to ask the publisher again. + * + * The library asks every time it is opened and every time the window comes + * back into focus, which is often. One clock in main, shared by boot, the + * Publisher screen and the library, turns that into at most one request per + * window, and a burst of navigation into none at all. + * + * An attempt counts, not only a success: a publisher that is switched off is + * asked once per window rather than on every alt-tab. + * + * Pure so `tests/unit` owns it; the clock lives in the action. + */ +export function statsAreStale(lastAttemptAt: number | null, now: number, maxAgeMs: number): boolean { + if (lastAttemptAt == null) return true; + // A clock that went backwards (a sleep, a manual change) is not "fresh for + // ever": treat it as stale rather than trusting a negative age. + const age = now - lastAttemptAt; + return age < 0 || age >= maxAgeMs; +} diff --git a/src/renderer/src/components/Library/ClipCard.vue b/src/renderer/src/components/Library/ClipCard.vue index 5c8192cd..75ae18f5 100644 --- a/src/renderer/src/components/Library/ClipCard.vue +++ b/src/renderer/src/components/Library/ClipCard.vue @@ -443,6 +443,8 @@ function handleCardClick(event: MouseEvent) { :duration-sec="clip.durationSec" :game="gameDisplayName" :recorded-at="clip.recordedAt ?? clip.fileModifiedAt ?? clip.createdAt" + :views="clip.published ? clip.publisherViews : null" + :last-viewed-at="clip.publisherLastViewedAt" /> diff --git a/src/renderer/src/components/Library/ClipMetadata.vue b/src/renderer/src/components/Library/ClipMetadata.vue index 70b3a286..222ba537 100644 --- a/src/renderer/src/components/Library/ClipMetadata.vue +++ b/src/renderer/src/components/Library/ClipMetadata.vue @@ -3,6 +3,7 @@ import { ref, computed } from 'vue'; import { useConfiguration } from '@renderer/composables/app/useConfiguration'; import { formatRelativeTime, formatExactDate } from '@renderer/helpers/dateFormat'; import { useFormat } from '@renderer/composables/ui/useFormat'; +import { viewCountLabel } from '@renderer/utils/viewCount'; /** * When the clip was recorded, which is the file's own date. @@ -29,6 +30,12 @@ interface Props { */ durationSec?: number | null; recordedAt?: string; + /** + * How often the published copy has been opened. Null for a clip that is not + * published or has not been counted; `viewCountLabel` decides what is said. + */ + views?: number | null; + lastViewedAt?: string | null; } const props = defineProps(); @@ -47,6 +54,11 @@ const length = computed(() => { return hours ? `${hours}:${two(mins)}:${two(secs)}` : `${mins}:${two(secs)}`; }); +const viewsLabel = computed(() => viewCountLabel(props.views)); +const viewsHint = computed(() => + props.lastViewedAt ? `Last opened ${formatRelativeTime(props.lastViewedAt)}` : undefined, +); + const displayDate = computed(() => { // No date is no date. Falling back to now would put a confident, wrong // "just now" on a clip whose age is simply not known. @@ -60,7 +72,8 @@ const displayDate = computed(() => {