mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-28 16:31:13 +08:00
feat(quota): add a shared minute clock and signed relative-time formatting
Relative time goes stale on its own — nothing re-renders when a minute passes, so a long-lived tab silently lies. The naive fix is a timer per component, which on the quota page means one per card body. Adds one app-wide clock instead, started on the first subscriber and cleared on the last, exposed through useNow(). QuotaTimeline drops its private interval and joins it, so the chart and the cards above it tick in lockstep. Also extracts the timeline's private relative-time formatter into a shared, testable one. It is now signed: the old version clamped with Math.max(0, …), so a credit that expired last week read "in 1 minute". Duration constants and the MM-DD HH:mm shape are deduplicated into utils/time/durations.ts and formatInstantShort, which the existing formatQuotaResetTime / formatUnixSeconds now delegate to.
This commit is contained in:
@@ -12,10 +12,11 @@
|
||||
* quotaTimeline.ts.)
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TYPE_COLORS } from '@/utils/quota';
|
||||
import { formatRelativeInstant, TYPE_COLORS } from '@/utils/quota';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import type { ResolvedTheme, ThemeColors } from '@/types';
|
||||
import {
|
||||
buildTimelineLane,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
projectResetCredits,
|
||||
timelineSpan,
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
} from '../quotaTimelineModel';
|
||||
import type { TimelineLane, TimelineMode } from '../quotaTimelineModel';
|
||||
import type { QuotaFileEntry } from '../logic';
|
||||
@@ -43,19 +43,6 @@ const formatTime = (ms: number) => {
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (expiresAtMs: number, now: number, locale?: string) => {
|
||||
const remainingMs = Math.max(0, expiresAtMs - now);
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always' });
|
||||
|
||||
if (remainingMs >= DAY_MS) {
|
||||
return formatter.format(Math.ceil(remainingMs / DAY_MS), 'day');
|
||||
}
|
||||
if (remainingMs >= HOUR_MS) {
|
||||
return formatter.format(Math.ceil(remainingMs / HOUR_MS), 'hour');
|
||||
}
|
||||
return formatter.format(Math.max(1, Math.ceil(remainingMs / 60_000)), 'minute');
|
||||
};
|
||||
|
||||
export interface QuotaTimelineProps {
|
||||
entries: QuotaFileEntry[];
|
||||
/**
|
||||
@@ -84,17 +71,11 @@ export function QuotaTimeline({
|
||||
const [mode, setMode] = useState<TimelineMode>(initialMode);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// The clock is state, not a read during render: bars are classified
|
||||
// past/live/next against it and the marker is positioned by it, so it has to
|
||||
// advance on its own or the chart quietly goes stale on a long-lived tab.
|
||||
// A minute is finer than any window boundary here (the shortest is 5 hours).
|
||||
const [tick, setTick] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (nowProp !== undefined) return; // fixed clock: tests and screenshots
|
||||
const id = setInterval(() => setTick(Date.now()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [nowProp]);
|
||||
|
||||
// The clock has to advance on its own: bars are classified past/live/next
|
||||
// against it and the marker is positioned by it, so a long-lived tab would
|
||||
// quietly go stale. Shared app-wide so the cards above tick in lockstep with
|
||||
// the chart rather than each running its own timer.
|
||||
const tick = useNow(nowProp === undefined); // fixed clock: tests and screenshots
|
||||
const now = nowProp ?? tick;
|
||||
|
||||
const span = useMemo(() => timelineSpan(mode, offset, now), [mode, offset, now]);
|
||||
@@ -438,7 +419,7 @@ function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneP
|
||||
? `${grantedLabel}: ${formatDay(credit.grantedAtMs)} ${formatTime(credit.grantedAtMs)}`
|
||||
: null,
|
||||
`${expiresLabel}: ${formatDay(credit.expiresAtMs)} ${formatTime(credit.expiresAtMs)}`,
|
||||
formatRelativeTime(credit.expiresAtMs, now, i18n.resolvedLanguage),
|
||||
formatRelativeInstant(credit.expiresAtMs, now, i18n.resolvedLanguage),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* week, and no per-card percentage shows that.
|
||||
*/
|
||||
|
||||
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
|
||||
import type { QuotaProviderType } from './providers/types';
|
||||
|
||||
export const HOUR_MS = 3_600_000;
|
||||
export const DAY_MS = 24 * HOUR_MS;
|
||||
export { DAY_MS, HOUR_MS };
|
||||
|
||||
/** Weekly view spans a fortnight; the session view zooms to three days. */
|
||||
export type TimelineMode = 'weekly' | 'session';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Current instant, refreshed once a minute, from a clock shared app-wide.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { MINUTE_CLOCK } from '@/utils/time/sharedClock';
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
/**
|
||||
* Frozen snapshot for the disabled path and for SSR.
|
||||
*
|
||||
* Captured once at module load rather than per call: `useSyncExternalStore`
|
||||
* requires a stable snapshot, and `renderToStaticMarkup` (used by
|
||||
* tests/quotaTimelineRendering.test.ts) calls `getServerSnapshot`, so this
|
||||
* cannot be `Date.now`.
|
||||
*/
|
||||
const FROZEN_NOW = Date.now();
|
||||
const frozenSnapshot = () => FROZEN_NOW;
|
||||
|
||||
/**
|
||||
* @param enabled pass false to opt out of minute re-renders — the hook still
|
||||
* runs (rules of hooks) but subscribes to nothing and returns a frozen value.
|
||||
* Callers that only need `now` in one branch should gate here rather than
|
||||
* calling the hook conditionally.
|
||||
*/
|
||||
export function useNow(enabled = true): number {
|
||||
return useSyncExternalStore(
|
||||
enabled ? MINUTE_CLOCK.subscribe : noopSubscribe,
|
||||
enabled ? MINUTE_CLOCK.getSnapshot : frozenSnapshot,
|
||||
frozenSnapshot
|
||||
);
|
||||
}
|
||||
@@ -5,31 +5,20 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { CodexUsageWindow } from '@/types';
|
||||
import { normalizeNumberValue } from './parsers';
|
||||
import { formatInstantShort } from './relativeTime';
|
||||
|
||||
export function formatQuotaResetTime(value?: string): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const ms = new Date(value).getTime();
|
||||
if (Number.isNaN(ms)) return '-';
|
||||
return formatInstantShort(ms);
|
||||
}
|
||||
|
||||
export function formatUnixSeconds(value: number | null): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value * 1000);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const ms = value * 1000;
|
||||
if (Number.isNaN(ms)) return '-';
|
||||
return formatInstantShort(ms);
|
||||
}
|
||||
|
||||
export function formatCodexResetLabel(window?: CodexUsageWindow | null): string {
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './parsers';
|
||||
export * from './planTier';
|
||||
export * from './resolvers';
|
||||
export * from './formatters';
|
||||
export * from './relativeTime';
|
||||
export * from './validators';
|
||||
export * from './builders';
|
||||
export * from './resetCredits';
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Relative time beside absolute time.
|
||||
*
|
||||
* A quota card used to say `08-13 14:30` and leave the arithmetic to the
|
||||
* reader. The absolute instant is what you need to plan around; the relative
|
||||
* one ("in 11 days") is what you need to *react* to. Showing both costs one
|
||||
* short span and removes the mental subtraction, so every date on a card now
|
||||
* carries its own countdown.
|
||||
*
|
||||
* Pure and clock-free — `nowMs` is always passed in, so every case is directly
|
||||
* testable and the caller decides how often it ticks (see `useNow`).
|
||||
*/
|
||||
|
||||
import { DAY_MS, HOUR_MS, MINUTE_MS } from '@/utils/time/durations';
|
||||
|
||||
export interface RelativeTimeParts {
|
||||
/** Signed magnitude: positive is future, negative is past. */
|
||||
value: number;
|
||||
unit: 'day' | 'hour' | 'minute';
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarsest unit that still describes the gap, with a signed magnitude.
|
||||
*
|
||||
* Signed on purpose: the earlier timeline-local version clamped with
|
||||
* `Math.max(0, …)`, so a credit that expired last week read "in 1 minute" —
|
||||
* technically monotonic, actively misleading. Sub-minute gaps floor to a
|
||||
* magnitude of 1 so nothing ever renders "in 0 minutes".
|
||||
*/
|
||||
export function relativeTimeParts(targetMs: number, nowMs: number): RelativeTimeParts {
|
||||
const delta = targetMs - nowMs;
|
||||
const sign = delta < 0 ? -1 : 1;
|
||||
const abs = Math.abs(delta);
|
||||
|
||||
if (abs >= DAY_MS) return { value: sign * Math.ceil(abs / DAY_MS), unit: 'day' };
|
||||
if (abs >= HOUR_MS) return { value: sign * Math.ceil(abs / HOUR_MS), unit: 'hour' };
|
||||
return { value: sign * Math.max(1, Math.ceil(abs / MINUTE_MS)), unit: 'minute' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatters are cached per locale: this runs once per quota row per minute,
|
||||
* and constructing an Intl formatter is the expensive part.
|
||||
*/
|
||||
const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||||
|
||||
function getRelativeFormatter(locale?: string): Intl.RelativeTimeFormat {
|
||||
const key = locale ?? '';
|
||||
const cached = relativeFormatters.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
let formatter: Intl.RelativeTimeFormat;
|
||||
try {
|
||||
formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always' });
|
||||
} catch {
|
||||
// An unexpected `resolvedLanguage` throws RangeError rather than falling
|
||||
// back on its own. A countdown in the wrong language beats no card at all.
|
||||
formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'always' });
|
||||
}
|
||||
relativeFormatters.set(key, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/** Localized relative phrase, e.g. `in 11 days` / `11 天后` / `11 days ago`. */
|
||||
export function formatRelativeInstant(targetMs: number, nowMs: number, locale?: string): string {
|
||||
const { value, unit } = relativeTimeParts(targetMs, nowMs);
|
||||
return getRelativeFormatter(locale).format(value, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute instant in the shape every quota row already uses (`MM-DD HH:mm`,
|
||||
* browser-local, 24-hour). Kept in one place so the reset labels baked at fetch
|
||||
* time and the ones formatted at render time can never drift apart.
|
||||
*/
|
||||
export function formatInstantShort(ms: number): string {
|
||||
if (!Number.isFinite(ms)) return '-';
|
||||
return new Date(ms).toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResetDisplay {
|
||||
absolute: string;
|
||||
/** Null when no usable instant was available — render the absolute half alone. */
|
||||
relative: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair an already-formatted absolute label with a freshly computed relative one.
|
||||
*
|
||||
* The absolute label wins when present because providers bake it at fetch time
|
||||
* and some of them cannot be reproduced at render (`formatCodexResetLabel`
|
||||
* resolves `reset_after_seconds` against the fetch-time clock). The instant is
|
||||
* only used for the relative half, and its absence degrades to exactly the
|
||||
* pre-existing rendering — which is what a store entry cached by an older
|
||||
* build, with no `resetAtMs` field, will hit.
|
||||
*
|
||||
* Returns null when there is nothing worth rendering at all.
|
||||
*/
|
||||
export function buildResetDisplay(
|
||||
absoluteLabel: string | undefined | null,
|
||||
atMs: number | undefined | null,
|
||||
nowMs: number,
|
||||
locale?: string
|
||||
): ResetDisplay | null {
|
||||
const trimmed = typeof absoluteLabel === 'string' ? absoluteLabel.trim() : '';
|
||||
const absolute = trimmed && trimmed !== '-' ? trimmed : null;
|
||||
const usableMs = typeof atMs === 'number' && Number.isFinite(atMs) ? atMs : null;
|
||||
|
||||
if (absolute === null && usableMs === null) return null;
|
||||
|
||||
return {
|
||||
absolute: absolute ?? formatInstantShort(usableMs as number),
|
||||
relative: usableMs === null ? null : formatRelativeInstant(usableMs, nowMs, locale),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Duration constants in milliseconds. Single source for every time helper. */
|
||||
|
||||
export const MINUTE_MS = 60_000;
|
||||
export const HOUR_MS = 3_600_000;
|
||||
export const DAY_MS = 24 * HOUR_MS;
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* A single ticking clock shared by every component that renders a countdown.
|
||||
*
|
||||
* Relative time ("in 3 hours") goes stale on its own — nothing re-renders when
|
||||
* a minute passes, so a long-lived tab silently lies. The naive fix is a timer
|
||||
* per component, which on the quota page means one per card body, all firing at
|
||||
* slightly different offsets and each scheduling its own wake-up.
|
||||
*
|
||||
* This is one timer for the whole app instead, started when the first consumer
|
||||
* subscribes and cleared when the last one leaves. Idle pages pay nothing.
|
||||
*
|
||||
* React-free by design: the store contract (`subscribe` / `getSnapshot`) is
|
||||
* exactly what `useSyncExternalStore` wants, and the timer functions are
|
||||
* injectable so tests never touch a real clock.
|
||||
*/
|
||||
|
||||
import { MINUTE_MS } from './durations';
|
||||
|
||||
export interface SharedClock {
|
||||
subscribe(listener: () => void): () => void;
|
||||
/**
|
||||
* The current instant.
|
||||
*
|
||||
* MUST be referentially stable between ticks. Returning a fresh `Date.now()`
|
||||
* on every call makes React's `useSyncExternalStore` see a changed snapshot
|
||||
* on every render, warn ("The result of getSnapshot should be cached"), and
|
||||
* re-render forever. The value is therefore cached and only advanced by the
|
||||
* timer callback.
|
||||
*/
|
||||
getSnapshot(): number;
|
||||
}
|
||||
|
||||
export interface SharedClockOptions {
|
||||
intervalMs?: number;
|
||||
now?: () => number;
|
||||
setTimer?: (fn: () => void, ms: number) => unknown;
|
||||
clearTimer?: (id: unknown) => void;
|
||||
}
|
||||
|
||||
export interface TestableSharedClock extends SharedClock {
|
||||
/** Live subscriber count — exposed so tests can assert timer lifecycle. */
|
||||
subscriberCount(): number;
|
||||
}
|
||||
|
||||
export function createSharedClock(options: SharedClockOptions = {}): TestableSharedClock {
|
||||
const {
|
||||
intervalMs = MINUTE_MS,
|
||||
now = Date.now,
|
||||
setTimer = (fn, ms) => setInterval(fn, ms),
|
||||
clearTimer = (id) => clearInterval(id as ReturnType<typeof setInterval>),
|
||||
} = options;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let current = now();
|
||||
let timerId: unknown = null;
|
||||
|
||||
const tick = () => {
|
||||
current = now();
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (timerId === null) {
|
||||
// First subscriber: resynchronize before starting, so a clock that sat
|
||||
// idle for an hour doesn't hand out a stale snapshot for one interval.
|
||||
current = now();
|
||||
timerId = setTimer(tick, intervalMs);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && timerId !== null) {
|
||||
clearTimer(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
getSnapshot() {
|
||||
return current;
|
||||
},
|
||||
subscriberCount() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The app-wide minute clock. Import this rather than making another one. */
|
||||
export const MINUTE_CLOCK: SharedClock = createSharedClock();
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Relative-time formatting for quota cards.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildResetDisplay,
|
||||
formatInstantShort,
|
||||
formatQuotaResetTime,
|
||||
formatRelativeInstant,
|
||||
relativeTimeParts,
|
||||
} from '@/utils/quota';
|
||||
import { DAY_MS, HOUR_MS, MINUTE_MS } from '@/utils/time/durations';
|
||||
|
||||
const NOW = new Date(2026, 7, 2, 12, 0, 0).getTime();
|
||||
|
||||
describe('relativeTimeParts', () => {
|
||||
test('picks the coarsest unit that still describes the gap', () => {
|
||||
expect(relativeTimeParts(NOW + DAY_MS, NOW)).toEqual({ value: 1, unit: 'day' });
|
||||
expect(relativeTimeParts(NOW + DAY_MS - 1, NOW)).toEqual({ value: 24, unit: 'hour' });
|
||||
expect(relativeTimeParts(NOW + HOUR_MS, NOW)).toEqual({ value: 1, unit: 'hour' });
|
||||
expect(relativeTimeParts(NOW + HOUR_MS - 1, NOW)).toEqual({ value: 60, unit: 'minute' });
|
||||
expect(relativeTimeParts(NOW + 30_000, NOW)).toEqual({ value: 1, unit: 'minute' });
|
||||
});
|
||||
|
||||
test('rounds up, so a partial unit never reads as fewer', () => {
|
||||
expect(relativeTimeParts(NOW + 11 * DAY_MS + 1, NOW)).toEqual({ value: 12, unit: 'day' });
|
||||
expect(relativeTimeParts(NOW + 90 * MINUTE_MS, NOW)).toEqual({ value: 2, unit: 'hour' });
|
||||
});
|
||||
|
||||
test('past instants are negative rather than clamped to zero', () => {
|
||||
// The timeline-local predecessor clamped with Math.max(0, …), so an expired
|
||||
// credit read "in 1 minute".
|
||||
expect(relativeTimeParts(NOW - 3 * DAY_MS, NOW)).toEqual({ value: -3, unit: 'day' });
|
||||
expect(relativeTimeParts(NOW - 2 * HOUR_MS, NOW)).toEqual({ value: -2, unit: 'hour' });
|
||||
});
|
||||
|
||||
test('sub-minute magnitudes floor to 1 in both directions', () => {
|
||||
expect(relativeTimeParts(NOW + 1, NOW)).toEqual({ value: 1, unit: 'minute' });
|
||||
expect(relativeTimeParts(NOW, NOW)).toEqual({ value: 1, unit: 'minute' });
|
||||
expect(relativeTimeParts(NOW - 1, NOW)).toEqual({ value: -1, unit: 'minute' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeInstant', () => {
|
||||
test('renders in the requested locale', () => {
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'en')).toContain('days');
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'zh-CN')).toContain('天');
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'zh-TW')).toContain('天');
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'ru')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('distinguishes past from future', () => {
|
||||
const future = formatRelativeInstant(NOW + 3 * DAY_MS, NOW, 'en');
|
||||
const past = formatRelativeInstant(NOW - 3 * DAY_MS, NOW, 'en');
|
||||
expect(future).not.toBe(past);
|
||||
expect(past).toContain('ago');
|
||||
});
|
||||
|
||||
test('falls back instead of throwing on an unusable locale tag', () => {
|
||||
expect(() => formatRelativeInstant(NOW + DAY_MS, NOW, 'not-a-locale!!')).not.toThrow();
|
||||
expect(formatRelativeInstant(NOW + DAY_MS, NOW, 'not-a-locale!!')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInstantShort', () => {
|
||||
test('matches the shape the baked reset labels already use', () => {
|
||||
const iso = new Date(2026, 7, 13, 14, 30).toISOString();
|
||||
expect(formatInstantShort(new Date(iso).getTime())).toBe(formatQuotaResetTime(iso));
|
||||
});
|
||||
|
||||
test('degrades to a dash rather than "Invalid Date"', () => {
|
||||
expect(formatInstantShort(Number.NaN)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildResetDisplay', () => {
|
||||
test('pairs a baked absolute label with a computed relative one', () => {
|
||||
const display = buildResetDisplay('08-13 14:30', NOW + 11 * DAY_MS, NOW, 'en');
|
||||
expect(display).not.toBeNull();
|
||||
expect(display?.absolute).toBe('08-13 14:30');
|
||||
expect(display?.relative).toContain('11 days');
|
||||
});
|
||||
|
||||
test('keeps the baked label alone when the instant is missing', () => {
|
||||
// A store entry cached by an older build carries resetLabel but no resetAtMs.
|
||||
expect(buildResetDisplay('08-13 14:30', null, NOW, 'en')).toEqual({
|
||||
absolute: '08-13 14:30',
|
||||
relative: null,
|
||||
});
|
||||
expect(buildResetDisplay('08-13 14:30', undefined, NOW, 'en')?.relative).toBeNull();
|
||||
});
|
||||
|
||||
test('derives the absolute half from the instant when no label was baked', () => {
|
||||
const at = NOW + 2 * HOUR_MS;
|
||||
const display = buildResetDisplay(undefined, at, NOW, 'en');
|
||||
expect(display?.absolute).toBe(formatInstantShort(at));
|
||||
expect(display?.relative).toContain('2 hours');
|
||||
});
|
||||
|
||||
test('returns null when there is nothing to render', () => {
|
||||
expect(buildResetDisplay(undefined, null, NOW, 'en')).toBeNull();
|
||||
expect(buildResetDisplay('', null, NOW, 'en')).toBeNull();
|
||||
expect(buildResetDisplay(' ', null, NOW, 'en')).toBeNull();
|
||||
// '-' is the providers' own placeholder for "no reset known".
|
||||
expect(buildResetDisplay('-', null, NOW, 'en')).toBeNull();
|
||||
});
|
||||
|
||||
test('treats a placeholder label with a real instant as renderable', () => {
|
||||
const display = buildResetDisplay('-', NOW + DAY_MS, NOW, 'en');
|
||||
expect(display?.absolute).toBe(formatInstantShort(NOW + DAY_MS));
|
||||
expect(display?.relative).toContain('1 day');
|
||||
});
|
||||
|
||||
test('rejects a non-finite instant', () => {
|
||||
expect(buildResetDisplay(undefined, Number.NaN, NOW, 'en')).toBeNull();
|
||||
expect(buildResetDisplay('08-13 14:30', Number.POSITIVE_INFINITY, NOW, 'en')?.relative).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shared minute clock: snapshot stability and timer lifecycle.
|
||||
*
|
||||
* The stability case is the important one — a `getSnapshot` that returns a
|
||||
* fresh `Date.now()` makes React's useSyncExternalStore re-render forever.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createSharedClock } from '@/utils/time/sharedClock';
|
||||
|
||||
/** Deterministic stand-in for setInterval: fires only when the test says so. */
|
||||
function makeFakeTimers() {
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextId = 1;
|
||||
let created = 0;
|
||||
let cleared = 0;
|
||||
|
||||
return {
|
||||
created: () => created,
|
||||
cleared: () => cleared,
|
||||
active: () => timers.size,
|
||||
fireAll: () => timers.forEach((fn) => fn()),
|
||||
setTimer: (fn: () => void) => {
|
||||
created += 1;
|
||||
const id = nextId++;
|
||||
timers.set(id, fn);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: unknown) => {
|
||||
cleared += 1;
|
||||
timers.delete(id as number);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeClock(startAt = 1_000_000) {
|
||||
const timers = makeFakeTimers();
|
||||
let current = startAt;
|
||||
const clock = createSharedClock({
|
||||
intervalMs: 60_000,
|
||||
now: () => current,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
return { clock, timers, advance: (ms: number) => (current += ms) };
|
||||
}
|
||||
|
||||
describe('createSharedClock', () => {
|
||||
test('getSnapshot is referentially stable until a tick fires', () => {
|
||||
const { clock, timers, advance } = makeClock();
|
||||
clock.subscribe(() => {});
|
||||
|
||||
const first = clock.getSnapshot();
|
||||
advance(30_000);
|
||||
// Wall time moved but no tick fired — the snapshot must not.
|
||||
expect(clock.getSnapshot()).toBe(first);
|
||||
expect(clock.getSnapshot()).toBe(first);
|
||||
|
||||
timers.fireAll();
|
||||
expect(clock.getSnapshot()).toBe(first + 30_000);
|
||||
});
|
||||
|
||||
test('notifies every subscriber on a tick', () => {
|
||||
const { clock, timers, advance } = makeClock();
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
clock.subscribe(() => (a += 1));
|
||||
clock.subscribe(() => (b += 1));
|
||||
|
||||
advance(60_000);
|
||||
timers.fireAll();
|
||||
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
});
|
||||
|
||||
test('three subscribers share exactly one timer', () => {
|
||||
const { clock, timers } = makeClock();
|
||||
clock.subscribe(() => {});
|
||||
clock.subscribe(() => {});
|
||||
clock.subscribe(() => {});
|
||||
|
||||
expect(clock.subscriberCount()).toBe(3);
|
||||
expect(timers.created()).toBe(1);
|
||||
expect(timers.active()).toBe(1);
|
||||
});
|
||||
|
||||
test('clears the timer when the last subscriber leaves, and restarts after', () => {
|
||||
const { clock, timers } = makeClock();
|
||||
const off1 = clock.subscribe(() => {});
|
||||
const off2 = clock.subscribe(() => {});
|
||||
|
||||
off1();
|
||||
expect(timers.active()).toBe(1); // still one listener
|
||||
expect(timers.cleared()).toBe(0);
|
||||
|
||||
off2();
|
||||
expect(clock.subscriberCount()).toBe(0);
|
||||
expect(timers.active()).toBe(0);
|
||||
expect(timers.cleared()).toBe(1);
|
||||
|
||||
clock.subscribe(() => {});
|
||||
expect(timers.created()).toBe(2);
|
||||
expect(timers.active()).toBe(1);
|
||||
});
|
||||
|
||||
test('resynchronizes on the first subscribe so an idle clock is not stale', () => {
|
||||
const { clock, timers, advance } = makeClock();
|
||||
advance(3_600_000); // an hour passes with nobody watching
|
||||
|
||||
clock.subscribe(() => {});
|
||||
expect(clock.getSnapshot()).toBe(1_000_000 + 3_600_000);
|
||||
expect(timers.created()).toBe(1);
|
||||
});
|
||||
|
||||
test('unsubscribing twice does not clear a fresh timer', () => {
|
||||
const { clock, timers } = makeClock();
|
||||
const off = clock.subscribe(() => {});
|
||||
off();
|
||||
off();
|
||||
clock.subscribe(() => {});
|
||||
|
||||
expect(timers.cleared()).toBe(1);
|
||||
expect(timers.active()).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user