Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d658824
fix(web): fetch the comparison period so Explorer stat trends stop re…
mthines Oct 20, 2026
e822e71
fix(web): fetch the trend comparison as whole grid buckets, not a wid…
mthines Oct 20, 2026
968219a
fix(web): size the trend comparison by the charted coverage and clip …
mthines Oct 20, 2026
d046909
test(web): assert the 7d/30d trend is 0% end-to-end, not just the win…
mthines Oct 20, 2026
05237c3
test(web): browser repro for the +100% trend, runnable against main
mthines Oct 20, 2026
3a085d9
fix(web): abbreviate stat-card headlines from five digits so the tren…
mthines Oct 20, 2026
e78a51b
docs: note that stat-card figures abbreviate from 10,000
mthines Oct 20, 2026
06ac47b
chore(web): update Storybook visual baselines
github-actions[bot] Oct 20, 2026
5816304
chore(web): keep only the baselines this PR's story graph runs
mthines Oct 20, 2026
2d49bea
docs(web): drop the stale stat-card claim from formatCompact's doc
mthines Oct 20, 2026
dc9ac44
fix(web): stop the trend comparison at now when a selection ends afte…
mthines Oct 20, 2026
aa1f870
fix(web): compare a partial period with the same span one grid length…
mthines Oct 20, 2026
e513499
docs(web): say the leading partial bucket is dropped from both trend …
mthines Oct 20, 2026
97d86d0
Merge remote-tracking branch 'origin/main' into fix/explorer-stats-tr…
mthines Oct 21, 2026
9b74f60
chore(web): update Storybook visual baselines
github-actions[bot] Oct 21, 2026
47f0dc6
ci: re-run checks on the regenerated visual baselines
mthines Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions packages/web/src/components/dashboard/StatCard.test.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<div style={{ width: '16rem' }}>
<StatCard
icon={BookOpenCheck}
label="Memories retrieved"
tag="Memory reads"
tooltip="Memory records read in the selected range."
value={1_566_285}
description="in the last 90 days"
trend={trendWith(10_400)}
trendTitle="Last 90 days vs. the preceding 90 days"
rangeTitle="the last 90 days"
unit="memories"
collapsible
/>
</div>
),
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: () => (
<div style={{ width: '16rem' }}>
<StatCard
icon={BookOpenCheck}
label="Memories opened"
tag="Memory reads"
tooltip="Memory records opened in the selected range."
value={5_341}
description="in the last 90 days"
collapsible
/>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const number = canvas.getByText('5,341', { selector: '[aria-hidden]' }).closest('p');
await expect(number).not.toHaveAttribute('title');
},
};
15 changes: 13 additions & 2 deletions packages/web/src/components/dashboard/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 = (
<p
className={`font-bold leading-tight tabular-nums text-[var(--color-content-primary)] ${
compact ? 'text-lg sm:text-xl' : 'text-2xl sm:text-3xl'
}`}
title={isHeadlineAbbreviated(value) ? formatExact(value) : undefined}
>
<AnimatedNumber value={value} />
<AnimatedNumber value={value} format={formatHeadline} />
</p>
);
const labelEl = (
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 12 additions & 4 deletions packages/web/src/components/lore/ExplorerStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
useExplorerStats,
} from '@/lib/queries/explorer-stats';
import {
comparisonWindow,
computeCountTrend,
computeRangeTrends,
RANGE_BUCKETS,
Expand Down Expand Up @@ -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(
Expand Down
131 changes: 131 additions & 0 deletions packages/web/src/components/lore/ExplorerStatsTrend.test.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
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<typeof ExplorerInsights> = {
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<typeof ExplorerInsights>;

/** The settled headline number on the card with `label` (the counter's sr-only half). */
function headline(canvas: ReturnType<typeof within>, 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<typeof within>, 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');
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading