From 184685f3a8a7d12ac7ee5787379808fe426b9238 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 6 Sep 2026 02:47:38 -0700
Subject: [PATCH] fix: gate catch-up on source count and covered freshness
---
.../src/v2/__tests__/V2CatchUpStrip.test.tsx | 72 ++++++++++++++++---
frontend/src/v2/components/V2CatchUpStrip.tsx | 24 ++++---
2 files changed, 75 insertions(+), 21 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx b/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx
index 061a353770..4fd1409e63 100644
--- a/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx
+++ b/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx
@@ -1,9 +1,7 @@
// @ts-nocheck
-// The constant agents-TL;DR pinned above the transcript (Sam 2026-09-01).
-// Pinned: reads the existing summaries surface, renders a one-line snippet,
-// offers Summarize when the pod has none, and a refresh replaces the content.
+// Catch-up earns its row from the existing summary's source evidence.
import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import V2CatchUpStrip from '../components/V2CatchUpStrip';
jest.mock('axios', () => {
@@ -20,13 +18,23 @@ jest.mock('axios', () => {
});
const axios = jest.requireMock('axios').default;
+const NOW = Date.now();
+const DAY = 24 * 60 * 60 * 1000;
+const summary = (overrides = {}) => ({
+ content: 'Three messages worth catching up on.',
+ createdAt: new Date(NOW - 5 * 60000).toISOString(),
+ metadata: { totalItems: 3 },
+ timeRange: { start: new Date(NOW - 3600000).toISOString(), end: new Date(NOW - 5 * 60000).toISOString() },
+ ...overrides,
+});
-afterEach(() => jest.clearAllMocks());
+beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW); });
+afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); });
describe('V2CatchUpStrip', () => {
test('renders the latest summary as a one-line snippet with its age', async () => {
axios.get.mockResolvedValueOnce({
- data: { content: 'Otto verified the token split.\nKai shipped the daemon auth.', createdAt: new Date(Date.now() - 5 * 60000).toISOString() },
+ data: summary({ content: 'Otto verified the token split.\nKai shipped the daemon auth.' }),
});
render();
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
@@ -36,8 +44,8 @@ describe('V2CatchUpStrip', () => {
expect(axios.get).toHaveBeenCalledWith('/api/summaries/pod/p1');
});
- test('expanding shows the full body; no summary shows the Summarize action', async () => {
- axios.get.mockResolvedValueOnce({ data: { content: 'Line one.\nLine two.', createdAt: new Date().toISOString() } });
+ test('expanding an eligible summary shows the full body', async () => {
+ axios.get.mockResolvedValueOnce({ data: summary({ content: 'Line one.\nLine two.' }) });
render();
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /Catch up/ }));
@@ -54,9 +62,10 @@ describe('V2CatchUpStrip', () => {
expect(screen.queryByTestId('catchup-strip')).toBeNull();
});
- test('a summary older than 24h renders NO strip', async () => {
+ test('a freshly generated summary covering only July stays hidden', async () => {
axios.get.mockResolvedValueOnce({
- data: { content: 'Old news.', createdAt: new Date(Date.now() - 25 * 3600000).toISOString() },
+ data: summary({ content: 'A quick update: hey guys', createdAt: new Date(NOW).toISOString(), metadata: { totalItems: 1 },
+ timeRange: { start: '2026-07-01T00:00:00Z', end: '2026-07-01T01:00:00Z' } }),
});
render();
await waitFor(() => expect(axios.get).toHaveBeenCalled());
@@ -65,7 +74,7 @@ describe('V2CatchUpStrip', () => {
test('dismiss hides this summary version and persists; the strip stays gone on re-render', async () => {
const createdAt = new Date(Date.now() - 5 * 60000).toISOString();
- axios.get.mockResolvedValue({ data: { content: 'Digest.', createdAt } });
+ axios.get.mockResolvedValue({ data: summary({ content: 'Digest.', createdAt }) });
const { unmount } = render();
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
@@ -83,4 +92,45 @@ describe('V2CatchUpStrip', () => {
await waitFor(() => expect(axios.get).toHaveBeenCalled());
expect(screen.queryByTestId('catchup-strip')).toBeNull();
});
+
+ test.each([0, 1, 2, undefined, -1, 2.5, '3'])('hides insufficient or invalid source count %s', async (totalItems) => {
+ axios.get.mockResolvedValue({ data: summary({ metadata: { totalItems } }) });
+ await act(async () => { render(); });
+ expect(screen.queryByTestId('catchup-strip')).toBeNull();
+ });
+
+ test.each([
+ ['inside seven days', 7 * DAY - 1, true],
+ ['exactly seven days', 7 * DAY, false],
+ ['past seven days', 7 * DAY + 1, false],
+ ['future window', -1, false],
+ ])('%s uses covered time rather than generation time', async (_label, age, visible) => {
+ const end = NOW - age;
+ axios.get.mockResolvedValue({ data: summary({ createdAt: new Date(NOW).toISOString(),
+ timeRange: { start: new Date(end - 3600000).toISOString(), end: new Date(end).toISOString() } }) });
+ await act(async () => { render(); });
+ expect(Boolean(screen.queryByTestId('catchup-strip'))).toBe(visible);
+ });
+
+ test.each([undefined, {}, { start: 'bad', end: 'bad' }, { start: new Date(NOW).toISOString(), end: new Date(NOW - 1).toISOString() }])('hides missing or invalid covered ranges %j', async (timeRange) => {
+ axios.get.mockResolvedValue({ data: summary({ timeRange }) });
+ await act(async () => { render(); });
+ expect(screen.queryByTestId('catchup-strip')).toBeNull();
+ });
+
+ test('refresh cannot replace an eligible strip with a thin or stale one', async () => {
+ axios.get.mockResolvedValue({ data: summary() });
+ axios.post.mockResolvedValue({ data: { summary: summary({ metadata: { totalItems: 2 } }) } });
+ render();
+ await screen.findByTestId('catchup-strip');
+ fireEvent.click(screen.getByRole('button', { name: /Refresh|Summarize/ }));
+ await waitFor(() => expect(screen.queryByTestId('catchup-strip')).toBeNull());
+ });
+
+ test('three messages covered six days ago remain eligible even if generated six days ago', async () => {
+ axios.get.mockResolvedValue({ data: summary({ createdAt: new Date(NOW - 6 * DAY).toISOString(),
+ timeRange: { start: new Date(NOW - 6 * DAY - 3600000).toISOString(), end: new Date(NOW - 6 * DAY).toISOString() } }) });
+ render();
+ expect(await screen.findByTestId('catchup-strip')).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/v2/components/V2CatchUpStrip.tsx b/frontend/src/v2/components/V2CatchUpStrip.tsx
index 78f53d95c5..525cb9992f 100644
--- a/frontend/src/v2/components/V2CatchUpStrip.tsx
+++ b/frontend/src/v2/components/V2CatchUpStrip.tsx
@@ -12,6 +12,8 @@ import axios from 'axios';
interface PodSummary {
content?: string;
createdAt?: string;
+ metadata?: { totalItems?: number };
+ timeRange?: { start?: string; end?: string };
}
interface Props {
@@ -90,16 +92,18 @@ const V2CatchUpStrip: React.FC = ({ podId }) => {
}, [podId, summary]);
if (!loaded) return null;
- // Sam's revised ruling (2026-09-01, hours after the strip shipped):
- // "always shows up is not a good design" and an empty/stale strip
- // "reveals no good info". So the strip EARNS its row: it renders only
- // when a summary exists, is fresh (24h), and this version has not been
- // dismissed. No summary -> no strip, not an empty shell with a
- // Summarize button — generating one on demand stays available from the
- // inspector/summaries surface.
- const FRESH_MS = 24 * 60 * 60 * 1000;
- const isFresh = !!(summary?.createdAt && Date.now() - new Date(summary.createdAt).getTime() < FRESH_MS);
- if (!summary?.content || !isFresh || dismissed) return null;
+ // Sam 2026-09-06: at least three source messages and a covered window
+ // ending within seven days. This supersedes the generation-age-only 24h
+ // gate: re-generating a July summary today must not make July fresh.
+ // Legacy summaries without source evidence stay available on the summary
+ // surface, but do not earn a catch-up strip.
+ const sourceCount = summary?.metadata?.totalItems;
+ const sourceStart = Date.parse(summary?.timeRange?.start || '');
+ const sourceEnd = Date.parse(summary?.timeRange?.end || '');
+ const sourceAge = Date.now() - sourceEnd;
+ const isFresh = Number.isFinite(sourceStart) && Number.isFinite(sourceEnd)
+ && sourceStart <= sourceEnd && sourceAge >= 0 && sourceAge < 7 * 24 * 60 * 60 * 1000;
+ if (!summary?.content?.trim() || !Number.isInteger(sourceCount) || (sourceCount ?? 0) < 3 || !isFresh || dismissed) return null;
return (