diff --git a/client/src/composables/useDeferredLoading.ts b/client/src/composables/useDeferredLoading.ts new file mode 100644 index 000000000..59ada84bf --- /dev/null +++ b/client/src/composables/useDeferredLoading.ts @@ -0,0 +1,64 @@ +import { onScopeDispose, ref, watch, type Ref } from 'vue' + +const SHOW_AFTER_MS = 220 +const MIN_VISIBLE_MS = 320 + +/** + * Gates a loading placeholder on how long the load actually takes. + * + * A skeleton that paints and clears inside a couple of frames is not feedback, it is a flash: the + * book detail tabs answer in well under 100ms locally, so binding shimmer straight to `loading` + * strobes the pane on every visit. This stays down until the wait is long enough to be worth + * announcing, and once raised it holds long enough to be read rather than blinking off again. + */ +export function useDeferredLoading(loading: Ref, options?: { showAfterMs?: number; minVisibleMs?: number }): Ref { + const showAfterMs = options?.showAfterMs ?? SHOW_AFTER_MS + const minVisibleMs = options?.minVisibleMs ?? MIN_VISIBLE_MS + + const visible = ref(false) + let showTimer: ReturnType | null = null + let hideTimer: ReturnType | null = null + let shownAt = 0 + + function clearTimers() { + if (showTimer !== null) { + clearTimeout(showTimer) + showTimer = null + } + if (hideTimer !== null) { + clearTimeout(hideTimer) + hideTimer = null + } + } + + watch( + loading, + (busy) => { + clearTimers() + if (busy) { + if (visible.value) return + showTimer = setTimeout(() => { + showTimer = null + shownAt = performance.now() + visible.value = true + }, showAfterMs) + return + } + if (!visible.value) return + const remaining = minVisibleMs - (performance.now() - shownAt) + if (remaining <= 0) { + visible.value = false + return + } + hideTimer = setTimeout(() => { + hideTimer = null + visible.value = false + }, remaining) + }, + { immediate: true }, + ) + + onScopeDispose(clearTimers) + + return visible +} diff --git a/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue b/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue index b999e56a8..ed8ef6a98 100644 --- a/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue +++ b/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue @@ -273,24 +273,38 @@ const formatLine = computed(() => const currentStatusOption = computed(() => STATUS_OPTIONS.find((option) => option.value === (localReadStatus.value ?? 'unread'))) +// A book with no sessions still answers with a stats object of zeros, so a null one means the +// first load has not landed. Printing its zeros claims the book is unread until the real count +// arrives; the dash the pace and last-read rows already use says "not known yet" instead. +const statsPending = computed(() => props.stats === null) + const ledgerRows = computed(() => [ { key: 'total', label: t('book.detail.readingLog.vitals.totalTime'), - value: formatDuration(props.stats?.totalSeconds ?? 0), + value: statsPending.value ? '-' : formatDuration(props.stats?.totalSeconds ?? 0), withMomentum: true, }, - { key: 'sessions', label: t('book.detail.readingLog.vitals.sessions'), value: formatNumber(props.stats?.totalSessions ?? 0), withMomentum: false }, + { + key: 'sessions', + label: t('book.detail.readingLog.vitals.sessions'), + value: statsPending.value ? '-' : formatNumber(props.stats?.totalSessions ?? 0), + withMomentum: false, + }, { key: 'average', label: t('book.detail.readingLog.vitals.avgSession'), - value: formatDuration(props.stats?.avgDurationSeconds ?? 0), + value: statsPending.value ? '-' : formatDuration(props.stats?.avgDurationSeconds ?? 0), withMomentum: false, }, { key: 'activeDays', label: t('book.detail.readingLog.vitals.activeDays'), - value: spanDays.value > 0 ? t('book.detail.readingLog.vitals.activeDaysOf', { active: activeDays.value, total: spanDays.value }) : '0', + value: statsPending.value + ? '-' + : spanDays.value > 0 + ? t('book.detail.readingLog.vitals.activeDaysOf', { active: activeDays.value, total: spanDays.value }) + : '0', withMomentum: false, }, { diff --git a/client/src/features/book/components/detail/tabs/HighlightsTab.vue b/client/src/features/book/components/detail/tabs/HighlightsTab.vue index 0ad2f41e2..4da0bf24e 100644 --- a/client/src/features/book/components/detail/tabs/HighlightsTab.vue +++ b/client/src/features/book/components/detail/tabs/HighlightsTab.vue @@ -1,11 +1,12 @@