diff --git a/docs/key-files.md b/docs/key-files.md index 1e77071a3..971f92927 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -169,7 +169,7 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/web/src/components/ui/AnchoredTooltip.tsx` | A portaled readout positioned against an arbitrary `anchor: Element`. Exists because a chart's per-bucket readout was an `absolute` panel inside the bar, and in the Explorer that bar sits in `CollapsibleStatCard`'s `overflow: hidden` reveal region — so it was CLIPPED at the card's edge exactly when the chart was worth reading. `z-index` cannot fix that; `overflow` clips descendants regardless of stacking, so the only fix is to stop being a descendant. It reuses the pure `computeTooltipPosition` exported by `Tooltip.tsx` verbatim rather than growing a second positioner. What it cannot reuse is `Tooltip`'s SHAPE: `Tooltip` wraps its own trigger and owns its own hover state, so it is one component, one portal and one listener set PER TRIGGER — 364 of each for a 52-week heatmap. This is the content-only half: the caller says which element is anchoring and when, and ONE instance serves every bucket. Holds the element, not a rect, because a `fixed` panel is in viewport coordinates and re-measures on capture-phase scroll/resize while open. Consumers: `dashboard/Sparkbar.tsx`, `activity/ContributionHeatmap.tsx` | | `packages/web/src/lib/persisted-preference.ts` (+ `lib/hooks/usePersistedPreference.ts`) | Per-viewer UI preferences — the pure codec plus the `localStorage` shell. Three call sites had each rolled their own read+parse and re-derived the same two questions: what does an ABSENT value mean, and what does a CORRUPT one mean (both: the default). `PREFERENCE_KEYS` is the single registry, so a writer and a reader can never disagree by one character. The hook is `useSyncExternalStore` over a module-level registry, mirroring `useMediaQuery` rather than `useDismissedInviteIds`: a mount-effect always paints the DEFAULT first and corrects on the next tick, which for a disclosure state is the exact "expanded, then snap shut" flash the feature exists to remove. **`getServerSnapshot` returns `null` and is its only producer** — "no client store consulted" is a DIFFERENT fact from `''` "consulted, nothing stored", and collapsing the two brings the flash back; consumers render their NEUTRAL state while unresolved. Subscribes to `storage`, so two tabs agree for free. Every access is inside `try/catch` (`localStorage` throws on the GETTER under blocked site data) and failure degrades to "does not persist", never to a thrown render. Covered by `persisted-preference.spec.ts` | | `packages/web/src/lib/explorer-insights-view.ts` | The Activity panel's two bodies as a closed union with its labels, accessible names, icons and defaults in ONE module — the `status-filter.ts` pattern. `charts` is the default because it answers a question about the CURRENT selection, which is what the scope chips, filter bar and range picker beside it have just been used to express; opening on the heatmap (deliberately account-wide and unfiltered) would make the panel look unresponsive to every control around it. `DEFAULT_INSIGHTS_OPEN = true` — it opened COLLAPSED while it showed both bodies at once; showing one at a time roughly halved the expanded panel, which is what made the evidence affordable by default. Covered by `explorer-insights-view.spec.ts` | -| `packages/web/src/lib/format-number.ts` | The dashboard's THREE number renderings, all pinned to `en-US` so a server render and its hydration cannot format one figure two ways: `formatExact` (grouped, and what `AnimatedNumber` always announces), `formatCompact` (a headline in a narrow slot, ≥10,000), and `formatPercentDelta` (a `TrendChip`'s signed change, ≥1,000). One private `compactAbove` parameterised by threshold AND `Intl` instance, so a `K` means the same thing everywhere — the property that breaks the moment a second call site rolls its own abbreviation. The percentage threshold is an order of magnitude lower because the slot is: a chip shares a line with the figure it annotates and carries a sign and a `%` the figure does not, so `+8834%` was seven characters in a gap sized for five and collided with a `22,425` beside it. 1000 and not lower, because `+100%` means "doubled" and must survive verbatim. The percentage uses `maximumSignificantDigits: 2`, not `maximumFractionDigits: 1`, which is unbounded in the hundreds band (`883.4K`, longer than what it is shortening). `isPercentDeltaAbbreviated` lets `TrendChip` expose the exact figure — via the `AnimatedNumber` two-node pattern plus the title it already had — ONLY where precision was actually dropped. Covered by `format-number.spec.ts` | +| `packages/web/src/lib/format-number.ts` | The dashboard's number renderings, all pinned to `en-US` so a server render and its hydration cannot format one figure two ways: `formatExact` (grouped, and what `AnimatedNumber` always announces), `formatHeadline` (a `StatCard` headline: exact below 10,000, three significant digits above — `1.57M` — so it never exceeds five characters and crowds the chip on its row; `isHeadlineAbbreviated` gates the exact-value hover title), `formatCompact` (a figure in a narrow slot, ≥10,000, ungrouped below), and `formatPercentDelta` (a `TrendChip`'s signed change, ≥1,000). One private `compactAbove` parameterised by threshold AND `Intl` instance, so a `K` means the same thing everywhere — the property that breaks the moment a second call site rolls its own abbreviation. The percentage threshold is an order of magnitude lower because the slot is: a chip shares a line with the figure it annotates and carries a sign and a `%` the figure does not, so `+8834%` was seven characters in a gap sized for five and collided with a `22,425` beside it. 1000 and not lower, because `+100%` means "doubled" and must survive verbatim. The percentage uses `maximumSignificantDigits: 2`, not `maximumFractionDigits: 1`, which is unbounded in the hundreds band (`883.4K`, longer than what it is shortening). `isPercentDeltaAbbreviated` lets `TrendChip` expose the exact figure — via the `AnimatedNumber` two-node pattern plus the title it already had — ONLY where precision was actually dropped. Covered by `format-number.spec.ts` | | `supabase/migrations/00103_reads_must_not_restamp_updated_at.sql` | The fix for the counter that was lying: `lorekit_record_memory_reads` (00099) bumped `read_count`/`last_read_at` with a plain UPDATE, which fires `lorekit_memories_set_updated_at`, so ONE 55-row `memory.list` page restamped 55 memories' `updated_at` with no write of any kind. `updated_at` is the DEFAULT SORT for the Explorer and `memory.list order=recency`, so the order agents received lore in was driven by what had recently been READ. Fixed inside the read-recording function via a session-local guard the trigger honours — not by dropping the trigger (writes need it) and not by making each caller restore the old value. Pinned by `migrations.test.sql` §101 | | `supabase/migrations/00104_opened_count_pull_through.sql` | `memories.opened_count` — the count behind `last_opened_at` (00099), and the numerator that makes lore value comparable. Every other counter is supply-side: `seen_count` is 1 for 88.4% of a live sample, and 99.80% of recorded reads are bulk ride-alongs so `read_count` ranks SCOPE BREADTH. **Pull-through is `opened_count / read_count`** — targeted opens over TOTAL reads, a proper fraction in [0,1] in which scope breadth appears in both halves and cancels. Bumped by the same read-recording function, under the same `updated_at` suppression 00103 added. Pinned by §102 | | `supabase/migrations/00105_max_opened_count_condition.sql` | The `max_opened_count` retention condition, whose `0` means what a reader expects — "no agent has ever reached for this" — and means the same for a `global` lesson as for a `branch` one. `max_read_count` (00101) could not: on a live 2,772-memory store `<= 5` matched nothing, the first non-empty result came at 26, and `<= 400` matched essentially everything, while the grooming UI suggested `0`. **Both are kept** — `max_read_count` answers "what is this COSTING me in context", a real and different question. Pinned by §103 | diff --git a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png index 8387861bc..6a1ae797f 100644 Binary files a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png index 7b2aca5cd..56f4ba65b 100644 Binary files a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png index 8642d8f99..cb8665299 100644 Binary files a/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/dashboard/StatCard.test.stories.tsx b/packages/web/src/components/dashboard/StatCard.test.stories.tsx index 6b4273340..21d9a3585 100644 --- a/packages/web/src/components/dashboard/StatCard.test.stories.tsx +++ b/packages/web/src/components/dashboard/StatCard.test.stories.tsx @@ -158,3 +158,72 @@ export const ChipAndHeadlineShareOneRowWithoutColliding: Story = { }); }, }; + +/** + * The reported overlap: in the Explorer's card (icon LEFT of the number, chip on + * the number's row) a `1,566,285` headline pushed `+10K%` past the card's edge. + * The headline now abbreviates from five digits up, so the two share the row at + * the card's real width — asserted from measured geometry. + */ +export const LargeHeadlineAbbreviatesAndLeavesTheChipRoom: Story = { + render: () => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const card = canvasElement.querySelector('[data-stat-card]') as HTMLElement; + const headline = canvas.getByText('1.57M'); + const chip = canvas.getByText('+10K%'); + + await step('the headline is abbreviated, the exact figure kept', async () => { + await expect(headline).toHaveAttribute('aria-hidden', 'true'); + const number = headline.closest('p'); + await expect(number?.querySelector('.sr-only')?.textContent).toBe('1,566,285'); + await expect(number).toHaveAttribute('title', '1,566,285'); + }); + + await step('the chip sits beside the headline, inside the card', async () => { + const cardBox = card.getBoundingClientRect(); + const headlineBox = headline.getBoundingClientRect(); + const chipBox = chip.getBoundingClientRect(); + await expect(chipBox.left).toBeGreaterThanOrEqual(headlineBox.right); + await expect(chipBox.right).toBeLessThanOrEqual(cardBox.right); + }); + }, +}; + +/** Below five digits nothing changed: exact, grouped, and no hover title. */ +export const SmallHeadlineStaysExact: Story = { + render: () => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const number = canvas.getByText('5,341', { selector: '[aria-hidden]' }).closest('p'); + await expect(number).not.toHaveAttribute('title'); + }, +}; diff --git a/packages/web/src/components/dashboard/StatCard.tsx b/packages/web/src/components/dashboard/StatCard.tsx index 1a7c28d75..466ca3ce1 100644 --- a/packages/web/src/components/dashboard/StatCard.tsx +++ b/packages/web/src/components/dashboard/StatCard.tsx @@ -23,7 +23,13 @@ import { Sparkbar } from '@/components/dashboard/Sparkbar'; import { AnimatedNumber } from '@/components/ui/AnimatedNumber'; import { Tooltip } from '@/components/ui/Tooltip'; import type { StatTrend } from '@/lib/aggregations'; -import { formatPercentDelta, isPercentDeltaAbbreviated } from '@/lib/format-number'; +import { + formatExact, + formatHeadline, + formatPercentDelta, + isHeadlineAbbreviated, + isPercentDeltaAbbreviated, +} from '@/lib/format-number'; /** The small uppercase dimension tag in a card's header. */ export function UnitTag({ label }: { label: string }) { @@ -209,13 +215,18 @@ export function StatCard({ // and the caption around it are all ≤12px, so the number carries the hierarchy // on its own. It COUNTS to a new value rather than swapping: see AnimatedNumber // for why that is a change indicator. + // + // From five digits up it abbreviates (`1.57M`), so it never outgrows the row it + // shares with the trend chip — a `1,566,285` pushed the chip off the card. The + // exact figure stays on hover here and in AnimatedNumber's announced twin. const numberEl = (

- +

); const labelEl = ( diff --git a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png index 1934b4cdb..009cfde94 100644 Binary files a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png index dec6850d6..3f01bb003 100644 Binary files a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png and b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/ExplorerStats.tsx b/packages/web/src/components/lore/ExplorerStats.tsx index 331f67e7f..3face79f8 100644 --- a/packages/web/src/components/lore/ExplorerStats.tsx +++ b/packages/web/src/components/lore/ExplorerStats.tsx @@ -47,6 +47,7 @@ import { useExplorerStats, } from '@/lib/queries/explorer-stats'; import { + comparisonWindow, computeCountTrend, computeRangeTrends, RANGE_BUCKETS, @@ -154,17 +155,24 @@ export function ExplorerStats({ [filters, retention], ); + // The absolute arm anchors its grid at its own end, not at the clock — see + // `gridAnchor`. + const gridNowIso = useMemo(() => gridAnchor(shown, nowIso), [shown, nowIso]); + // The trend chip's preceding period — see `comparisonWindow`. It takes the + // clock too: an absolute range can end after now, and nothing past now is charted. + const comparison = useMemo( + () => comparisonWindow(plan, gridNowIso, queryWindow, nowIso), + [plan, gridNowIso, queryWindow, nowIso], + ); + const { data, isLoading, isError, isFetching } = useExplorerStats( scope, activityFilters, plan.unit, queryWindow.since, queryWindow.until, + comparison, ); - - // The absolute arm anchors its grid at its own end, not at the clock — see - // `gridAnchor`. - const gridNowIso = useMemo(() => gridAnchor(shown, nowIso), [shown, nowIso]); const rows = data?.rows; const readBuckets = data?.readBuckets; const memoryTrends = useMemo( diff --git a/packages/web/src/components/lore/ExplorerStatsTrend.test.stories.tsx b/packages/web/src/components/lore/ExplorerStatsTrend.test.stories.tsx new file mode 100644 index 000000000..a6c48d94b --- /dev/null +++ b/packages/web/src/components/lore/ExplorerStatsTrend.test.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; +import { expect, waitFor, within } from 'storybook/test'; + +import { ExplorerInsights } from './ExplorerInsights'; +import { memoryHandlers, FROZEN_NOW } from '@/mocks/memories'; +import { withQueryClient, withFrozenClock } from '@/mocks/decorators'; +import { PREFERENCE_KEYS } from '@/lib/persisted-preference'; +import { NO_RETENTION_CONDITIONS } from '@/lib/retention-filter'; + +/** + * Regression: every Activity-panel trend chip read "+100%". + * + * The chip compares the charted window against the equal-length period before + * it, but the header only fetched the charted window, so the comparison half was + * always empty and growth-from-zero printed +100% on every card at every range. + * + * The default MSW handlers ignore `since`/`until`, which is exactly why no story + * caught it. These handlers honour the window the way the RPCs do (rows are + * filtered by time, then bucketed) and serve a UNIFORM rate — one write and one + * read per minute — so the only honest trend is 0%. + */ +const MINUTE = 60_000; +const UNIT_MS = { hour: 3_600_000, day: 86_400_000 } as const; +const HISTORY_START = Date.parse(FROZEN_NOW) - 120 * UNIT_MS.day; + +function uniformBuckets(unit: 'hour' | 'day', since?: string | null, until?: string | null) { + const lo = Math.max(HISTORY_START, since ? Date.parse(since) : HISTORY_START); + const hi = Math.min(Date.parse(FROZEN_NOW), until ? Date.parse(until) : Date.parse(FROZEN_NOW)); + const step = UNIT_MS[unit]; + const out: { bucket: string; count: number }[] = []; + for (let b = Math.floor(lo / step) * step; b < hi; b += step) { + const overlap = Math.min(hi, b + step) - Math.max(lo, b); + const count = Math.round(overlap / MINUTE); + if (count > 0) out.push({ bucket: new Date(b).toISOString(), count }); + } + return out; +} + +const unitOf = (v: unknown) => (v === 'hour' ? 'hour' : 'day'); + +const uniformHandlers = [ + http.post('*/functions/v1/memories/activity', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as Record; + const bucket = unitOf(body.bucket); + return HttpResponse.json({ + bucket, + since: body.since, + until: body.until, + buckets: uniformBuckets(bucket, body.since, body.until).map((b) => ({ ...b, scope: 'global' })), + }); + }), + http.get('*/functions/v1/memories/read-activity', ({ request }) => { + const url = new URL(request.url); + const bucket = unitOf(url.searchParams.get('bucket')); + const since = url.searchParams.get('since'); + const until = url.searchParams.get('until'); + return HttpResponse.json({ + bucket, + since, + until, + buckets: uniformBuckets(bucket, since, until).map((b) => ({ ...b, scope: 'global', read_kind: 'bulk' })), + }); + }), + // Everything else (usage, scopes, auth) from the shared fixtures. MSW takes + // the first matching handler, so the two above win. + ...memoryHandlers(), +]; + +const meta: Meta = { + title: 'Lore/ExplorerInsights/Tests/Trend', + component: ExplorerInsights, + tags: ['test'], + parameters: { + layout: 'fullscreen', + chromatic: { disableSnapshot: true }, + msw: { handlers: uniformHandlers }, + }, + decorators: [withFrozenClock(FROZEN_NOW), withQueryClient], + beforeEach: () => { + for (const key of Object.values(PREFERENCE_KEYS)) window.localStorage.removeItem(key); + }, + args: { + scope: null, + scopeLabel: 'All scopes', + onRangeChange: () => undefined, + filters: [], + retention: NO_RETENTION_CONDITIONS, + heatmapData: [], + highlightRange: null, + onSelectDate: () => undefined, + nowIso: FROZEN_NOW, + }, +}; + +export default meta; +type Story = StoryObj; + +/** The settled headline number on the card with `label` (the counter's sr-only half). */ +function headline(canvas: ReturnType, label: string): number { + // The card's first paragraph is the counter; its last span is the settled + // value (the first is the tween). Formatted with separators, e.g. "9,360". + const counter = canvas.getByText(label).closest('[data-stat-card]')?.querySelector('p'); + const settled = counter?.querySelectorAll('span span'); + return Number(settled?.[settled.length - 1]?.textContent?.replace(/[^\d.-]/g, '')); +} + +/** The trend chip's visible text on the card with `label`. */ +function chipText(canvas: ReturnType, label: string): string | undefined { + const card = canvas.getByText(label).closest('[data-stat-card]'); + return card?.querySelector('[title*="preceding period"]')?.textContent?.trim(); +} + +function flatTrendStory(preset: '24h' | '7d' | '30d'): Story { + return { + args: { range: { preset } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('button', { name: /hide activity detail/i }); + for (const label of ['Memories written', 'Memories retrieved', 'Scopes active']) { + // Data first: an empty first render is 0 vs 0, which also reads 0%. + await waitFor(() => expect(headline(canvas, label)).toBeGreaterThan(0)); + await expect(chipText(canvas, label)).toBe('0%'); + } + }, + }; +} + +export const FlatRateTrendsZeroAt24h = flatTrendStory('24h'); +export const FlatRateTrendsZeroAt7d = flatTrendStory('7d'); +export const FlatRateTrendsZeroAt30d = flatTrendStory('30d'); diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png index d8821626e..8377ec73b 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png index fdcb8aa1c..eaa2b6cbd 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png index df8669bb3..354286ef9 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png index 8f42a4298..93c37e427 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png differ diff --git a/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Default-1-chromium-linux.png index 8ce4fd923..fcfc23f3e 100644 Binary files a/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Playground-1-chromium-linux.png index f6c58cb96..a80091ca2 100644 Binary files a/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/ui/__screenshots__/Badge.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index 71915a4bf..5ddac95d2 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -123,7 +123,7 @@ The Explorer opens with four cards above the memory list, describing **the scope A **Stat charts / Heatmap** switch in the panel header chooses which of the two you get — one or the other, never both stacked: -- **Stat charts** shows the four cards and unfolds each one's evidence: a period-over-period trend chip and a bar per hour or day. Every card with bars is **additive** — summing the bars reproduces the number above them, so the chart and the total can never tell different stories. A very large change is abbreviated to keep the badge on one line (`+8.8K%`); hovering it shows the exact percentage. +- **Stat charts** shows the four cards and unfolds each one's evidence: a period-over-period trend chip and a bar per hour or day. Every card with bars is **additive** — summing the bars reproduces the number above them, so the chart and the total can never tell different stories. A figure of 10,000 or more is abbreviated so it never crowds the trend chip beside it (`12.3K`, `1.57M`), and a very large change is abbreviated to keep the badge on one line (`+8.8K%`); hovering either shows the exact value. - **Heatmap** gives the write calendar the panel to itself — a quarter of activity on a phone, a full year on a desktop, sized to the width it has. The cards step aside rather than stacking above it, so the calendar is all you scroll past. It is deliberately **account-wide and unfiltered**, because you use it to *pick* a window rather than to read the one you already picked. Hover or tab to a day for its exact count. The panel opens **expanded**, and the chevron folds it away to just the four numbers — from either view, so the figures are always one chevron away and folded is a compact summary line rather than a screen of tiles. **Your choice is remembered** — collapse it once and it stays collapsed on every visit, in every tab, until you open it again; the same goes for which of the two views you were last on. It is a preference for you and this browser, not part of the page's address, so a `/lore` link you share carries what you were looking at and not how tall you left the panel. When a number changes because you picked a different scope, range, or filter, it counts to its new value rather than swapping, so you can see which figures moved. diff --git a/packages/web/src/lib/aggregations.ts b/packages/web/src/lib/aggregations.ts index ec1f753ff..0de6c5437 100644 --- a/packages/web/src/lib/aggregations.ts +++ b/packages/web/src/lib/aggregations.ts @@ -219,6 +219,57 @@ function bucketAnchor(now: number, unit: 'hour' | 'day'): number { return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } +/** The fetch window {@link comparisonWindow} returns, plus where its rows end. */ +export interface ComparisonWindow { + since: string; + until: string; + /** The first charted bucket's start: rows before it are the comparison half. */ + chartStart: string; +} + +/** + * The COMPARISON half of a grid: the charted span moved back one whole grid + * length, as a fetch window `[since, until)`, plus `chartStart`, the first + * charted bucket's start, where fetched rows split into the two halves. + * + * {@link computeRangeTrends} / {@link computeCountTrend} tally `2 × count` + * buckets and compare the charted half against the preceding one, so a caller + * that fetched only the charted window hands them an empty comparison half — + * and `pctChange(n, 0)` reads +100% for every n. + * + * The charted span runs from the selection's `since` or `chartStart`, whichever + * is later, to the selection's `until`, the clock, or the grid's end, whichever + * is earliest: nothing after now is charted, and an absolute selection such as + * today's heatmap cell ends in the future. Moving that span back a WHOLE grid + * length, rather than placing it directly before `chartStart`, keeps the time + * of day: today until 13:00 is compared with yesterday until 13:00, not with + * yesterday afternoon, which reads any daily rhythm as a decline. + * + * `gridNowIso` is where the grid is anchored: the clock for a preset, the + * range's own end for an absolute range (`gridAnchor`). + */ +export function comparisonWindow( + plan: BucketPlan, + gridNowIso: string, + selection: { since: string; until: string }, + clockIso: string, +): ComparisonWindow { + const unitMs = plan.unit === 'hour' ? HOUR_MS : DAY_MS; + const gridMs = plan.count * unitMs; + const gridEnd = bucketAnchor(Date.parse(gridNowIso), plan.unit) + unitMs; + const chartStart = gridEnd - gridMs; + const chartedFrom = Math.max(Date.parse(selection.since), chartStart); + const chartedTo = Math.max( + chartedFrom, + Math.min(Date.parse(selection.until), Date.parse(clockIso), gridEnd), + ); + return { + since: new Date(chartedFrom - gridMs).toISOString(), + until: new Date(chartedTo - gridMs).toISOString(), + chartStart: new Date(chartStart).toISOString(), + }; +} + /** Display label for a bucket start (UTC): "HH:00" for hours, "Mon D" for days. */ function bucketLabel(start: number, unit: 'hour' | 'day'): string { if (unit === 'hour') return `${String(new Date(start).getUTCHours()).padStart(2, '0')}:00`; diff --git a/packages/web/src/lib/format-number.spec.ts b/packages/web/src/lib/format-number.spec.ts index 57fde7c11..f3886c18e 100644 --- a/packages/web/src/lib/format-number.spec.ts +++ b/packages/web/src/lib/format-number.spec.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import { formatCompact, formatExact, + formatHeadline, formatPercentDelta, + isHeadlineAbbreviated, isPercentDeltaAbbreviated, } from './format-number'; @@ -47,7 +49,9 @@ describe('formatCompact', () => { it('switches to compact notation at the threshold', () => { expect(formatCompact(10_000)).toBe('10K'); expect(formatCompact(12_345)).toBe('12.3K'); - expect(formatCompact(1_234_567)).toBe('1.2M'); + expect(formatCompact(1_234_567)).toBe('1.23M'); + // Three significant digits, so the hundreds band never grows to `883.4K`. + expect(formatCompact(883_400)).toBe('883K'); }); /** @@ -69,6 +73,51 @@ describe('formatCompact', () => { }); }); +/** + * A stat card's headline shares its row with the trend chip. `1,566,285` pushed + * the chip off the card, so from five digits up it abbreviates. + */ +describe('formatHeadline', () => { + it('keeps figures below 10,000 exact and grouped', () => { + // Same width as compact notation tops out at, so abbreviating here would + // drop precision for no room. + expect(formatHeadline(0)).toBe('0'); + expect(formatHeadline(541)).toBe('541'); + expect(formatHeadline(2800)).toBe('2,800'); + expect(formatHeadline(9999)).toBe('9,999'); + }); + + it('abbreviates from 10,000 up to three significant digits', () => { + expect(formatHeadline(10_000)).toBe('10K'); + expect(formatHeadline(12_345)).toBe('12.3K'); + expect(formatHeadline(883_400)).toBe('883K'); + expect(formatHeadline(1_566_285)).toBe('1.57M'); + expect(formatHeadline(12_345_678)).toBe('12.3M'); + expect(formatHeadline(1_000_000_000)).toBe('1B'); + }); + + it('rounds across a magnitude instead of growing a digit', () => { + expect(formatHeadline(99_950)).toBe('100K'); + expect(formatHeadline(999_500)).toBe('1M'); + }); + + /** The property the rule exists for: the chip's row always has room. */ + it('never exceeds five characters at any magnitude', () => { + const magnitudes = [0, 9, 99, 999, 9999, 10_000, 99_999, 883_400, 999_999, 1_566_285, 99_999_999, 1e9, 1e12]; + for (const n of magnitudes) { + expect(formatHeadline(n).length).toBeLessThanOrEqual(5); + } + }); + + it('agrees with isHeadlineAbbreviated at the boundary', () => { + expect(isHeadlineAbbreviated(9999)).toBe(false); + expect(isHeadlineAbbreviated(10_000)).toBe(true); + for (const n of [0, 541, 9999, 10_000, 1_566_285]) { + expect(formatHeadline(n) === formatExact(n)).toBe(!isHeadlineAbbreviated(n)); + } + }); +}); + /** * The trend chip shares a line with the headline figure it annotates and carries * two characters the figure does not (a sign and a `%`), so it abbreviates an diff --git a/packages/web/src/lib/format-number.ts b/packages/web/src/lib/format-number.ts index 00fdd6f41..7240a8efa 100644 --- a/packages/web/src/lib/format-number.ts +++ b/packages/web/src/lib/format-number.ts @@ -22,21 +22,27 @@ /** Grouped and exact: `1247` → `1,247`. The canonical rendering of a figure. */ const EXACT = new Intl.NumberFormat('en-US'); -/** Compact: `12345` → `12.3K`. Mirrors `formatLikeCount`'s options. */ +/** + * Compact, capped at THREE significant digits: `12345` → `12.3K`, `1566285` → + * `1.57M`, `883400` → `883K`. + * + * Significant digits rather than `maximumFractionDigits: 1`, because a fraction + * cap is unbounded in the hundreds band (`883400` → `883.4K`, six characters), + * while three significant digits is never more than five characters at any + * magnitude — which is the width the headline slot is sized for. + */ const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', - maximumFractionDigits: 1, + maximumSignificantDigits: 3, }); /** * Compact, capped at TWO significant digits: `883400` → `880K`, `8834` → `8.8K`. * * Same vocabulary as {@link COMPACT} — same locale, same `K`/`M`/`B` — carrying - * one fewer digit of precision, because the slot that uses it is tighter. It is - * a real difference and not a stylistic one: `maximumFractionDigits: 1` is - * unbounded in the hundreds band (`883400` → `883.4K`, six characters, LONGER - * than the four-digit percentages this exists to shorten), while two significant - * digits is never more than four characters at any magnitude. + * one fewer digit of precision, because the slot that uses it is tighter: two + * significant digits is never more than four characters at any magnitude, which + * a trend chip's sign and `%` then stretch to six. */ const COMPACT_TERSE = new Intl.NumberFormat('en-US', { notation: 'compact', @@ -54,6 +60,16 @@ const COMPACT_TERSE = new Intl.NumberFormat('en-US', { */ const COMPACT_THRESHOLD = 10_000; +/** + * Above this, a stat card's HEADLINE is abbreviated. + * + * Below it the exact, grouped figure is at most five characters (`9,999`) — the + * same width compact notation tops out at (`12.3K`, `1.57M`) — so abbreviating + * earlier would drop precision and buy no room. At it, `10,000` is six and the + * figures only grow: `1,566,285` pushed the trend chip beside it off the card. + */ +const HEADLINE_COMPACT_THRESHOLD = 10_000; + /** * Above this, a PERCENTAGE is abbreviated. * @@ -99,19 +115,35 @@ function compactAbove(value: number, threshold: number, formatter: Intl.NumberFo * The figure as it should appear in a narrow, dense slot. * * Ungrouped below the threshold and compact above it, so the result is at most - * five characters through the magnitudes a memory count reaches. (It carries one - * fraction digit, so the hundreds-of-thousands band can run to six — `883.4K`. - * {@link formatPercentDelta} uses {@link COMPACT_TERSE} where that matters.) - * Grouping is dropped rather than kept because the separator is the character - * that pushes a four-digit figure past its column, and it buys little there. + * five characters at any magnitude. Grouping is dropped rather than kept + * because the separator is the character that pushes a four-digit figure past + * its column, and it buys little there. * * The exact value is never lost: `AnimatedNumber` announces {@link formatExact} - * regardless of how it renders, and the expanded stat card shows it in full. + * regardless of how it renders. */ export function formatCompact(value: number): string { return compactAbove(value, COMPACT_THRESHOLD, COMPACT); } +/** + * A stat card's headline figure: `5,341`, `12.3K`, `1.57M`. + * + * Exact and grouped below {@link HEADLINE_COMPACT_THRESHOLD}, compact above it, + * so a headline is never wider than five characters and always leaves the trend + * chip on its row room to sit beside it. The exact value stays reachable: + * `AnimatedNumber` announces {@link formatExact}, and `StatCard` puts it in the + * hover title when {@link isHeadlineAbbreviated}. + */ +export function formatHeadline(value: number): string { + return Math.abs(value) < HEADLINE_COMPACT_THRESHOLD ? EXACT.format(value) : COMPACT.format(value); +} + +/** Whether {@link formatHeadline} lost precision on this value. */ +export function isHeadlineAbbreviated(value: number): boolean { + return Math.abs(value) >= HEADLINE_COMPACT_THRESHOLD; +} + /** * A period-over-period change, signed and suffixed, for a trend chip. * diff --git a/packages/web/src/lib/queries/explorer-stats.trend.spec.ts b/packages/web/src/lib/queries/explorer-stats.trend.spec.ts new file mode 100644 index 000000000..3676e45f5 --- /dev/null +++ b/packages/web/src/lib/queries/explorer-stats.trend.spec.ts @@ -0,0 +1,160 @@ +/** + * Regression: every Explorer stat-card trend chip read "+100%". + * + * `computeRangeTrends` / `computeCountTrend` compare the charted window against + * the equal-length window BEFORE it, so the rows they are handed must cover + * both. The fetch only asked the server for the charted window, the comparison + * half was always empty, and `pctChange(n, 0)` is +100% for any n > 0. + * + * The fake server mirrors the RPCs: it filters raw events by `created_at` + * against `[since, until)` and only THEN truncates to the bucket, so a fetch + * that reaches past an unaligned `since` would be caught leaking into a bar. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { comparisonWindow, computeCountTrend, computeRangeTrends } from '@/lib/aggregations'; +import { bucketPlanForRange, gridAnchor, type TimeRange } from '@/lib/time-range'; + +const HOUR = 3_600_000; +const NOW = '2026-10-20T12:34:56.000Z'; +const FALLBACK = { unit: 'hour' as const, count: 24 }; + +const MINUTE = 60_000; +// One write + one read every minute of the last 61 days: a uniform rate, so two +// equal-length windows hold equal counts and the honest change is exactly 0%. +// (An hourly series would not do: equal windows can catch 24 vs 23 events.) +const anchor = Math.floor(Date.parse(NOW) / HOUR) * HOUR; +const lastMinute = Math.floor(Date.parse(NOW) / MINUTE) * MINUTE; +const EVENTS = Array.from({ length: 61 * 24 * 60 }, (_, i) => lastMinute - i * MINUTE); +const eventsIn = (from: string, to: string) => + EVENTS.filter((t) => t >= Date.parse(from) && t < Date.parse(to)).length; +// The same minutes, kept only between 09:00 and 18:00 UTC: identical days with +// a daily rhythm. A uniform rate cannot tell a comparison that keeps the time +// of day from one that does not; this fixture can. +const WORKDAY_EVENTS = EVENTS.filter((t) => { + const hour = new Date(t).getUTCHours(); + return hour >= 9 && hour < 18; +}); +let served = EVENTS; + +function serve(since?: string, until?: string) { + const lo = since ? Date.parse(since) : -Infinity; + const hi = until ? Date.parse(until) : Infinity; + const perBucket = new Map(); + for (const t of served) { + if (t < lo || t >= hi) continue; + const b = Math.floor(t / HOUR) * HOUR; + perBucket.set(b, (perBucket.get(b) ?? 0) + 1); + } + return [...perBucket].map(([b, count]) => ({ bucket: new Date(b).toISOString(), count })); +} + +const requested: { usage?: string } = {}; + +vi.mock('@/lib/api/session-browser', () => ({ browserAccessToken: async () => 'token' })); +vi.mock('@/lib/api/memories', () => ({ + activityPostRequest: async (_t: string, q: { since?: string; until?: string; bucket: string }) => ({ + ...q, + buckets: serve(q.since, q.until).map((b) => ({ ...b, scope: 'global' })), + }), + readActivityRequest: async (_t: string, q: { since?: string; until?: string; bucket: string }) => ({ + ...q, + buckets: serve(q.since, q.until).map((b) => ({ ...b, scope: 'global', read_kind: 'bulk' as const })), + }), + usageRequest: async (_t: string, q: { since?: string }) => { + requested.usage = q.since; + return { summary: { archived: 0, expired: 0 } }; + }, +})); + +const { fetchExplorerStats, statsWindow } = await import('./explorer-stats'); + +async function trendsFor(range: TimeRange, now = NOW) { + const plan = bucketPlanForRange(range, now) ?? FALLBACK; + const gridNow = gridAnchor(range, now); + const { since, until } = statsWindow(range, now); + const comparison = comparisonWindow(plan, gridNow, { since, until }, now); + const data = await fetchExplorerStats(null, {}, plan.unit, since, until, comparison); + return { + since, + until, + chartStart: comparison.chartStart, + written: computeRangeTrends(data.rows, gridNow, plan), + read: computeCountTrend(data.readBuckets, gridNow, plan), + }; +} + +const sum = (points: { value: number }[]) => points.reduce((a, p) => a + p.value, 0); + +describe('Explorer stat trends compare against a FETCHED preceding period', () => { + beforeEach(() => { + delete requested.usage; + }); + + it('a flat 24h series trends 0%, not +100%', async () => { + const { written, read, until, chartStart } = await trendsFor({ preset: '24h' }); + expect(written.lessons.changePct).toBe(0); + expect(written.scopes.changePct).toBe(0); + expect(read.changePct).toBe(0); + // The charted half still describes only the charted buckets. + expect(sum(written.lessons.points)).toBe(eventsIn(chartStart, until)); + expect(sum(read.points)).toBe(eventsIn(chartStart, until)); + }); + + it('never lets a row from before an UNALIGNED since into a charted bar', async () => { + // 10:30→12:00 charts the 10:00 and 11:00 bars; the 10:00–10:29 events + // precede the selection and must not reach the headline. + const day = new Date(anchor).toISOString().slice(0, 10); + const from = `${day}T10:30:00.000Z`; + const to = `${day}T12:00:00.000Z`; + const { written, read } = await trendsFor({ from, to }); + expect(sum(written.lessons.points)).toBe(eventsIn(from, to)); + expect(sum(read.points)).toBe(eventsIn(from, to)); + // …and the comparison is the same 90 minutes, so a uniform rate reads 0%. + expect(written.lessons.changePct).toBe(0); + }); + + it('stops the comparison at now when the selection ends after it', async () => { + // Today's heatmap cell runs to tomorrow's midnight but has rows only up to + // now; a comparison sized by that future end read a flat account as -48%. + const today = NOW.slice(0, 10); + const { written, read } = await trendsFor({ from: today, to: today }); + expect(written.lessons.changePct).toBe(0); + expect(written.scopes.changePct).toBe(0); + expect(read.changePct).toBe(0); + }); + + it.each([ + ['today', { from: NOW.slice(0, 10), to: NOW.slice(0, 10) }], + ['the 7d preset', { preset: '7d' }], + ] as const)('a daily rhythm trends 0%% on %s: the comparison keeps the time of day', async (_label, range) => { + // Today until 12:34 is compared with yesterday until 12:34. Placed directly + // before the chart, the comparison weighed this morning against yesterday + // afternoon and read identical days as a decline. + served = WORKDAY_EVENTS; + try { + const { written, read } = await trendsFor(range); + expect(written.lessons.changePct).toBe(0); + expect(read.changePct).toBe(0); + } finally { + served = EVENTS; + } + }); + + it.each([ + ['24h at hh:05', '2026-10-20T12:05:00.000Z', { preset: '24h' }], + ['7d at 00:30', '2026-10-20T00:30:00.000Z', { preset: '7d' }], + ['30d at 00:30', '2026-10-20T00:30:00.000Z', { preset: '30d' }], + ] as const)('a uniform rate trends 0%% end-to-end on %s', async (_label, now, range) => { + // The charted half ends at now, mid-bucket; a comparison a whole bucket + // longer would read a flat account as a decline (-4% to -14% here). + const { written, read } = await trendsFor(range, now); + expect(written.lessons.changePct).toBe(0); + expect(written.scopes.changePct).toBe(0); + expect(read.changePct).toBe(0); + }); + + it('keeps the account-wide usage summary on the SELECTED window', async () => { + const { since } = await trendsFor({ preset: '24h' }); + expect(requested.usage).toBe(since); + }); +}); diff --git a/packages/web/src/lib/queries/explorer-stats.ts b/packages/web/src/lib/queries/explorer-stats.ts index 7d3d8d071..3dd0f6ddc 100644 --- a/packages/web/src/lib/queries/explorer-stats.ts +++ b/packages/web/src/lib/queries/explorer-stats.ts @@ -37,7 +37,7 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { browserAccessToken } from '@/lib/api/session-browser'; import { activityPostRequest, readActivityRequest, usageRequest } from '@/lib/api/memories'; -import { trendRowsFromActivity, type TrendRow } from '@/lib/aggregations'; +import { trendRowsFromActivity, type ComparisonWindow, type TrendRow } from '@/lib/aggregations'; import type { ActivityBody, ReadActivityBucket } from '@lorekit/schemas/memory'; import { resolveRange, type BucketUnit, type TimeRange } from '@/lib/time-range'; @@ -115,37 +115,67 @@ export function statsWindow( return { since: new Date(now).toISOString(), until: new Date(now).toISOString() }; } -async function fetchExplorerStats( +/** Exported for the trend regression spec; the hook below is the only caller. */ +export async function fetchExplorerStats( scope: string | null, filters: Partial, bucket: BucketUnit, since: string, until: string, + comparison: ComparisonWindow, signal?: AbortSignal, ): Promise { const token = await browserAccessToken(); if (!token) return EMPTY; - const [activity, readActivity, usage] = await Promise.all([ + // Written / Scopes / Read carry a trend chip, which compares the charted + // window against the preceding one — so each series is fetched TWICE: the + // selection as before, and the grid's comparison buckets (`comparisonWindow`). + // Two requests rather than one wider one: a single `since` reaching further + // back would let rows from before an unaligned `since` into the first charted + // bar. The usage summary has no trend and stays on the selection alone. + const scopeParam = scope ? { scope } : {}; + const [activity, prevActivity, readActivity, prevReadActivity, usage] = await Promise.all([ // Scope AND the dimension filters go to the SERVER now (migration 00063): // the response is aggregated per (bucket, scope) and carries no per-memory // tag/agent/repo, so a dimension filter CANNOT be applied client-side — the // written/scopes counts have to be narrowed in the RPC to agree with the list. - activityPostRequest(token, { bucket, since, until, ...(scope ? { scope } : {}), ...filters }, signal), + activityPostRequest(token, { bucket, since, until, ...scopeParam, ...filters }, signal), + activityPostRequest( + token, + { bucket, since: comparison.since, until: comparison.until, ...scopeParam, ...filters }, + signal, + ), // Read follows scope + range ONLY: usage_events has no per-memory dimension, // so a label/repo filter is unanswerable for reads (the Read card is // scope-level by design). `scope` still goes server-side (00058) because the // read series carries a NULL-scope remainder that client filtering would fold // in or out depending on how the predicate was written. - readActivityRequest(token, { bucket, since, until, ...(scope ? { scope } : {}) }, signal), + readActivityRequest(token, { bucket, since, until, ...scopeParam }, signal), + readActivityRequest( + token, + { bucket, since: comparison.since, until: comparison.until, ...scopeParam }, + signal, + ), usageRequest(token, { since, until }, signal), ]); + // Each half is clipped to its side of the first charted bucket. The + // selection's leading partial bucket (a preset's `since` is mid-bucket) is + // dropped from both halves: the second request stops at the same time of day + // one grid earlier. And a backend that ignores the bounds (the Storybook + // mocks) must not count a charted bar twice. + const chartStart = Date.parse(comparison.chartStart); + const charted = (rows: T[]) => + rows.filter((r) => Date.parse(r.bucket) >= chartStart); + const preceding = (rows: T[]) => + rows.filter((r) => Date.parse(r.bucket) < chartStart); + return { // No client-side scope filter: the server already returns exactly the scope // (and dimension) selection, so re-filtering here would double-apply it. - rows: trendRowsFromActivity(activity.buckets), - readBuckets: readActivity.buckets, + rows: trendRowsFromActivity([...preceding(prevActivity.buckets), ...charted(activity.buckets)]), + readBuckets: [...preceding(prevReadActivity.buckets), ...charted(readActivity.buckets)], archived: usage.summary.archived, expired: usage.summary.expired, }; @@ -166,13 +196,15 @@ export function useExplorerStats( bucket: BucketUnit, since: string, until: string, + comparison: ComparisonWindow, ) { return useQuery({ // `filters` is a plain object; React Query's default hash sorts its keys, so // the same filter set always produces the same key regardless of insertion // order — the header refetches when (and only when) the predicate changes. - queryKey: ['explorer-stats', scope, filters, bucket, since, until], - queryFn: ({ signal }) => fetchExplorerStats(scope, filters, bucket, since, until, signal), + // `comparison.until` follows the clock for a range that ends after now, so it is keyed too. + queryKey: ['explorer-stats', scope, filters, bucket, since, until, comparison.since, comparison.until], + queryFn: ({ signal }) => fetchExplorerStats(scope, filters, bucket, since, until, comparison, signal), placeholderData: keepPreviousData, // Matches the lesson list's staleTime: the two are read together and a // header that refreshed on a different cadence than the list under it would