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