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 (