From fc54cfda2a8ad5b18065b880091508f531ccaff3 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 8 Jul 2026 11:46:47 -0600 Subject: [PATCH 1/7] Add updatedAt timestamp to interlinear projects (#138) Set updatedAt (ISO 8601) in projectStorage on create/updateAnalysis/ updateProjectMetadata. Surface it in the select-project picker (sort by recency + "Modified" subline, #61) and the project-info modal (#117). Per-book last-modified deferred until #87's partitioning is settled. Co-Authored-By: Claude Opus 4.8 (1M context) --- contributions/localizedStrings.json | 2 + .../modals/ProjectMetadataModal.test.tsx | 8 +++ .../components/modals/ProjectModals.test.tsx | 2 + .../modals/SaveAsProjectModal.test.tsx | 2 + .../SelectInterlinearProjectModal.test.tsx | 23 +++++++ src/__tests__/services/projectStorage.test.ts | 62 ++++++++++++++++++- src/__tests__/test-helpers.ts | 1 + .../modals/ProjectMetadataModal.tsx | 9 +++ src/components/modals/ProjectModals.tsx | 1 + .../modals/SelectInterlinearProjectModal.tsx | 50 +++++++++++---- src/services/projectStorage.ts | 20 ++++-- src/types/interlinear-project-summary.ts | 1 + src/types/interlinearizer.d.ts | 7 +++ src/types/type-guards.ts | 2 + 14 files changed, 169 insertions(+), 21 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index a068f4c1..bb043b5b 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -65,6 +65,7 @@ "%interlinearizer_modal_metadata_description_label%": "Description", "%interlinearizer_modal_metadata_description_placeholder%": "e.g. Token-level English glosses", "%interlinearizer_modal_metadata_created_label%": "Created", + "%interlinearizer_modal_metadata_modified_label%": "Modified", "%interlinearizer_modal_metadata_source_label%": "Source Project", "%interlinearizer_modal_metadata_analysis_language_label%": "Analysis Language", "%interlinearizer_modal_metadata_language_placeholder%": "e.g. en", @@ -81,6 +82,7 @@ "%interlinearizer_modal_select_name_unnamed%": "Unnamed", "%interlinearizer_modal_select_info_button_label%": "Project info", "%interlinearizer_modal_select_active_badge%": "Active", + "%interlinearizer_modal_select_modified_prefix%": "Modified", "%interlinearizer_modal_select_create_new%": "Create New", "%interlinearizer_modal_select_cancel%": "Cancel", diff --git a/src/__tests__/components/modals/ProjectMetadataModal.test.tsx b/src/__tests__/components/modals/ProjectMetadataModal.test.tsx index 28864e52..76112274 100644 --- a/src/__tests__/components/modals/ProjectMetadataModal.test.tsx +++ b/src/__tests__/components/modals/ProjectMetadataModal.test.tsx @@ -20,6 +20,7 @@ const LOCALIZED: Record = { '%interlinearizer_modal_metadata_analysis_language_label%': 'Analysis Language', '%interlinearizer_modal_metadata_language_placeholder%': 'e.g. en', '%interlinearizer_modal_metadata_created_label%': 'Created', + '%interlinearizer_modal_metadata_modified_label%': 'Modified', '%interlinearizer_modal_metadata_source_label%': 'Source Project', '%interlinearizer_modal_metadata_save%': 'Save', '%interlinearizer_modal_metadata_close%': 'Close', @@ -35,6 +36,7 @@ const testProps = { sourceProjectId: 'src-project-id', analysisLanguages: ['en'], createdAt: '2026-01-15T10:30:00.000Z', + updatedAt: '2026-01-20T14:00:00.000Z', onClose: jest.fn(), onProjectSaved: jest.fn(), onProjectDeleted: jest.fn(), @@ -72,6 +74,12 @@ describe('ProjectMetadataModal', () => { expect(screen.getByLabelText(/analysis language/i)).toHaveValue('en'); }); + it('displays the locale-formatted modified date', () => { + render(); + const expectedDate = new Date(testProps.updatedAt).toLocaleString(); + expect(screen.getByText(expectedDate)).toBeInTheDocument(); + }); + it('calls onClose when the Close button is clicked', async () => { const onClose = jest.fn(); render(); diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 00460e15..4b7b3004 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -16,6 +16,7 @@ import { emptyAnalysis } from '../../../types/empty-factories'; const MOCK_PROJECT: InterlinearProjectSummary = { id: 'proj-1', createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', sourceProjectId: 'source-proj', analysisLanguages: ['en'], name: 'My Project', @@ -24,6 +25,7 @@ const MOCK_PROJECT: InterlinearProjectSummary = { const MOCK_PROJECT_2: InterlinearProjectSummary = { id: 'proj-2', createdAt: '2026-02-01T00:00:00Z', + updatedAt: '2026-02-01T00:00:00Z', sourceProjectId: 'source-proj', analysisLanguages: ['fr'], name: 'French Project', diff --git a/src/__tests__/components/modals/SaveAsProjectModal.test.tsx b/src/__tests__/components/modals/SaveAsProjectModal.test.tsx index bdf7ec37..bc789270 100644 --- a/src/__tests__/components/modals/SaveAsProjectModal.test.tsx +++ b/src/__tests__/components/modals/SaveAsProjectModal.test.tsx @@ -32,6 +32,7 @@ const LOCALIZED: Record = { const STUB_PROJECT: InterlinearProjectSummary = { id: 'proj-uuid', createdAt: '2026-01-15T10:30:00.000Z', + updatedAt: '2026-01-15T10:30:00.000Z', sourceProjectId: 'src-proj', analysisLanguages: ['en'], }; @@ -39,6 +40,7 @@ const STUB_PROJECT: InterlinearProjectSummary = { const STUB_PROJECT_2: InterlinearProjectSummary = { id: 'proj-uuid-2', createdAt: '2026-02-01T08:00:00.000Z', + updatedAt: '2026-02-01T08:00:00.000Z', sourceProjectId: 'src-proj', analysisLanguages: ['fr'], name: 'French glosses', diff --git a/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx b/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx index 007ab439..5f60af67 100644 --- a/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx +++ b/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx @@ -19,11 +19,13 @@ const LOCALIZED: Record = { '%interlinearizer_modal_select_name_unnamed%': 'Unnamed', '%interlinearizer_modal_select_info_button_label%': 'Project info', '%interlinearizer_modal_select_active_badge%': 'Active', + '%interlinearizer_modal_select_modified_prefix%': 'Modified', }; const STUB_PROJECT: InterlinearProjectSummary = { id: 'proj-uuid', createdAt: '2026-01-15T10:30:00.000Z', + updatedAt: '2026-01-15T10:30:00.000Z', sourceProjectId: 'src-proj', analysisLanguages: ['en'], }; @@ -31,6 +33,7 @@ const STUB_PROJECT: InterlinearProjectSummary = { const STUB_PROJECT_2: InterlinearProjectSummary = { id: 'proj-uuid-2', createdAt: '2026-02-01T08:00:00.000Z', + updatedAt: '2026-02-01T08:00:00.000Z', sourceProjectId: 'src-proj', analysisLanguages: ['fr'], name: 'French glosses', @@ -101,6 +104,26 @@ describe('SelectInterlinearProjectModal', () => { await waitFor(() => expect(screen.getByText('en')).toBeInTheDocument()); }); + it('orders projects most-recently-modified first', async () => { + // STUB_PROJECT is older (Jan 15) than STUB_PROJECT_2 (Feb 1); returned oldest-first so the + // component must reorder to put the newer project on top. + mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, STUB_PROJECT_2])); + render(); + await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument()); + + const rows = screen.getAllByRole('button', { name: /french glosses|unnamed/i }); + expect(rows[0]).toHaveAccessibleName(/french glosses/i); + expect(rows[1]).toHaveAccessibleName(/unnamed/i); + }); + + it('shows the modified date in each row', async () => { + mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT])); + render(); + + const expectedDate = new Date(STUB_PROJECT.updatedAt).toLocaleString(); + await waitFor(() => expect(screen.getByText(`Modified ${expectedDate}`)).toBeInTheDocument()); + }); + it('calls onSelect with the project when a project row is clicked', async () => { const onSelect = jest.fn(); mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT])); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 214381a0..4e1f9632 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -86,6 +86,14 @@ describe('projectStorage', () => { expect(project.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); + it('sets updatedAt equal to createdAt at creation', async () => { + __mockReadUserData.mockRejectedValue(enoentError()); + + const project = await createProject(token, 'src-proj', ['en']); + + expect(project.updatedAt).toBe(project.createdAt); + }); + it('omits links and targetProjectId for analysis-only projects', async () => { __mockReadUserData.mockRejectedValue(enoentError()); @@ -243,6 +251,17 @@ describe('projectStorage', () => { describe('updateProjectMetadata', () => { const storedProject = makeStubProject('proj-id'); + // `updateProjectMetadata` stamps `updatedAt` with `new Date()`; freeze the clock so the exact + // stored payload is deterministic and distinct from the fixture's `createdAt`/`updatedAt`. + const UPDATE_TIME = '2026-03-01T12:00:00.000Z'; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(UPDATE_TIME)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); it('returns the updated project with the new name and description', async () => { __mockReadUserData.mockResolvedValue(JSON.stringify(storedProject)); @@ -260,10 +279,23 @@ describe('projectStorage', () => { expect(__mockWriteUserData).toHaveBeenCalledWith( token, 'project:proj-id', - JSON.stringify({ ...storedProject, name: 'My Name', description: 'My Desc' }), + JSON.stringify({ + ...storedProject, + updatedAt: UPDATE_TIME, + name: 'My Name', + description: 'My Desc', + }), ); }); + it('refreshes updatedAt to the current time', async () => { + __mockReadUserData.mockResolvedValue(JSON.stringify(storedProject)); + + const result = await updateProjectMetadata(token, 'proj-id', 'My Name', 'My Desc', ['en']); + + expect(result?.updatedAt).toBe(UPDATE_TIME); + }); + it('removes name and description when called with undefined', async () => { const withMeta = { ...storedProject, name: 'Old', description: 'Old desc' }; __mockReadUserData.mockResolvedValue(JSON.stringify(withMeta)); @@ -419,6 +451,17 @@ describe('projectStorage', () => { ...emptyAnalysis(), tokenAnalyses: [{ id: 'ta-1', surfaceText: 'In', gloss: { en: 'in' } }], }; + // `updateAnalysis` stamps `updatedAt` with `new Date()`; freeze the clock so the exact stored + // payload is deterministic and distinct from the fixture's `createdAt`/`updatedAt`. + const UPDATE_TIME = '2026-03-01T12:00:00.000Z'; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(UPDATE_TIME)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); it('returns the updated project with the new analysis', async () => { __mockReadUserData.mockResolvedValue(JSON.stringify(storedProject)); @@ -436,10 +479,18 @@ describe('projectStorage', () => { expect(__mockWriteUserData).toHaveBeenCalledWith( token, 'project:proj-id', - JSON.stringify({ ...storedProject, analysis: newAnalysis }), + JSON.stringify({ ...storedProject, analysis: newAnalysis, updatedAt: UPDATE_TIME }), ); }); + it('refreshes updatedAt to the current time', async () => { + __mockReadUserData.mockResolvedValue(JSON.stringify(storedProject)); + + const result = await updateAnalysis(token, 'proj-id', newAnalysis); + + expect(result?.updatedAt).toBe(UPDATE_TIME); + }); + it('returns undefined when the project does not exist', async () => { __mockReadUserData.mockRejectedValue(enoentError()); @@ -465,7 +516,12 @@ describe('projectStorage', () => { expect(__mockWriteUserData).toHaveBeenCalledWith( token, 'project:proj-id', - JSON.stringify({ ...storedProject, analysis: newAnalysis, segmentation }), + JSON.stringify({ + ...storedProject, + analysis: newAnalysis, + updatedAt: UPDATE_TIME, + segmentation, + }), ); }); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 26f73d55..4d3f03f3 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -186,6 +186,7 @@ export function makeStubProject(id = 'proj-id'): InterlinearProject { return { id, createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', sourceProjectId: 'src-project', analysisLanguages: ['en'], analysis: emptyAnalysis(), diff --git a/src/components/modals/ProjectMetadataModal.tsx b/src/components/modals/ProjectMetadataModal.tsx index 7b2a0276..21445a1e 100644 --- a/src/components/modals/ProjectMetadataModal.tsx +++ b/src/components/modals/ProjectMetadataModal.tsx @@ -18,6 +18,7 @@ const PROJECT_METADATA_MODAL_STRING_KEYS: `%${string}%`[] = [ '%interlinearizer_modal_metadata_analysis_language_label%', '%interlinearizer_modal_metadata_language_placeholder%', '%interlinearizer_modal_metadata_created_label%', + '%interlinearizer_modal_metadata_modified_label%', '%interlinearizer_modal_metadata_source_label%', '%interlinearizer_modal_metadata_save%', '%interlinearizer_modal_metadata_close%', @@ -44,6 +45,8 @@ type ProjectMetadataModalProps = Readonly<{ analysisLanguages: string[]; /** ISO 8601 creation timestamp. */ createdAt: string; + /** ISO 8601 timestamp of the most recent modification. */ + updatedAt: string; /** Callback invoked when the modal should be dismissed without saving. */ onClose: () => void; /** Optional callback invoked with updated metadata after a successful save. */ @@ -72,6 +75,7 @@ export function ProjectMetadataModal({ targetProjectId, analysisLanguages, createdAt, + updatedAt, onClose, onProjectSaved, onProjectDeleted, @@ -88,6 +92,7 @@ export function ProjectMetadataModal({ const { isSubmitting, runGuarded } = useSubmitGuard(); const formattedDate = useMemo(() => new Date(createdAt).toLocaleString(), [createdAt]); + const formattedModifiedDate = useMemo(() => new Date(updatedAt).toLocaleString(), [updatedAt]); /** * Parsed analysis-language tags from the comma-separated field. Computed once and reused by both @@ -229,6 +234,10 @@ export function ProjectMetadataModal({ label={localizedStrings['%interlinearizer_modal_metadata_created_label%']} value={formattedDate} /> + b.updatedAt.localeCompare(a.updatedAt)); + setProjects(sorted); } catch (e) { logger.error('Interlinearizer: failed to load projects for source', e); await papi.notifications @@ -126,24 +144,32 @@ export function SelectInterlinearProjectModal({