From b60a789f5cc75515062ab31f1fb8ac1983331d2e Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:53:20 +0400 Subject: [PATCH] feat(notes): add review preview and note sorting Add a review preview to the note editor, including basic/cloze preview states and cloze selection/reveal controls. Expand note sorting to support due date and progress, update shared sort typing to be field-generic, and propagate the new sort fields through the mock API, generated contract, and note/deck/folder services. Also add timestamp tooltips and DateText wrappers for relative date labels across note, deck, folder, workspace, and search surfaces. --- .mockapi/behavior.md | 2 +- api/mock-server/src/app.test.ts | 28 ++ .../src/features/decks/repository.ts | 4 +- api/mock-server/src/features/decks/service.ts | 4 +- .../src/features/folders/repository.ts | 4 +- .../src/features/folders/service.ts | 3 +- .../src/features/notes/repository.test.ts | 50 ++++ .../src/features/notes/repository.ts | 12 +- api/mock-server/src/features/notes/service.ts | 8 +- .../clear-web-api/contract/types.gen.ts | 6 +- .../clear-web-api/contract/zod.gen.ts | 11 +- api/openapi/shared/components.yaml | 8 +- ui/src/core/i18n/resources/ar.ts | 1 - ui/src/core/i18n/resources/bg.ts | 1 - ui/src/core/i18n/resources/bs.ts | 1 - ui/src/core/i18n/resources/ca.ts | 1 - ui/src/core/i18n/resources/cs.ts | 1 - ui/src/core/i18n/resources/da.ts | 1 - ui/src/core/i18n/resources/de.ts | 1 - ui/src/core/i18n/resources/el.ts | 1 - ui/src/core/i18n/resources/en-US.ts | 1 - ui/src/core/i18n/resources/es.ts | 1 - ui/src/core/i18n/resources/et.ts | 1 - ui/src/core/i18n/resources/fa.ts | 1 - ui/src/core/i18n/resources/fi.ts | 1 - ui/src/core/i18n/resources/fr.ts | 1 - ui/src/core/i18n/resources/he.ts | 1 - ui/src/core/i18n/resources/hr.ts | 1 - ui/src/core/i18n/resources/hu.ts | 1 - ui/src/core/i18n/resources/id.ts | 1 - ui/src/core/i18n/resources/it.ts | 1 - ui/src/core/i18n/resources/ja.ts | 1 - ui/src/core/i18n/resources/ko.ts | 1 - ui/src/core/i18n/resources/lt.ts | 1 - ui/src/core/i18n/resources/lv.ts | 1 - ui/src/core/i18n/resources/nb.ts | 1 - ui/src/core/i18n/resources/nl.ts | 1 - ui/src/core/i18n/resources/pl.ts | 1 - ui/src/core/i18n/resources/pt-BR.ts | 1 - ui/src/core/i18n/resources/ro.ts | 1 - ui/src/core/i18n/resources/ru.ts | 1 - ui/src/core/i18n/resources/sk.ts | 1 - ui/src/core/i18n/resources/sl.ts | 1 - ui/src/core/i18n/resources/sr-Latn.ts | 1 - ui/src/core/i18n/resources/sv.ts | 1 - ui/src/core/i18n/resources/th.ts | 1 - ui/src/core/i18n/resources/tr.ts | 1 - ui/src/core/i18n/resources/uk.ts | 1 - ui/src/core/i18n/resources/vi.ts | 1 - ui/src/core/i18n/resources/zh-Hans.ts | 1 - ui/src/core/i18n/resources/zh-Hant.ts | 1 - .../components/SearchResults.tsx | 5 +- .../dashboard/pages/DashboardPage.tsx | 24 +- .../decks/components/DeckCard.test.tsx | 22 ++ ui/src/features/decks/components/DeckCard.tsx | 10 +- .../decks/components/DeckList.test.tsx | 5 +- ui/src/features/decks/components/DeckList.tsx | 9 +- ui/src/features/decks/hooks/useDecks.ts | 11 +- ui/src/features/decks/index.ts | 9 +- .../features/decks/pages/DetailPage.test.tsx | 107 +++++++- ui/src/features/decks/pages/DetailPage.tsx | 13 +- ui/src/features/decks/services/deckService.ts | 12 +- ui/src/features/decks/types/deck.types.ts | 12 + .../folders/components/FolderList.stories.tsx | 2 +- .../folders/components/FolderList.tsx | 20 +- ui/src/features/folders/hooks/useFolders.ts | 14 +- ui/src/features/folders/index.ts | 8 +- ui/src/features/folders/pages/DetailPage.tsx | 24 +- .../folders/services/folderService.ts | 7 +- ui/src/features/folders/types/folder.types.ts | 13 + .../components/NoteDetailContent.test.tsx | 20 ++ .../notes/components/NoteDetailContent.tsx | 22 +- .../notes/components/NoteDetailPageView.tsx | 11 +- .../components/NoteEditorForm.stories.tsx | 63 +++++ .../notes/components/NoteEditorForm.test.tsx | 125 ++++++++- .../notes/components/NoteEditorForm.tsx | 239 +++++++++++------- .../notes/components/NoteList.stories.tsx | 2 +- .../notes/components/NoteList.test.tsx | 56 +++- ui/src/features/notes/components/NoteList.tsx | 184 ++++++++------ .../notes/components/NoteReviewPreview.tsx | 201 +++++++++++++++ ui/src/features/notes/hooks/useNotes.ts | 7 +- ui/src/features/notes/index.ts | 3 + .../features/notes/pages/DetailPage.test.tsx | 15 +- ui/src/features/notes/services/noteService.ts | 11 +- ui/src/features/notes/types/note.types.ts | 13 + .../trash/components/WorkspaceTrashList.tsx | 3 +- .../WorkspaceTrashSummary.stories.tsx | 2 + .../components/WorkspaceTrashSummary.test.tsx | 13 +- .../components/WorkspaceTrashSummary.tsx | 6 +- ui/src/features/trash/pages/TrashPage.tsx | 6 +- .../components/WorkspaceSpaceCard.tsx | 14 +- ui/src/platform/mock/mockApi.test.ts | 12 + .../services/decks/web/deckService.test.ts | 4 +- .../folders/web/folderService.test.ts | 4 +- .../services/folders/web/folderService.ts | 11 +- .../services/notes/web/noteService.test.ts | 4 +- .../services/notes/web/noteService.ts | 6 +- .../components/data/DateText.stories.tsx | 32 +++ .../shared/components/data/DateText.test.tsx | 46 ++++ ui/src/shared/components/data/DateText.tsx | 26 ++ .../data/InventoryListWithSort.stories.tsx | 16 +- .../data/InventoryListWithSort.test.tsx | 8 +- .../components/data/InventoryListWithSort.tsx | 13 +- .../components/data/SortMenu.stories.tsx | 2 +- .../shared/components/data/SortMenu.test.tsx | 4 +- ui/src/shared/components/data/SortMenu.tsx | 35 ++- ui/src/shared/lib/date-format.test.ts | 11 +- ui/src/shared/lib/date-format.ts | 18 +- .../shared/lib/translated-date-format.test.ts | 12 +- ui/src/shared/lib/translated-date-format.ts | 20 +- .../shared/services/api/adapters/sortQuery.ts | 2 +- .../api/generated/clear-api/types.gen.ts | 6 +- .../api/generated/clear-api/zod.gen.ts | 11 +- ui/src/shared/types/sort.types.ts | 10 +- ui/src/test/storybook/page-services.test.ts | 16 +- ui/src/test/storybook/page-services.ts | 65 +++-- 116 files changed, 1465 insertions(+), 451 deletions(-) create mode 100644 api/mock-server/src/features/notes/repository.test.ts create mode 100644 ui/src/features/notes/components/NoteReviewPreview.tsx create mode 100644 ui/src/shared/components/data/DateText.stories.tsx create mode 100644 ui/src/shared/components/data/DateText.test.tsx create mode 100644 ui/src/shared/components/data/DateText.tsx diff --git a/.mockapi/behavior.md b/.mockapi/behavior.md index 38a3806..aeeb52e 100644 --- a/.mockapi/behavior.md +++ b/.mockapi/behavior.md @@ -133,7 +133,7 @@ Require the visible deck and next parent, reject duplicate visible titles in the Status: inferred -Require the visible deck and return visible note list items for that deck. Support `sortField=title|updated` and `sortDirection=asc|desc`. +Require the visible deck and return visible note list items for that deck. Support `sortField=dueAt|progress|title|updatedAt` and `sortDirection=asc|desc`. ## operation:createNote diff --git a/api/mock-server/src/app.test.ts b/api/mock-server/src/app.test.ts index d39a806..946ea2f 100644 --- a/api/mock-server/src/app.test.ts +++ b/api/mock-server/src/app.test.ts @@ -247,6 +247,34 @@ describe('mock api app', () => { ) }) + it('sorts deck notes by due date', async () => { + const app = await newMockApiApp() + + const response = await app.fetch( + new Request('http://localhost/api/v1/decks/world-history/notes?sortField=dueAt&sortDirection=desc'), + ) + + expect(response.status).toBe(200) + await expect(json>(response)).resolves.toEqual([ + expect.objectContaining({ id: 'postwar-institutions' }), + expect.objectContaining({ id: 'industrial-revolution-causes' }), + ]) + }) + + it('sorts deck notes by progress', async () => { + const app = await newMockApiApp() + + const response = await app.fetch( + new Request('http://localhost/api/v1/decks/world-history/notes?sortField=progress&sortDirection=asc'), + ) + + expect(response.status).toBe(200) + await expect(json>(response)).resolves.toEqual([ + expect.objectContaining({ id: 'postwar-institutions' }), + expect.objectContaining({ id: 'industrial-revolution-causes' }), + ]) + }) + it('exposes admin state reset and inspection endpoints', async () => { const app = await newMockApiApp() diff --git a/api/mock-server/src/features/decks/repository.ts b/api/mock-server/src/features/decks/repository.ts index d848b0a..91a675e 100644 --- a/api/mock-server/src/features/decks/repository.ts +++ b/api/mock-server/src/features/decks/repository.ts @@ -5,7 +5,7 @@ import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' type SortDirection = 'asc' | 'desc' -type DeckSortField = 'dueToday' | 'title' | 'updated' +type DeckSortField = 'dueToday' | 'title' | 'updatedAt' const sortDecks = ( decks: DeckRecord[], @@ -23,7 +23,7 @@ const sortDecks = ( return (left.dueToday - right.dueToday) * direction } - if (sortField === 'updated') { + if (sortField === 'updatedAt') { return left.updatedAt.localeCompare(right.updatedAt) * direction } diff --git a/api/mock-server/src/features/decks/service.ts b/api/mock-server/src/features/decks/service.ts index 4845241..736940c 100644 --- a/api/mock-server/src/features/decks/service.ts +++ b/api/mock-server/src/features/decks/service.ts @@ -184,7 +184,9 @@ export class DeckService { return { sortDirection: query.sortDirection === 'desc' ? 'desc' : 'asc', sortField: - query.sortField === 'dueToday' || query.sortField === 'title' || query.sortField === 'updated' + query.sortField === 'dueToday' || + query.sortField === 'title' || + query.sortField === 'updatedAt' ? query.sortField : undefined, } as const diff --git a/api/mock-server/src/features/folders/repository.ts b/api/mock-server/src/features/folders/repository.ts index 1344581..4dae540 100644 --- a/api/mock-server/src/features/folders/repository.ts +++ b/api/mock-server/src/features/folders/repository.ts @@ -5,7 +5,7 @@ import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' type SortDirection = 'asc' | 'desc' -type FolderSortField = 'title' | 'updated' +type FolderSortField = 'title' | 'updatedAt' const sortFolders = ( folders: FolderRecord[], @@ -19,7 +19,7 @@ const sortFolders = ( const direction = sortDirection === 'desc' ? -1 : 1 return [...folders].sort((left, right) => { - if (sortField === 'updated') { + if (sortField === 'updatedAt') { return left.updatedAt.localeCompare(right.updatedAt) * direction } diff --git a/api/mock-server/src/features/folders/service.ts b/api/mock-server/src/features/folders/service.ts index 6ca7a69..f7fdfa9 100644 --- a/api/mock-server/src/features/folders/service.ts +++ b/api/mock-server/src/features/folders/service.ts @@ -181,7 +181,8 @@ export class FolderService { private parseSortQuery(query: { sortField?: string; sortDirection?: string } = {}) { return { sortDirection: query.sortDirection === 'desc' ? 'desc' : 'asc', - sortField: query.sortField === 'title' || query.sortField === 'updated' ? query.sortField : undefined, + sortField: + query.sortField === 'title' || query.sortField === 'updatedAt' ? query.sortField : undefined, } as const } diff --git a/api/mock-server/src/features/notes/repository.test.ts b/api/mock-server/src/features/notes/repository.test.ts new file mode 100644 index 0000000..0215ee1 --- /dev/null +++ b/api/mock-server/src/features/notes/repository.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { newMemoryMockStateStore } from '../../lib/stateStore.ts' +import { NotesRepository } from './repository.ts' + +describe('NotesRepository', () => { + it('sorts deck notes by due date', async () => { + const notes = new NotesRepository(await newMemoryMockStateStore()) + + expect( + notes + .listByDeck('world-history', { + sortDirection: 'asc', + sortField: 'dueAt', + }) + .map((note) => note.id), + ).toEqual(['industrial-revolution-causes', 'postwar-institutions']) + + expect( + notes + .listByDeck('world-history', { + sortDirection: 'desc', + sortField: 'dueAt', + }) + .map((note) => note.id), + ).toEqual(['postwar-institutions', 'industrial-revolution-causes']) + }) + + it('sorts deck notes by progress', async () => { + const notes = new NotesRepository(await newMemoryMockStateStore()) + + expect( + notes + .listByDeck('world-history', { + sortDirection: 'asc', + sortField: 'progress', + }) + .map((note) => note.id), + ).toEqual(['postwar-institutions', 'industrial-revolution-causes']) + + expect( + notes + .listByDeck('world-history', { + sortDirection: 'desc', + sortField: 'progress', + }) + .map((note) => note.id), + ).toEqual(['industrial-revolution-causes', 'postwar-institutions']) + }) +}) diff --git a/api/mock-server/src/features/notes/repository.ts b/api/mock-server/src/features/notes/repository.ts index 754b609..e4cb433 100644 --- a/api/mock-server/src/features/notes/repository.ts +++ b/api/mock-server/src/features/notes/repository.ts @@ -5,7 +5,7 @@ import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' type SortDirection = 'asc' | 'desc' -type NoteSortField = 'title' | 'updated' +type NoteSortField = 'dueAt' | 'progress' | 'title' | 'updatedAt' const sortNotes = ( notes: NoteDetailRecord[], @@ -19,7 +19,15 @@ const sortNotes = ( const direction = sortDirection === 'desc' ? -1 : 1 return [...notes].sort((left, right) => { - if (sortField === 'updated') { + if (sortField === 'dueAt') { + return (new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime()) * direction + } + + if (sortField === 'progress') { + return (left.progress - right.progress) * direction + } + + if (sortField === 'updatedAt') { return left.updatedAt.localeCompare(right.updatedAt) * direction } diff --git a/api/mock-server/src/features/notes/service.ts b/api/mock-server/src/features/notes/service.ts index 5d7069e..a53058a 100644 --- a/api/mock-server/src/features/notes/service.ts +++ b/api/mock-server/src/features/notes/service.ts @@ -49,7 +49,13 @@ export class NotesService { listNotesByDeck(deckId: string, query?: { sortField?: string; sortDirection?: string }): NoteListItem[] { const deck = this.decks.require(deckId) const sortQuery = query ?? {} - const sortField = sortQuery.sortField === 'title' || sortQuery.sortField === 'updated' ? sortQuery.sortField : undefined + const sortField = + sortQuery.sortField === 'dueAt' || + sortQuery.sortField === 'progress' || + sortQuery.sortField === 'title' || + sortQuery.sortField === 'updatedAt' + ? sortQuery.sortField + : undefined const sortDirection = sortQuery.sortDirection === 'desc' ? 'desc' : 'asc' return this.notes.listByDeck(deck.id ?? '', { sortField, sortDirection }).map(toNoteListItem) diff --git a/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts b/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts index d74eec6..0ef189f 100644 --- a/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts +++ b/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts @@ -447,9 +447,9 @@ export type Workspaces = unknown; export type DateTime = string; -export type DeckSortField = 'dueToday' | 'title' | 'updated'; +export type DeckSortField = 'dueToday' | 'title' | 'updatedAt'; -export type FolderSortField = 'title' | 'updated'; +export type FolderSortField = 'title' | 'updatedAt'; export type Id = string; @@ -463,7 +463,7 @@ export type MessageProblemDetails = { entityId?: string; }; -export type NoteSortField = 'title' | 'updated'; +export type NoteSortField = 'dueAt' | 'progress' | 'title' | 'updatedAt'; export type ComponentsProblemDetails = ({ type: '/problems/validation'; diff --git a/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts b/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts index e112093..5c02c23 100644 --- a/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts +++ b/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts @@ -139,10 +139,10 @@ export const zDateTime = z.iso.datetime(); export const zDeckSortField = z.enum([ 'dueToday', 'title', - 'updated' + 'updatedAt' ]); -export const zFolderSortField = z.enum(['title', 'updated']); +export const zFolderSortField = z.enum(['title', 'updatedAt']); export const zId = z.string().min(1); @@ -407,7 +407,12 @@ export const zMessageProblemDetails = z.object({ entityId: z.string().optional() }); -export const zNoteSortField = z.enum(['title', 'updated']); +export const zNoteSortField = z.enum([ + 'dueAt', + 'progress', + 'title', + 'updatedAt' +]); export const zSortDirection = z.enum(['asc', 'desc']); diff --git a/api/openapi/shared/components.yaml b/api/openapi/shared/components.yaml index 9e63bbb..7370265 100644 --- a/api/openapi/shared/components.yaml +++ b/api/openapi/shared/components.yaml @@ -197,21 +197,23 @@ components: enum: - dueToday - title - - updated + - updatedAt example: title FolderSortField: type: string enum: - title - - updated + - updatedAt example: title NoteSortField: type: string enum: + - dueAt + - progress - title - - updated + - updatedAt example: title SortDirection: diff --git a/ui/src/core/i18n/resources/ar.ts b/ui/src/core/i18n/resources/ar.ts index cc7110a..fa3dc0f 100644 --- a/ui/src/core/i18n/resources/ar.ts +++ b/ui/src/core/i18n/resources/ar.ts @@ -132,7 +132,6 @@ export const ar = { }, labels: { deleted: 'حذف {{value}}', - due: 'مستحق: {{value}}', reviewed: 'تمت المراجعة: {{value}}', updated: 'تم التحديث {{value}}', updatedUppercase: 'تم التحديث {{value}}', diff --git a/ui/src/core/i18n/resources/bg.ts b/ui/src/core/i18n/resources/bg.ts index 83a8440..171cfb9 100644 --- a/ui/src/core/i18n/resources/bg.ts +++ b/ui/src/core/i18n/resources/bg.ts @@ -116,7 +116,6 @@ export const bg = { }, labels: { deleted: 'Изтрито {{value}}', - due: 'Срок: {{value}}', reviewed: 'Преговорено: {{value}}', updated: 'Обновено {{value}}', updatedUppercase: 'ОБНОВЕНО {{value}}', diff --git a/ui/src/core/i18n/resources/bs.ts b/ui/src/core/i18n/resources/bs.ts index 84b3692..8b132a4 100644 --- a/ui/src/core/i18n/resources/bs.ts +++ b/ui/src/core/i18n/resources/bs.ts @@ -120,7 +120,6 @@ export const bs = { }, labels: { deleted: 'Izbrisano {{value}}', - due: 'Rok: {{value}}', reviewed: 'Ponovljeno: {{value}}', updated: 'Ažurirano {{value}}', updatedUppercase: 'AŽURIRANO {{value}}', diff --git a/ui/src/core/i18n/resources/ca.ts b/ui/src/core/i18n/resources/ca.ts index dba66f4..c656a13 100644 --- a/ui/src/core/i18n/resources/ca.ts +++ b/ui/src/core/i18n/resources/ca.ts @@ -120,7 +120,6 @@ export const ca = { }, labels: { deleted: 'Suprimit {{value}}', - due: 'Venciment: {{value}}', reviewed: 'Repassat: {{value}}', updated: 'Actualitzat {{value}}', updatedUppercase: 'ACTUALITZAT {{value}}', diff --git a/ui/src/core/i18n/resources/cs.ts b/ui/src/core/i18n/resources/cs.ts index f8a7f5e..f4beeb2 100644 --- a/ui/src/core/i18n/resources/cs.ts +++ b/ui/src/core/i18n/resources/cs.ts @@ -124,7 +124,6 @@ export const cs = { }, labels: { deleted: 'Smazáno {{value}}', - due: 'Termín: {{value}}', reviewed: 'Opakováno: {{value}}', updated: 'Aktualizováno {{value}}', updatedUppercase: 'AKTUALIZOVÁNO {{value}}', diff --git a/ui/src/core/i18n/resources/da.ts b/ui/src/core/i18n/resources/da.ts index 6861ef5..bef711b 100644 --- a/ui/src/core/i18n/resources/da.ts +++ b/ui/src/core/i18n/resources/da.ts @@ -116,7 +116,6 @@ export const da = { }, labels: { deleted: 'Slettet {{value}}', - due: 'Forfalder: {{value}}', reviewed: 'Gentaget: {{value}}', updated: 'Opdateret {{value}}', updatedUppercase: 'OPDATERET {{value}}', diff --git a/ui/src/core/i18n/resources/de.ts b/ui/src/core/i18n/resources/de.ts index 9ce179a..5ab6c80 100644 --- a/ui/src/core/i18n/resources/de.ts +++ b/ui/src/core/i18n/resources/de.ts @@ -116,7 +116,6 @@ export const de = { }, labels: { deleted: 'Gelöscht {{value}}', - due: 'Fällig: {{value}}', reviewed: 'Wiederholt: {{value}}', updated: 'Aktualisiert {{value}}', updatedUppercase: 'AKTUALISIERT {{value}}', diff --git a/ui/src/core/i18n/resources/el.ts b/ui/src/core/i18n/resources/el.ts index e85d975..faae02a 100644 --- a/ui/src/core/i18n/resources/el.ts +++ b/ui/src/core/i18n/resources/el.ts @@ -116,7 +116,6 @@ export const el = { }, labels: { deleted: 'Διαγράφηκε {{value}}', - due: 'Προθεσμία: {{value}}', reviewed: 'Επαναλήφθηκε: {{value}}', updated: 'Ενημερώθηκε {{value}}', updatedUppercase: 'ΕΝΗΜΕΡΩΘΗΚΕ {{value}}', diff --git a/ui/src/core/i18n/resources/en-US.ts b/ui/src/core/i18n/resources/en-US.ts index e96fcda..669da3b 100644 --- a/ui/src/core/i18n/resources/en-US.ts +++ b/ui/src/core/i18n/resources/en-US.ts @@ -116,7 +116,6 @@ export const enUS = { }, labels: { deleted: 'Deleted {{value}}', - due: 'Due: {{value}}', reviewed: 'Reviewed: {{value}}', updated: 'Updated {{value}}', updatedUppercase: 'UPDATED {{value}}', diff --git a/ui/src/core/i18n/resources/es.ts b/ui/src/core/i18n/resources/es.ts index 699a036..2e390f1 100644 --- a/ui/src/core/i18n/resources/es.ts +++ b/ui/src/core/i18n/resources/es.ts @@ -116,7 +116,6 @@ export const es = { }, labels: { deleted: 'Eliminado {{value}}', - due: 'Vence: {{value}}', reviewed: 'Repasado: {{value}}', updated: 'Actualizado {{value}}', updatedUppercase: 'ACTUALIZADO {{value}}', diff --git a/ui/src/core/i18n/resources/et.ts b/ui/src/core/i18n/resources/et.ts index 2a050a4..2c04fb4 100644 --- a/ui/src/core/i18n/resources/et.ts +++ b/ui/src/core/i18n/resources/et.ts @@ -116,7 +116,6 @@ export const et = { }, labels: { deleted: 'Kustutatud {{value}}', - due: 'Tähtaeg: {{value}}', reviewed: 'Korratud: {{value}}', updated: 'Uuendatud {{value}}', updatedUppercase: 'UUENDATUD {{value}}', diff --git a/ui/src/core/i18n/resources/fa.ts b/ui/src/core/i18n/resources/fa.ts index 6c2d81d..db58afa 100644 --- a/ui/src/core/i18n/resources/fa.ts +++ b/ui/src/core/i18n/resources/fa.ts @@ -116,7 +116,6 @@ export const fa = { }, labels: { deleted: 'حذف‌شده {{value}}', - due: 'موعد: {{value}}', reviewed: 'مرور شد: {{value}}', updated: 'به‌روزرسانی‌شده {{value}}', updatedUppercase: 'به‌روزرسانی‌شده {{value}}', diff --git a/ui/src/core/i18n/resources/fi.ts b/ui/src/core/i18n/resources/fi.ts index 2f52ab6..166f9f3 100644 --- a/ui/src/core/i18n/resources/fi.ts +++ b/ui/src/core/i18n/resources/fi.ts @@ -116,7 +116,6 @@ export const fi = { }, labels: { deleted: 'Poistettu {{value}}', - due: 'Erääntyy: {{value}}', reviewed: 'Kerrattu: {{value}}', updated: 'Päivitetty {{value}}', updatedUppercase: 'PÄIVITETTY {{value}}', diff --git a/ui/src/core/i18n/resources/fr.ts b/ui/src/core/i18n/resources/fr.ts index 230eb8d..3cfb1f7 100644 --- a/ui/src/core/i18n/resources/fr.ts +++ b/ui/src/core/i18n/resources/fr.ts @@ -116,7 +116,6 @@ export const fr = { }, labels: { deleted: 'Supprimé {{value}}', - due: 'À réviser : {{value}}', reviewed: 'Révisé : {{value}}', updated: 'Mis à jour {{value}}', updatedUppercase: 'MIS À JOUR {{value}}', diff --git a/ui/src/core/i18n/resources/he.ts b/ui/src/core/i18n/resources/he.ts index d2321dd..b762421 100644 --- a/ui/src/core/i18n/resources/he.ts +++ b/ui/src/core/i18n/resources/he.ts @@ -120,7 +120,6 @@ export const he = { }, labels: { deleted: 'נמחק {{value}}', - due: 'מועד: {{value}}', reviewed: 'נסקר: {{value}}', updated: 'עודכן {{value}}', updatedUppercase: 'עודכן {{value}}', diff --git a/ui/src/core/i18n/resources/hr.ts b/ui/src/core/i18n/resources/hr.ts index 6b17495..19449e2 100644 --- a/ui/src/core/i18n/resources/hr.ts +++ b/ui/src/core/i18n/resources/hr.ts @@ -120,7 +120,6 @@ export const hr = { }, labels: { deleted: 'Izbrisano {{value}}', - due: 'Rok: {{value}}', reviewed: 'Ponovljeno: {{value}}', updated: 'Ažurirano {{value}}', updatedUppercase: 'AŽURIRANO {{value}}', diff --git a/ui/src/core/i18n/resources/hu.ts b/ui/src/core/i18n/resources/hu.ts index 127f1af..3a82899 100644 --- a/ui/src/core/i18n/resources/hu.ts +++ b/ui/src/core/i18n/resources/hu.ts @@ -116,7 +116,6 @@ export const hu = { }, labels: { deleted: 'Törölve {{value}}', - due: 'Esedékes: {{value}}', reviewed: 'Ismételve: {{value}}', updated: 'Frissítve {{value}}', updatedUppercase: 'FRISSÍTVE {{value}}', diff --git a/ui/src/core/i18n/resources/id.ts b/ui/src/core/i18n/resources/id.ts index 9a08d42..f6ba562 100644 --- a/ui/src/core/i18n/resources/id.ts +++ b/ui/src/core/i18n/resources/id.ts @@ -116,7 +116,6 @@ export const id = { }, labels: { deleted: 'Dihapus {{value}}', - due: 'Jatuh tempo: {{value}}', reviewed: 'Diulas: {{value}}', updated: 'Diperbarui {{value}}', updatedUppercase: 'DIPERBARUI {{value}}', diff --git a/ui/src/core/i18n/resources/it.ts b/ui/src/core/i18n/resources/it.ts index 6ec92e7..17d7fe7 100644 --- a/ui/src/core/i18n/resources/it.ts +++ b/ui/src/core/i18n/resources/it.ts @@ -120,7 +120,6 @@ export const it = { }, labels: { deleted: 'Eliminato {{value}}', - due: 'In scadenza: {{value}}', reviewed: 'Ripassato: {{value}}', updated: 'Aggiornato {{value}}', updatedUppercase: 'AGGIORNATO {{value}}', diff --git a/ui/src/core/i18n/resources/ja.ts b/ui/src/core/i18n/resources/ja.ts index 56f2e90..8b52362 100644 --- a/ui/src/core/i18n/resources/ja.ts +++ b/ui/src/core/i18n/resources/ja.ts @@ -116,7 +116,6 @@ export const ja = { }, labels: { deleted: '{{value}}に削除', - due: '期限: {{value}}', reviewed: '復習済み: {{value}}', updated: '{{value}}に更新', updatedUppercase: '{{value}}に更新', diff --git a/ui/src/core/i18n/resources/ko.ts b/ui/src/core/i18n/resources/ko.ts index 361e803..3235457 100644 --- a/ui/src/core/i18n/resources/ko.ts +++ b/ui/src/core/i18n/resources/ko.ts @@ -116,7 +116,6 @@ export const ko = { }, labels: { deleted: '{{value}} 삭제됨', - due: '기한: {{value}}', reviewed: '복습함: {{value}}', updated: '{{value}} 업데이트됨', updatedUppercase: '{{value}} 업데이트됨', diff --git a/ui/src/core/i18n/resources/lt.ts b/ui/src/core/i18n/resources/lt.ts index ef6ba50..d5c224c 100644 --- a/ui/src/core/i18n/resources/lt.ts +++ b/ui/src/core/i18n/resources/lt.ts @@ -124,7 +124,6 @@ export const lt = { }, labels: { deleted: 'Ištrinta {{value}}', - due: 'Terminas: {{value}}', reviewed: 'Kartota: {{value}}', updated: 'Atnaujinta {{value}}', updatedUppercase: 'ATNAUJINTA {{value}}', diff --git a/ui/src/core/i18n/resources/lv.ts b/ui/src/core/i18n/resources/lv.ts index 2b70aa8..9fa1a96 100644 --- a/ui/src/core/i18n/resources/lv.ts +++ b/ui/src/core/i18n/resources/lv.ts @@ -120,7 +120,6 @@ export const lv = { }, labels: { deleted: 'Dzēsts {{value}}', - due: 'Termiņš: {{value}}', reviewed: 'Atkārtots: {{value}}', updated: 'Atjaunināts {{value}}', updatedUppercase: 'ATJAUNINĀTS {{value}}', diff --git a/ui/src/core/i18n/resources/nb.ts b/ui/src/core/i18n/resources/nb.ts index 4eae091..9a8fdf1 100644 --- a/ui/src/core/i18n/resources/nb.ts +++ b/ui/src/core/i18n/resources/nb.ts @@ -116,7 +116,6 @@ export const nb = { }, labels: { deleted: 'Slettet {{value}}', - due: 'Frist: {{value}}', reviewed: 'Repetert: {{value}}', updated: 'Oppdatert {{value}}', updatedUppercase: 'OPPDATERT {{value}}', diff --git a/ui/src/core/i18n/resources/nl.ts b/ui/src/core/i18n/resources/nl.ts index 392daeb..59d4b44 100644 --- a/ui/src/core/i18n/resources/nl.ts +++ b/ui/src/core/i18n/resources/nl.ts @@ -116,7 +116,6 @@ export const nl = { }, labels: { deleted: 'Verwijderd {{value}}', - due: 'Te doen: {{value}}', reviewed: 'Herhaald: {{value}}', updated: 'Bijgewerkt {{value}}', updatedUppercase: 'BIJGEWERKT {{value}}', diff --git a/ui/src/core/i18n/resources/pl.ts b/ui/src/core/i18n/resources/pl.ts index d0e3f43..a40509e 100644 --- a/ui/src/core/i18n/resources/pl.ts +++ b/ui/src/core/i18n/resources/pl.ts @@ -124,7 +124,6 @@ export const pl = { }, labels: { deleted: 'Usunięto {{value}}', - due: 'Do powtórki: {{value}}', reviewed: 'Powtórzono: {{value}}', updated: 'Zaktualizowano {{value}}', updatedUppercase: 'ZAKTUALIZOWANO {{value}}', diff --git a/ui/src/core/i18n/resources/pt-BR.ts b/ui/src/core/i18n/resources/pt-BR.ts index feebcc5..8acefc0 100644 --- a/ui/src/core/i18n/resources/pt-BR.ts +++ b/ui/src/core/i18n/resources/pt-BR.ts @@ -116,7 +116,6 @@ export const ptBR = { }, labels: { deleted: 'Excluído {{value}}', - due: 'Vence: {{value}}', reviewed: 'Revisado: {{value}}', updated: 'Atualizado {{value}}', updatedUppercase: 'ATUALIZADO {{value}}', diff --git a/ui/src/core/i18n/resources/ro.ts b/ui/src/core/i18n/resources/ro.ts index 248cb29..f47d29f 100644 --- a/ui/src/core/i18n/resources/ro.ts +++ b/ui/src/core/i18n/resources/ro.ts @@ -120,7 +120,6 @@ export const ro = { }, labels: { deleted: 'Șters {{value}}', - due: 'Scadent: {{value}}', reviewed: 'Recapitulat: {{value}}', updated: 'Actualizat {{value}}', updatedUppercase: 'ACTUALIZAT {{value}}', diff --git a/ui/src/core/i18n/resources/ru.ts b/ui/src/core/i18n/resources/ru.ts index 1770d35..72a01cb 100644 --- a/ui/src/core/i18n/resources/ru.ts +++ b/ui/src/core/i18n/resources/ru.ts @@ -124,7 +124,6 @@ export const ru = { }, labels: { deleted: 'Удалено {{value}}', - due: 'К повторению: {{value}}', reviewed: 'Повторено: {{value}}', updated: 'Обновлено {{value}}', updatedUppercase: 'ОБНОВЛЕНО {{value}}', diff --git a/ui/src/core/i18n/resources/sk.ts b/ui/src/core/i18n/resources/sk.ts index dfbbda0..818e7ba 100644 --- a/ui/src/core/i18n/resources/sk.ts +++ b/ui/src/core/i18n/resources/sk.ts @@ -124,7 +124,6 @@ export const sk = { }, labels: { deleted: 'Odstránené {{value}}', - due: 'Termín: {{value}}', reviewed: 'Opakované: {{value}}', updated: 'Aktualizované {{value}}', updatedUppercase: 'AKTUALIZOVANÉ {{value}}', diff --git a/ui/src/core/i18n/resources/sl.ts b/ui/src/core/i18n/resources/sl.ts index b7dbc29..fd29676 100644 --- a/ui/src/core/i18n/resources/sl.ts +++ b/ui/src/core/i18n/resources/sl.ts @@ -124,7 +124,6 @@ export const sl = { }, labels: { deleted: 'Izbrisano {{value}}', - due: 'Rok: {{value}}', reviewed: 'Ponovljeno: {{value}}', updated: 'Posodobljeno {{value}}', updatedUppercase: 'POSODOBLJENO {{value}}', diff --git a/ui/src/core/i18n/resources/sr-Latn.ts b/ui/src/core/i18n/resources/sr-Latn.ts index 265443b..3df5e78 100644 --- a/ui/src/core/i18n/resources/sr-Latn.ts +++ b/ui/src/core/i18n/resources/sr-Latn.ts @@ -120,7 +120,6 @@ export const srLatn = { }, labels: { deleted: 'Obrisano {{value}}', - due: 'Rok: {{value}}', reviewed: 'Ponovljeno: {{value}}', updated: 'Ažurirano {{value}}', updatedUppercase: 'AŽURIRANO {{value}}', diff --git a/ui/src/core/i18n/resources/sv.ts b/ui/src/core/i18n/resources/sv.ts index 3074ce4..7c21a39 100644 --- a/ui/src/core/i18n/resources/sv.ts +++ b/ui/src/core/i18n/resources/sv.ts @@ -116,7 +116,6 @@ export const sv = { }, labels: { deleted: 'Borttaget {{value}}', - due: 'Förfaller: {{value}}', reviewed: 'Repeterat: {{value}}', updated: 'Uppdaterat {{value}}', updatedUppercase: 'UPPDATERAT {{value}}', diff --git a/ui/src/core/i18n/resources/th.ts b/ui/src/core/i18n/resources/th.ts index 8ef5806..06086ec 100644 --- a/ui/src/core/i18n/resources/th.ts +++ b/ui/src/core/i18n/resources/th.ts @@ -116,7 +116,6 @@ export const th = { }, labels: { deleted: 'ลบแล้ว {{value}}', - due: 'ครบกำหนด: {{value}}', reviewed: 'ทบทวนแล้ว: {{value}}', updated: 'อัปเดตแล้ว {{value}}', updatedUppercase: 'อัปเดตแล้ว {{value}}', diff --git a/ui/src/core/i18n/resources/tr.ts b/ui/src/core/i18n/resources/tr.ts index 9c52af4..01a9b36 100644 --- a/ui/src/core/i18n/resources/tr.ts +++ b/ui/src/core/i18n/resources/tr.ts @@ -116,7 +116,6 @@ export const tr = { }, labels: { deleted: '{{value}} silindi', - due: 'Sıra zamanı: {{value}}', reviewed: 'Tekrarlandı: {{value}}', updated: '{{value}} güncellendi', updatedUppercase: '{{value}} GÜNCELLENDİ', diff --git a/ui/src/core/i18n/resources/uk.ts b/ui/src/core/i18n/resources/uk.ts index fcc995e..baab19c 100644 --- a/ui/src/core/i18n/resources/uk.ts +++ b/ui/src/core/i18n/resources/uk.ts @@ -124,7 +124,6 @@ export const uk = { }, labels: { deleted: 'Видалено {{value}}', - due: 'До повторення: {{value}}', reviewed: 'Повторено: {{value}}', updated: 'Оновлено {{value}}', updatedUppercase: 'ОНОВЛЕНО {{value}}', diff --git a/ui/src/core/i18n/resources/vi.ts b/ui/src/core/i18n/resources/vi.ts index 3df162f..0186c79 100644 --- a/ui/src/core/i18n/resources/vi.ts +++ b/ui/src/core/i18n/resources/vi.ts @@ -116,7 +116,6 @@ export const vi = { }, labels: { deleted: 'Đã xóa {{value}}', - due: 'Đến hạn: {{value}}', reviewed: 'Đã ôn tập: {{value}}', updated: 'Đã cập nhật {{value}}', updatedUppercase: 'ĐÃ CẬP NHẬT {{value}}', diff --git a/ui/src/core/i18n/resources/zh-Hans.ts b/ui/src/core/i18n/resources/zh-Hans.ts index 18af59c..e4e0a48 100644 --- a/ui/src/core/i18n/resources/zh-Hans.ts +++ b/ui/src/core/i18n/resources/zh-Hans.ts @@ -116,7 +116,6 @@ export const zhHans = { }, labels: { deleted: '已删除 {{value}}', - due: '到期:{{value}}', reviewed: '已复习:{{value}}', updated: '已更新 {{value}}', updatedUppercase: '已更新 {{value}}', diff --git a/ui/src/core/i18n/resources/zh-Hant.ts b/ui/src/core/i18n/resources/zh-Hant.ts index f74c105..a25e71e 100644 --- a/ui/src/core/i18n/resources/zh-Hant.ts +++ b/ui/src/core/i18n/resources/zh-Hant.ts @@ -116,7 +116,6 @@ export const zhHant = { }, labels: { deleted: '已刪除 {{value}}', - due: '到期:{{value}}', reviewed: '已複習:{{value}}', updated: '已更新 {{value}}', updatedUppercase: '已更新 {{value}}', diff --git a/ui/src/features/content-search/components/SearchResults.tsx b/ui/src/features/content-search/components/SearchResults.tsx index c3e9b45..6da6ae6 100644 --- a/ui/src/features/content-search/components/SearchResults.tsx +++ b/ui/src/features/content-search/components/SearchResults.tsx @@ -3,6 +3,7 @@ import { Braces, ChevronRight, FileText, Folder } from 'lucide-react' import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { DateText } from '@shared/components/data/DateText' import { LazyIconGlyph } from '@shared/components/icons/IconGlyph' import { Card } from '@shared/components/ui/card' import { Button } from '@shared/components/ui/button' @@ -192,7 +193,9 @@ const SearchResultRow = ({ result }: { result: SearchResult }) => { - {formatRelativeDate(result.updatedAt)} + + {formatRelativeDate(result.updatedAt)} + diff --git a/ui/src/features/dashboard/pages/DashboardPage.tsx b/ui/src/features/dashboard/pages/DashboardPage.tsx index 5e37778..4f21fb6 100644 --- a/ui/src/features/dashboard/pages/DashboardPage.tsx +++ b/ui/src/features/dashboard/pages/DashboardPage.tsx @@ -5,7 +5,11 @@ import { useTranslation } from 'react-i18next' import { useDeleteDeck, useWorkspaceRootDecks } from '@features/decks/hooks/useDecks' import { DeckList } from '@features/decks/components/DeckList' -import type { Deck } from '@features/decks' +import { + deckSortFields, + defaultDeckSortPreference, + type Deck, +} from '@features/decks' import { SearchResults, useContentSearch, @@ -16,7 +20,11 @@ import { useWorkspaceRootFolders, } from '@features/folders/hooks/useFolders' import { FolderList } from '@features/folders/components/FolderList' -import type { Folder } from '@features/folders' +import { + defaultFolderSortPreference, + folderSortFields, + type Folder, +} from '@features/folders' import { useActiveWorkspaceId, useDeleteWorkspace, @@ -48,8 +56,16 @@ import { export const DashboardPage = ({ workspaceId }: { workspaceId: string }) => { const { t } = useTranslation() const navigate = useNavigate() - const [folderSort, setFolderSort] = usePersistedSort('workspace-sort:folders') - const [deckSort, setDeckSort] = usePersistedSort('workspace-sort:decks') + const [folderSort, setFolderSort] = usePersistedSort( + 'workspace-sort:folders', + defaultFolderSortPreference, + folderSortFields, + ) + const [deckSort, setDeckSort] = usePersistedSort( + 'workspace-sort:decks', + defaultDeckSortPreference, + deckSortFields, + ) const workspaceQuery = useWorkspace(workspaceId) const activeWorkspaceIdQuery = useActiveWorkspaceId() const foldersQuery = useWorkspaceRootFolders(workspaceId, folderSort) diff --git a/ui/src/features/decks/components/DeckCard.test.tsx b/ui/src/features/decks/components/DeckCard.test.tsx index 206f044..082dd1f 100644 --- a/ui/src/features/decks/components/DeckCard.test.tsx +++ b/ui/src/features/decks/components/DeckCard.test.tsx @@ -297,6 +297,28 @@ describe('DeckCard', () => { expect(onDelete).toHaveBeenCalledWith(baseDeck) }) + it('adds the absolute updated date tooltip to the row overlay control', () => { + const callbacks = { + onDelete: vi.fn(), + onEdit: vi.fn(), + onOpen: vi.fn(), + onReview: vi.fn(), + } + + render( + , + ) + + expect(screen.getByRole('button', { name: 'Open Biology deck' })).toHaveAttribute( + 'title', + '24.04.2026 12:00', + ) + }) + it('opens deck details from keyboard interaction', async () => { const user = userEvent.setup() renderDeckRoute() diff --git a/ui/src/features/decks/components/DeckCard.tsx b/ui/src/features/decks/components/DeckCard.tsx index 5eaf49f..9496aa4 100644 --- a/ui/src/features/decks/components/DeckCard.tsx +++ b/ui/src/features/decks/components/DeckCard.tsx @@ -3,6 +3,7 @@ import { ArrowRight, Clock3, Pencil, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { LazyIconGlyph } from '@shared/components/icons/IconGlyph' +import { DateText } from '@shared/components/data/DateText' import { InventoryRowShell, inventoryRowOverlayClassName, @@ -45,8 +46,9 @@ export const DeckCard = ({ surface = 'card', }: DeckCardProps) => { const { t } = useTranslation() - const { formatRelativeDate } = useDateFormatters() + const { formatAbsoluteDateTime, formatRelativeDate } = useDateFormatters() const deckTitle = deck.title.trim() + const updatedAtTitle = formatAbsoluteDateTime(deck.updatedAt) const dueToday = normalizeNonNegativeInteger(deck.dueToday) const dueTodayLabel = formatNonNegativeInteger(deck.dueToday) const hasDueToday = dueToday > 0 @@ -70,6 +72,7 @@ export const DeckCard = ({ + ))} + + ) : null} + +
+ {t(($) => $.notes.labels.noteBody)} + {body ? ( + + ) : ( + {t(($) => $.notes.fields.bodyPlaceholder)} + )} +
+ + {clozeIds.length === 0 ? ( +
+

+ {t(($) => $.notes.labels.clozeFormat)} +

+

+ {t(($) => $.notes.descriptions.clozeFormatPrefix)}{' '} + {'{{c1::...}}'}.{' '} + {t(($) => $.notes.descriptions.clozeFormat)} +

+
+ ) : null} + + ) +} diff --git a/ui/src/features/notes/hooks/useNotes.ts b/ui/src/features/notes/hooks/useNotes.ts index d1245c3..47b8331 100644 --- a/ui/src/features/notes/hooks/useNotes.ts +++ b/ui/src/features/notes/hooks/useNotes.ts @@ -7,18 +7,17 @@ import { import { unwrapDomainResult } from '@core/query/domain-query' import { useServices } from '@core/services' -import type { SortPreference } from '@shared/types/sort.types' -import type { NoteDraft } from '../types/note.types' +import type { NoteDraft, NoteSortPreference } from '../types/note.types' export const noteKeys = { all: ['notes'] as const, - deck: (deckId: string, sort?: SortPreference) => + deck: (deckId: string, sort?: NoteSortPreference) => ['notes', 'deck', deckId, sort ?? 'default'] as const, detail: (deckId: string, noteId: string) => ['notes', deckId, noteId] as const, } -export const useNotesByDeck = (deckId: string, sort?: SortPreference) => { +export const useNotesByDeck = (deckId: string, sort?: NoteSortPreference) => { const { notes } = useServices() return useQuery({ diff --git a/ui/src/features/notes/index.ts b/ui/src/features/notes/index.ts index 2b1b5fc..8df7539 100644 --- a/ui/src/features/notes/index.ts +++ b/ui/src/features/notes/index.ts @@ -1,5 +1,6 @@ export { NoteDetailPage } from './pages/DetailPage' export { NoteEditorPage } from './pages/EditorPage' +export { defaultNoteSortPreference, noteSortFields } from './types/note.types' export type { BasicNote, BasicNoteEditor, @@ -11,4 +12,6 @@ export type { NoteKind, NoteListItem, NoteRef, + NoteSortField, + NoteSortPreference, } from './types/note.types' diff --git a/ui/src/features/notes/pages/DetailPage.test.tsx b/ui/src/features/notes/pages/DetailPage.test.tsx index 659ce09..1e00427 100644 --- a/ui/src/features/notes/pages/DetailPage.test.tsx +++ b/ui/src/features/notes/pages/DetailPage.test.tsx @@ -6,6 +6,7 @@ import { createAppServices } from '@core/services' import { renderRoute } from '@/test/renderRoute' import { mockMatchMedia } from '@/test/matchMedia' import { domainError, err } from '@shared/errors' +import { formatDueLabel } from '@shared/lib/date-format' const createDeferred = () => { let resolve!: (value: T) => void @@ -251,6 +252,18 @@ describe('NoteDetailPage', () => { it('keeps derived card timing in desktop cloze content instead of the right panel', async () => { mockMatchMedia(true) + const baseServices = createAppServices('mock') + const noteResult = await baseServices.notes.getById( + 'cognitive-biases', + 'availability-heuristic', + ) + + if (!noteResult.ok) { + throw new Error('Expected availability-heuristic note fixture to exist.') + } + + const dueLabel = formatDueLabel(noteResult.value.dueAt) + renderRoute('/dashboard/independent-study/decks/cognitive-biases/notes/availability-heuristic') expect( @@ -265,7 +278,7 @@ describe('NoteDetailPage', () => { within(noteContent).getByRole('button', { name: 'Show derived cards note' }), ).toHaveAttribute('aria-expanded', 'false') expect(within(noteContent).getAllByText(/Reviewed:/).length).toBeGreaterThan(0) - expect(within(noteContent).getAllByText(/Due:/).length).toBeGreaterThan(0) + expect(noteContent.textContent).toContain(dueLabel) const metadata = await screen.findByRole('complementary', { name: 'Note metadata' }) diff --git a/ui/src/features/notes/services/noteService.ts b/ui/src/features/notes/services/noteService.ts index 1a1a856..a1631fb 100644 --- a/ui/src/features/notes/services/noteService.ts +++ b/ui/src/features/notes/services/noteService.ts @@ -1,12 +1,17 @@ import type { DomainResult } from '@shared/errors' -import type { SortPreference } from '@shared/types/sort.types' -import type { NoteDetail, NoteDraft, NoteListItem, NoteRef } from '../types/note.types' +import type { + NoteDetail, + NoteDraft, + NoteListItem, + NoteRef, + NoteSortPreference, +} from '../types/note.types' export interface NoteService { create(draft: NoteDraft): DomainResult delete(noteId: string): DomainResult getById(deckId: string, noteId: string): DomainResult - listByDeck(deckId: string, sort?: SortPreference): DomainResult + listByDeck(deckId: string, sort?: NoteSortPreference): DomainResult update(noteId: string, draft: NoteDraft): DomainResult } diff --git a/ui/src/features/notes/types/note.types.ts b/ui/src/features/notes/types/note.types.ts index 42d9735..a3a5817 100644 --- a/ui/src/features/notes/types/note.types.ts +++ b/ui/src/features/notes/types/note.types.ts @@ -1,3 +1,16 @@ +import type { SortPreference } from '@shared/types/sort.types' + +export const noteSortFields = ['title', 'dueAt', 'progress', 'updatedAt'] as const + +export type NoteSortField = (typeof noteSortFields)[number] + +export type NoteSortPreference = SortPreference + +export const defaultNoteSortPreference: NoteSortPreference = { + direction: 'asc', + field: 'title', +} + export type NoteKind = 'basic' | 'cloze' export type NoteStatus = 'in-progress' | 'mastered' diff --git a/ui/src/features/trash/components/WorkspaceTrashList.tsx b/ui/src/features/trash/components/WorkspaceTrashList.tsx index 6dcc6c7..5d83c25 100644 --- a/ui/src/features/trash/components/WorkspaceTrashList.tsx +++ b/ui/src/features/trash/components/WorkspaceTrashList.tsx @@ -9,6 +9,7 @@ import { import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { DateText } from '@shared/components/data/DateText' import { ActionMenu } from '@shared/components/feedback/ActionMenu' import { PendingSpinner } from '@shared/components/feedback/PendingSpinner' import { Card } from '@shared/components/ui/card' @@ -124,7 +125,7 @@ const WorkspaceTrashRow = ({ {compactLocationLabel}

- {formatDeletedAge(item.deletedAt)} + {formatDeletedAge(item.deletedAt)}

diff --git a/ui/src/features/trash/components/WorkspaceTrashSummary.stories.tsx b/ui/src/features/trash/components/WorkspaceTrashSummary.stories.tsx index 816c3ef..71ee21e 100644 --- a/ui/src/features/trash/components/WorkspaceTrashSummary.stories.tsx +++ b/ui/src/features/trash/components/WorkspaceTrashSummary.stories.tsx @@ -7,6 +7,7 @@ import { WorkspaceTrashSummary } from './WorkspaceTrashSummary' const meta = { args: { ageLabel: 'Last emptied 2 days ago', + ageTimestamp: '2026-05-01T12:00:00', countLabel: '3 items', }, component: WorkspaceTrashSummary, @@ -26,6 +27,7 @@ export const Default: Story = {} export const SingleItem: Story = { args: { ageLabel: 'Last emptied 1 hour ago', + ageTimestamp: '2026-05-03T11:00:00', countLabel: '1 item', }, } diff --git a/ui/src/features/trash/components/WorkspaceTrashSummary.test.tsx b/ui/src/features/trash/components/WorkspaceTrashSummary.test.tsx index 7d981fd..e7a8fe0 100644 --- a/ui/src/features/trash/components/WorkspaceTrashSummary.test.tsx +++ b/ui/src/features/trash/components/WorkspaceTrashSummary.test.tsx @@ -5,9 +5,18 @@ import { WorkspaceTrashSummary } from './WorkspaceTrashSummary' describe('WorkspaceTrashSummary', () => { it('renders trash count and last emptied age', () => { - render() + render( + , + ) expect(screen.getByText('3 items')).toBeInTheDocument() - expect(screen.getByText('Last emptied 2 days ago')).toBeInTheDocument() + expect(screen.getByText('Last emptied 2 days ago')).toHaveAttribute( + 'title', + '01.05.2026 12:00', + ) }) }) diff --git a/ui/src/features/trash/components/WorkspaceTrashSummary.tsx b/ui/src/features/trash/components/WorkspaceTrashSummary.tsx index 3dccc50..cf2114e 100644 --- a/ui/src/features/trash/components/WorkspaceTrashSummary.tsx +++ b/ui/src/features/trash/components/WorkspaceTrashSummary.tsx @@ -1,8 +1,12 @@ +import { DateText } from '@shared/components/data/DateText' + export const WorkspaceTrashSummary = ({ ageLabel, + ageTimestamp, countLabel, }: { ageLabel: string + ageTimestamp: string countLabel: string }) => (
@@ -13,7 +17,7 @@ export const WorkspaceTrashSummary = ({
- {ageLabel} + {ageLabel} ) diff --git a/ui/src/features/trash/pages/TrashPage.tsx b/ui/src/features/trash/pages/TrashPage.tsx index 5fb738f..44afba3 100644 --- a/ui/src/features/trash/pages/TrashPage.tsx +++ b/ui/src/features/trash/pages/TrashPage.tsx @@ -120,7 +120,11 @@ export const TrashPage = () => { rightSlot={} screenClassName={screenBottomPadding} > - +
{itemCount > 0 ? ( { const { t } = useTranslation() - const { formatRelativeDate } = useDateFormatters() + const { formatAbsoluteDateTime, formatRelativeDate } = useDateFormatters() + const updatedAtTitle = formatAbsoluteDateTime(workspace.updatedAt) const openWorkspace = () => { if (opening) { return @@ -113,6 +115,7 @@ export const WorkspaceSpaceCard = ({ 'absolute inset-0 z-10 cursor-pointer rounded-compact text-left focus-visible:ring-inset focus-visible:ring-offset-0', )} type="button" + title={updatedAtTitle} onClick={openWorkspace} />

- {formatRelativeDate(workspace.updatedAt)} + + {formatRelativeDate(workspace.updatedAt)} +

{active ? (
@@ -203,7 +209,9 @@ export const WorkspaceSpaceCard = ({

- {formatRelativeDate(workspace.updatedAt)} + + {formatRelativeDate(workspace.updatedAt)} +

diff --git a/ui/src/platform/mock/mockApi.test.ts b/ui/src/platform/mock/mockApi.test.ts index 0d353bc..df5d82e 100644 --- a/ui/src/platform/mock/mockApi.test.ts +++ b/ui/src/platform/mock/mockApi.test.ts @@ -221,10 +221,15 @@ describe('mock API services', () => { 'world-history', { direction: 'asc', field: 'title' }, ) + const notesByProgress = await mockNoteService.listByDeck( + 'world-history', + { direction: 'asc', field: 'progress' }, + ) expect(decksByDue.ok).toBe(true) expect(foldersByTitleDesc.ok).toBe(true) expect(notesByTitle.ok).toBe(true) + expect(notesByProgress.ok).toBe(true) if (decksByDue.ok) { expect(decksByDue.value.map((deck) => deck.id)).toEqual([ @@ -247,6 +252,13 @@ describe('mock API services', () => { 'postwar-institutions', ]) } + + if (notesByProgress.ok) { + expect(notesByProgress.value.map((note) => note.id)).toEqual([ + 'postwar-institutions', + 'industrial-revolution-causes', + ]) + } }) it('matches card-based review progression before summary', async () => { diff --git a/ui/src/platform/services/decks/web/deckService.test.ts b/ui/src/platform/services/decks/web/deckService.test.ts index d74cfbf..548c443 100644 --- a/ui/src/platform/services/decks/web/deckService.test.ts +++ b/ui/src/platform/services/decks/web/deckService.test.ts @@ -84,7 +84,7 @@ describe('webDeckService', () => { expect(params.folderId).toBe('reading-notes') expect(url.searchParams.get('sortDirection')).toBe('desc') - expect(url.searchParams.get('sortField')).toBe('updated') + expect(url.searchParams.get('sortField')).toBe('updatedAt') return HttpResponse.json([deck]) }), @@ -93,7 +93,7 @@ describe('webDeckService', () => { await expect( webDeckService.listFolderChildren('reading-notes', { direction: 'desc', - field: 'updated', + field: 'updatedAt', }), ).resolves.toEqual({ ok: true, diff --git a/ui/src/platform/services/folders/web/folderService.test.ts b/ui/src/platform/services/folders/web/folderService.test.ts index 4057198..3a799c3 100644 --- a/ui/src/platform/services/folders/web/folderService.test.ts +++ b/ui/src/platform/services/folders/web/folderService.test.ts @@ -88,7 +88,7 @@ describe('webFolderService', () => { expect(params.folderId).toBe('reading-notes') expect(url.searchParams.get('sortDirection')).toBe('desc') - expect(url.searchParams.get('sortField')).toBe('updated') + expect(url.searchParams.get('sortField')).toBe('updatedAt') return HttpResponse.json([folder]) }), @@ -97,7 +97,7 @@ describe('webFolderService', () => { await expect( webFolderService.listFolderChildren('reading-notes', { direction: 'desc', - field: 'updated', + field: 'updatedAt', }), ).resolves.toEqual({ ok: true, diff --git a/ui/src/platform/services/folders/web/folderService.ts b/ui/src/platform/services/folders/web/folderService.ts index fb033c0..a923d58 100644 --- a/ui/src/platform/services/folders/web/folderService.ts +++ b/ui/src/platform/services/folders/web/folderService.ts @@ -10,7 +10,6 @@ import { import type { Folder as ApiFolder, FolderDraft as ApiFolderDraft, - FolderSortField as ApiFolderSortField, } from '@api-generated/clear-api' import type { FolderService } from '@features/folders/services/folderService' @@ -50,10 +49,7 @@ export const webFolderService: FolderService = { ) }, listFolderChildren(folderId, sort) { - const query = toSortQuery(sort) as { - sortDirection?: 'asc' | 'desc' - sortField?: ApiFolderSortField - } + const query = toSortQuery(sort) return toDomainResult( apiListFolderFolders({ path: { folderId }, query }), @@ -62,10 +58,7 @@ export const webFolderService: FolderService = { ) }, listWorkspaceRoot(workspaceId, sort) { - const query = toSortQuery(sort) as { - sortDirection?: 'asc' | 'desc' - sortField?: ApiFolderSortField - } + const query = toSortQuery(sort) return toDomainResult( apiListWorkspaceFolders({ path: { workspaceId }, query }), diff --git a/ui/src/platform/services/notes/web/noteService.test.ts b/ui/src/platform/services/notes/web/noteService.test.ts index 769ce37..e346fb2 100644 --- a/ui/src/platform/services/notes/web/noteService.test.ts +++ b/ui/src/platform/services/notes/web/noteService.test.ts @@ -102,7 +102,7 @@ describe('webNoteService', () => { expect(params.deckId).toBe('world-history') expect(url.searchParams.get('sortDirection')).toBe('desc') - expect(url.searchParams.get('sortField')).toBe('updated') + expect(url.searchParams.get('sortField')).toBe('progress') return HttpResponse.json([noteListItem]) }), @@ -111,7 +111,7 @@ describe('webNoteService', () => { const result = expectOk( await webNoteService.listByDeck('world-history', { direction: 'desc', - field: 'updated', + field: 'progress', }), ) diff --git a/ui/src/platform/services/notes/web/noteService.ts b/ui/src/platform/services/notes/web/noteService.ts index bc0dc2b..7ee2a52 100644 --- a/ui/src/platform/services/notes/web/noteService.ts +++ b/ui/src/platform/services/notes/web/noteService.ts @@ -10,7 +10,6 @@ import type { NoteDraft as ApiNoteDraft, NoteListItem as ApiNoteListItem, NoteRef as ApiNoteRef, - NoteSortField as ApiNoteSortField, } from '@api-generated/clear-api' import type { NoteService } from '@features/notes/services/noteService' @@ -48,10 +47,7 @@ export const webNoteService: NoteService = { ) }, listByDeck(deckId, sort) { - const query = toSortQuery(sort) as { - sortDirection?: 'asc' | 'desc' - sortField?: ApiNoteSortField - } + const query = toSortQuery(sort) return toDomainResult( apiListNotesByDeck({ diff --git a/ui/src/shared/components/data/DateText.stories.tsx b/ui/src/shared/components/data/DateText.stories.tsx new file mode 100644 index 0000000..1859231 --- /dev/null +++ b/ui/src/shared/components/data/DateText.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { componentCanvas } from '@/test/storybook/decorators' + +import { DateText } from './DateText' + +const meta = { + args: { + children: 'Updated yesterday', + className: 'text-sm text-muted-foreground', + timestamp: '2026-05-01T12:34:00', + }, + component: DateText, + decorators: [componentCanvas], + parameters: { + layout: 'fullscreen', + }, + title: 'Shared/Data/DateText', +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ValidTimestamp: Story = {} + +export const InvalidTimestampFallback: Story = { + args: { + children: 'Updated date unavailable', + timestamp: 'not-a-date', + }, +} diff --git a/ui/src/shared/components/data/DateText.test.tsx b/ui/src/shared/components/data/DateText.test.tsx new file mode 100644 index 0000000..52e0715 --- /dev/null +++ b/ui/src/shared/components/data/DateText.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { DateText } from './DateText' + +describe('DateText', () => { + it('renders valid timestamps as semantic time with an absolute tooltip', () => { + render( + + Updated today + , + ) + + const dateText = screen.getByTestId('date-text') + + expect(dateText.tagName).toBe('TIME') + expect(dateText).toHaveTextContent('Updated today') + expect(dateText).toHaveAttribute('datetime', '2026-05-01T12:34:00') + expect(dateText).toHaveAttribute('title', '01.05.2026 12:34') + expect(dateText).toHaveClass('text-muted-foreground') + }) + + it('renders invalid timestamps as a plain text fallback without date metadata', () => { + render( + + Updated date unavailable + , + ) + + const dateText = screen.getByTestId('date-text') + + expect(dateText.tagName).toBe('SPAN') + expect(dateText).toHaveTextContent('Updated date unavailable') + expect(dateText).not.toHaveAttribute('datetime') + expect(dateText).not.toHaveAttribute('title') + expect(dateText).toHaveClass('text-muted-foreground') + }) +}) diff --git a/ui/src/shared/components/data/DateText.tsx b/ui/src/shared/components/data/DateText.tsx new file mode 100644 index 0000000..3cc9384 --- /dev/null +++ b/ui/src/shared/components/data/DateText.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react' + +import { useDateFormatters } from '@shared/lib/translated-date-format' + +type DateTextProps = Omit< + ComponentPropsWithoutRef<'time'>, + 'children' | 'dateTime' | 'title' +> & { + children: ReactNode + timestamp: string +} + +export const DateText = ({ children, timestamp, ...props }: DateTextProps) => { + const { formatAbsoluteDateTime } = useDateFormatters() + const title = formatAbsoluteDateTime(timestamp) + + if (!title) { + return {children} + } + + return ( + + ) +} diff --git a/ui/src/shared/components/data/InventoryListWithSort.stories.tsx b/ui/src/shared/components/data/InventoryListWithSort.stories.tsx index 6a00c94..cb62427 100644 --- a/ui/src/shared/components/data/InventoryListWithSort.stories.tsx +++ b/ui/src/shared/components/data/InventoryListWithSort.stories.tsx @@ -20,6 +20,8 @@ type DemoItem = { title: string } +type DemoSortField = 'title' | 'updatedAt' + type DemoInventoryListWithSortProps = { items: DemoItem[] showSort?: boolean @@ -47,9 +49,9 @@ const DemoInventoryListWithSort = ({ items, showSort = true, }: DemoInventoryListWithSortProps) => { - const [sort, setSort] = useState({ + const [sort, setSort] = useState>({ direction: 'desc', - field: 'updated', + field: 'updatedAt', }) return ( @@ -90,7 +92,7 @@ const DemoInventoryListWithSort = ({ sortAriaLabel="Sort items" sortFieldOptions={[ { field: 'title', label: 'Title' }, - { field: 'updated', label: 'Updated' }, + { field: 'updatedAt', label: 'Updated' }, ]} title="Items" onSortChange={setSort} @@ -99,9 +101,9 @@ const DemoInventoryListWithSort = ({ } const OffsetRegressionInventorySections = () => { - const [sort, setSort] = useState({ + const [sort, setSort] = useState>({ direction: 'desc', - field: 'updated', + field: 'updatedAt', }) return ( @@ -139,7 +141,7 @@ const OffsetRegressionInventorySections = () => { sortAriaLabel="Sort many items" sortFieldOptions={[ { field: 'title', label: 'Title' }, - { field: 'updated', label: 'Updated' }, + { field: 'updatedAt', label: 'Updated' }, ]} title="Many Items" onSortChange={setSort} @@ -178,7 +180,7 @@ const OffsetRegressionInventorySections = () => { sortAriaLabel="Sort single item" sortFieldOptions={[ { field: 'title', label: 'Title' }, - { field: 'updated', label: 'Updated' }, + { field: 'updatedAt', label: 'Updated' }, ]} title="Single Item" onSortChange={setSort} diff --git a/ui/src/shared/components/data/InventoryListWithSort.test.tsx b/ui/src/shared/components/data/InventoryListWithSort.test.tsx index ed1e2e6..40fdfeb 100644 --- a/ui/src/shared/components/data/InventoryListWithSort.test.tsx +++ b/ui/src/shared/components/data/InventoryListWithSort.test.tsx @@ -13,14 +13,16 @@ const items = [ const sortFieldOptions = [ { field: 'title', label: 'Title' }, - { field: 'updated', label: 'Updated' }, + { field: 'updatedAt', label: 'Updated' }, ] as const +type DemoSortField = (typeof sortFieldOptions)[number]['field'] + const renderInventoryListWithSort = ({ onSortChange = vi.fn(), showSort = true, }: { - onSortChange?: (sort: SortPreference) => void + onSortChange?: (sort: SortPreference) => void showSort?: boolean } = {}) => { render( @@ -55,7 +57,7 @@ describe('InventoryListWithSort', () => { await user.click(screen.getByRole('button', { name: 'Sort items' })) await user.click(await screen.findByRole('menuitem', { name: 'Updated' })) - expect(onSortChange).toHaveBeenCalledWith({ direction: 'asc', field: 'updated' }) + expect(onSortChange).toHaveBeenCalledWith({ direction: 'asc', field: 'updatedAt' }) await user.click(screen.getByRole('button', { name: 'Sort items' })) await user.click(await screen.findByRole('menuitem', { name: 'Desc' })) diff --git a/ui/src/shared/components/data/InventoryListWithSort.tsx b/ui/src/shared/components/data/InventoryListWithSort.tsx index a9e0491..5fa6233 100644 --- a/ui/src/shared/components/data/InventoryListWithSort.tsx +++ b/ui/src/shared/components/data/InventoryListWithSort.tsx @@ -6,18 +6,19 @@ import { InventoryList, InventorySection } from './InventoryList' import type { InventoryListProps } from './InventoryList' import { SortMenu, type SortFieldOption } from './SortMenu' -export type InventoryListWithSortProps = InventoryListProps & { +export type InventoryListWithSortProps = + InventoryListProps & { headerClassName?: string - onSortChange: (sort: SortPreference) => void + onSortChange: (sort: SortPreference) => void sectionClassName?: string showSort?: boolean - sort: SortPreference + sort: SortPreference sortAriaLabel: string - sortFieldOptions: readonly SortFieldOption[] + sortFieldOptions: readonly SortFieldOption[] title: ReactNode } -export const InventoryListWithSort = ({ +export const InventoryListWithSort = ({ headerClassName, items, onSortChange, @@ -28,7 +29,7 @@ export const InventoryListWithSort = ({ sortFieldOptions, title, ...listProps -}: InventoryListWithSortProps) => { +}: InventoryListWithSortProps) => { if (items.length === 0) { return null } diff --git a/ui/src/shared/components/data/SortMenu.stories.tsx b/ui/src/shared/components/data/SortMenu.stories.tsx index e1916de..a84ec1a 100644 --- a/ui/src/shared/components/data/SortMenu.stories.tsx +++ b/ui/src/shared/components/data/SortMenu.stories.tsx @@ -8,7 +8,7 @@ import { SortMenu } from './SortMenu' const deckFieldOptions = [ { field: 'title', label: 'Title' }, - { field: 'updated', label: 'Updated' }, + { field: 'updatedAt', label: 'Updated' }, ] as const const deckFieldOptionsWithDueToday = [ diff --git a/ui/src/shared/components/data/SortMenu.test.tsx b/ui/src/shared/components/data/SortMenu.test.tsx index 870731f..85866ab 100644 --- a/ui/src/shared/components/data/SortMenu.test.tsx +++ b/ui/src/shared/components/data/SortMenu.test.tsx @@ -6,7 +6,7 @@ import { SortMenu } from './SortMenu' const fieldOptions = [ { field: 'title', label: 'Name' }, - { field: 'updated', label: 'Updated' }, + { field: 'updatedAt', label: 'Updated' }, ] as const describe('SortMenu', () => { @@ -71,7 +71,7 @@ describe('SortMenu', () => { await user.click(screen.getByRole('button', { name: 'Sort decks' })) await user.click(await screen.findByRole('menuitem', { name: 'Updated' })) - expect(onFieldChange).toHaveBeenCalledWith('updated') + expect(onFieldChange).toHaveBeenCalledWith('updatedAt') await user.click(screen.getByRole('button', { name: 'Sort decks' })) await user.click(await screen.findByRole('menuitem', { name: 'Desc' })) diff --git a/ui/src/shared/components/data/SortMenu.tsx b/ui/src/shared/components/data/SortMenu.tsx index b490520..dec468c 100644 --- a/ui/src/shared/components/data/SortMenu.tsx +++ b/ui/src/shared/components/data/SortMenu.tsx @@ -13,47 +13,44 @@ import { dropdownMenuItemClassName, } from '@shared/components/ui/dropdown-menu' import { - defaultSortPreference, type SortDirection, - type SortField, type SortPreference, } from '@shared/types/sort.types' -export type SortFieldOption = Readonly<{ - field: SortField +export type SortFieldOption = Readonly<{ + field: TField label: string }> -export interface SortMenuProps { +export interface SortMenuProps { ariaLabel: string - fieldOptions: readonly SortFieldOption[] + fieldOptions: readonly SortFieldOption[] onDirectionChange: (direction: SortDirection) => void - onFieldChange: (field: SortField) => void - sort: SortPreference + onFieldChange: (field: TField) => void + sort: SortPreference } -export const usePersistedSort = ( +export const usePersistedSort = ( storageKey: string, - initial: SortPreference = defaultSortPreference, + initial: SortPreference, + validFields: readonly TField[], ) => { - const [sort, setSort] = useState(() => { + const [sort, setSort] = useState>(() => { if (typeof window === 'undefined') { return initial } try { const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null') as - | Partial + | Partial> | null + const parsedField = parsed?.field + return { direction: parsed?.direction === 'desc' ? 'desc' : initial.direction, field: - parsed?.field === 'dueToday' || - parsed?.field === 'updated' || - parsed?.field === 'title' - ? parsed.field - : initial.field, + parsedField && validFields.includes(parsedField) ? parsedField : initial.field, } } catch { return initial @@ -71,13 +68,13 @@ export const usePersistedSort = ( return [sort, setSort] as const } -export const SortMenu = ({ +export const SortMenu = ({ ariaLabel, fieldOptions, onDirectionChange, onFieldChange, sort, -}: SortMenuProps) => { +}: SortMenuProps) => { const { t } = useTranslation() const directionLabels: Record = { asc: t(($) => $.common.sort.ascending), diff --git a/ui/src/shared/lib/date-format.test.ts b/ui/src/shared/lib/date-format.test.ts index b24dc3e..2c80ecd 100644 --- a/ui/src/shared/lib/date-format.test.ts +++ b/ui/src/shared/lib/date-format.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + formatAbsoluteDateTime, formatDeletedAge, formatDueLabel, formatRelativeAge, @@ -43,7 +44,8 @@ describe('date-format', () => { it('formats review, due, chip, and deleted labels', () => { expect(formatReviewedLabel('2026-04-25T12:00:00.000Z')).toBe('Reviewed: Yesterday') - expect(formatDueLabel('2026-04-27T12:00:00.000Z')).toBe('Due: Tomorrow') + expect(formatDueLabel('2026-04-27T12:00:00.000Z')).toBe('Tomorrow') + expect(formatDueLabel('2026-04-12T12:00:00.000Z')).toBe('2 weeks ago') expect(formatUpdatedChipLabel('2026-04-26T11:30:00.000Z')).toBe('UPDATED 30M AGO') expect(formatDeletedAge('2026-04-26T12:00:00.000Z')).toBe('Deleted just now') expect(formatRelativeAge('2026-04-26T11:59:00.000Z')).toBe('1 minute ago') @@ -53,7 +55,12 @@ describe('date-format', () => { expect(formatRelativeAge('bad-date')).toBe('date unavailable') expect(formatDeletedAge('bad-date')).toBe('Deleted date unavailable') expect(formatReviewedLabel('bad-date')).toBe('Reviewed: Date unavailable') - expect(formatDueLabel('bad-date')).toBe('Due: Date unavailable') + expect(formatDueLabel('bad-date')).toBe('Date unavailable') expect(formatUpdatedChipLabel('bad-date')).toBe('UPDATED DATE UNAVAILABLE') }) + + it('formats absolute date-time values for native date tooltips', () => { + expect(formatAbsoluteDateTime('2026-04-26T09:05:00')).toBe('26.04.2026 09:05') + expect(formatAbsoluteDateTime('bad-date')).toBeUndefined() + }) }) diff --git a/ui/src/shared/lib/date-format.ts b/ui/src/shared/lib/date-format.ts index 3a65439..748d9ca 100644 --- a/ui/src/shared/lib/date-format.ts +++ b/ui/src/shared/lib/date-format.ts @@ -9,6 +9,9 @@ const isValidDate = (value: Date) => !Number.isNaN(value.getTime()) const pad2 = (value: number) => value.toString().padStart(2, '0') +const formatDateParts = (value: Date) => + `${pad2(value.getDate())}.${pad2(value.getMonth() + 1)}.${value.getFullYear()}` + const formatAbsoluteDate = (timestamp: string) => { const parsed = new Date(timestamp) @@ -16,7 +19,17 @@ const formatAbsoluteDate = (timestamp: string) => { return timestamp } - return `${pad2(parsed.getDate())}.${pad2(parsed.getMonth() + 1)}.${parsed.getFullYear()}` + return formatDateParts(parsed) +} + +export const formatAbsoluteDateTime = (timestamp: string) => { + const parsed = new Date(timestamp) + + if (!isValidDate(parsed)) { + return undefined + } + + return `${formatDateParts(parsed)} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}` } const getCalendarDayDifference = (left: Date, right: Date) => { @@ -119,8 +132,7 @@ export const formatRelativeTimestamp = ( export const formatReviewedLabel = (timestamp: string) => `Reviewed: ${formatRelativeTimestamp(timestamp, 'past')}` -export const formatDueLabel = (timestamp: string) => - `Due: ${formatRelativeTimestamp(timestamp, 'future')}` +export const formatDueLabel = (timestamp: string) => formatRelativeTimestamp(timestamp) export const formatUpdatedChipLabel = (timestamp: string) => `UPDATED ${formatRelativeDate(timestamp).replace('Updated ', '').toUpperCase()}` diff --git a/ui/src/shared/lib/translated-date-format.test.ts b/ui/src/shared/lib/translated-date-format.test.ts index e6ea4d5..ff5bcc6 100644 --- a/ui/src/shared/lib/translated-date-format.test.ts +++ b/ui/src/shared/lib/translated-date-format.test.ts @@ -23,7 +23,8 @@ describe('translated date formatters', () => { it('formats labeled dates through translations', () => { const formatters = createFormatters() - expect(formatters.formatDueLabel('2026-06-05T12:00:00.000Z')).toBe('Due: Tomorrow') + expect(formatters.formatDueLabel('2026-06-05T12:00:00.000Z')).toBe('Tomorrow') + expect(formatters.formatDueLabel('2026-05-20T12:00:00.000Z')).toBe('2 weeks ago') expect(formatters.formatReviewedLabel('2026-06-03T12:00:00.000Z')).toBe( 'Reviewed: Yesterday', ) @@ -76,6 +77,15 @@ describe('translated date formatters', () => { expect(formatters.formatUpdatedAge('2026-05-01T12:00:00.000Z')).toBe('01.05.2026') }) + it('formats absolute date-time values for native date tooltips', () => { + const formatters = createFormatters() + + expect(formatters.formatAbsoluteDateTime('2026-05-01T12:34:00')).toBe( + '01.05.2026 12:34', + ) + expect(formatters.formatAbsoluteDateTime('not-a-date')).toBeUndefined() + }) + it('formats review durations through translations', () => { const t = createAppI18n().t diff --git a/ui/src/shared/lib/translated-date-format.ts b/ui/src/shared/lib/translated-date-format.ts index b1a1661..d7b7731 100644 --- a/ui/src/shared/lib/translated-date-format.ts +++ b/ui/src/shared/lib/translated-date-format.ts @@ -14,6 +14,9 @@ const isValidDate = (value: Date) => !Number.isNaN(value.getTime()) const pad2 = (value: number) => value.toString().padStart(2, '0') +const formatDateParts = (value: Date) => + `${pad2(value.getDate())}.${pad2(value.getMonth() + 1)}.${value.getFullYear()}` + const formatAbsoluteDate = (timestamp: string) => { const parsed = new Date(timestamp) @@ -21,7 +24,17 @@ const formatAbsoluteDate = (timestamp: string) => { return timestamp } - return `${pad2(parsed.getDate())}.${pad2(parsed.getMonth() + 1)}.${parsed.getFullYear()}` + return formatDateParts(parsed) +} + +export const formatAbsoluteDateTime = (timestamp: string) => { + const parsed = new Date(timestamp) + + if (!isValidDate(parsed)) { + return undefined + } + + return `${formatDateParts(parsed)} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}` } const getCalendarDayDifference = (left: Date, right: Date) => { @@ -188,15 +201,14 @@ export const createDateFormatters = (t: TFunction) => { } return { + formatAbsoluteDateTime, formatDeletedAge(value: string) { return t(($) => $.dates.labels.deleted, { value: formatRelativeAgeValue(t, value), }) }, formatDueLabel(timestamp: string) { - return t(($) => $.dates.labels.due, { - value: formatRelativeTimestamp(timestamp, 'future'), - }) + return formatRelativeTimestamp(timestamp) }, formatRelativeAge(value: string) { return formatRelativeAgeValue(t, value) diff --git a/ui/src/shared/services/api/adapters/sortQuery.ts b/ui/src/shared/services/api/adapters/sortQuery.ts index 33c3c20..4551098 100644 --- a/ui/src/shared/services/api/adapters/sortQuery.ts +++ b/ui/src/shared/services/api/adapters/sortQuery.ts @@ -1,6 +1,6 @@ import type { SortPreference } from '@shared/types/sort.types' -export const toSortQuery = (sort?: SortPreference) => +export const toSortQuery = (sort?: SortPreference) => sort ? { sortDirection: sort.direction, diff --git a/ui/src/shared/services/api/generated/clear-api/types.gen.ts b/ui/src/shared/services/api/generated/clear-api/types.gen.ts index 7fa8ff2..25d8251 100644 --- a/ui/src/shared/services/api/generated/clear-api/types.gen.ts +++ b/ui/src/shared/services/api/generated/clear-api/types.gen.ts @@ -447,9 +447,9 @@ export type Workspaces = unknown; export type DateTime = string; -export type DeckSortField = 'dueToday' | 'title' | 'updated'; +export type DeckSortField = 'dueToday' | 'title' | 'updatedAt'; -export type FolderSortField = 'title' | 'updated'; +export type FolderSortField = 'title' | 'updatedAt'; export type Id = string; @@ -463,7 +463,7 @@ export type MessageProblemDetails = { entityId?: string; }; -export type NoteSortField = 'title' | 'updated'; +export type NoteSortField = 'dueAt' | 'progress' | 'title' | 'updatedAt'; export type ComponentsProblemDetails = ({ type: '/problems/validation'; diff --git a/ui/src/shared/services/api/generated/clear-api/zod.gen.ts b/ui/src/shared/services/api/generated/clear-api/zod.gen.ts index e112093..5c02c23 100644 --- a/ui/src/shared/services/api/generated/clear-api/zod.gen.ts +++ b/ui/src/shared/services/api/generated/clear-api/zod.gen.ts @@ -139,10 +139,10 @@ export const zDateTime = z.iso.datetime(); export const zDeckSortField = z.enum([ 'dueToday', 'title', - 'updated' + 'updatedAt' ]); -export const zFolderSortField = z.enum(['title', 'updated']); +export const zFolderSortField = z.enum(['title', 'updatedAt']); export const zId = z.string().min(1); @@ -407,7 +407,12 @@ export const zMessageProblemDetails = z.object({ entityId: z.string().optional() }); -export const zNoteSortField = z.enum(['title', 'updated']); +export const zNoteSortField = z.enum([ + 'dueAt', + 'progress', + 'title', + 'updatedAt' +]); export const zSortDirection = z.enum(['asc', 'desc']); diff --git a/ui/src/shared/types/sort.types.ts b/ui/src/shared/types/sort.types.ts index 03b54e8..1ca967f 100644 --- a/ui/src/shared/types/sort.types.ts +++ b/ui/src/shared/types/sort.types.ts @@ -1,12 +1,6 @@ export type SortDirection = 'asc' | 'desc' -export type SortField = 'dueToday' | 'title' | 'updated' -export type SortPreference = { +export type SortPreference = { direction: SortDirection - field: SortField -} - -export const defaultSortPreference: SortPreference = { - direction: 'asc', - field: 'title', + field: TField } diff --git a/ui/src/test/storybook/page-services.test.ts b/ui/src/test/storybook/page-services.test.ts index ba039fb..c639abb 100644 --- a/ui/src/test/storybook/page-services.test.ts +++ b/ui/src/test/storybook/page-services.test.ts @@ -88,18 +88,21 @@ describe('storybook page services', () => { noteDetails: [ createBasicNoteDetail({ deckId, + dueAt: '2026-02-01T00:00:00.000Z', id: 'zeta-note', title: 'Zeta Note', updatedAt: '2026-01-02T00:00:00.000Z', }), createBasicNoteDetail({ deckId, + dueAt: '2026-01-03T00:00:00.000Z', id: 'alpha-note', title: 'Alpha Note', updatedAt: '2026-01-03T00:00:00.000Z', }), createBasicNoteDetail({ deckId, + dueAt: '2026-01-12T00:00:00.000Z', id: 'middle-note', title: 'Middle Note', updatedAt: '2026-01-01T00:00:00.000Z', @@ -125,7 +128,13 @@ describe('storybook page services', () => { const notesByUpdatedDesc = expectOkValue( await noteService.listByDeck(deckId, { direction: 'desc', - field: 'updated', + field: 'updatedAt', + }), + ) + const notesByDueAtAsc = expectOkValue( + await noteService.listByDeck(deckId, { + direction: 'asc', + field: 'dueAt', }), ) @@ -149,6 +158,11 @@ describe('storybook page services', () => { 'zeta-note', 'middle-note', ]) + expect(notesByDueAtAsc.map((note) => note.id)).toEqual([ + 'alpha-note', + 'middle-note', + 'zeta-note', + ]) }) it('models review page state transitions', async () => { diff --git a/ui/src/test/storybook/page-services.ts b/ui/src/test/storybook/page-services.ts index d82e8ec..dcf8adb 100644 --- a/ui/src/test/storybook/page-services.ts +++ b/ui/src/test/storybook/page-services.ts @@ -4,15 +4,28 @@ import type { ContentSearchService } from '@features/content-search/services/con import type { BootstrapService } from '@features/bootstrap' import type { SearchResultGroup } from '@features/content-search/types/search.types' import type { DeckService } from '@features/decks/services/deckService' -import type { Deck, DeckDetail, DeckDraft } from '@features/decks/types/deck.types' +import { + defaultDeckSortPreference, + type Deck, + type DeckDetail, + type DeckDraft, + type DeckSortPreference, +} from '@features/decks/types/deck.types' import type { FolderService } from '@features/folders/services/folderService' -import type { Folder, FolderDraft } from '@features/folders/types/folder.types' +import { + defaultFolderSortPreference, + type Folder, + type FolderDraft, + type FolderSortPreference, +} from '@features/folders/types/folder.types' import type { NoteService } from '@features/notes/services/noteService' -import type { - NoteDetail, - NoteDraft, - NoteListItem, - NoteRef, +import { + defaultNoteSortPreference, + type NoteDetail, + type NoteDraft, + type NoteListItem, + type NoteRef, + type NoteSortPreference, } from '@features/notes/types/note.types' import type { ReviewService } from '@features/review/services/reviewService' import type { @@ -38,10 +51,7 @@ import { type BootstrapResult, type RuntimeProfile, } from '@shared/lib/runtime-profile' -import { - defaultSortPreference, - type SortPreference, -} from '@shared/types/sort.types' +import type { SortPreference } from '@shared/types/sort.types' import { baseBasicNoteDetail, @@ -115,17 +125,20 @@ export const createSettings = (settings: Partial = {}): Settings => ({ }) const sortByPreference = < - T extends { dueToday?: number; name?: string; title?: string; updatedAt: string }, + T extends { dueAt?: string; dueToday?: number; name?: string; title?: string; updatedAt: string }, + TField extends string, >( items: readonly T[], - sort: SortPreference = defaultSortPreference, + sort: SortPreference, ) => [...items].sort((left, right) => { const direction = sort.direction === 'asc' ? 1 : -1 let value: number - if (sort.field === 'updated') { + if (sort.field === 'updatedAt') { value = new Date(left.updatedAt).getTime() - new Date(right.updatedAt).getTime() + } else if (sort.field === 'dueAt') { + value = new Date(left.dueAt ?? '').getTime() - new Date(right.dueAt ?? '').getTime() } else if (sort.field === 'dueToday') { value = (left.dueToday ?? 0) - (right.dueToday ?? 0) } else { @@ -510,7 +523,7 @@ export const createFolderService = ({ parentId const listFolders = async ( predicate: (folder: Folder) => boolean, - sort?: SortPreference, + sort?: FolderSortPreference, ): DomainResult => { if (loading) { return pendingDomainResult() @@ -526,7 +539,9 @@ export const createFolderService = ({ listCallCount += 1 - return ok(sortByPreference(folderRecords.filter(predicate), sort)) + return ok( + sortByPreference(folderRecords.filter(predicate), sort ?? defaultFolderSortPreference), + ) } return { @@ -588,10 +603,10 @@ export const createFolderService = ({ return ok(folderPaths[folderId] ?? ['Workspace']) }, - async listFolderChildren(folderId: string, sort?: SortPreference) { + async listFolderChildren(folderId: string, sort?: FolderSortPreference) { return listFolders((folder) => folder.parentId === folderId, sort) }, - async listWorkspaceRoot(workspaceId: string, sort?: SortPreference) { + async listWorkspaceRoot(workspaceId: string, sort?: FolderSortPreference) { return listFolders( (folder) => folder.workspaceId === workspaceId && folder.parentId === workspaceId, sort, @@ -658,7 +673,7 @@ export const createDeckService = ({ (parentId === baseDeck.workspaceId ? parentId : baseDeck.workspaceId) const listDecks = async ( predicate: (deck: Deck) => boolean, - sort?: SortPreference, + sort?: DeckSortPreference, ): DomainResult => { if (loading) { return pendingDomainResult() @@ -674,7 +689,9 @@ export const createDeckService = ({ listCallCount += 1 - return ok(sortByPreference(deckRecords.filter(predicate), sort)) + return ok( + sortByPreference(deckRecords.filter(predicate), sort ?? defaultDeckSortPreference), + ) } return { @@ -725,10 +742,10 @@ export const createDeckService = ({ return ok(deckDetails[deckId] ?? deckRecords.find((deck) => deck.id === deckId) ?? baseDeck) }, - async listFolderChildren(folderId: string, sort?: SortPreference) { + async listFolderChildren(folderId: string, sort?: DeckSortPreference) { return listDecks((deck) => deck.parentId === folderId, sort) }, - async listWorkspaceRoot(workspaceId: string, sort?: SortPreference) { + async listWorkspaceRoot(workspaceId: string, sort?: DeckSortPreference) { return listDecks( (deck) => deck.workspaceId === workspaceId && deck.parentId === workspaceId, sort, @@ -850,7 +867,7 @@ export const createNoteService = ({ baseBasicNoteDetail, ) }, - async listByDeck(deckId: string, sort?: SortPreference) { + async listByDeck(deckId: string, sort?: NoteSortPreference) { if (loading) { return pendingDomainResult() } @@ -868,7 +885,7 @@ export const createNoteService = ({ return ok( sortByPreference( noteRecords.filter((note) => note.deckId === deckId), - sort, + sort ?? defaultNoteSortPreference, ).map(toNoteListItem), ) },