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..f694390b 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(); @@ -146,6 +154,33 @@ describe('ProjectMetadataModal', () => { ); }); + it('threads the server-refreshed updatedAt from the returned project into onProjectSaved', async () => { + mockSendCommand.mockResolvedValue( + JSON.stringify({ id: 'il-project-uuid', updatedAt: '2026-06-15T09:00:00.000Z' }), + ); + const onProjectSaved = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => + expect(onProjectSaved).toHaveBeenCalledWith( + expect.objectContaining({ updatedAt: '2026-06-15T09:00:00.000Z' }), + ), + ); + }); + + it('omits updatedAt from onProjectSaved when the returned project has none', async () => { + // The default mock returns '{}', so no updatedAt is present to thread through. + const onProjectSaved = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => expect(onProjectSaved).toHaveBeenCalled()); + expect(onProjectSaved.mock.calls[0][0]).not.toHaveProperty('updatedAt'); + }); + it('calls onClose after a successful save', 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..5d2f33d1 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', @@ -214,6 +216,7 @@ jest.mock('../../../components/modals/ProjectMetadataModal', () => ({ name?: string; description?: string; analysisLanguages: string[]; + updatedAt?: string; }) => void; onProjectDeleted?: (id: string) => void; }) => ( @@ -225,7 +228,11 @@ jest.mock('../../../components/modals/ProjectMetadataModal', () => ({ type="button" data-testid="metadata-save" onClick={() => { - onProjectSaved?.({ name: 'Updated', analysisLanguages: ['fr'] }); + onProjectSaved?.({ + name: 'Updated', + analysisLanguages: ['fr'], + updatedAt: '2026-06-15T09:00:00Z', + }); onClose(); }} > @@ -850,6 +857,55 @@ describe('ProjectModals', () => { expect(setModal).toHaveBeenCalledWith('none'); }); + it('makes the server-returned project (with its refreshed updatedAt) the active project on overwrite', async () => { + const setActiveProject = jest.fn(); + const refreshed = { ...MOCK_PROJECT, updatedAt: '2026-06-15T09:00:00Z' }; + jest + .mocked(papi.commands.sendCommand) + // saveAnalysis resolves void, then updateProjectMetadata returns the refreshed project. + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(JSON.stringify(refreshed)); + render( + , + ); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + // The refreshed project — not a copy of the pre-save summary — becomes active, so the cached + // Modified time reflects the write rather than the stale value. + await waitFor(() => expect(setActiveProject).toHaveBeenCalledWith(refreshed)); + }); + + it('falls back to the pre-save summary with the draft config when overwrite metadata returns no project', async () => { + const setActiveProject = jest.fn(); + // Both commands resolve undefined (the blanket default), so the update payload is missing. + render( + , + ); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + // With no server payload the active project is rebuilt from the target plus the draft's + // config (languages carried over, target cleared since the draft has none). + await waitFor(() => + expect(setActiveProject).toHaveBeenCalledWith({ + ...MOCK_PROJECT, + analysisLanguages: ['en'], + targetProjectId: undefined, + }), + ); + }); + it('sends the draft segmentation delta when overwriting an existing project', async () => { const markSynced = jest.fn(); render( @@ -967,11 +1023,14 @@ describe('ProjectModals', () => { />, ); await userEvent.click(screen.getByTestId('metadata-save')); - // The saved edits ({ name: 'Updated', analysisLanguages: ['fr'] }) merge onto the active project. + // The saved edits (name, languages, and the server-refreshed updatedAt) are merged onto the + // active project, so a regression that drops the update branch — or the refreshed timestamp — + // is caught here. expect(setActiveProject).toHaveBeenCalledWith({ ...MOCK_PROJECT, name: 'Updated', analysisLanguages: ['fr'], + updatedAt: '2026-06-15T09:00:00Z', }); expect(setModal).toHaveBeenCalledWith('none'); }); 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..b1c9e1d6 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,41 @@ 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('keeps both projects listed when their modified times are equal', async () => { + // Two distinct projects sharing an identical updatedAt exercise the sort comparator's + // equal-timestamp branch; both must still render. + const sameTimeAsStub: InterlinearProjectSummary = { + ...STUB_PROJECT_2, + id: 'proj-uuid-3', + updatedAt: STUB_PROJECT.updatedAt, + }; + mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, sameTimeAsStub])); + render(); + + await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument()); + expect(screen.getByText('Unnamed')).toBeInTheDocument(); + }); + + 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..74ecc8bb 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. */ @@ -51,6 +54,12 @@ type ProjectMetadataModalProps = Readonly<{ name?: string; description?: string; analysisLanguages: string[]; + /** + * The server-refreshed modification timestamp, when the backend returned it. Threaded through + * so the caller's cached `activeProject` reflects the new Modified time immediately rather than + * keeping the pre-save value. + */ + updatedAt?: string; }) => void; /** Optional callback invoked with the deleted project ID after deletion. */ onProjectDeleted?: (deletedProjectId: string) => void; @@ -72,6 +81,7 @@ export function ProjectMetadataModal({ targetProjectId, analysisLanguages, createdAt, + updatedAt, onClose, onProjectSaved, onProjectDeleted, @@ -88,6 +98,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 @@ -121,10 +132,19 @@ export function ProjectMetadataModal({ targetProjectId, ); if (!updatedProjectJson) return; + // The command returns the saved project JSON with a refreshed `updatedAt`; parse it out so + // the caller's cached copy shows the new Modified time. Falls back to omitting it if the + // payload is unexpectedly shaped rather than surfacing a parse error to the user. + const parsed: unknown = JSON.parse(updatedProjectJson); + const refreshedUpdatedAt = + parsed && typeof parsed === 'object' && 'updatedAt' in parsed + ? parsed.updatedAt + : undefined; onProjectSaved?.({ name: newName, description: newDescription, analysisLanguages: parsedLanguages, + ...(typeof refreshedUpdatedAt === 'string' && { updatedAt: refreshedUpdatedAt }), }); onClose(); } catch (e) { @@ -229,6 +249,10 @@ export function ProjectMetadataModal({ label={localizedStrings['%interlinearizer_modal_metadata_created_label%']} value={formattedDate} /> + { + (updated: { + name?: string; + description?: string; + analysisLanguages: string[]; + updatedAt?: string; + }) => { if (activeProject && resolvedMetadataProject?.id === activeProject.id) { setActiveProject({ ...activeProject, ...updated }); } @@ -448,7 +454,7 @@ export default function ProjectModals({ // metadata stays consistent with the glosses just written (mirroring how Save As → New // carries the draft's config into the created project). The project's name and description // are intentionally preserved — overwriting keeps the target's identity. - await papi.commands.sendCommand( + const updatedJson = await papi.commands.sendCommand( 'interlinearizer.updateProjectMetadata', project.id, project.name, @@ -456,13 +462,21 @@ export default function ProjectModals({ snapshot.analysisLanguages, snapshot.targetProjectId, ); - setActiveProject({ - ...project, - analysisLanguages: snapshot.analysisLanguages, - // Assign explicitly (rather than a conditional spread) so a target binding on the - // overwritten project is cleared when the draft has none, matching what was persisted. - targetProjectId: snapshot.targetProjectId, - }); + // Prefer the server-returned project (it carries the refreshed `updatedAt`) as the new + // active project; fall back to the pre-save object with the draft's config if the payload is + // missing or malformed so the Save still completes. + const parsedUpdated: unknown = updatedJson ? JSON.parse(updatedJson) : undefined; + setActiveProject( + isInterlinearProjectSummary(parsedUpdated) + ? parsedUpdated + : { + ...project, + analysisLanguages: snapshot.analysisLanguages, + // Assign explicitly (rather than a conditional spread) so a target binding on the + // overwritten project is cleared when the draft has none, matching what was persisted. + targetProjectId: snapshot.targetProjectId, + }, + ); markSynced(snapshot.analysis, snapshot.segmentation); setModal('none'); } catch (e) { @@ -550,6 +564,7 @@ export default function ProjectModals({ targetProjectId={resolvedMetadataProject.targetProjectId} analysisLanguages={resolvedMetadataProject.analysisLanguages} createdAt={resolvedMetadataProject.createdAt} + updatedAt={resolvedMetadataProject.updatedAt} onClose={handleMetadataClose} onProjectSaved={handleMetadataProjectSaved} onProjectDeleted={handleMetadataProjectDeleted} diff --git a/src/components/modals/SelectInterlinearProjectModal.tsx b/src/components/modals/SelectInterlinearProjectModal.tsx index d3b39028..46abe7e4 100644 --- a/src/components/modals/SelectInterlinearProjectModal.tsx +++ b/src/components/modals/SelectInterlinearProjectModal.tsx @@ -16,8 +16,36 @@ const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [ '%interlinearizer_modal_select_name_unnamed%', '%interlinearizer_modal_select_info_button_label%', '%interlinearizer_modal_select_active_badge%', + '%interlinearizer_modal_select_modified_prefix%', ]; +/** + * Compares two ISO 8601 timestamps for a descending (newest-first) sort by their parsed epoch + * milliseconds, so ordering is locale-independent (unlike `localeCompare`, whose result can vary by + * collator). + * + * @param a - The first ISO 8601 timestamp. + * @param b - The second ISO 8601 timestamp. + * @returns A negative number when `a` is newer than `b` (sorts first), positive when older, `0` + * when the two timestamps are equal. + */ +function compareUpdatedAtDescending(a: string, b: string): number { + return Date.parse(b) - Date.parse(a); +} + +/** + * Formats the modified-date subline for a project row, e.g. `"Modified Jan 1, 2026, 12:00 PM"`. The + * prefix is a localized label; the timestamp is rendered in the user's locale via + * `toLocaleString`. + * + * @param prefix - Localized `"Modified"` label to precede the date. + * @param updatedAt - ISO 8601 modification timestamp. + * @returns The prefix followed by the locale-formatted timestamp. + */ +function formatModified(prefix: string, updatedAt: string): string { + return `${prefix} ${new Date(updatedAt).toLocaleString()}`; +} + /** * Modal that lists all existing interlinearizer projects for a source project and lets the user * select one, view its details (via the info icon), or request that a new one be created. Fires @@ -89,7 +117,14 @@ export function SelectInterlinearProjectModal({ 'Interlinearizer: skipped malformed project entries', parsed.length - valid.length, ); - setProjects(valid); + // Most-recently-modified first so the project the user is likeliest to reopen sits at the top + // and the modified date reads as a meaningful distinguisher between otherwise-identical + // unnamed projects. Ordered by parsed epoch time so it stays locale-independent (no + // `localeCompare`/collator involvement). + const sorted = [...valid].sort((a, b) => + compareUpdatedAtDescending(a.updatedAt, b.updatedAt), + ); + setProjects(sorted); } catch (e) { logger.error('Interlinearizer: failed to load projects for source', e); await papi.notifications @@ -126,24 +161,32 @@ export function SelectInterlinearProjectModal({