diff --git a/README.md b/README.md index de63ecc..0954e05 100644 --- a/README.md +++ b/README.md @@ -68,21 +68,11 @@ If you change the UI API client or routes, regenerate the checked-in outputs wit pnpm codegen:ui ``` -## Run in mock mode +## Run with the mock API -This is the fastest way to explore the UI. It runs Vite with in-browser mock -services, so no backend process is required. - -```bash -VITE_SERVICE_MODE=mock pnpm dev:ui -``` - -Open `http://localhost:5173`. - -## Web mode with mock API - -Use this mode to exercise the generated API client against the local mock API -server. Run both commands from the repository root in separate terminals. +Use this mode for local UI development against the generated API client and +mock API server. Run both commands from the repository root in separate +terminals. Terminal 1: diff --git a/api/mock-server/package.json b/api/mock-server/package.json index d23ca9c..00c7231 100644 --- a/api/mock-server/package.json +++ b/api/mock-server/package.json @@ -3,6 +3,10 @@ "private": true, "version": "0.1.0", "type": "module", + "exports": { + "./browser": "./src/browser.ts", + "./package.json": "./package.json" + }, "scripts": { "build": "node scripts/build.mjs", "check": "tsc -p tsconfig.json --noEmit", @@ -14,6 +18,7 @@ }, "dependencies": { "@hono/node-server": "^1.19.7", + "@msw/data": "1.1.6", "hono": "^4.12.23", "zod": "^4.4.3" }, diff --git a/api/mock-server/src/app.test.ts b/api/mock-server/src/app.test.ts index c17b707..f6d6884 100644 --- a/api/mock-server/src/app.test.ts +++ b/api/mock-server/src/app.test.ts @@ -6,7 +6,7 @@ const json = async (response: Response) => response.json() as Promise describe('mock api app', () => { it('creates counter ids and exposes them through the workspace list', async () => { - const app = newMockApiApp() + const app = await newMockApiApp() const createResponse = await app.fetch( new Request('http://localhost/api/v1/workspaces', { @@ -38,7 +38,7 @@ describe('mock api app', () => { }) it('mounts the product routes under a custom base path', async () => { - const app = newMockApiApp({ basePath: '/custom' }) + const app = await newMockApiApp({ basePath: '/custom' }) const response = await app.fetch(new Request('http://localhost/custom/workspaces')) expect(response.status).toBe(200) @@ -50,7 +50,7 @@ describe('mock api app', () => { }) it('keeps seeded deck stats aligned with seeded notes', async () => { - const app = newMockApiApp() + const app = await newMockApiApp() const response = await app.fetch(new Request('http://localhost/__mock/state')) @@ -70,7 +70,7 @@ describe('mock api app', () => { }) it('exposes admin state reset and inspection endpoints', async () => { - const app = newMockApiApp() + const app = await newMockApiApp() const createResponse = await app.fetch( new Request('http://localhost/api/v1/workspaces', { diff --git a/api/mock-server/src/app.ts b/api/mock-server/src/app.ts index c943bc1..cdc1033 100644 --- a/api/mock-server/src/app.ts +++ b/api/mock-server/src/app.ts @@ -21,20 +21,21 @@ export type NewMockApiAppOptions = { controllers?: MockApiControllers } -export const newMockApiApp = ({ +export const newMockApiApp = async ({ basePath = "/api/v1", - controllers = newMemoryMockApiControllers(), + controllers, }: NewMockApiAppOptions = {}) => { const app = new Hono() + const mockControllers = controllers ?? await newMemoryMockApiControllers() app.use('*', cors()) registerGeneratedMockRoutes(app, { - controllers, + controllers: mockControllers, runtime: adminRuntime, }) registerGeneratedMockRoutes(app, { basePath: basePath, - controllers, + controllers: mockControllers, runtime: clearWebApiRuntime, }) diff --git a/api/mock-server/src/browser.ts b/api/mock-server/src/browser.ts new file mode 100644 index 0000000..08c0320 --- /dev/null +++ b/api/mock-server/src/browser.ts @@ -0,0 +1,31 @@ +export { newMockApiDependencies, type MockApiDependencies } from './dependencies.ts' +export { + newBrowserMockStateStore, + type BrowserMockStateStoreOptions, +} from './lib/browserStateStore.ts' +export { DEFAULT_SETTINGS } from './features/settings/defaults.ts' +export { seedState } from './generated/mock-admin/state/seed.ts' +export { zMockState } from './generated/mock-admin/contract/zod.gen.ts' +export type { MockState, SettingsRecord } from './generated/mock-admin/contract/index.ts' +export type { + Deck, + DeckDraft, + Folder, + FolderDraft, + NoteDetail, + NoteDraft, + NoteListItem, + NoteRef, + ReviewGrade, + ReviewSession, + ReviewStartResult, + SearchRequest, + SearchResultGroup, + Settings, + TrashState, + Workspace, + WorkspaceDraft, + WorkspaceListResult, +} from './generated/clear-web-api/contract/types.gen.ts' +export { MockHttpError } from './generated/clear-web-api/mock-runtime.ts' +export type { MockStateStore } from './lib/stateStore.ts' diff --git a/api/mock-server/src/controllers.ts b/api/mock-server/src/controllers.ts index 39a4695..9642eac 100644 --- a/api/mock-server/src/controllers.ts +++ b/api/mock-server/src/controllers.ts @@ -6,27 +6,17 @@ import { type GeneratedMockControllers as ClearWebApiGeneratedMockControllers, } from './generated/clear-web-api/mock-runtime.ts' import type { GeneratedMockControllers as AdminGeneratedMockControllers } from './generated/mock-admin/mock-runtime.ts' -import { newAdminStateController } from './generated/mock-admin/state/controller.ts' -import { MockStateRepository, type MockStateOptions } from './generated/mock-admin/state/repository.ts' -import { AdminStateService } from './generated/mock-admin/state/service.ts' import { seedState } from './generated/mock-admin/state/seed.ts' -import type { MockState } from './generated/mock-admin/contract/index.ts' -import { WorkspaceRepository } from './features/workspaces/repository.ts' -import { FolderRepository } from './features/folders/repository.ts' -import { DeckRepository } from './features/decks/repository.ts' -import { NotesRepository } from './features/notes/repository.ts' -import { ReviewRepository } from './features/review/repository.ts' -import { SettingsRepository } from './features/settings/repository.ts' -import { TrashRepository } from './features/trash/repository.ts' -import { LocationPathResolver } from './features/location-path/resolver.ts' -import { WorkspacesService } from './features/workspaces/service.ts' -import { FolderService } from './features/folders/service.ts' -import { DeckService } from './features/decks/service.ts' -import { NotesService } from './features/notes/service.ts' -import { ReviewService } from './features/review/service.ts' -import { SettingsService } from './features/settings/service.ts' -import { TrashService } from './features/trash/service.ts' -import { SearchService } from './features/search/service.ts' +import type { MockClock, MockState } from './generated/mock-admin/contract/index.ts' +import { + newMockApiDependencies, + type MockApiDependencies, +} from './dependencies.ts' +import { + newFileMockStateStore, + type MockStateOptions, +} from './lib/nodeStateStore.ts' +import type { MockStateStore } from './lib/stateStore.ts' import { newBootstrapController } from './features/bootstrap/controllers/bootstrap.ts' import { newListWorkspacesController } from './features/workspaces/controllers/listWorkspaces.ts' import { newCreateWorkspaceController } from './features/workspaces/controllers/createWorkspace.ts' @@ -68,101 +58,13 @@ import { newSearchContentController } from './features/search/controllers/search export type ProductMockControllers = ClearWebApiGeneratedMockControllers export type MockApiControllers = ProductMockControllers & AdminGeneratedMockControllers -export type MockApiDependencies = { - stateRepository: MockStateRepository - workspacesService: WorkspacesService - foldersService: FolderService - decksService: DeckService - notesService: NotesService - reviewService: ReviewService - settingsService: SettingsService - trashService: TrashService - searchService: SearchService -} +export type { MockApiDependencies } -export const newMockApiControllers = ( +export const newMockApiControllers = async ( options: MockStateOptions = {}, -): MockApiControllers => { - const stateRepository = new MockStateRepository(options) - const workspacesRepository = new WorkspaceRepository(stateRepository) - const foldersRepository = new FolderRepository(stateRepository) - const decksRepository = new DeckRepository(stateRepository) - const notesRepository = new NotesRepository(stateRepository) - const reviewRepository = new ReviewRepository(stateRepository) - const settingsRepository = new SettingsRepository(stateRepository) - const trashRepository = new TrashRepository(stateRepository) - const paths = new LocationPathResolver(workspacesRepository, foldersRepository, decksRepository) - const settingsService = new SettingsService(settingsRepository, stateRepository) - const notesService = new NotesService( - notesRepository, - decksRepository, - workspacesRepository, - trashRepository, - paths, - stateRepository, - ) - const workspacesService = new WorkspacesService( - workspacesRepository, - foldersRepository, - decksRepository, - notesRepository, - trashRepository, - paths, - stateRepository, - ) - const foldersService = new FolderService( - foldersRepository, - workspacesRepository, - decksRepository, - notesRepository, - trashRepository, - paths, - stateRepository, - ) - const decksService = new DeckService( - decksRepository, - workspacesRepository, - foldersRepository, - notesRepository, - trashRepository, - paths, - stateRepository, - ) - const reviewService = new ReviewService( - reviewRepository, - notesRepository, - decksRepository, - notesService, - stateRepository, - ) - const trashService = new TrashService( - trashRepository, - workspacesRepository, - foldersRepository, - decksRepository, - notesRepository, - paths, - stateRepository, - ) - const searchService = new SearchService( - workspacesRepository, - foldersRepository, - decksRepository, - notesRepository, - paths, - ) - const deps: MockApiDependencies = { - stateRepository, - workspacesService, - foldersService, - decksService, - notesService, - reviewService, - settingsService, - trashService, - searchService, - } - const adminStateService = new AdminStateService(stateRepository, clearWebApiGeneratedRouteDefinitions.length) +): Promise => { + const stateStore = await newFileMockStateStore(options) + const deps = newMockApiDependencies(stateStore) return { ...newBootstrapController(deps), @@ -203,10 +105,26 @@ export const newMockApiControllers = ( ...newRestoreTrashItemController(deps), ...newDeleteTrashItemController(deps), ...newSearchContentController(deps), - ...newAdminStateController(adminStateService), + ...newAdminStateControllers(stateStore, clearWebApiGeneratedRouteDefinitions.length), } as MockApiControllers } -export const newMemoryMockApiControllers = ( +export const newMemoryMockApiControllers = async ( initialState: MockState = seedState(), -): MockApiControllers => newMockApiControllers({ initialState }) +): Promise => newMockApiControllers({ initialState }) + +const newAdminStateControllers = ( + stateStore: MockStateStore, + operationCount: number, +): AdminGeneratedMockControllers => ({ + mockGetSnapshot: () => stateStore.snapshot(), + mockGetState: () => stateStore.snapshot(), + mockHealth: () => ({ + ok: true, + operationCount, + }), + mockPostSnapshot: ({ body }) => stateStore.replace(body as MockState), + mockPutClock: ({ body }) => stateStore.setClock((body as MockClock).now), + mockPutState: ({ body }) => stateStore.replace(body as MockState), + mockResetState: () => stateStore.reset(), +}) diff --git a/api/mock-server/src/dependencies.ts b/api/mock-server/src/dependencies.ts new file mode 100644 index 0000000..e6325ac --- /dev/null +++ b/api/mock-server/src/dependencies.ts @@ -0,0 +1,117 @@ +import { DeckRepository } from './features/decks/repository.ts' +import { DeckService } from './features/decks/service.ts' +import { FolderRepository } from './features/folders/repository.ts' +import { FolderService } from './features/folders/service.ts' +import { LocationPathResolver } from './features/location-path/resolver.ts' +import { NotesRepository } from './features/notes/repository.ts' +import { NotesService } from './features/notes/service.ts' +import { ReviewRepository } from './features/review/repository.ts' +import { ReviewService } from './features/review/service.ts' +import { SearchService } from './features/search/service.ts' +import { SettingsRepository } from './features/settings/repository.ts' +import { SettingsService } from './features/settings/service.ts' +import { TrashRepository } from './features/trash/repository.ts' +import { TrashService } from './features/trash/service.ts' +import { WorkspaceRepository } from './features/workspaces/repository.ts' +import { WorkspacesService } from './features/workspaces/service.ts' +import type { MockStateStore } from './lib/stateStore.ts' + +export type MockApiDependencies = { + stateStore: MockStateStore + workspacesService: WorkspacesService + foldersService: FolderService + decksService: DeckService + notesService: NotesService + reviewService: ReviewService + settingsService: SettingsService + trashService: TrashService + searchService: SearchService +} + +export const newMockApiDependencies = ( + stateStore: MockStateStore, +): MockApiDependencies => { + const workspacesRepository = new WorkspaceRepository(stateStore) + const foldersRepository = new FolderRepository(stateStore) + const decksRepository = new DeckRepository(stateStore) + const notesRepository = new NotesRepository(stateStore) + const reviewRepository = new ReviewRepository(stateStore) + const settingsRepository = new SettingsRepository(stateStore) + const trashRepository = new TrashRepository(stateStore) + const paths = new LocationPathResolver( + workspacesRepository, + foldersRepository, + decksRepository, + ) + const settingsService = new SettingsService(settingsRepository, stateStore) + const notesService = new NotesService( + notesRepository, + decksRepository, + workspacesRepository, + trashRepository, + paths, + stateStore, + ) + const workspacesService = new WorkspacesService( + workspacesRepository, + foldersRepository, + decksRepository, + notesRepository, + trashRepository, + paths, + stateStore, + ) + const foldersService = new FolderService( + foldersRepository, + workspacesRepository, + decksRepository, + notesRepository, + trashRepository, + paths, + stateStore, + ) + const decksService = new DeckService( + decksRepository, + workspacesRepository, + foldersRepository, + notesRepository, + trashRepository, + paths, + stateStore, + ) + const reviewService = new ReviewService( + reviewRepository, + notesRepository, + decksRepository, + notesService, + stateStore, + ) + const trashService = new TrashService( + trashRepository, + workspacesRepository, + foldersRepository, + decksRepository, + notesRepository, + paths, + stateStore, + ) + const searchService = new SearchService( + workspacesRepository, + foldersRepository, + decksRepository, + notesRepository, + paths, + ) + + return { + stateStore, + workspacesService, + foldersService, + decksService, + notesService, + reviewService, + settingsService, + trashService, + searchService, + } +} diff --git a/api/mock-server/src/features/decks/repository.ts b/api/mock-server/src/features/decks/repository.ts index 687914c..d848b0a 100644 --- a/api/mock-server/src/features/decks/repository.ts +++ b/api/mock-server/src/features/decks/repository.ts @@ -1,6 +1,6 @@ import type { DeckRecord } from '../../generated/mock-admin/contract/index.ts' import { notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' @@ -32,10 +32,14 @@ const sortDecks = ( } export class DeckRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } all() { - return this.stateStore.getSlice('decks') + return this.stateStore.findEntities('decks') } visible() { @@ -43,7 +47,7 @@ export class DeckRepository { } find(deckId: string) { - return this.all().find((deck) => deck.id === deckId) + return this.stateStore.findEntity('decks', deckId) } require(deckId: string, options: { includeDeleted?: boolean } = {}) { @@ -57,52 +61,33 @@ export class DeckRepository { return deck } - create(deck: DeckRecord) { - this.stateStore.setSlice('decks', [deck, ...this.all()]) - return deck + async create(deck: DeckRecord) { + return this.stateStore.createEntity('decks', deck, { prepend: true }) } - update(deckId: string, updater: (deck: DeckRecord) => DeckRecord) { - let next: DeckRecord | undefined - - this.stateStore.setSlice('decks', this.all().map((deck) => { - if (deck.id !== deckId) { - return deck - } - - next = updater(deck) - - return next - })) - - return next ?? this.require(deckId, { includeDeleted: true }) + async update(deckId: string, updater: (deck: DeckRecord) => DeckRecord) { + return ( + await this.stateStore.updateEntity('decks', deckId, updater) + ) ?? this.require(deckId, { includeDeleted: true }) } - touch(deckId: string, updatedAt: string) { + async touch(deckId: string, updatedAt: string) { return this.update(deckId, (deck) => ({ ...deck, updatedAt })) } - markDeleted(deckId: string, deletedAt: string) { + async markDeleted(deckId: string, deletedAt: string) { return this.update(deckId, (deck) => ({ ...deck, deletedAt })) } - restore(deckId: string) { + async restore(deckId: string) { return this.update(deckId, (deck) => { const { deletedAt: _deletedAt, ...restored } = deck return restored }) } - remove(deckId: string) { - const existing = this.find(deckId) - - if (!existing) { - return undefined - } - - this.stateStore.setSlice('decks', this.all().filter((deck) => deck.id !== deckId)) - - return existing + async remove(deckId: string) { + return this.stateStore.deleteEntity('decks', deckId) } listByWorkspace(workspaceId: string, options: { sortField?: DeckSortField; sortDirection?: SortDirection } = {}) { diff --git a/api/mock-server/src/features/decks/service.ts b/api/mock-server/src/features/decks/service.ts index c430d37..d0c5a51 100644 --- a/api/mock-server/src/features/decks/service.ts +++ b/api/mock-server/src/features/decks/service.ts @@ -1,7 +1,7 @@ import type { DeckDraft } from '../../generated/clear-web-api/contract/types.gen.ts' import type { DeckRecord } from '../../generated/mock-admin/contract/index.ts' import { conflict } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { newIdAllocator } from '../../lib/ids.ts' import type { FolderRepository } from '../folders/repository.ts' import type { LocationPathResolver } from '../location-path/resolver.ts' @@ -11,15 +11,31 @@ import type { WorkspaceRepository } from '../workspaces/repository.ts' import { DeckRepository } from './repository.ts' export class DeckService { + private readonly decks: DeckRepository + private readonly workspaces: WorkspaceRepository + private readonly folders: FolderRepository + private readonly notes: NotesRepository + private readonly trash: TrashRepository + private readonly paths: LocationPathResolver + private readonly stateStore: MockStateStore + constructor( - private readonly decks: DeckRepository, - private readonly workspaces: WorkspaceRepository, - private readonly folders: FolderRepository, - private readonly notes: NotesRepository, - private readonly trash: TrashRepository, - private readonly paths: LocationPathResolver, - private readonly stateStore: MockStateRepository, - ) {} + decks: DeckRepository, + workspaces: WorkspaceRepository, + folders: FolderRepository, + notes: NotesRepository, + trash: TrashRepository, + paths: LocationPathResolver, + stateStore: MockStateStore, + ) { + this.decks = decks + this.workspaces = workspaces + this.folders = folders + this.notes = notes + this.trash = trash + this.paths = paths + this.stateStore = stateStore + } listWorkspaceDecks(workspaceId: string, query?: { sortField?: string; sortDirection?: string }) { this.workspaces.require(workspaceId) @@ -31,7 +47,7 @@ export class DeckService { return this.decks.listByParent(folderId, this.parseSortQuery(query)) } - createDeck(draft: DeckDraft): DeckRecord { + async createDeck(draft: DeckDraft): Promise { const parent = this.resolveParent(draft.parentId) const duplicate = this.decks.visible().some( (deck) => deck.parentId === draft.parentId && deck.title === draft.title, @@ -41,7 +57,7 @@ export class DeckService { throw conflict(`Deck titled ${draft.title} already exists in this location`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const now = this.stateStore.now() const deck: DeckRecord = { @@ -57,11 +73,11 @@ export class DeckService { workspaceId: parent.workspaceId, } - this.decks.create(deck) - this.touchFolderAncestors(draft.parentId, now) - this.workspaces.touch(parent.workspaceId, now) + const created = await this.decks.create(deck) + await this.touchFolderAncestors(draft.parentId, now) + await this.workspaces.touch(parent.workspaceId, now) - return deck + return created }) } @@ -69,7 +85,7 @@ export class DeckService { return this.decks.require(deckId) } - updateDeck(deckId: string, draft: DeckDraft) { + async updateDeck(deckId: string, draft: DeckDraft) { const current = this.decks.require(deckId) const nextParent = this.resolveParent(draft.parentId) const duplicate = this.decks.visible().some( @@ -80,9 +96,9 @@ export class DeckService { throw conflict(`Deck titled ${draft.title} already exists in this location`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const now = this.stateStore.now() - const updated = this.decks.update(deckId, (deck) => ({ + const updated = await this.decks.update(deckId, (deck) => ({ ...deck, description: draft.description, icon: draft.icon, @@ -92,28 +108,28 @@ export class DeckService { workspaceId: nextParent.workspaceId, })) - this.touchFolderAncestors(current.parentId, now) - this.touchFolderAncestors(draft.parentId, now) - this.workspaces.touch(current.workspaceId, now) + await this.touchFolderAncestors(current.parentId, now) + await this.touchFolderAncestors(draft.parentId, now) + await this.workspaces.touch(current.workspaceId, now) if (nextParent.workspaceId !== current.workspaceId) { - this.workspaces.touch(nextParent.workspaceId, now) + await this.workspaces.touch(nextParent.workspaceId, now) } return updated }) } - deleteDeck(deckId: string) { + async deleteDeck(deckId: string) { const deck = this.decks.require(deckId) - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const deletedAt = this.stateStore.now() const deckPath = this.paths.deckLocationPath(deckId) for (const note of this.notes.listByDeck(deckId)) { const notePath = this.paths.noteLocationPath(note) - this.notes.markDeleted(note.id ?? '', deletedAt) - this.trash.addItem({ + await this.notes.markDeleted(note.id ?? '', deletedAt) + await this.trash.addItem({ deletedAt, id: note.id ?? '', kind: 'note', @@ -122,16 +138,16 @@ export class DeckService { }) } - this.decks.markDeleted(deckId, deletedAt) - this.trash.addItem({ + await this.decks.markDeleted(deckId, deletedAt) + await this.trash.addItem({ deletedAt, id: deck.id ?? '', kind: 'deck', locationPath: deckPath, title: deck.title, }) - this.touchFolderAncestors(deck.parentId, deletedAt) - this.workspaces.touch(deck.workspaceId, deletedAt) + await this.touchFolderAncestors(deck.parentId, deletedAt) + await this.workspaces.touch(deck.workspaceId, deletedAt) }) } @@ -160,15 +176,15 @@ export class DeckService { } as const } - private touchFolderAncestors(parentId: string, updatedAt: string) { + private async touchFolderAncestors(parentId: string, updatedAt: string) { if (this.workspaces.find(parentId)) { return } for (const ancestorId of this.paths.folderParentFolderIds(parentId)) { - this.folders.touch(ancestorId, updatedAt) + await this.folders.touch(ancestorId, updatedAt) } - this.folders.touch(parentId, updatedAt) + await this.folders.touch(parentId, updatedAt) } } diff --git a/api/mock-server/src/features/folders/repository.ts b/api/mock-server/src/features/folders/repository.ts index 68c4b63..1344581 100644 --- a/api/mock-server/src/features/folders/repository.ts +++ b/api/mock-server/src/features/folders/repository.ts @@ -1,6 +1,6 @@ import type { FolderRecord } from '../../generated/mock-admin/contract/index.ts' import { notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' @@ -28,10 +28,14 @@ const sortFolders = ( } export class FolderRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } all() { - return this.stateStore.getSlice('folders') + return this.stateStore.findEntities('folders') } visible() { @@ -39,7 +43,7 @@ export class FolderRepository { } find(folderId: string) { - return this.all().find((folder) => folder.id === folderId) + return this.stateStore.findEntity('folders', folderId) } require(folderId: string, options: { includeDeleted?: boolean } = {}) { @@ -53,52 +57,33 @@ export class FolderRepository { return folder } - create(folder: FolderRecord) { - this.stateStore.setSlice('folders', [folder, ...this.all()]) - return folder + async create(folder: FolderRecord) { + return this.stateStore.createEntity('folders', folder, { prepend: true }) } - update(folderId: string, updater: (folder: FolderRecord) => FolderRecord) { - let next: FolderRecord | undefined - - this.stateStore.setSlice('folders', this.all().map((folder) => { - if (folder.id !== folderId) { - return folder - } - - next = updater(folder) - - return next - })) - - return next ?? this.require(folderId, { includeDeleted: true }) + async update(folderId: string, updater: (folder: FolderRecord) => FolderRecord) { + return ( + await this.stateStore.updateEntity('folders', folderId, updater) + ) ?? this.require(folderId, { includeDeleted: true }) } - touch(folderId: string, updatedAt: string) { + async touch(folderId: string, updatedAt: string) { return this.update(folderId, (folder) => ({ ...folder, updatedAt })) } - markDeleted(folderId: string, deletedAt: string) { + async markDeleted(folderId: string, deletedAt: string) { return this.update(folderId, (folder) => ({ ...folder, deletedAt })) } - restore(folderId: string) { + async restore(folderId: string) { return this.update(folderId, (folder) => { const { deletedAt: _deletedAt, ...restored } = folder return restored }) } - remove(folderId: string) { - const existing = this.find(folderId) - - if (!existing) { - return undefined - } - - this.stateStore.setSlice('folders', this.all().filter((folder) => folder.id !== folderId)) - - return existing + async remove(folderId: string) { + return this.stateStore.deleteEntity('folders', folderId) } listByWorkspace(workspaceId: string, options: { sortField?: FolderSortField; sortDirection?: SortDirection } = {}) { diff --git a/api/mock-server/src/features/folders/service.ts b/api/mock-server/src/features/folders/service.ts index a139e86..6ec91af 100644 --- a/api/mock-server/src/features/folders/service.ts +++ b/api/mock-server/src/features/folders/service.ts @@ -1,7 +1,7 @@ import type { FolderDraft } from '../../generated/clear-web-api/contract/types.gen.ts' import type { FolderRecord } from '../../generated/mock-admin/contract/index.ts' import { conflict } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { newIdAllocator } from '../../lib/ids.ts' import type { DeckRepository } from '../decks/repository.ts' import type { LocationPathResolver } from '../location-path/resolver.ts' @@ -11,15 +11,31 @@ import type { WorkspaceRepository } from '../workspaces/repository.ts' import { FolderRepository } from './repository.ts' export class FolderService { + private readonly folders: FolderRepository + private readonly workspaces: WorkspaceRepository + private readonly decks: DeckRepository + private readonly notes: NotesRepository + private readonly trash: TrashRepository + private readonly paths: LocationPathResolver + private readonly stateStore: MockStateStore + constructor( - private readonly folders: FolderRepository, - private readonly workspaces: WorkspaceRepository, - private readonly decks: DeckRepository, - private readonly notes: NotesRepository, - private readonly trash: TrashRepository, - private readonly paths: LocationPathResolver, - private readonly stateStore: MockStateRepository, - ) {} + folders: FolderRepository, + workspaces: WorkspaceRepository, + decks: DeckRepository, + notes: NotesRepository, + trash: TrashRepository, + paths: LocationPathResolver, + stateStore: MockStateStore, + ) { + this.folders = folders + this.workspaces = workspaces + this.decks = decks + this.notes = notes + this.trash = trash + this.paths = paths + this.stateStore = stateStore + } listWorkspaceFolders(workspaceId: string, query?: { sortField?: string; sortDirection?: string }) { this.workspaces.require(workspaceId) @@ -31,7 +47,7 @@ export class FolderService { return this.folders.listByParent(folderId, this.parseSortQuery(query)) } - createFolder(draft: FolderDraft): FolderRecord { + async createFolder(draft: FolderDraft): Promise { const parent = this.resolveParent(draft.parentId) const duplicate = this.folders.visible().some( (folder) => folder.parentId === draft.parentId && folder.name === draft.name, @@ -41,7 +57,7 @@ export class FolderService { throw conflict(`Folder named ${draft.name} already exists in this location`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const now = this.stateStore.now() const folder: FolderRecord = { @@ -53,11 +69,11 @@ export class FolderService { workspaceId: parent.workspaceId, } - this.folders.create(folder) - this.touchFolderAncestors(draft.parentId, now) - this.workspaces.touch(parent.workspaceId, now) + const created = await this.folders.create(folder) + await this.touchFolderAncestors(draft.parentId, now) + await this.workspaces.touch(parent.workspaceId, now) - return folder + return created }) } @@ -65,7 +81,7 @@ export class FolderService { return this.folders.require(folderId) } - updateFolder(folderId: string, draft: FolderDraft) { + async updateFolder(folderId: string, draft: FolderDraft) { const current = this.folders.require(folderId) const nextParent = this.resolveParent(draft.parentId) const duplicate = this.folders.visible().some( @@ -79,9 +95,9 @@ export class FolderService { throw conflict(`Folder named ${draft.name} already exists in this location`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const now = this.stateStore.now() - const updated = this.folders.update(folderId, (folder) => ({ + const updated = await this.folders.update(folderId, (folder) => ({ ...folder, description: draft.description, name: draft.name, @@ -90,42 +106,42 @@ export class FolderService { workspaceId: nextParent.workspaceId, })) - this.touchFolderAncestors(current.parentId, now) - this.touchFolderAncestors(draft.parentId, now) - this.workspaces.touch(current.workspaceId, now) + await this.touchFolderAncestors(current.parentId, now) + await this.touchFolderAncestors(draft.parentId, now) + await this.workspaces.touch(current.workspaceId, now) if (nextParent.workspaceId !== current.workspaceId) { - this.workspaces.touch(nextParent.workspaceId, now) + await this.workspaces.touch(nextParent.workspaceId, now) } return updated }) } - deleteFolder(folderId: string) { + async deleteFolder(folderId: string) { const folder = this.folders.require(folderId) - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const deletedAt = this.stateStore.now() const folderPath = this.paths.folderLocationPath(folderId) for (const childFolder of this.folders.listByParent(folderId)) { - this.deleteFolderTree(childFolder.id ?? '', deletedAt) + await this.deleteFolderTree(childFolder.id ?? '', deletedAt) } for (const childDeck of this.decks.listByParent(folderId)) { - this.deleteDeckTree(childDeck.id ?? '', deletedAt) + await this.deleteDeckTree(childDeck.id ?? '', deletedAt) } - this.folders.markDeleted(folderId, deletedAt) - this.trash.addItem({ + await this.folders.markDeleted(folderId, deletedAt) + await this.trash.addItem({ deletedAt, id: folder.id ?? '', kind: 'folder', locationPath: folderPath, title: folder.name, }) - this.touchFolderAncestors(folder.parentId, deletedAt) - this.workspaces.touch(folder.workspaceId, deletedAt) + await this.touchFolderAncestors(folder.parentId, deletedAt) + await this.workspaces.touch(folder.workspaceId, deletedAt) }) } @@ -158,48 +174,48 @@ export class FolderService { } as const } - private touchFolderAncestors(folderId: string, updatedAt: string) { + private async touchFolderAncestors(folderId: string, updatedAt: string) { if (!this.folders.find(folderId)) { return } for (const ancestorId of this.paths.folderParentFolderIds(folderId)) { - this.folders.touch(ancestorId, updatedAt) + await this.folders.touch(ancestorId, updatedAt) } } - private deleteFolderTree(folderId: string, deletedAt: string) { + private async deleteFolderTree(folderId: string, deletedAt: string) { const folder = this.folders.require(folderId) const folderPath = this.paths.folderLocationPath(folderId) for (const childFolder of this.folders.listByParent(folderId)) { - this.deleteFolderTree(childFolder.id ?? '', deletedAt) + await this.deleteFolderTree(childFolder.id ?? '', deletedAt) } for (const childDeck of this.decks.listByParent(folderId)) { - this.deleteDeckTree(childDeck.id ?? '', deletedAt) + await this.deleteDeckTree(childDeck.id ?? '', deletedAt) } - this.folders.markDeleted(folderId, deletedAt) - this.trash.addItem({ + await this.folders.markDeleted(folderId, deletedAt) + await this.trash.addItem({ deletedAt, id: folder.id ?? '', kind: 'folder', locationPath: folderPath, title: folder.name, }) - this.touchFolderAncestors(folder.parentId, deletedAt) - this.workspaces.touch(folder.workspaceId, deletedAt) + await this.touchFolderAncestors(folder.parentId, deletedAt) + await this.workspaces.touch(folder.workspaceId, deletedAt) } - private deleteDeckTree(deckId: string, deletedAt: string) { + private async deleteDeckTree(deckId: string, deletedAt: string) { const deck = this.decks.require(deckId) const deckPath = this.paths.deckLocationPath(deckId) for (const note of this.notes.listByDeck(deckId)) { const notePath = this.paths.noteLocationPath(note) - this.notes.markDeleted(note.id ?? '', deletedAt) - this.trash.addItem({ + await this.notes.markDeleted(note.id ?? '', deletedAt) + await this.trash.addItem({ deletedAt, id: note.id ?? '', kind: 'note', @@ -208,15 +224,15 @@ export class FolderService { }) } - this.decks.markDeleted(deckId, deletedAt) - this.trash.addItem({ + await this.decks.markDeleted(deckId, deletedAt) + await this.trash.addItem({ deletedAt, id: deck.id ?? '', kind: 'deck', locationPath: deckPath, title: deck.title, }) - this.touchFolderAncestors(deck.parentId, deletedAt) - this.workspaces.touch(deck.workspaceId, deletedAt) + await this.touchFolderAncestors(deck.parentId, deletedAt) + await this.workspaces.touch(deck.workspaceId, deletedAt) } } diff --git a/api/mock-server/src/features/location-path/resolver.ts b/api/mock-server/src/features/location-path/resolver.ts index ee94d4a..1f597e7 100644 --- a/api/mock-server/src/features/location-path/resolver.ts +++ b/api/mock-server/src/features/location-path/resolver.ts @@ -4,11 +4,19 @@ import { FolderRepository } from '../folders/repository.ts' import { WorkspaceRepository } from '../workspaces/repository.ts' export class LocationPathResolver { + private readonly workspaces: WorkspaceRepository + private readonly folders: FolderRepository + private readonly decks: DeckRepository + constructor( - private readonly workspaces: WorkspaceRepository, - private readonly folders: FolderRepository, - private readonly decks: DeckRepository, - ) {} + workspaces: WorkspaceRepository, + folders: FolderRepository, + decks: DeckRepository, + ) { + this.workspaces = workspaces + this.folders = folders + this.decks = decks + } workspacePath(workspaceId: string) { const workspace = this.workspaces.require(workspaceId) diff --git a/api/mock-server/src/features/notes/noteDetails.ts b/api/mock-server/src/features/notes/noteDetails.ts index 9c686ac..8891459 100644 --- a/api/mock-server/src/features/notes/noteDetails.ts +++ b/api/mock-server/src/features/notes/noteDetails.ts @@ -1,5 +1,4 @@ import type { - BasicNoteEditor, ClozeNoteCard, NoteDraft, NoteListItem, diff --git a/api/mock-server/src/features/notes/repository.ts b/api/mock-server/src/features/notes/repository.ts index 4ed80f5..754b609 100644 --- a/api/mock-server/src/features/notes/repository.ts +++ b/api/mock-server/src/features/notes/repository.ts @@ -1,6 +1,6 @@ import type { NoteDetailRecord } from '../../generated/mock-admin/contract/index.ts' import { notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' @@ -28,10 +28,14 @@ const sortNotes = ( } export class NotesRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } all() { - return this.stateStore.getSlice('notes') + return this.stateStore.findEntities('notes') } visible() { @@ -39,7 +43,7 @@ export class NotesRepository { } find(noteId: string) { - return this.all().find((note) => note.id === noteId) + return this.stateStore.findEntity('notes', noteId) } require(noteId: string, options: { includeDeleted?: boolean } = {}) { @@ -53,52 +57,33 @@ export class NotesRepository { return note } - create(note: NoteDetailRecord) { - this.stateStore.setSlice('notes', [note, ...this.all()]) - return note + async create(note: NoteDetailRecord) { + return this.stateStore.createEntity('notes', note, { prepend: true }) } - update(noteId: string, updater: (note: NoteDetailRecord) => NoteDetailRecord) { - let next: NoteDetailRecord | undefined - - this.stateStore.setSlice('notes', this.all().map((note) => { - if (note.id !== noteId) { - return note - } - - next = updater(note) - - return next - })) - - return next ?? this.require(noteId, { includeDeleted: true }) + async update(noteId: string, updater: (note: NoteDetailRecord) => NoteDetailRecord) { + return ( + await this.stateStore.updateEntity('notes', noteId, updater) + ) ?? this.require(noteId, { includeDeleted: true }) } - touch(noteId: string, updatedAt: string) { + async touch(noteId: string, updatedAt: string) { return this.update(noteId, (note) => ({ ...note, updatedAt })) } - markDeleted(noteId: string, deletedAt: string) { + async markDeleted(noteId: string, deletedAt: string) { return this.update(noteId, (note) => ({ ...note, deletedAt })) } - restore(noteId: string) { + async restore(noteId: string) { return this.update(noteId, (note) => { const { deletedAt: _deletedAt, ...restored } = note return restored }) } - remove(noteId: string) { - const existing = this.find(noteId) - - if (!existing) { - return undefined - } - - this.stateStore.setSlice('notes', this.all().filter((note) => note.id !== noteId)) - - return existing + async remove(noteId: string) { + return this.stateStore.deleteEntity('notes', noteId) } listByDeck(deckId: string, options: { sortField?: NoteSortField; sortDirection?: SortDirection } = {}) { diff --git a/api/mock-server/src/features/notes/service.ts b/api/mock-server/src/features/notes/service.ts index 438e48a..5d7069e 100644 --- a/api/mock-server/src/features/notes/service.ts +++ b/api/mock-server/src/features/notes/service.ts @@ -1,6 +1,3 @@ -import type { - NoteDetailRecord, -} from '../../generated/mock-admin/contract/index.ts' import type { NoteDraft, NoteListItem, @@ -8,7 +5,7 @@ import type { } from '../../generated/clear-web-api/contract/types.gen.ts' import type { ReviewGrade } from '../../generated/clear-web-api/contract/types.gen.ts' import { conflict } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { newIdAllocator } from '../../lib/ids.ts' import type { DeckRepository } from '../decks/repository.ts' import { summarizeDeckNotes } from '../decks/stats.ts' @@ -26,14 +23,28 @@ import type { TrashRepository } from '../trash/repository.ts' import type { WorkspaceRepository } from '../workspaces/repository.ts' export class NotesService { + private readonly notes: NotesRepository + private readonly decks: DeckRepository + private readonly workspaces: WorkspaceRepository + private readonly trash: TrashRepository + private readonly paths: LocationPathResolver + private readonly stateStore: MockStateStore + constructor( - private readonly notes: NotesRepository, - private readonly decks: DeckRepository, - private readonly workspaces: WorkspaceRepository, - private readonly trash: TrashRepository, - private readonly paths: LocationPathResolver, - private readonly stateStore: MockStateRepository, - ) {} + notes: NotesRepository, + decks: DeckRepository, + workspaces: WorkspaceRepository, + trash: TrashRepository, + paths: LocationPathResolver, + stateStore: MockStateStore, + ) { + this.notes = notes + this.decks = decks + this.workspaces = workspaces + this.trash = trash + this.paths = paths + this.stateStore = stateStore + } listNotesByDeck(deckId: string, query?: { sortField?: string; sortDirection?: string }): NoteListItem[] { const deck = this.decks.require(deckId) @@ -44,18 +55,18 @@ export class NotesService { return this.notes.listByDeck(deck.id ?? '', { sortField, sortDirection }).map(toNoteListItem) } - createNote(draft: NoteDraft): NoteRef { + async createNote(draft: NoteDraft): Promise { const deck = this.decks.require(draft.deckId) const workspaceId = deck.workspaceId - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const now = this.stateStore.now() const noteId = ids.next('note') const note = buildNoteDetail(draft, noteId, now, ids) - const created = this.notes.create(note) + const created = await this.notes.create(note) - this.recomputeDeckStats(draft.deckId, workspaceId, now) + await this.recomputeDeckStats(draft.deckId, workspaceId, now) return { deckId: created.deckId, @@ -68,26 +79,26 @@ export class NotesService { return this.notes.require(noteId) } - updateNote(noteId: string, draft: NoteDraft): NoteRef { + async updateNote(noteId: string, draft: NoteDraft): Promise { const current = this.notes.require(noteId) const currentDeckId = current.deckId const currentWorkspaceId = this.decks.require(currentDeckId).workspaceId const nextDeck = this.decks.require(draft.deckId) const nextWorkspaceId = nextDeck.workspaceId - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const now = this.stateStore.now() const replacement = buildNoteDetail(draft, noteId, now, ids) - const updated = this.notes.update(noteId, () => ({ + const updated = await this.notes.update(noteId, () => ({ ...replacement, id: noteId, updatedAt: now, })) - this.recomputeDeckStats(currentDeckId, currentWorkspaceId, now) + await this.recomputeDeckStats(currentDeckId, currentWorkspaceId, now) if (currentDeckId !== draft.deckId) { - this.recomputeDeckStats(draft.deckId, nextWorkspaceId, now) + await this.recomputeDeckStats(draft.deckId, nextWorkspaceId, now) } return { @@ -97,21 +108,21 @@ export class NotesService { }) } - deleteNote(noteId: string) { + async deleteNote(noteId: string) { const note = this.notes.require(noteId) const deck = this.decks.require(note.deckId) - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const deletedAt = this.stateStore.now() - this.notes.markDeleted(noteId, deletedAt) - this.trash.addItem({ + await this.notes.markDeleted(noteId, deletedAt) + await this.trash.addItem({ deletedAt, id: note.id ?? '', kind: 'note', locationPath: this.paths.noteLocationPath(note), title: note.title, }) - this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, deletedAt) + await this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, deletedAt) }) } @@ -119,7 +130,7 @@ export class NotesService { return this.notes.listByDeck(deckId).flatMap((note) => buildReviewCards(note)) } - gradeNoteCard(noteId: string, cardId: string, grade: ReviewGrade) { + async gradeNoteCard(noteId: string, cardId: string, grade: ReviewGrade) { const note = this.notes.require(noteId) const deck = this.decks.require(note.deckId) const now = this.stateStore.now() @@ -133,8 +144,8 @@ export class NotesService { throw conflict(`Review card ${cardId} does not belong to note ${noteId}`) } - return this.stateStore.transaction(() => { - const updated = this.notes.update(noteId, (current) => { + return this.stateStore.transaction(async () => { + const updated = await this.notes.update(noteId, (current) => { if (current.kind === 'basic') { return gradeBasicNote(current, grade, dueAt, now) } @@ -142,22 +153,22 @@ export class NotesService { return gradeClozeCard(current, cardId, grade, dueAt, now) }) - this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, now) + await this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, now) return updated }) } - private recomputeDeckStats(deckId: string, workspaceId: string, updatedAt: string) { + private async recomputeDeckStats(deckId: string, workspaceId: string, updatedAt: string) { const notes = this.notes.listByDeck(deckId) const nextStats = summarizeDeckNotes(notes, updatedAt) - this.decks.update(deckId, (deck) => ({ + await this.decks.update(deckId, (deck) => ({ ...deck, ...nextStats, updatedAt, })) - this.workspaces.touch(workspaceId, updatedAt) + await this.workspaces.touch(workspaceId, updatedAt) } } diff --git a/api/mock-server/src/features/review/repository.ts b/api/mock-server/src/features/review/repository.ts index 92ddd0d..53b4e5b 100644 --- a/api/mock-server/src/features/review/repository.ts +++ b/api/mock-server/src/features/review/repository.ts @@ -1,16 +1,20 @@ import type { ReviewSessionRecord } from '../../generated/mock-admin/contract/index.ts' import { notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' export class ReviewRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } all() { - return this.stateStore.getSlice('reviewSessions') + return this.stateStore.findEntities('reviewSessions') } find(reviewId: string) { - return this.all().find((session) => session.id === reviewId) + return this.stateStore.findEntity('reviewSessions', reviewId) } require(reviewId: string) { @@ -23,25 +27,13 @@ export class ReviewRepository { return session } - create(session: ReviewSessionRecord) { - this.stateStore.setSlice('reviewSessions', [session, ...this.all()]) - return session + async create(session: ReviewSessionRecord) { + return this.stateStore.createEntity('reviewSessions', session, { prepend: true }) } - update(reviewId: string, updater: (session: ReviewSessionRecord) => ReviewSessionRecord) { - let next: ReviewSessionRecord | undefined - - this.stateStore.setSlice('reviewSessions', this.all().map((session) => { - if (session.id !== reviewId) { - return session - } - - next = updater(session) - - return next - })) - - return next ?? this.require(reviewId) + async update(reviewId: string, updater: (session: ReviewSessionRecord) => ReviewSessionRecord) { + return ( + await this.stateStore.updateEntity('reviewSessions', reviewId, updater) + ) ?? this.require(reviewId) } - } diff --git a/api/mock-server/src/features/review/service.ts b/api/mock-server/src/features/review/service.ts index 6936b87..c455264 100644 --- a/api/mock-server/src/features/review/service.ts +++ b/api/mock-server/src/features/review/service.ts @@ -7,7 +7,7 @@ import type { ReviewStartResult, } from '../../generated/clear-web-api/contract/types.gen.ts' import type { ReviewSessionRecord } from '../../generated/mock-admin/contract/index.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { conflict } from '../../generated/clear-web-api/mock-runtime.ts' import { newIdAllocator } from '../../lib/ids.ts' import type { DeckRepository } from '../decks/repository.ts' @@ -26,15 +26,27 @@ const elapsedSeconds = (startedAt: string, now: string) => Math.max(0, Math.floor((Date.parse(now) - Date.parse(startedAt)) / 1000)) export class ReviewService { + private readonly reviews: ReviewRepository + private readonly notes: NotesRepository + private readonly decks: DeckRepository + private readonly noteService: NotesService + private readonly stateStore: MockStateStore + constructor( - private readonly reviews: ReviewRepository, - private readonly notes: NotesRepository, - private readonly decks: DeckRepository, - private readonly noteService: NotesService, - private readonly stateStore: MockStateRepository, - ) {} - - startReviewSession(deckId: string): ReviewStartResult { + reviews: ReviewRepository, + notes: NotesRepository, + decks: DeckRepository, + noteService: NotesService, + stateStore: MockStateStore, + ) { + this.reviews = reviews + this.notes = notes + this.decks = decks + this.noteService = noteService + this.stateStore = stateStore + } + + async startReviewSession(deckId: string): Promise { const deck = this.decks.require(deckId) const queue = this.buildQueue(deckId) @@ -45,7 +57,7 @@ export class ReviewService { } } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const now = this.stateStore.now() const dueQueue = queue.filter((entry) => entry.dueAt <= now) @@ -63,7 +75,7 @@ export class ReviewService { status: 'active', } - this.reviews.create(session as unknown as ReviewSessionRecord) + await this.reviews.create(session as unknown as ReviewSessionRecord) return session } @@ -78,7 +90,7 @@ export class ReviewService { startedAt: now, } - this.reviews.create(session as unknown as ReviewSessionRecord) + await this.reviews.create(session as unknown as ReviewSessionRecord) return session }) @@ -88,7 +100,7 @@ export class ReviewService { return this.reviews.require(reviewId) } - gradeReviewSessionCard(reviewId: string, cardId: string, grade: ReviewGrade): ReviewSession { + async gradeReviewSessionCard(reviewId: string, cardId: string, grade: ReviewGrade): Promise { const session = this.reviews.require(reviewId) const queue = this.buildQueue(session.deckId) const currentIndex = queue.findIndex((entry) => entry.card.id === cardId) @@ -97,9 +109,9 @@ export class ReviewService { throw conflict(`Review card ${cardId} is not the current card for session ${reviewId}`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const now = this.stateStore.now() - this.noteService.gradeNoteCard(queue[currentIndex].noteId, cardId, grade) + await this.noteService.gradeNoteCard(queue[currentIndex].noteId, cardId, grade) const nextQueue = this.buildQueue(session.deckId) const durationSeconds = elapsedSeconds(session.startedAt, now) @@ -110,7 +122,7 @@ export class ReviewService { nextQueue.findIndex((candidate) => candidate.card.id === entry.card.id) > currentIndex, ) ?? remainingDue[0] - const updated = this.reviews.update(reviewId, (current) => ({ + const updated = await this.reviews.update(reviewId, (current) => ({ ...(current as DueReviewSession), completedAt: completed ? now : (current as DueReviewSession).completedAt, currentCard: completed ? null : nextCard?.card ?? null, diff --git a/api/mock-server/src/features/search/service.ts b/api/mock-server/src/features/search/service.ts index 9bf7b54..6d2bcb8 100644 --- a/api/mock-server/src/features/search/service.ts +++ b/api/mock-server/src/features/search/service.ts @@ -5,8 +5,8 @@ import type { FolderSearchResultGroup, NoteSearchResult, NoteSearchResultGroup, - SearchRequest, SearchSearchResultGroup, + SearchRequest, } from '../../generated/clear-web-api/contract/types.gen.ts' import type { DeckRecord, FolderRecord, NoteDetailRecord } from '../../generated/mock-admin/contract/index.ts' import type { DeckRepository } from '../decks/repository.ts' @@ -15,8 +15,6 @@ import type { LocationPathResolver } from '../location-path/resolver.ts' import type { NotesRepository } from '../notes/repository.ts' import type { WorkspaceRepository } from '../workspaces/repository.ts' -type SearchContext = SearchRequest['scope'] - const includesQuery = (value: string, query: string) => value.toLowerCase().includes(query.toLowerCase()) @@ -24,13 +22,25 @@ const sortByUpdatedDesc = (items: T[]) => [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) export class SearchService { + private readonly workspaces: WorkspaceRepository + private readonly folders: FolderRepository + private readonly decks: DeckRepository + private readonly notes: NotesRepository + private readonly paths: LocationPathResolver + constructor( - private readonly workspaces: WorkspaceRepository, - private readonly folders: FolderRepository, - private readonly decks: DeckRepository, - private readonly notes: NotesRepository, - private readonly paths: LocationPathResolver, - ) {} + workspaces: WorkspaceRepository, + folders: FolderRepository, + decks: DeckRepository, + notes: NotesRepository, + paths: LocationPathResolver, + ) { + this.workspaces = workspaces + this.folders = folders + this.decks = decks + this.notes = notes + this.paths = paths + } searchContent(request: SearchRequest): SearchSearchResultGroup[] { const query = request.query.trim() @@ -90,7 +100,7 @@ export class SearchService { } private searchDeck(deckId: string, query: string) { - const deck = this.decks.require(deckId) + this.decks.require(deckId) const noteResults = sortByUpdatedDesc( this.notes.visible().filter((note) => note.deckId === deckId && this.matchesNote(note, query)), ).map((note) => this.toNoteSearchResult(note)) diff --git a/api/mock-server/src/features/settings/repository.ts b/api/mock-server/src/features/settings/repository.ts index 9e71f86..370aa79 100644 --- a/api/mock-server/src/features/settings/repository.ts +++ b/api/mock-server/src/features/settings/repository.ts @@ -1,21 +1,25 @@ import { clone } from '../../lib/clone.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import type { SettingsRecord } from '../../generated/mock-admin/contract/index.ts' import { DEFAULT_SETTINGS } from './defaults.ts' export class SettingsRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } get() { return this.stateStore.getSlice('settings') } - set(settings: SettingsRecord) { - this.stateStore.setSlice('settings', clone(settings)) + async set(settings: SettingsRecord) { + await this.stateStore.setSlice('settings', clone(settings)) return this.get() } - reset() { + async reset() { return this.set(clone(DEFAULT_SETTINGS)) } } diff --git a/api/mock-server/src/features/settings/service.ts b/api/mock-server/src/features/settings/service.ts index e6a3dfa..6c2d7ce 100644 --- a/api/mock-server/src/features/settings/service.ts +++ b/api/mock-server/src/features/settings/service.ts @@ -1,19 +1,25 @@ import type { SettingsRecord } from '../../generated/mock-admin/contract/index.ts' import { DEFAULT_SETTINGS } from './defaults.ts' import { SettingsRepository } from './repository.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' export class SettingsService { + private readonly settings: SettingsRepository + private readonly stateStore: MockStateStore + constructor( - private readonly settings: SettingsRepository, - private readonly stateStore: MockStateRepository, - ) {} + settings: SettingsRepository, + stateStore: MockStateStore, + ) { + this.settings = settings + this.stateStore = stateStore + } getSettings() { return this.settings.get() } - updateSettings(settings: SettingsRecord) { + async updateSettings(settings: SettingsRecord) { return this.stateStore.transaction(() => this.settings.set(settings)) } @@ -21,7 +27,7 @@ export class SettingsService { return DEFAULT_SETTINGS } - resetSettings() { + async resetSettings() { return this.stateStore.transaction(() => this.settings.reset()) } } diff --git a/api/mock-server/src/features/trash/repository.ts b/api/mock-server/src/features/trash/repository.ts index a299b5e..6a80c26 100644 --- a/api/mock-server/src/features/trash/repository.ts +++ b/api/mock-server/src/features/trash/repository.ts @@ -1,16 +1,20 @@ import type { TrashStateRecord, TrashItem } from '../../generated/mock-admin/contract/index.ts' import { notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' export class TrashRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } get() { return this.stateStore.getSlice('trash') } - set(trash: TrashStateRecord) { - this.stateStore.setSlice('trash', trash) + async set(trash: TrashStateRecord) { + await this.stateStore.setSlice('trash', trash) return this.get() } @@ -28,10 +32,10 @@ export class TrashRepository { return this.get().items.find((candidate) => candidate.id === itemId) } - addItem(item: TrashItem) { + async addItem(item: TrashItem) { const trash = this.get() - this.stateStore.setSlice('trash', { + await this.stateStore.setSlice('trash', { ...trash, items: [item, ...trash.items.filter((candidate) => candidate.id !== item.id)], }) @@ -39,10 +43,10 @@ export class TrashRepository { return this.get() } - removeItem(itemId: string) { + async removeItem(itemId: string) { const trash = this.get() - this.stateStore.setSlice('trash', { + await this.stateStore.setSlice('trash', { ...trash, items: trash.items.filter((item) => item.id !== itemId), }) @@ -50,8 +54,8 @@ export class TrashRepository { return this.get() } - empty(lastEmptiedAt: string) { - this.stateStore.setSlice('trash', { + async empty(lastEmptiedAt: string) { + await this.stateStore.setSlice('trash', { items: [], lastEmptiedAt, }) diff --git a/api/mock-server/src/features/trash/service.ts b/api/mock-server/src/features/trash/service.ts index 60dafce..34e9642 100644 --- a/api/mock-server/src/features/trash/service.ts +++ b/api/mock-server/src/features/trash/service.ts @@ -3,7 +3,7 @@ import type { TrashStateRecord, } from '../../generated/mock-admin/contract/index.ts' import { conflict, notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import type { DeckRepository } from '../decks/repository.ts' import { summarizeDeckNotes } from '../decks/stats.ts' import type { FolderRepository } from '../folders/repository.ts' @@ -13,84 +13,100 @@ import type { WorkspaceRepository } from '../workspaces/repository.ts' import { TrashRepository } from './repository.ts' export class TrashService { + private readonly trash: TrashRepository + private readonly workspaces: WorkspaceRepository + private readonly folders: FolderRepository + private readonly decks: DeckRepository + private readonly notes: NotesRepository + private readonly paths: LocationPathResolver + private readonly stateStore: MockStateStore + constructor( - private readonly trash: TrashRepository, - private readonly workspaces: WorkspaceRepository, - private readonly folders: FolderRepository, - private readonly decks: DeckRepository, - private readonly notes: NotesRepository, - private readonly paths: LocationPathResolver, - private readonly stateStore: MockStateRepository, - ) {} + trash: TrashRepository, + workspaces: WorkspaceRepository, + folders: FolderRepository, + decks: DeckRepository, + notes: NotesRepository, + paths: LocationPathResolver, + stateStore: MockStateStore, + ) { + this.trash = trash + this.workspaces = workspaces + this.folders = folders + this.decks = decks + this.notes = notes + this.paths = paths + this.stateStore = stateStore + } getTrash(): TrashStateRecord { return this.trash.get() } - emptyTrash() { - return this.stateStore.transaction(() => { + async emptyTrash() { + return this.stateStore.transaction(async () => { for (const item of this.trash.get().items) { - this.removeUnderlyingRecord(item) + await this.removeUnderlyingRecord(item) } return this.trash.empty(this.stateStore.now()) }) } - restoreTrashItem(itemId: string) { + async restoreTrashItem(itemId: string) { const item = this.trash.requireItem(itemId) - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const now = this.stateStore.now() switch (item.kind) { case 'workspace': - this.restoreWorkspace(item, now) + await this.restoreWorkspace(item, now) break case 'folder': - this.restoreFolder(item, now) + await this.restoreFolder(item, now) break case 'deck': - this.restoreDeck(item, now) + await this.restoreDeck(item, now) break case 'note': - this.restoreNote(item, now) + await this.restoreNote(item, now) break } - this.trash.removeItem(itemId) + await this.trash.removeItem(itemId) return undefined }) } - deleteTrashItem(itemId: string) { + async deleteTrashItem(itemId: string) { const item = this.trash.requireItem(itemId) - return this.stateStore.transaction(() => { - this.removeUnderlyingRecord(item) - this.trash.removeItem(itemId) + return this.stateStore.transaction(async () => { + await this.removeUnderlyingRecord(item) + await this.trash.removeItem(itemId) return undefined }) } - private removeUnderlyingRecord(item: TrashItem) { + private async removeUnderlyingRecord(item: TrashItem) { switch (item.kind) { case 'workspace': - this.workspaces.remove(item.id) + await this.workspaces.remove(item.id) return case 'folder': - this.folders.remove(item.id) + await this.folders.remove(item.id) return case 'deck': - this.decks.remove(item.id) + await this.decks.remove(item.id) return case 'note': - this.notes.remove(item.id) + await this.notes.remove(item.id) return } } - private restoreWorkspace(item: TrashItem, updatedAt: string) { + private async restoreWorkspace(item: TrashItem, updatedAt: string) { const workspace = this.workspaces.find(item.id) if (!workspace) { @@ -101,11 +117,11 @@ export class TrashService { throw conflict(`Workspace titled ${workspace.title} already exists`) } - this.workspaces.restore(item.id) - this.workspaces.touch(item.id, updatedAt) + await this.workspaces.restore(item.id) + await this.workspaces.touch(item.id, updatedAt) } - private restoreFolder(item: TrashItem, updatedAt: string) { + private async restoreFolder(item: TrashItem, updatedAt: string) { const folder = this.folders.find(item.id) if (!folder) { @@ -135,13 +151,13 @@ export class TrashService { throw conflict(`Folder named ${folder.name} already exists in this location`) } - this.folders.restore(item.id) - this.folders.touch(item.id, updatedAt) - this.touchFolderAncestors(folder.parentId, updatedAt) - this.workspaces.touch(folder.workspaceId, updatedAt) + await this.folders.restore(item.id) + await this.folders.touch(item.id, updatedAt) + await this.touchFolderAncestors(folder.parentId, updatedAt) + await this.workspaces.touch(folder.workspaceId, updatedAt) } - private restoreDeck(item: TrashItem, updatedAt: string) { + private async restoreDeck(item: TrashItem, updatedAt: string) { const deck = this.decks.find(item.id) if (!deck) { @@ -171,14 +187,14 @@ export class TrashService { throw conflict(`Deck titled ${deck.title} already exists in this location`) } - this.decks.restore(item.id) - this.decks.touch(item.id, updatedAt) - this.touchFolderAncestors(deck.parentId, updatedAt) - this.workspaces.touch(deck.workspaceId, updatedAt) - this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, updatedAt) + await this.decks.restore(item.id) + await this.decks.touch(item.id, updatedAt) + await this.touchFolderAncestors(deck.parentId, updatedAt) + await this.workspaces.touch(deck.workspaceId, updatedAt) + await this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, updatedAt) } - private restoreNote(item: TrashItem, updatedAt: string) { + private async restoreNote(item: TrashItem, updatedAt: string) { const note = this.notes.find(item.id) if (!note) { @@ -189,33 +205,33 @@ export class TrashService { if (!deck || deck.deletedAt) { throw conflict(`Note ${note.title} can no longer be restored`) } - this.notes.restore(item.id) - this.notes.touch(item.id, updatedAt) - this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, updatedAt) + await this.notes.restore(item.id) + await this.notes.touch(item.id, updatedAt) + await this.recomputeDeckStats(deck.id ?? '', deck.workspaceId, updatedAt) } - private touchFolderAncestors(parentId: string, updatedAt: string) { + private async touchFolderAncestors(parentId: string, updatedAt: string) { if (this.workspaces.find(parentId)) { return } for (const ancestorId of this.paths.folderParentFolderIds(parentId)) { - this.folders.touch(ancestorId, updatedAt) + await this.folders.touch(ancestorId, updatedAt) } - this.folders.touch(parentId, updatedAt) + await this.folders.touch(parentId, updatedAt) } - private recomputeDeckStats(deckId: string, workspaceId: string, updatedAt: string) { + private async recomputeDeckStats(deckId: string, workspaceId: string, updatedAt: string) { const notes = this.notes.listByDeck(deckId) const nextStats = summarizeDeckNotes(notes, updatedAt) - this.decks.update(deckId, (deck) => ({ + await this.decks.update(deckId, (deck) => ({ ...deck, ...nextStats, updatedAt, })) - this.workspaces.touch(workspaceId, updatedAt) + await this.workspaces.touch(workspaceId, updatedAt) } } diff --git a/api/mock-server/src/features/workspaces/repository.ts b/api/mock-server/src/features/workspaces/repository.ts index 12100c2..e896453 100644 --- a/api/mock-server/src/features/workspaces/repository.ts +++ b/api/mock-server/src/features/workspaces/repository.ts @@ -1,14 +1,18 @@ import type { WorkspaceRecord } from '../../generated/mock-admin/contract/index.ts' import { notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { visible } from '../../lib/softDelete.ts' import { byStringField } from '../../lib/sort.ts' export class WorkspaceRepository { - constructor(private readonly stateStore: MockStateRepository) {} + private readonly stateStore: MockStateStore + + constructor(stateStore: MockStateStore) { + this.stateStore = stateStore + } all() { - return this.stateStore.getSlice('workspaces') + return this.stateStore.findEntities('workspaces') } visible() { @@ -16,7 +20,7 @@ export class WorkspaceRepository { } find(workspaceId: string) { - return this.all().find((workspace) => workspace.id === workspaceId) + return this.stateStore.findEntity('workspaces', workspaceId) } require(workspaceId: string, options: { includeDeleted?: boolean } = {}) { @@ -38,59 +42,37 @@ export class WorkspaceRepository { return this.stateStore.getSlice('activeWorkspace') } - setActiveWorkspace(workspaceId: string) { - this.stateStore.setSlice('activeWorkspace', { workspaceId }) + async setActiveWorkspace(workspaceId: string) { + await this.stateStore.setSlice('activeWorkspace', { workspaceId }) } - create(workspace: WorkspaceRecord) { - this.stateStore.setSlice('workspaces', [workspace, ...this.all()]) - return workspace + async create(workspace: WorkspaceRecord) { + return this.stateStore.createEntity('workspaces', workspace, { prepend: true }) } - update(workspaceId: string, updater: (workspace: WorkspaceRecord) => WorkspaceRecord) { - let next: WorkspaceRecord | undefined - - this.stateStore.setSlice('workspaces', this.all().map((workspace) => { - if (workspace.id !== workspaceId) { - return workspace - } - - next = updater(workspace) - - return next - })) - - return next ?? this.require(workspaceId, { includeDeleted: true }) + async update(workspaceId: string, updater: (workspace: WorkspaceRecord) => WorkspaceRecord) { + return ( + await this.stateStore.updateEntity('workspaces', workspaceId, updater) + ) ?? this.require(workspaceId, { includeDeleted: true }) } - touch(workspaceId: string, updatedAt: string) { + async touch(workspaceId: string, updatedAt: string) { return this.update(workspaceId, (workspace) => ({ ...workspace, updatedAt })) } - markDeleted(workspaceId: string, deletedAt: string) { + async markDeleted(workspaceId: string, deletedAt: string) { return this.update(workspaceId, (workspace) => ({ ...workspace, deletedAt })) } - restore(workspaceId: string) { + async restore(workspaceId: string) { return this.update(workspaceId, (workspace) => { const { deletedAt: _deletedAt, ...restored } = workspace return restored }) } - remove(workspaceId: string) { - const existing = this.find(workspaceId) - - if (!existing) { - return undefined - } - - this.stateStore.setSlice( - 'workspaces', - this.all().filter((workspace) => workspace.id !== workspaceId), - ) - - return existing + async remove(workspaceId: string) { + return this.stateStore.deleteEntity('workspaces', workspaceId) } firstVisibleOtherThan(workspaceId: string) { diff --git a/api/mock-server/src/features/workspaces/service.ts b/api/mock-server/src/features/workspaces/service.ts index 094cb3e..b93b6f8 100644 --- a/api/mock-server/src/features/workspaces/service.ts +++ b/api/mock-server/src/features/workspaces/service.ts @@ -5,8 +5,8 @@ import type { WorkspaceListResult, } from '../../generated/clear-web-api/contract/types.gen.ts' import type { WorkspaceRecord } from '../../generated/mock-admin/contract/index.ts' -import { conflict, notFound } from '../../generated/clear-web-api/mock-runtime.ts' -import type { MockStateRepository } from '../../generated/mock-admin/state/repository.ts' +import { conflict } from '../../generated/clear-web-api/mock-runtime.ts' +import type { MockStateStore } from '../../lib/stateStore.ts' import { newIdAllocator } from '../../lib/ids.ts' import type { DeckRepository } from '../decks/repository.ts' import type { FolderRepository } from '../folders/repository.ts' @@ -16,15 +16,31 @@ import type { TrashRepository } from '../trash/repository.ts' import { WorkspaceRepository } from './repository.ts' export class WorkspacesService { + private readonly workspaces: WorkspaceRepository + private readonly folders: FolderRepository + private readonly decks: DeckRepository + private readonly notes: NotesRepository + private readonly trash: TrashRepository + private readonly paths: LocationPathResolver + private readonly stateStore: MockStateStore + constructor( - private readonly workspaces: WorkspaceRepository, - private readonly folders: FolderRepository, - private readonly decks: DeckRepository, - private readonly notes: NotesRepository, - private readonly trash: TrashRepository, - private readonly paths: LocationPathResolver, - private readonly stateStore: MockStateRepository, - ) {} + workspaces: WorkspaceRepository, + folders: FolderRepository, + decks: DeckRepository, + notes: NotesRepository, + trash: TrashRepository, + paths: LocationPathResolver, + stateStore: MockStateStore, + ) { + this.workspaces = workspaces + this.folders = folders + this.decks = decks + this.notes = notes + this.trash = trash + this.paths = paths + this.stateStore = stateStore + } listWorkspaces(): WorkspaceListResult { const activeWorkspaceId = this.activeWorkspaceId() @@ -35,14 +51,14 @@ export class WorkspacesService { } } - createWorkspace(draft: WorkspaceDraft): WorkspaceRecord { + async createWorkspace(draft: WorkspaceDraft): Promise { const duplicate = this.workspaces.visible().some((workspace) => workspace.title === draft.title) if (duplicate) { throw conflict(`Workspace titled ${draft.title} already exists`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const ids = newIdAllocator(this.stateStore.getSlice('idCounters')) const now = this.stateStore.now() const workspace: WorkspaceRecord = { @@ -66,11 +82,11 @@ export class WorkspacesService { } } - setActiveWorkspace(workspaceId: string) { + async setActiveWorkspace(workspaceId: string) { const workspace = this.workspaces.require(workspaceId) - return this.stateStore.transaction(() => { - this.workspaces.setActiveWorkspace(workspace.id ?? '') + return this.stateStore.transaction(async () => { + await this.workspaces.setActiveWorkspace(workspace.id ?? '') }) } @@ -78,8 +94,8 @@ export class WorkspacesService { return this.workspaces.require(workspaceId) } - updateWorkspace(workspaceId: string, draft: WorkspaceDraft) { - const current = this.workspaces.require(workspaceId) + async updateWorkspace(workspaceId: string, draft: WorkspaceDraft) { + this.workspaces.require(workspaceId) const duplicate = this.workspaces.visible().some( (workspace) => workspace.id !== workspaceId && workspace.title === draft.title, ) @@ -88,7 +104,7 @@ export class WorkspacesService { throw conflict(`Workspace titled ${draft.title} already exists`) } - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const now = this.stateStore.now() return this.workspaces.update(workspaceId, (workspace) => ({ ...workspace, @@ -100,24 +116,24 @@ export class WorkspacesService { }) } - deleteWorkspace(workspaceId: string): DeleteWorkspaceResult { + async deleteWorkspace(workspaceId: string): Promise { const workspace = this.workspaces.require(workspaceId) const currentActive = this.workspaces.getActiveWorkspace().workspaceId - return this.stateStore.transaction(() => { + return this.stateStore.transaction(async () => { const deletedAt = this.stateStore.now() const workspacePath = this.paths.workspacePath(workspaceId) for (const folder of this.folders.listByWorkspace(workspaceId)) { - this.deleteFolderTree(folder.id ?? '', deletedAt) + await this.deleteFolderTree(folder.id ?? '', deletedAt) } for (const deck of this.decks.listByWorkspace(workspaceId)) { - this.deleteDeckTree(deck.id ?? '', deletedAt) + await this.deleteDeckTree(deck.id ?? '', deletedAt) } - this.workspaces.markDeleted(workspaceId, deletedAt) - this.trash.addItem({ + await this.workspaces.markDeleted(workspaceId, deletedAt) + await this.trash.addItem({ deletedAt, id: workspace.id ?? '', kind: 'workspace', @@ -130,7 +146,7 @@ export class WorkspacesService { : null if (nextVisible) { - this.workspaces.setActiveWorkspace(nextVisible.id ?? '') + await this.workspaces.setActiveWorkspace(nextVisible.id ?? '') } return { @@ -146,37 +162,37 @@ export class WorkspacesService { return activeWorkspace && !activeWorkspace.deletedAt ? activeWorkspaceId : null } - private deleteFolderTree(folderId: string, deletedAt: string) { + private async deleteFolderTree(folderId: string, deletedAt: string) { const folder = this.folders.require(folderId) const folderPath = this.paths.folderLocationPath(folderId) for (const childFolder of this.folders.listByParent(folderId)) { - this.deleteFolderTree(childFolder.id ?? '', deletedAt) + await this.deleteFolderTree(childFolder.id ?? '', deletedAt) } for (const childDeck of this.decks.listByParent(folderId)) { - this.deleteDeckTree(childDeck.id ?? '', deletedAt) + await this.deleteDeckTree(childDeck.id ?? '', deletedAt) } - this.folders.markDeleted(folderId, deletedAt) - this.trash.addItem({ + await this.folders.markDeleted(folderId, deletedAt) + await this.trash.addItem({ deletedAt, id: folder.id ?? '', kind: 'folder', locationPath: folderPath, title: folder.name, }) - this.workspaces.touch(folder.workspaceId, deletedAt) + await this.workspaces.touch(folder.workspaceId, deletedAt) } - private deleteDeckTree(deckId: string, deletedAt: string) { + private async deleteDeckTree(deckId: string, deletedAt: string) { const deck = this.decks.require(deckId) const deckPath = this.paths.deckLocationPath(deckId) for (const note of this.notes.listByDeck(deckId)) { const notePath = this.paths.noteLocationPath(note) - this.notes.markDeleted(note.id ?? '', deletedAt) - this.trash.addItem({ + await this.notes.markDeleted(note.id ?? '', deletedAt) + await this.trash.addItem({ deletedAt, id: note.id ?? '', kind: 'note', @@ -185,14 +201,14 @@ export class WorkspacesService { }) } - this.decks.markDeleted(deckId, deletedAt) - this.trash.addItem({ + await this.decks.markDeleted(deckId, deletedAt) + await this.trash.addItem({ deletedAt, id: deck.id ?? '', kind: 'deck', locationPath: deckPath, title: deck.title, }) - this.workspaces.touch(deck.workspaceId, deletedAt) + await this.workspaces.touch(deck.workspaceId, deletedAt) } } diff --git a/api/mock-server/src/lib/browserStateStore.ts b/api/mock-server/src/lib/browserStateStore.ts new file mode 100644 index 0000000..8394eb7 --- /dev/null +++ b/api/mock-server/src/lib/browserStateStore.ts @@ -0,0 +1,54 @@ +import { clone } from './clone.ts' +import { + newMswDataStateStore, + type MswDataStateStore, +} from './stateStore.ts' +import type { MockState } from '../generated/mock-admin/contract/index.ts' +import { zMockState } from '../generated/mock-admin/contract/zod.gen.ts' +import { seedState } from '../generated/mock-admin/state/seed.ts' + +export type BrowserMockStateStoreOptions = { + initialState?: MockState + storageKey: string +} + +export const newBrowserMockStateStore = async ( + options: BrowserMockStateStoreOptions, +): Promise => { + const initialState = options.initialState + ? clone(options.initialState) + : readPersistedState(options.storageKey) ?? seedState() + + return newMswDataStateStore({ + initialState, + persist: (state) => persistState(options.storageKey, state), + }) +} + +const persistState = (storageKey: string, state: MockState) => { + if (typeof window === 'undefined') { + return + } + + window.localStorage.setItem(storageKey, JSON.stringify(state)) +} + +const readPersistedState = (storageKey: string) => { + if (typeof window === 'undefined') { + return null + } + + const raw = window.localStorage.getItem(storageKey) + + if (!raw) { + return null + } + + try { + const result = zMockState.safeParse(JSON.parse(raw)) + + return result.success ? result.data : null + } catch { + return null + } +} diff --git a/api/mock-server/src/lib/nodeStateStore.ts b/api/mock-server/src/lib/nodeStateStore.ts new file mode 100644 index 0000000..1efd752 --- /dev/null +++ b/api/mock-server/src/lib/nodeStateStore.ts @@ -0,0 +1,65 @@ +import { + mkdir, + readFile, + writeFile, +} from 'node:fs/promises' +import path from 'node:path' + +import { clone } from './clone.ts' +import { + newMswDataStateStore, + type MswDataStateStore, +} from './stateStore.ts' +import type { MockState } from '../generated/mock-admin/contract/index.ts' +import { zMockState } from '../generated/mock-admin/contract/zod.gen.ts' +import { seedState } from '../generated/mock-admin/state/seed.ts' + +export type MockStateOptions = { + initialState?: MockState + stateFile?: string +} + +export const newFileMockStateStore = async ( + options: MockStateOptions = {}, +): Promise => { + const initialState = options.initialState + ? clone(options.initialState) + : (await readPersistedState(options.stateFile)) ?? seedState() + + return newMswDataStateStore({ + initialState, + persist: (state) => persistState(options.stateFile, state), + }) +} + +const persistState = async (stateFile: string | undefined, state: MockState) => { + if (!stateFile) { + return + } + + await mkdir(path.dirname(stateFile), { recursive: true }) + await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8') +} + +const readPersistedState = async (stateFile: string | undefined) => { + if (!stateFile) { + return null + } + + try { + const result = zMockState.safeParse(JSON.parse(await readFile(stateFile, 'utf8'))) + + return result.success ? result.data : null + } catch (error) { + if (isNodeFileError(error) && error.code === 'ENOENT') { + return null + } + + console.warn(`Ignoring invalid mock state at ${stateFile}. Resetting to seed state.`) + + return null + } +} + +const isNodeFileError = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error diff --git a/api/mock-server/src/lib/stateStore.test.ts b/api/mock-server/src/lib/stateStore.test.ts new file mode 100644 index 0000000..154d740 --- /dev/null +++ b/api/mock-server/src/lib/stateStore.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { tmpdir } from 'node:os' + +import { afterEach, describe, expect, it } from 'vitest' + +import { seedState } from '../generated/mock-admin/state/seed.ts' +import { newFileMockStateStore } from './nodeStateStore.ts' +import { newMemoryMockStateStore } from './stateStore.ts' + +const tempRoots: string[] = [] + +const newTempStateFile = () => { + const root = mkdtempSync(path.join(tmpdir(), 'clear-mock-state-')) + tempRoots.push(root) + + return path.join(root, 'db.json') +} + +describe('MswDataStateStore', () => { + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }) + } + }) + + it('hydrates seed records and preserves snapshot order', async () => { + const store = await newMemoryMockStateStore() + + expect(store.snapshot().workspaces.map((workspace) => workspace.id)).toEqual([ + 'independent-study', + 'reading-archive', + ]) + expect(store.snapshot().folders.map((folder) => folder.id)).toEqual([ + 'reading-notes', + 'reference', + 'history', + ]) + }) + + it('creates, updates, and deletes entity records through data collections', async () => { + const store = await newMemoryMockStateStore() + + await store.transaction(async () => { + await store.createEntity('workspaces', { + description: 'Draft workspace.', + icon: 'layers-3', + id: 'draft-workspace', + title: 'Draft Workspace', + updatedAt: store.now(), + }, { prepend: true }) + await store.updateEntity('workspaces', 'draft-workspace', (workspace) => ({ + ...workspace, + title: 'Updated Workspace', + })) + }) + + expect(store.snapshot().workspaces[0]).toMatchObject({ + id: 'draft-workspace', + title: 'Updated Workspace', + }) + + await store.deleteEntity('workspaces', 'draft-workspace') + + expect(store.snapshot().workspaces.map((workspace) => workspace.id)).not.toContain('draft-workspace') + }) + + it('resets, replaces state, and updates clock snapshots', async () => { + const store = await newMemoryMockStateStore() + const state = seedState() + + await store.setClock('2026-06-01T00:00:00.000Z') + expect(store.snapshot().clock.now).toBe('2026-06-01T00:00:00.000Z') + + await store.replace({ + ...state, + workspaces: [], + }) + expect(store.snapshot().workspaces).toEqual([]) + + await store.reset() + expect(store.snapshot().workspaces).toHaveLength(state.workspaces.length) + }) + + it('persists and reloads a file-backed snapshot', async () => { + const stateFile = newTempStateFile() + const first = await newFileMockStateStore({ stateFile }) + + await first.transaction(async () => { + await first.createEntity('workspaces', { + description: 'Persisted workspace.', + icon: 'layers-3', + id: 'persisted-workspace', + title: 'Persisted Workspace', + updatedAt: first.now(), + }, { prepend: true }) + }) + + const second = await newFileMockStateStore({ stateFile }) + + expect(second.snapshot().workspaces[0]).toMatchObject({ + id: 'persisted-workspace', + }) + + writeFileSync(stateFile, '{broken', 'utf8') + + const repaired = await newFileMockStateStore({ stateFile }) + + expect(repaired.snapshot().workspaces.map((workspace) => workspace.id)).toContain('independent-study') + }) +}) diff --git a/api/mock-server/src/lib/stateStore.ts b/api/mock-server/src/lib/stateStore.ts new file mode 100644 index 0000000..320988d --- /dev/null +++ b/api/mock-server/src/lib/stateStore.ts @@ -0,0 +1,334 @@ +import { Collection } from '@msw/data' + +import { clone } from './clone.ts' +import type { + MockClock, + MockState, +} from '../generated/mock-admin/contract/index.ts' +import { + zDeckRecord, + zFolderRecord, + zNoteDetailRecord, + zReviewSessionRecord, + zWorkspaceRecord, +} from '../generated/mock-admin/contract/zod.gen.ts' +import { seedState } from '../generated/mock-admin/state/seed.ts' + +export type EntitySliceKey = + | 'decks' + | 'folders' + | 'notes' + | 'reviewSessions' + | 'workspaces' + +type EntityRecord = + EntityRecordMap[TKey] + +type EntityRecordMap = { + decks: MockState['decks'][number] + folders: MockState['folders'][number] + notes: MockState['notes'][number] + reviewSessions: MockState['reviewSessions'][number] + workspaces: MockState['workspaces'][number] +} + +type EntityCollection = { + all: () => TRecord[] + clear: () => void + create: (record: TRecord) => Promise + delete: (record: TRecord) => TRecord | undefined + findFirst: ( + predicate?: (query: { where: (predicate: unknown) => unknown }) => unknown, + ) => TRecord | undefined + update: ( + record: TRecord, + options: { data: (draft: TRecord) => void }, + ) => Promise +} + +type EntityCollections = ReturnType +type MetaState = Omit + +export type MockStatePersist = (state: MockState) => Promise | void + +export type MswDataStateStoreOptions = { + initialState?: MockState + persist?: MockStatePersist +} + +export interface MockStateStore { + createEntity( + key: TKey, + record: EntityRecord, + options?: { prepend?: boolean }, + ): Promise> + deleteEntity( + key: TKey, + id: string, + ): Promise | undefined> + daysFromNow(days: number): string + findEntity( + key: TKey, + id: string, + ): EntityRecord | undefined + findEntities( + key: TKey, + predicate?: (record: EntityRecord) => boolean, + ): Array> + getSlice(key: TKey): MockState[TKey] + now(): string + replace(state: MockState): Promise + reset(): Promise + setClock(now: string): Promise + setSlice(key: TKey, value: MockState[TKey]): Promise + snapshot(): MockState + transaction(callback: () => Promise | T): Promise + updateEntity( + key: TKey, + id: string, + updater: (record: EntityRecord) => EntityRecord, + ): Promise | undefined> +} + +const dayMs = 24 * 60 * 60 * 1000 + +const entitySliceKeys = [ + 'decks', + 'folders', + 'notes', + 'reviewSessions', + 'workspaces', +] as const satisfies readonly EntitySliceKey[] + +const newEntityCollections = () => ({ + decks: new Collection({ schema: zDeckRecord }), + folders: new Collection({ schema: zFolderRecord }), + notes: new Collection({ schema: zNoteDetailRecord }), + reviewSessions: new Collection({ schema: zReviewSessionRecord }), + workspaces: new Collection({ schema: zWorkspaceRecord }), +}) + +const isEntitySliceKey = (key: keyof MockState): key is EntitySliceKey => + entitySliceKeys.includes(key as EntitySliceKey) + +const metaFromState = (state: MockState): MetaState => ({ + activeWorkspace: clone(state.activeWorkspace), + clock: clone(state.clock), + idCounters: clone(state.idCounters), + schemaVersion: state.schemaVersion, + settings: clone(state.settings), + trash: clone(state.trash), +}) + +const replaceDraft = (draft: TRecord, next: TRecord) => { + const draftRecord = draft as Record + const nextRecord = next as Record + + for (const key of Object.keys(draftRecord)) { + if (!(key in nextRecord)) { + delete draftRecord[key] + } + } + + Object.assign(draftRecord, nextRecord) +} + +export class MswDataStateStore implements MockStateStore { + private readonly collections: EntityCollections = newEntityCollections() + private readonly persistState: MockStatePersist | undefined + private meta: MetaState = metaFromState(seedState()) + + constructor(options: Pick = {}) { + this.persistState = options.persist + } + + async reset() { + return this.replace(seedState()) + } + + async replace(state: MockState) { + await this.replaceState(state) + await this.persist() + + return this.snapshot() + } + + async setClock(now: string) { + this.meta.clock = { now } + await this.persist() + + return clone(this.meta.clock) + } + + getSlice(key: TKey): MockState[TKey] { + if (isEntitySliceKey(key)) { + return this.entitySnapshot(key) as MockState[TKey] + } + + return this.meta[key as keyof MetaState] as MockState[TKey] + } + + async setSlice(key: TKey, value: MockState[TKey]) { + if (isEntitySliceKey(key)) { + await this.replaceEntitySlice(key, value as MockState[typeof key]) + return + } + + this.meta = { + ...this.meta, + [key]: clone(value), + } + } + + findEntities( + key: TKey, + predicate: (record: EntityRecord) => boolean = () => true, + ): Array> { + return this.collection(key) + .all() + .filter((record) => predicate(this.toPlainRecord(record))) + .map((record) => this.toPlainRecord(record)) + } + + findEntity(key: TKey, id: string) { + const record = this.findInternalEntity(key, id) + + return record ? this.toPlainRecord(record) : undefined + } + + async createEntity( + key: TKey, + record: EntityRecord, + options: { prepend?: boolean } = {}, + ): Promise> { + const collection = this.collection(key) + const created = await collection.create(clone(record)) + + if (options.prepend) { + const records = collection.all() + const appended = records.pop() + + if (appended) { + records.unshift(appended) + } + } + + return this.toPlainRecord(created) + } + + async updateEntity( + key: TKey, + id: string, + updater: (record: EntityRecord) => EntityRecord, + ) { + const collection = this.collection(key) + const current = this.findInternalEntity(key, id) + + if (!current) { + return undefined + } + + const next = updater(this.toPlainRecord(current)) + const updated = await collection.update(current, { + data: (draft) => replaceDraft(draft, next), + }) + + return updated ? this.toPlainRecord(updated) : undefined + } + + async deleteEntity(key: TKey, id: string) { + const current = this.findInternalEntity(key, id) + + if (!current) { + return undefined + } + + return this.toPlainRecord(this.collection(key).delete(current) ?? current) + } + + snapshot(): MockState { + return { + schemaVersion: this.meta.schemaVersion, + clock: clone(this.meta.clock), + workspaces: this.entitySnapshot('workspaces'), + activeWorkspace: clone(this.meta.activeWorkspace), + folders: this.entitySnapshot('folders'), + decks: this.entitySnapshot('decks'), + notes: this.entitySnapshot('notes'), + reviewSessions: this.entitySnapshot('reviewSessions'), + settings: clone(this.meta.settings), + trash: clone(this.meta.trash), + idCounters: clone(this.meta.idCounters), + } + } + + now() { + return this.meta.clock.now + } + + daysFromNow(days: number) { + return new Date(Date.parse(this.now()) + days * dayMs).toISOString() + } + + async transaction(callback: () => Promise | T): Promise { + const result = await callback() + await this.persist() + + return result + } + + private collection(key: TKey) { + return this.collections[key] as unknown as EntityCollection> + } + + private findInternalEntity(key: TKey, id: string) { + return this.collection(key).findFirst((query) => query.where({ id })) + } + + private entitySnapshot(key: TKey): MockState[TKey] { + return this.collection(key) + .all() + .map((record) => this.toPlainRecord(record)) as MockState[TKey] + } + + private async replaceState(state: MockState) { + this.meta = metaFromState(state) + + for (const key of entitySliceKeys) { + await this.replaceEntitySlice(key, state[key]) + } + } + + private async replaceEntitySlice( + key: TKey, + records: MockState[TKey], + ) { + const collection = this.collection(key) + + collection.clear() + for (const record of records) { + await collection.create(clone(record) as EntityRecord) + } + } + + private toPlainRecord(record: TRecord): TRecord { + return clone({ ...record }) as TRecord + } + + private async persist() { + await this.persistState?.(this.snapshot()) + } +} + +export const newMswDataStateStore = async ( + options: MswDataStateStoreOptions = {}, +): Promise => { + const store = new MswDataStateStore({ persist: options.persist }) + + await store.replace(options.initialState ?? seedState()) + + return store +} + +export const newMemoryMockStateStore = (initialState: MockState = seedState()) => + newMswDataStateStore({ initialState }) diff --git a/api/mock-server/src/server.ts b/api/mock-server/src/server.ts index 77a6169..709f0c5 100644 --- a/api/mock-server/src/server.ts +++ b/api/mock-server/src/server.ts @@ -5,8 +5,8 @@ import { readMockServerConfig } from './config.ts' import { newMockApiControllers } from './controllers.ts' const config = readMockServerConfig() -const app = newMockApiApp({ - controllers: newMockApiControllers({ +const app = await newMockApiApp({ + controllers: await newMockApiControllers({ stateFile: config.stateFile, }), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 999985b..60cf500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@hono/node-server': specifier: ^1.19.7 version: 1.19.14(hono@4.12.23) + '@msw/data': + specifier: 1.1.6 + version: 1.1.6 hono: specifier: ^4.12.23 version: 4.12.23 @@ -57,6 +60,9 @@ importers: ui: dependencies: + '@local/mock-server': + specifier: workspace:* + version: link:../api/mock-server '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1055,6 +1061,9 @@ packages: '@types/react': '>=16' react: '>=16' + '@msw/data@1.1.6': + resolution: {integrity: sha512-Kp0JhuaQBgMR4C6sXdoDYUNeaF/JhhzLToumYiV9flciOwSzZe7FSnlMNcL9Yj1tm1R/3f8mktDR4M6y1xVS8Q==} + '@mswjs/interceptors@0.41.9': resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} @@ -1124,48 +1133,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.127.0': resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.127.0': resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.127.0': resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.127.0': resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.127.0': resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.127.0': resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} @@ -1241,41 +1258,49 @@ packages: resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.20.0': resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.20.0': resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.20.0': resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.20.0': resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} @@ -1661,36 +1686,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -1886,24 +1917,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -2057,30 +2092,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.2': resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.2': resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.2': resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} @@ -3451,24 +3491,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3732,6 +3776,10 @@ packages: resolution: {integrity: sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==} engines: {node: '>=10'} + mutative@1.3.0: + resolution: {integrity: sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==} + engines: {node: '>=14.0'} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -5431,6 +5479,14 @@ snapshots: '@types/react': 19.2.16 react: 19.2.7 + '@msw/data@1.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + es-toolkit: 1.47.0 + mutative: 1.3.0 + outvariant: 1.4.3 + rettime: 0.11.11 + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -8375,6 +8431,8 @@ snapshots: arrify: 2.0.1 minimatch: 3.1.5 + mutative@1.3.0: {} + mute-stream@3.0.0: {} nanoid@3.3.12: {} diff --git a/ui/package.json b/ui/package.json index 87f8940..1919367 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@local/mock-server": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", @@ -50,8 +51,8 @@ "@storybook/addon-docs": "10.4.2", "@storybook/addon-vitest": "10.4.2", "@storybook/react-vite": "10.4.2", - "@tanstack/router-plugin": "^1.167.32", "@tanstack/router-cli": "^1.167.17", + "@tanstack/router-plugin": "^1.167.32", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/ui/src/core/services/service-registry.test.ts b/ui/src/core/services/service-registry.test.ts index 6541b8b..ec89df9 100644 --- a/ui/src/core/services/service-registry.test.ts +++ b/ui/src/core/services/service-registry.test.ts @@ -29,14 +29,6 @@ describe('createAppServices', () => { runtime: 'web', }) - vi.stubEnv('VITE_SERVICE_MODE', 'mock') - expect(createAppServices()).toMatchObject({ - bootstrap: mockBootstrapService, - configuredMode: 'mock', - mode: 'mock', - runtime: 'web', - }) - vi.stubEnv('VITE_SERVICE_MODE', 'unknown') expect(createAppServices()).toMatchObject({ configuredMode: 'auto', @@ -45,6 +37,15 @@ describe('createAppServices', () => { }) }) + it('supports explicit mock services for tests and Storybook', () => { + expect(createAppServices('mock')).toMatchObject({ + bootstrap: mockBootstrapService, + configuredMode: 'mock', + mode: 'mock', + runtime: 'web', + }) + }) + it('resolves auto mode to tauri when the Tauri runtime is present', () => { window.__TAURI_INTERNALS__ = {} diff --git a/ui/src/features/dashboard/pages/DashboardPage.test.tsx b/ui/src/features/dashboard/pages/DashboardPage.test.tsx index e3dcf13..b5cd7d3 100644 --- a/ui/src/features/dashboard/pages/DashboardPage.test.tsx +++ b/ui/src/features/dashboard/pages/DashboardPage.test.tsx @@ -377,11 +377,11 @@ describe('DashboardPage', () => { renderRoute('/dashboard/independent-study') fireEvent.change(await screen.findByPlaceholderText('Search folders, decks, and notes…'), { - target: { value: 'neural' }, + target: { value: 'institutions' }, }) expect(await screen.findByRole('heading', { name: 'Search results' })).toBeInTheDocument() - expect(await screen.findByText('Neural Models')).toBeInTheDocument() + expect(await screen.findByText('Postwar Institutions')).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Folders' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Sort folders' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Sort decks' })).not.toBeInTheDocument() @@ -460,8 +460,8 @@ describe('DashboardPage', () => { expect( appearsBefore( - screen.getByRole('heading', { name: 'World History' }), screen.getByRole('heading', { name: 'Cognitive Biases' }), + screen.getByRole('heading', { name: 'World History' }), ), ).toBe(true) expect(JSON.parse(window.localStorage.getItem('workspace-sort:decks') ?? '{}')).toMatchObject( diff --git a/ui/src/features/decks/hooks/useDecks.test.tsx b/ui/src/features/decks/hooks/useDecks.test.tsx index 4010691..36cb73f 100644 --- a/ui/src/features/decks/hooks/useDecks.test.tsx +++ b/ui/src/features/decks/hooks/useDecks.test.tsx @@ -31,7 +31,7 @@ describe('deck hooks', () => { ), ).toBe(true), ) - expect(result.current.detail.data?.totalNotes).toBe(7) + expect(result.current.detail.data?.totalNotes).toBe(2) let createdId = '' await act(async () => { diff --git a/ui/src/features/decks/pages/DetailPage.test.tsx b/ui/src/features/decks/pages/DetailPage.test.tsx index e3fc618..3b000d3 100644 --- a/ui/src/features/decks/pages/DetailPage.test.tsx +++ b/ui/src/features/decks/pages/DetailPage.test.tsx @@ -177,12 +177,12 @@ describe('DeckDetailPage', () => { expect(createButton.textContent).toBe('') expect(createButton.closest('[class*="bottom-24"]')).toBeNull() expect(await screen.findByText(deckDescription)).toHaveClass('max-w-copy') - expect(await screen.findByText('71%')).toBeInTheDocument() - expect(await screen.findByText('9')).toBeInTheDocument() - expect(await screen.findByText('7')).toBeInTheDocument() + expect(await screen.findByText('66%')).toBeInTheDocument() + expect(await screen.findByText('1')).toBeInTheDocument() + expect(await screen.findByText('2')).toBeInTheDocument() expect(await screen.findByRole('heading', { name: 'Notes' })).toBeInTheDocument() expect(await screen.findByText('Industrial Revolution Causes')).toBeInTheDocument() - expect(await screen.findByText('Collective Memory')).toBeInTheDocument() + expect(await screen.findByText('Postwar Institutions')).toBeInTheDocument() expect(await screen.findByRole('link', { name: 'Back' })).toHaveAttribute('href', '/dashboard/independent-study') }) @@ -302,7 +302,7 @@ describe('DeckDetailPage', () => { expect(screen.getByRole('heading', { name: 'Notes' }).closest('[class*="max-w-section"]')).not.toBeNull() fireEvent.change(searchInput, { - target: { value: 'narratives' }, + target: { value: 'institutions' }, }) expect(await screen.findByRole('heading', { name: 'Search results' })).toBeInTheDocument() @@ -341,11 +341,11 @@ describe('DeckDetailPage', () => { renderRoute('/dashboard/independent-study/decks/world-history') fireEvent.change(await screen.findByPlaceholderText('Search notes…'), { - target: { value: 'narratives' }, + target: { value: 'institutions' }, }) expect(await screen.findByRole('heading', { name: 'Search results' })).toBeInTheDocument() - expect(await screen.findByText('Collective Memory')).toBeInTheDocument() + expect(await screen.findByText('Postwar Institutions')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Sort notes' })).not.toBeInTheDocument() }) @@ -416,8 +416,8 @@ describe('DeckDetailPage', () => { expect( appearsBefore( - await screen.findByRole('heading', { name: 'Collective Memory' }), - await screen.findByRole('heading', { name: 'Constitutional Crisis' }), + await screen.findByRole('heading', { name: 'Industrial Revolution Causes' }), + await screen.findByRole('heading', { name: 'Postwar Institutions' }), ), ).toBe(true) diff --git a/ui/src/features/folders/pages/DetailPage.test.tsx b/ui/src/features/folders/pages/DetailPage.test.tsx index c57f9d3..6119e38 100644 --- a/ui/src/features/folders/pages/DetailPage.test.tsx +++ b/ui/src/features/folders/pages/DetailPage.test.tsx @@ -143,7 +143,7 @@ describe('FolderDetailPage', () => { '/dashboard/independent-study', ) expect(await screen.findByText('History')).toBeInTheDocument() - expect(await screen.findByText('Reading Review Queue')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Decks' })).not.toBeInTheDocument() await user.click(await screen.findByRole('link', { name: 'History' })) expect(await screen.findByRole('heading', { name: 'History' })).toBeInTheDocument() @@ -239,7 +239,7 @@ describe('FolderDetailPage', () => { await user.keyboard('{Escape}') expect(await screen.findByRole('button', { name: 'Reading Notes actions' })).toBeInTheDocument() expect(await screen.findByText('History')).toBeInTheDocument() - expect(await screen.findByText('Reading Review Queue')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Decks' })).not.toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Folders' }).closest('[class*="max-w-section"]')).not.toBeNull() expect(screen.queryByText('Folder')).not.toBeInTheDocument() expect(await screen.findByRole('link', { name: 'Back' })).toHaveAttribute( @@ -257,9 +257,9 @@ describe('FolderDetailPage', () => { it('keeps a single folder deck row constrained to the scan column', async () => { mockMatchMedia(true) - renderRoute('/dashboard/independent-study/folders/philosophy') + renderRoute('/dashboard/independent-study/folders/reference') - const deckButton = await screen.findByRole('button', { name: 'Open Social Theory deck' }) + const deckButton = await screen.findByRole('button', { name: 'Open Statistics Basics deck' }) const decksHeading = await screen.findByRole('heading', { name: 'Decks' }) const deckSection = deckButton.closest('section') const listSurface = deckSection?.querySelector('.overflow-hidden') @@ -272,15 +272,15 @@ describe('FolderDetailPage', () => { }) it('searches recursively inside a folder', async () => { - renderRoute('/dashboard/independent-study/folders/reading-notes') + renderRoute('/dashboard/independent-study/folders/reference') fireEvent.change(await screen.findByPlaceholderText('Search folders, decks, and notes…'), { - target: { value: 'neural' }, + target: { value: 'sampling' }, }) expect(await screen.findByRole('heading', { name: 'Search results' })).toBeInTheDocument() - expect(await screen.findByText('Neural Models')).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Folders' })).not.toBeInTheDocument() + expect(await screen.findByText('Sampling Error')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Decks' })).not.toBeInTheDocument() }) it('clears empty folder search results back to folder content', async () => { @@ -328,7 +328,7 @@ describe('FolderDetailPage', () => { expect(screen.queryByRole('heading', { name: 'Search results' })).not.toBeInTheDocument() expect(screen.getByText('History')).toBeInTheDocument() - expect(screen.getByText('Reading Review Queue')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Decks' })).not.toBeInTheDocument() }) it('opens folder-scoped creation from the inline create menu', async () => { diff --git a/ui/src/features/notes/pages/DetailPage.test.tsx b/ui/src/features/notes/pages/DetailPage.test.tsx index ec692c4..659ce09 100644 --- a/ui/src/features/notes/pages/DetailPage.test.tsx +++ b/ui/src/features/notes/pages/DetailPage.test.tsx @@ -145,10 +145,15 @@ describe('NoteDetailPage', () => { expect(await screen.findByText('BASIC')).toBeInTheDocument() renderRoute( - '/dashboard/independent-study/decks/world-history/notes/collective-memory', + '/dashboard/independent-study/decks/cognitive-biases/notes/availability-heuristic', ) - expect(await screen.findByText('Collective Memory')).toBeInTheDocument() + expect( + await screen.findByRole('heading', { + level: 2, + name: 'Availability Heuristic', + }), + ).toBeInTheDocument() expect(await screen.findByText('CLOZE')).toBeInTheDocument() }) @@ -192,7 +197,7 @@ describe('NoteDetailPage', () => { expect(within(noteContent).queryByText('TITLE')).not.toBeInTheDocument() expect(within(noteContent).getByText('BASIC')).toBeInTheDocument() expect(within(noteContent).queryByText(/UPDATED/)).not.toBeInTheDocument() - expect(within(noteContent).queryByText('Mastered')).not.toBeInTheDocument() + expect(within(noteContent).queryByText('In progress')).not.toBeInTheDocument() expect(within(noteContent).getByText('FRONT')).toBeInTheDocument() expect(within(noteContent).getByText('BACK')).toBeInTheDocument() @@ -201,7 +206,7 @@ describe('NoteDetailPage', () => { expect( within(metadata).getByRole('heading', { name: 'Study Progress' }), ).toBeInTheDocument() - const status = within(metadata).getByText('Mastered') + const status = within(metadata).getByText('In progress') expect(status).toBeInTheDocument() expect(status).toHaveClass('border-border', 'text-muted-foreground') expect(status).not.toHaveClass('bg-primary') @@ -246,10 +251,10 @@ describe('NoteDetailPage', () => { it('keeps derived card timing in desktop cloze content instead of the right panel', async () => { mockMatchMedia(true) - renderRoute('/dashboard/independent-study/decks/world-history/notes/collective-memory') + renderRoute('/dashboard/independent-study/decks/cognitive-biases/notes/availability-heuristic') expect( - await screen.findByRole('heading', { level: 1, name: 'Collective Memory' }), + await screen.findByRole('heading', { level: 1, name: 'Availability Heuristic' }), ).toBeInTheDocument() const noteContent = await screen.findByRole('region', { name: 'Note content' }) diff --git a/ui/src/features/review/hooks/useReview.test.tsx b/ui/src/features/review/hooks/useReview.test.tsx index 2796601..ee9fd4d 100644 --- a/ui/src/features/review/hooks/useReview.test.tsx +++ b/ui/src/features/review/hooks/useReview.test.tsx @@ -29,7 +29,7 @@ describe('review hooks', () => { await result.current.start.mutateAsync() }) await waitFor(() => { - expect(result.current.session.data?.mode).toBe('practice') + expect(result.current.session.data?.mode).toBe('due') expect(result.current.session.data?.currentCard).toBeDefined() }) @@ -45,9 +45,10 @@ describe('review hooks', () => { grade: 'good', }) }) - await waitFor(() => expect(result.current.grade.data?.mode).toBe('practice')) + await waitFor(() => expect(result.current.grade.data?.mode).toBe('due')) expect(result.current.grade.data?.reviewedCount).toBe(1) - expect(result.current.grade.data?.currentCard).toBeDefined() + expect(result.current.grade.data?.currentCard).toBeUndefined() + expect(result.current.grade.data?.mode === 'due' ? result.current.grade.data.status : undefined).toBe('completed') await waitFor(() => expect(result.current.session.data?.reviewedCount).toBe(1)) }) }) diff --git a/ui/src/features/review/pages/SessionPage.test.tsx b/ui/src/features/review/pages/SessionPage.test.tsx index 1edad7e..676ce08 100644 --- a/ui/src/features/review/pages/SessionPage.test.tsx +++ b/ui/src/features/review/pages/SessionPage.test.tsx @@ -240,7 +240,7 @@ describe('ReviewSessionPage', () => { }) expect(screen.getByRole('heading', { name: 'Review' })).toBeInTheDocument() - expect(screen.getByText(/Collective memory shapes/)).toBeInTheDocument() + expect(screen.getByText(/Industrial Revolution/)).toBeInTheDocument() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Show answer' })) @@ -258,7 +258,7 @@ describe('ReviewSessionPage', () => { fireEvent.click(disabledGoodButton) expect(grade).toHaveBeenCalledTimes(1) expect(screen.queryByRole('status', { name: 'Loading review' })).not.toBeInTheDocument() - expect(screen.getByText(/Collective memory shapes/)).toBeInTheDocument() + expect(screen.getByText(/Industrial Revolution/)).toBeInTheDocument() expect( disabledGoodButton.querySelector('[data-slot="pending-spinner"]'), ).not.toBeInTheDocument() @@ -267,7 +267,7 @@ describe('ReviewSessionPage', () => { await vi.advanceTimersByTimeAsync(249) }) expect(screen.queryByRole('status', { name: 'Loading review' })).not.toBeInTheDocument() - expect(screen.getByText(/Collective memory shapes/)).toBeInTheDocument() + expect(screen.getByText(/Industrial Revolution/)).toBeInTheDocument() expect( disabledGoodButton.querySelector('[data-slot="pending-spinner"]'), ).not.toBeInTheDocument() @@ -276,7 +276,7 @@ describe('ReviewSessionPage', () => { await vi.advanceTimersByTimeAsync(1) }) expect(screen.queryByRole('status', { name: 'Loading review' })).not.toBeInTheDocument() - expect(screen.getByText(/Collective memory shapes/)).toBeInTheDocument() + expect(screen.getByText(/Industrial Revolution/)).toBeInTheDocument() const pendingGoodButton = screen.getByRole('button', { name: 'Good' }) expect(pendingGoodButton).toHaveAccessibleName('Good') expect(pendingGoodButton.querySelector('[data-slot="pending-spinner"]')).toBeInTheDocument() diff --git a/ui/src/features/trash/hooks/useTrash.test.tsx b/ui/src/features/trash/hooks/useTrash.test.tsx index 40b5e42..239985c 100644 --- a/ui/src/features/trash/hooks/useTrash.test.tsx +++ b/ui/src/features/trash/hooks/useTrash.test.tsx @@ -1,20 +1,24 @@ import { act, waitFor } from '@testing-library/react' import { describe, expect, it } from 'vitest' +import { createAppServices } from '@core/services' import { renderHookWithProviders } from '@/test/renderHook' import { useDeleteTrashItem, useEmptyTrash, useRestoreTrashItem, useTrash } from './useTrash' describe('trash hooks', () => { it('lists, restores, deletes, and empties trash items', async () => { + const services = createAppServices('mock') + await services.notes.delete('industrial-revolution-causes') + const { result } = renderHookWithProviders(() => ({ deleteItem: useDeleteTrashItem(), empty: useEmptyTrash(), list: useTrash(), restore: useRestoreTrashItem(), - })) + }), { services }) - await waitFor(() => expect(result.current.list.data?.items.length).toBe(5)) + await waitFor(() => expect(result.current.list.data?.items.length).toBe(2)) const items = result.current.list.data?.items ?? [] const restoredItem = items[0] const deletedItem = items[1] diff --git a/ui/src/features/trash/pages/TrashPage.test.tsx b/ui/src/features/trash/pages/TrashPage.test.tsx index cc0c403..a268a53 100644 --- a/ui/src/features/trash/pages/TrashPage.test.tsx +++ b/ui/src/features/trash/pages/TrashPage.test.tsx @@ -42,7 +42,7 @@ describe('TrashPage', () => { expect(screen.getByRole('heading', { name: 'Trash' })).toBeInTheDocument() expect(screen.queryByRole('status', { name: 'Loading trash' })).not.toBeInTheDocument() - expect(screen.queryByText('5 items')).not.toBeInTheDocument() + expect(screen.queryByText('1 item')).not.toBeInTheDocument() await act(async () => { await vi.advanceTimersByTimeAsync(179) @@ -115,7 +115,7 @@ describe('TrashPage', () => { await vi.advanceTimersByTimeAsync(0) }) - expect(screen.getByText('5 items')).toBeInTheDocument() + expect(screen.getByText('1 item')).toBeInTheDocument() await act(async () => { await vi.advanceTimersByTimeAsync(180) @@ -125,35 +125,39 @@ describe('TrashPage', () => { it('renders trash and supports empty, restore, and delete actions', async () => { const user = userEvent.setup() - renderRoute('/menu/trash') + const services = createAppServices('mock') + await services.notes.delete('industrial-revolution-causes') + await services.notes.delete('postwar-institutions') + + renderRoute('/menu/trash', { services }) const heading = await screen.findByRole('heading', { name: 'Trash' }) expect(heading).toBeInTheDocument() expect(heading.closest('section')).toHaveClass('mb-7') expect(heading.closest('section')?.querySelector('[class*="min-h-[3.75rem]"]')).toBeNull() expect(await screen.findByRole('link', { name: 'Back' })).toHaveAttribute('href', '/menu') - expect(await screen.findByText('5 items')).toBeInTheDocument() - expect(await screen.findByText('Sampling Error Notes')).toBeInTheDocument() + expect(await screen.findByText('3 items')).toBeInTheDocument() + expect(await screen.findByText('Base Rates')).toBeInTheDocument() await user.click( await screen.findByRole('button', { - name: 'Drafting Patterns trash actions', + name: 'Industrial Revolution Causes trash actions', }), ) await user.click(await screen.findByRole('menuitem', { name: 'Restore' })) await waitFor(() => { - expect(screen.queryByText('Drafting Patterns')).not.toBeInTheDocument() + expect(screen.queryByText('Industrial Revolution Causes')).not.toBeInTheDocument() }) - expect(await screen.findByText('4 items')).toBeInTheDocument() + expect(await screen.findByText('2 items')).toBeInTheDocument() - await user.click(await screen.findByRole('button', { name: 'Sampling Error Notes trash actions' })) + await user.click(await screen.findByRole('button', { name: 'Base Rates trash actions' })) await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) const deleteDialog = await screen.findByRole('dialog') await user.click(within(deleteDialog).getByRole('button', { name: 'Delete permanently' })) await waitFor(() => { - expect(screen.queryByText('Sampling Error Notes')).not.toBeInTheDocument() + expect(screen.queryByText('Base Rates')).not.toBeInTheDocument() }) - expect(await screen.findByText('3 items')).toBeInTheDocument() + expect(await screen.findByText('1 item')).toBeInTheDocument() await user.click(await screen.findByRole('button', { name: 'Empty' })) const emptyDialog = await screen.findByRole('dialog', { name: 'Empty trash?' }) @@ -166,6 +170,8 @@ describe('TrashPage', () => { it('shows a stale refresh status when delete succeeds but trash refetch fails', async () => { const user = userEvent.setup() const baseServices = createAppServices('mock') + await baseServices.notes.delete('industrial-revolution-causes') + const refreshError = domainError.unexpected( 'Trash storage is temporarily unavailable.', ) @@ -197,15 +203,16 @@ describe('TrashPage', () => { renderRoute('/menu/trash', { services }) - expect(await screen.findByText('Sampling Error Notes')).toBeInTheDocument() - await user.click(await screen.findByRole('button', { name: 'Sampling Error Notes trash actions' })) + expect(await screen.findByText('Base Rates')).toBeInTheDocument() + expect(await screen.findByText('Industrial Revolution Causes')).toBeInTheDocument() + await user.click(await screen.findByRole('button', { name: 'Base Rates trash actions' })) await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) await user.click(await screen.findByRole('button', { name: 'Delete permanently' })) - expect(deleteItem).toHaveBeenCalledWith('sampling-error-notes') + expect(deleteItem).toHaveBeenCalledWith('base-rates') expect(await screen.findByText('Trash may be out of date')).toBeInTheDocument() expect(screen.getByText('Trash storage is temporarily unavailable.')).toBeInTheDocument() - expect(screen.getByText('Sampling Error Notes')).toBeInTheDocument() + expect(screen.getByText('Base Rates')).toBeInTheDocument() await waitFor(() => { expect(list.mock.calls.length).toBeGreaterThanOrEqual(2) }) @@ -215,7 +222,11 @@ describe('TrashPage', () => { expect(screen.queryByText('Trash may be out of date')).not.toBeInTheDocument() }) - await user.click(await screen.findByRole('button', { name: 'Sampling Error Notes trash actions' })) + await user.click( + await screen.findByRole('button', { + name: 'Industrial Revolution Causes trash actions', + }), + ) await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) await user.click(await screen.findByRole('button', { name: 'Delete permanently' })) @@ -232,7 +243,7 @@ describe('TrashPage', () => { const heading = await screen.findByRole('heading', { name: 'Trash' }) expect(heading).toBeInTheDocument() expect(heading.closest('div.mx-auto')).toHaveClass('max-w-page-narrow') - expect(await screen.findByText('5 items')).toBeInTheDocument() + expect(await screen.findByText('1 item')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Trash' })).toHaveAttribute( 'aria-current', 'page', @@ -259,15 +270,15 @@ describe('TrashPage', () => { renderRoute('/menu/trash', { services }) - expect(await screen.findByText('Drafting Patterns')).toBeInTheDocument() + expect(await screen.findByText('Base Rates')).toBeInTheDocument() await user.click( await screen.findByRole('button', { - name: 'Drafting Patterns trash actions', + name: 'Base Rates trash actions', }), ) await user.click(await screen.findByRole('menuitem', { name: 'Restore' })) - expect(await screen.findByText('Drafting Patterns')).toBeInTheDocument() + expect(await screen.findByText('Base Rates')).toBeInTheDocument() const status = await screen.findByRole('status') expect(status).toHaveTextContent('Could not restore item') expect(status).toHaveTextContent('Restore failed.') @@ -299,20 +310,20 @@ describe('TrashPage', () => { await user.click( await screen.findByRole('button', { - name: 'Drafting Patterns trash actions', + name: 'Base Rates trash actions', }), ) await user.click(await screen.findByRole('menuitem', { name: 'Restore' })) expect(restoreItem).toHaveBeenCalledTimes(1) expect( - screen.queryByRole('status', { name: 'Restoring Drafting Patterns' }), + screen.queryByRole('status', { name: 'Restoring Base Rates' }), ).not.toBeInTheDocument() expect( - await screen.findByRole('status', { name: 'Restoring Drafting Patterns' }), + await screen.findByRole('status', { name: 'Restoring Base Rates' }), ).toBeInTheDocument() expect( - screen.queryByRole('status', { name: 'Restoring Sampling Error Notes' }), + screen.queryByRole('status', { name: 'Restoring Industrial Revolution Causes' }), ).not.toBeInTheDocument() }) }) diff --git a/ui/src/features/workspaces/pages/ListPage.test.tsx b/ui/src/features/workspaces/pages/ListPage.test.tsx index 44b00fc..2d63570 100644 --- a/ui/src/features/workspaces/pages/ListPage.test.tsx +++ b/ui/src/features/workspaces/pages/ListPage.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' import { createAppServices } from '@core/services' +import { mockStorageKey } from '@platform/mock/mockApi' import { ok } from '@shared/errors' import { renderRoute } from '@/test/renderRoute' import { mockMatchMedia } from '@/test/matchMedia' @@ -141,8 +142,8 @@ describe('WorkspaceListPage', () => { await screen.findByRole('heading', { name: 'Reading Archive' }), ).toBeInTheDocument() expect( - JSON.parse(window.localStorage.getItem('clear-ui:mock-state:v15') ?? '{}'), - ).toMatchObject({ activeWorkspaceId: 'reading-archive' }) + JSON.parse(window.localStorage.getItem(mockStorageKey) ?? '{}'), + ).toMatchObject({ activeWorkspace: { workspaceId: 'reading-archive' } }) }) it('renders desktop navigation and workspace actions at the desktop breakpoint', async () => { diff --git a/ui/src/platform/mock/mockAppDataStore.test.ts b/ui/src/platform/mock/mockApi.test.ts similarity index 65% rename from ui/src/platform/mock/mockAppDataStore.test.ts rename to ui/src/platform/mock/mockApi.test.ts index f4a4cf3..0d353bc 100644 --- a/ui/src/platform/mock/mockAppDataStore.test.ts +++ b/ui/src/platform/mock/mockApi.test.ts @@ -7,35 +7,42 @@ import { mockNoteService } from '@platform/services/notes/mock/noteService' import { mockReviewService } from '@platform/services/review/mock/reviewService' import { mockTrashService } from '@platform/services/trash/mock/trashService' import { mockSettingsService } from '@platform/services/settings/mock/settingsService' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import { + mockApi, + mockStateRepository, + mockStorageKey, +} from '@platform/mock/mockApi' import { mockWorkspaceService } from '@platform/services/workspaces/mock/workspaceService' -describe('mock services backed by app data store', () => { +describe('mock API services', () => { it('persists the seeded snapshot immediately when mock storage is empty', async () => { window.localStorage.clear() vi.resetModules() - const { mockAppDataStore: reloadedStore } = await import('./mockAppDataStore') - const persisted = window.localStorage.getItem('clear-ui:mock-state:v15') + const { + mockApi: reloadedApi, + mockStorageKey: reloadedStorageKey, + } = await import('./mockApi') + const persisted = window.localStorage.getItem(reloadedStorageKey) expect(persisted).not.toBeNull() const parsed = JSON.parse(persisted ?? '{}') as { folders?: Array<{ id: string; updatedAt: string }> } - const persistedAcademic = parsed.folders?.find((folder) => folder.id === 'reading-notes') + const persistedReadingNotes = parsed.folders?.find((folder) => folder.id === 'reading-notes') - expect(persistedAcademic?.updatedAt).toBe( - reloadedStore.getFolderById('reading-notes')?.updatedAt, + expect(persistedReadingNotes?.updatedAt).toBe( + reloadedApi.foldersService.getFolder('reading-notes').updatedAt, ) }) it('replaces invalid mock storage with a valid seeded snapshot', async () => { - window.localStorage.setItem('clear-ui:mock-state:v15', '{broken') + window.localStorage.setItem(mockStorageKey, '{broken') vi.resetModules() - const { mockAppDataStore: reloadedStore } = await import('./mockAppDataStore') - const persisted = window.localStorage.getItem('clear-ui:mock-state:v15') + const { mockApi: reloadedApi } = await import('./mockApi') + const persisted = window.localStorage.getItem(mockStorageKey) expect(() => JSON.parse(persisted ?? '')).not.toThrow() @@ -44,26 +51,26 @@ describe('mock services backed by app data store', () => { } expect(parsed.folders?.some((folder) => folder.id === 'reading-notes')).toBe(true) - expect(reloadedStore.getFolderById('reading-notes')).toBeDefined() + expect(reloadedApi.foldersService.getFolder('reading-notes')).toBeDefined() }) it('reuses the persisted mock snapshot across reload-like initialization', async () => { window.localStorage.clear() vi.resetModules() - const { mockAppDataStore: firstStore } = await import('./mockAppDataStore') - const firstUpdatedAt = firstStore.getFolderById('reading-notes')?.updatedAt - const firstPersisted = window.localStorage.getItem('clear-ui:mock-state:v15') + const { mockApi: firstApi } = await import('./mockApi') + const firstUpdatedAt = firstApi.foldersService.getFolder('reading-notes').updatedAt + const firstPersisted = window.localStorage.getItem(mockStorageKey) vi.resetModules() - const { mockAppDataStore: secondStore } = await import('./mockAppDataStore') - const secondUpdatedAt = secondStore.getFolderById('reading-notes')?.updatedAt - const secondPersisted = window.localStorage.getItem('clear-ui:mock-state:v15') + const { mockApi: secondApi } = await import('./mockApi') + const secondUpdatedAt = secondApi.foldersService.getFolder('reading-notes').updatedAt + const secondPersisted = window.localStorage.getItem(mockStorageKey) expect(firstUpdatedAt).toBeDefined() expect(secondUpdatedAt).toBe(firstUpdatedAt) - expect(secondPersisted).toBe(firstPersisted) + expect(JSON.parse(secondPersisted ?? '{}')).toEqual(JSON.parse(firstPersisted ?? '{}')) }) it('lists visible workspaces with an active workspace', async () => { @@ -142,15 +149,14 @@ describe('mock services backed by app data store', () => { if (rootDecks.ok) { expect(rootDecks.value.map((deck) => deck.id)).toEqual([ 'cognitive-biases', - 'political-thought', 'world-history', + 'political-thought', ]) expect(rootDecks.value.every((deck) => deck.parentId === 'independent-study')).toBe(true) } if (readingNotesDecks.ok) { - expect(readingNotesDecks.value.map((deck) => deck.id)).toEqual(['reading-review-queue']) - expect(readingNotesDecks.value[0]).not.toHaveProperty('detail') + expect(readingNotesDecks.value).toEqual([]) } if (referenceDecks.ok) { @@ -164,41 +170,29 @@ describe('mock services backed by app data store', () => { expect(placedIds.some((deckId) => rootIds.has(deckId))).toBe(false) } - const allSeededFolders = [ - ...mockAppDataStore.listWorkspaceFolders('independent-study'), - ...mockAppDataStore.listFoldersInFolder('reading-notes'), - ...mockAppDataStore.listFoldersInFolder('history'), - ...mockAppDataStore.listFoldersInFolder('reference'), - ] - const allSeededDecks = [ - ...mockAppDataStore.listWorkspaceDecks('independent-study'), - ...mockAppDataStore.listDecksInFolder('reading-notes'), - ...mockAppDataStore.listDecksInFolder('reference'), - ...mockAppDataStore.listDecksInFolder('psychology'), - ...mockAppDataStore.listDecksInFolder('philosophy'), - ...mockAppDataStore.listDecksInFolder('writing'), - ...mockAppDataStore.listDecksInFolder('methods'), - ] - - expect(allSeededFolders).toHaveLength(7) - expect(allSeededFolders.every((folder) => folder.parentId.length > 0)).toBe(true) - expect(allSeededDecks).toHaveLength(11) - expect(allSeededDecks.every((deck) => deck.parentId.length > 0)).toBe(true) - expect(new Set(allSeededDecks.map((deck) => deck.id)).size).toBe(allSeededDecks.length) + const snapshot = mockStateRepository.snapshot() + const visibleFolders = snapshot.folders.filter((folder) => !folder.deletedAt) + const visibleDecks = snapshot.decks.filter((deck) => !deck.deletedAt) + + expect(mockApi.stateStore).toBe(mockStateRepository) + expect(visibleFolders).toHaveLength(3) + expect(visibleFolders.every((folder) => folder.parentId.length > 0)).toBe(true) + expect(visibleDecks).toHaveLength(4) + expect(visibleDecks.every((deck) => deck.parentId.length > 0)).toBe(true) + expect(new Set(visibleDecks.map((deck) => deck.id)).size).toBe(visibleDecks.length) if (worldHistory.ok) { expect(worldHistory.value).toMatchObject({ - dueToday: 9, - progress: 71, - totalNotes: 7, + dueToday: 1, + progress: 66, + totalNotes: 2, }) } if (notes.ok) { - expect(notes.value.slice(0, 3).map((note) => note.id)).toEqual([ + expect(notes.value.map((note) => note.id)).toEqual([ 'industrial-revolution-causes', - 'collective-memory', - 'constitutional-crisis', + 'postwar-institutions', ]) expect(notes.value[0]).not.toHaveProperty('editor') expect(notes.value[0]).not.toHaveProperty('bodySegments') @@ -206,17 +200,11 @@ describe('mock services backed by app data store', () => { } if (civicNotes.ok) { - expect(civicNotes.value.map((note) => note.id)).toContain('base-rates') + expect(civicNotes.value.map((note) => note.id)).toEqual(['sampling-error']) } if (trash.ok) { - expect(trash.value.items.map((item) => item.id)).toEqual([ - 'drafting-patterns', - 'sampling-error-notes', - 'drafts', - 'completed-reading-log', - 'linguistic-atlas', - ]) + expect(trash.value.items.map((item) => item.id)).toEqual(['base-rates']) } }) @@ -240,9 +228,9 @@ describe('mock services backed by app data store', () => { if (decksByDue.ok) { expect(decksByDue.value.map((deck) => deck.id)).toEqual([ - 'world-history', 'cognitive-biases', 'political-thought', + 'world-history', ]) } @@ -254,10 +242,9 @@ describe('mock services backed by app data store', () => { } if (notesByTitle.ok) { - expect(notesByTitle.value.slice(0, 3).map((note) => note.id)).toEqual([ - 'atlantic-revolutions-outline', - 'civil-rights-movement-cards', - 'cold-war-detente-recap', + expect(notesByTitle.value.map((note) => note.id)).toEqual([ + 'industrial-revolution-causes', + 'postwar-institutions', ]) } }) @@ -270,8 +257,8 @@ describe('mock services backed by app data store', () => { return } - expect(first.value.currentCard.id).toBe('industrial-revolution-causes:basic') - expect(first.value.plannedCount).toBe(8) + expect(first.value.currentCard.id).toBe('industrial-revolution-causes') + expect(first.value.plannedCount).toBe(1) const second = await mockReviewService.grade( first.value.id, @@ -280,88 +267,144 @@ describe('mock services backed by app data store', () => { ) expect(second.ok).toBe(true) - expect(second.ok ? second.value.currentCard?.id : undefined).toBe('collective-memory:c1') - expect(second.ok && second.value.mode === 'due' ? second.value.plannedCount : undefined).toBe(8) + expect(second.ok ? second.value.currentCard?.id : undefined).toBeUndefined() + expect(second.ok && second.value.mode === 'due' ? second.value.plannedCount : undefined).toBe(1) expect(second.ok ? second.value.reviewedCount : undefined).toBe(1) - const third = await mockReviewService.grade( - first.value.id, - 'collective-memory:c1', - 'good', - ) - - expect(third.ok).toBe(true) - expect(third.ok ? third.value.currentCard?.id : undefined).toBe('collective-memory:c2') - const summary = await mockReviewService.get(first.value.id) expect(summary.ok).toBe(true) if (summary.ok) { - expect(summary.value.reviewedCount).toBe(2) - expect(summary.value.mode === 'due' ? summary.value.plannedCount : undefined).toBe(8) + expect(summary.value.reviewedCount).toBe(1) + expect(summary.value.mode === 'due' ? summary.value.plannedCount : undefined).toBe(1) expect(summary.value.durationSeconds).toBe(0) - expect(summary.value.mode === 'due' ? summary.value.status : undefined).toBe('active') + expect(summary.value.mode === 'due' ? summary.value.status : undefined).toBe('completed') + } + }) + + it('derives deck counters from visible notes and review cards', async () => { + await mockStateRepository.reset() + + try { + const before = await mockDeckService.getById('world-history') + + expect(before.ok ? before.value : undefined).toMatchObject({ + dueToday: 1, + progress: 66, + totalNotes: 2, + }) + + const review = await mockReviewService.start('world-history') + expect(review.ok && review.value.mode === 'due' ? review.value.plannedCount : undefined).toBe(1) + + if (!review.ok || review.value.mode !== 'due' || !review.value.currentCard) { + throw new Error('Expected world-history to start a due review.') + } + + await mockReviewService.grade(review.value.id, review.value.currentCard.id, 'good') + + const afterGrade = await mockDeckService.getById('world-history') + expect(afterGrade.ok ? afterGrade.value : undefined).toMatchObject({ + dueToday: 0, + progress: 79, + totalNotes: 2, + }) + + const created = await mockNoteService.create({ + deckId: 'world-history', + editor: { back: 'Back draft', front: 'Front draft' }, + kind: 'basic', + title: 'New Draft Note', + }) + expect(created.ok).toBe(true) + + const afterCreate = await mockDeckService.getById('world-history') + expect(afterCreate.ok ? afterCreate.value : undefined).toMatchObject({ + dueToday: 1, + progress: 52, + totalNotes: 3, + }) + + await mockNoteService.delete('industrial-revolution-causes') + + const afterDelete = await mockDeckService.getById('world-history') + expect(afterDelete.ok ? afterDelete.value : undefined).toMatchObject({ + dueToday: 1, + progress: 29, + totalNotes: 2, + }) + + await mockTrashService.restoreItem('industrial-revolution-causes') + + const afterRestore = await mockDeckService.getById('world-history') + expect(afterRestore.ok ? afterRestore.value : undefined).toMatchObject({ + dueToday: 1, + progress: 52, + totalNotes: 3, + }) + } finally { + await mockStateRepository.reset() } }) it('recreates removed cloze ids as fresh derived cards without changing deck notes', async () => { - mockAppDataStore.reset() + await mockStateRepository.reset() try { - const beforeDeck = await mockDeckService.getById('world-history') - const beforeNote = await mockNoteService.getById('world-history', 'collective-memory') + const beforeDeck = await mockDeckService.getById('cognitive-biases') + const beforeNote = await mockNoteService.getById('cognitive-biases', 'availability-heuristic') - expect(beforeDeck.ok ? beforeDeck.value.totalNotes : undefined).toBe(7) + expect(beforeDeck.ok ? beforeDeck.value.totalNotes : undefined).toBe(3) expect( beforeNote.ok && beforeNote.value.kind === 'cloze' ? beforeNote.value.cards[0] : undefined, ).toMatchObject({ - id: 'collective-memory:c1', - progress: 74, + id: 'availability-heuristic-card-1', + progress: 53, }) - await mockNoteService.update('collective-memory', { - deckId: 'world-history', + await mockNoteService.update('availability-heuristic', { + deckId: 'cognitive-biases', editor: { - body: 'Collective memory shapes historical evidence and public narratives across generations.', + body: 'Availability bias makes vivid examples feel more common than they really are.', }, kind: 'cloze', - title: 'Collective Memory', + title: 'Availability Heuristic', }) - const removed = await mockNoteService.getById('world-history', 'collective-memory') + const removed = await mockNoteService.getById('cognitive-biases', 'availability-heuristic') expect( removed.ok && removed.value.kind === 'cloze' ? removed.value.cards : undefined, - ).toEqual([]) + ).toHaveLength(1) expect(removed.ok ? removed.value.progress : undefined).toBe(0) expect(removed.ok ? removed.value.status : undefined).toBe('in-progress') - await mockNoteService.update('collective-memory', { - deckId: 'world-history', + await mockNoteService.update('availability-heuristic', { + deckId: 'cognitive-biases', editor: { body: - 'Collective memory shapes {{c1::new evidence}} and public narratives across generations.', + 'Availability bias makes {{c1::fresh examples}} feel more common than they really are.', }, kind: 'cloze', - title: 'Collective Memory', + title: 'Availability Heuristic', }) - const readded = await mockNoteService.getById('world-history', 'collective-memory') - const afterDeck = await mockDeckService.getById('world-history') + const readded = await mockNoteService.getById('cognitive-biases', 'availability-heuristic') + const afterDeck = await mockDeckService.getById('cognitive-biases') const freshCard = readded.ok && readded.value.kind === 'cloze' ? readded.value.cards[0] : undefined expect(freshCard).toMatchObject({ clozeId: 'c1', progress: 0, - title: 'new evidence', + title: 'Availability Heuristic', }) - expect(freshCard?.id).not.toBe('collective-memory:c1') - expect(afterDeck.ok ? afterDeck.value.totalNotes : undefined).toBe(7) + expect(freshCard?.id).not.toBe('availability-heuristic-card-1') + expect(afterDeck.ok ? afterDeck.value.totalNotes : undefined).toBe(3) } finally { - mockAppDataStore.reset() + await mockStateRepository.reset() } }) @@ -445,10 +488,10 @@ describe('mock services backed by app data store', () => { const reset = await mockSettingsService.reset() expect(reset.ok ? reset.value.dailyNewLimit : undefined).toBe(defaults.value.dailyNewLimit) - const restored = await mockTrashService.restoreItem('sampling-error-notes') + const restored = await mockTrashService.restoreItem('base-rates') expect(restored.ok).toBe(true) const afterRestore = await mockTrashService.list() - expect(afterRestore.ok ? afterRestore.value.items.length : undefined).toBe(4) + expect(afterRestore.ok ? afterRestore.value.items.length : undefined).toBe(0) const emptied = await mockTrashService.empty() expect(emptied.ok).toBe(true) diff --git a/ui/src/platform/mock/mockApi.ts b/ui/src/platform/mock/mockApi.ts new file mode 100644 index 0000000..6107478 --- /dev/null +++ b/ui/src/platform/mock/mockApi.ts @@ -0,0 +1,12 @@ +import { + newBrowserMockStateStore, + newMockApiDependencies, +} from '@local/mock-server/browser' + +export const mockStorageKey = 'clear-ui:mock-server-state:v1' + +export const mockStateRepository = await newBrowserMockStateStore({ + storageKey: mockStorageKey, +}) + +export const mockApi = newMockApiDependencies(mockStateRepository) diff --git a/ui/src/platform/mock/mockAppDataStore.ts b/ui/src/platform/mock/mockAppDataStore.ts deleted file mode 100644 index e54d683..0000000 --- a/ui/src/platform/mock/mockAppDataStore.ts +++ /dev/null @@ -1,2186 +0,0 @@ -import type { - DeckSearchResult, - FolderSearchResult, - NoteSearchResult, - SearchResult, - SearchResultGroup, - SearchScope, -} from '@features/content-search' -import type { Deck, DeckDetail, DeckDraft } from '@features/decks' -import type { Folder, FolderDraft } from '@features/folders' -import type { - ClozeNoteCard, - NoteDetail, - NoteDraft, - NoteListItem, - NoteRef, -} from '@features/notes' -import type { - DueReviewSession, - PracticeReviewSession, - ReviewCard, - ReviewGrade, - ReviewSession, - ReviewStartResult, -} from '@features/review' -import type { TrashItem, TrashState } from '@features/trash' -import type { Settings } from '@features/settings' -import type { Workspace, WorkspaceDraft } from '@features/workspaces' -import { writeJson } from '@shared/services/storage/jsonStorage' -import type { SortPreference } from '@shared/types/sort.types' - -type MockState = { - activeWorkspaceId: string - deckDetails: Record> - decks: DeckRecord[] - folders: FolderRecord[] - idCounters: Record - notes: NoteRecord[] - reviews: ReviewSessionRecord[] - settings: Settings - trash: TrashState - workspaces: WorkspaceRecord[] -} - -type ReviewGradeRecord = { - cardId: string - grade: ReviewGrade - noteId: string - reviewedAt: string -} -type DueReviewSessionRecord = { - cardIds: string[] - completedAt?: string - deckId: string - id: string - mode: 'due' - reviewedCards: ReviewGradeRecord[] - startedAt: string - status: DueReviewSession['status'] -} -type PracticeReviewSessionRecord = { - currentCardId: string - deckId: string - id: string - mode: 'practice' - reviewedCards: ReviewGradeRecord[] - startedAt: string -} -type ReviewSessionRecord = DueReviewSessionRecord | PracticeReviewSessionRecord - -type DeletedRecord = { - deletedAt?: string -} -type DeckRecord = Deck & DeletedRecord -type FolderRecord = Folder & DeletedRecord -type WorkspaceRecord = Workspace & DeletedRecord -type BasicNoteRecord = Extract & DeletedRecord -type ClozeNoteRecord = Extract & DeletedRecord -type NoteRecord = BasicNoteRecord | ClozeNoteRecord -type ReviewSchedulerFields = { - deckId: string - dueAt: string - noteId: string - reviewedAt: string - status: NoteDetail['status'] - title: string - workspaceId: string -} -type BasicReviewSchedulerCard = Extract & - ReviewSchedulerFields -type ClozeReviewSchedulerCard = Extract & - ReviewSchedulerFields -type ReviewSchedulerCard = BasicReviewSchedulerCard | ClozeReviewSchedulerCard - -type SeedNote = { - note: NoteRecord -} - -const storageKey = 'clear-ui:mock-state:v15' -const dayMs = 24 * 60 * 60 * 1000 -const hourMs = 60 * 60 * 1000 -const minuteMs = 60 * 1000 -const seedNow = Date.now() - -const isoNow = () => new Date(seedNow).toISOString() -const daysAgo = (days: number) => new Date(seedNow - days * dayMs).toISOString() -const daysFromNow = (days: number) => new Date(seedNow + days * dayMs).toISOString() -const hoursAgo = (hours: number) => new Date(seedNow - hours * hourMs).toISOString() -const minutesAgo = (minutes: number) => new Date(seedNow - minutes * minuteMs).toISOString() - -const createIdAllocator = (counters: Record) => ({ - next(prefix: string) { - const value = counters[prefix] ?? 1 - counters[prefix] = value + 1 - - return `${prefix}-${value}` - }, -}) - -const defaultSettings = (): Settings => ({ - dailyNewLimit: 20, - dailyReviewLimit: 100, - fsrsParams: [ - 0.212, 1.2931, 2.3065, 8.2956, 6.4133, 0.8334, 3.0194, 0.001, 1.8722, - 0.1666, 0.796, 1.4835, 0.0614, 0.2629, 1.6483, 0.6014, 1.8729, - 0.5425, 0.0912, 0.0658, 0.1542, - ], - fsrsRetention: 90, - language: 'en-US', - masteryHorizonDays: 30, - newCardsOrder: 'before_review', - timezone: 'auto', -}) - -const basicCardId = (noteId: string) => `${noteId}:basic` - -const clozeCardId = (noteId: string, clozeId: string) => `${noteId}:${clozeId}` - -const aggregateClozeCards = ( - cards: ClozeNoteCard[], - fallback: Pick, -) => { - if (cards.length === 0) { - return fallback - } - - return { - dueAt: cards.reduce((earliest, card) => (card.dueAt < earliest ? card.dueAt : earliest), cards[0].dueAt), - progress: cards.reduce((total, card) => total + card.progress, 0) / cards.length, - reviewedAt: cards.reduce( - (latest, card) => (card.reviewedAt > latest ? card.reviewedAt : latest), - cards[0].reviewedAt, - ), - status: cards.every((card) => card.status === 'mastered') ? 'mastered' : 'in-progress', - } satisfies Pick -} - -const basicNote = ({ - back, - deckId, - dueAt, - front, - id, - progress, - reviewedAt, - status, - title, - updatedAt, -}: { - back: string - deckId: string - dueAt: string - front: string - id: string - progress: number - reviewedAt: string - status: NoteDetail['status'] - title: string - updatedAt: string -}): SeedNote => ({ - note: { - deckId, - dueAt, - editor: { back, front }, - id, - kind: 'basic', - progress, - reviewedAt, - status, - title, - updatedAt, - }, -}) - -const clozeNote = ({ - body, - cards, - deckId, - id, - progress, - reviewedAt, - status, - title, - updatedAt, -}: { - body: string - cards: Array<{ - clozeId?: string - dueAt: string - id: string - progress: number - reviewedAt: string - status?: NoteDetail['status'] - title: string - }> - deckId: string - id: string - progress: number - reviewedAt: string - status: NoteDetail['status'] - title: string - updatedAt: string -}): SeedNote => { - const noteCards = cards.map((card) => { - const clozeId = card.clozeId ?? card.id - - return { - clozeId, - dueAt: card.dueAt, - id: clozeCardId(id, clozeId), - progress: card.progress, - reviewedAt: card.reviewedAt, - status: card.status ?? status, - title: card.title, - } - }) - const aggregate = aggregateClozeCards(noteCards, { - dueAt: noteCards[0]?.dueAt ?? daysFromNow(1), - progress, - reviewedAt, - status, - }) - - return { - note: { - cards: noteCards, - deckId, - dueAt: aggregate.dueAt, - editor: { body }, - id, - kind: 'cloze', - progress: aggregate.progress, - reviewedAt: aggregate.reviewedAt, - status: aggregate.status, - title, - updatedAt, - }, - } -} - -const seedNoteRecords = (): SeedNote[] => [ - basicNote({ - back: 'Mechanization, capital investment, and urban labor markets accelerated industrialization.', - deckId: 'world-history', - dueAt: daysFromNow(1), - front: 'Industrial Revolution', - id: 'industrial-revolution-causes', - progress: 74, - reviewedAt: daysAgo(3), - status: 'mastered', - title: 'Industrial Revolution Causes', - updatedAt: daysAgo(3), - }), - clozeNote({ - body: - 'Collective memory shapes how communities interpret {{c1::historical evidence}} and preserve {{c2::public narratives}} across generations.', - deckId: 'world-history', - cards: [ - { - dueAt: daysFromNow(1), - id: 'c1', - progress: 74, - reviewedAt: daysAgo(3), - title: 'Historical Evidence', - }, - { - dueAt: daysFromNow(4), - id: 'c2', - progress: 42, - reviewedAt: daysAgo(1), - title: 'Public Narratives', - }, - ], - id: 'collective-memory', - progress: 42, - reviewedAt: daysAgo(1), - status: 'in-progress', - title: 'Collective Memory', - updatedAt: daysAgo(1), - }), - basicNote({ - back: 'A constitutional crisis emerges when institutions dispute authority or legitimacy.', - deckId: 'world-history', - dueAt: daysFromNow(1), - front: 'Separation of Powers', - id: 'constitutional-crisis', - progress: 52, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Constitutional Crisis', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Diaries, letters, newspapers, and official records anchor historical interpretation.', - deckId: 'world-history', - dueAt: daysFromNow(1), - front: 'Primary Sources', - id: 'primary-source-notes', - progress: 61, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Primary Source Notes', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Atlantic revolutions linked republican ideas, rights claims, and popular sovereignty.', - deckId: 'world-history', - dueAt: daysFromNow(2), - front: 'Atlantic Revolutions', - id: 'atlantic-revolutions-outline', - progress: 88, - reviewedAt: isoNow(), - status: 'mastered', - title: 'Atlantic Revolutions Outline', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Detente used diplomacy and arms control to reduce Cold War escalation risk.', - deckId: 'world-history', - dueAt: daysFromNow(1), - front: 'Cold War Detente', - id: 'cold-war-detente-recap', - progress: 47, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Cold War Detente Recap', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Civil rights campaigns combined mass mobilization, legal strategy, and federal pressure.', - deckId: 'world-history', - dueAt: daysFromNow(3), - front: 'Civil Rights Movement', - id: 'civil-rights-movement-cards', - progress: 84, - reviewedAt: isoNow(), - status: 'mastered', - title: 'Civil Rights Movement Cards', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Working memory keeps a small amount of information active for immediate reasoning.', - deckId: 'attention-and-memory', - dueAt: daysFromNow(1), - front: 'Working Memory', - id: 'working-memory-limits', - progress: 68, - reviewedAt: daysAgo(2), - status: 'mastered', - title: 'Working Memory Limits', - updatedAt: daysAgo(2), - }), - clozeNote({ - body: - 'Cognitive biases shift how {{c1::evidence}} is weighted when {{c2::context}} changes.', - deckId: 'attention-and-memory', - cards: [ - { - dueAt: daysFromNow(3), - id: 'c1', - progress: 57, - reviewedAt: daysAgo(1), - title: 'Evidence', - }, - { - dueAt: daysFromNow(5), - id: 'c2', - progress: 35, - reviewedAt: isoNow(), - title: 'Context', - }, - ], - id: 'cognitive-bias-review', - progress: 57, - reviewedAt: daysAgo(1), - status: 'in-progress', - title: 'Cognitive Bias Review', - updatedAt: daysAgo(1), - }), - basicNote({ - back: 'Schemas organize prior knowledge so new information can be interpreted quickly.', - deckId: 'attention-and-memory', - dueAt: daysFromNow(1), - front: 'Schema Formation', - id: 'schema-formation', - progress: 49, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Schema Formation', - updatedAt: daysAgo(2), - }), - basicNote({ - back: 'Checks and balances distribute authority so institutions can constrain each other.', - deckId: 'political-thought', - dueAt: daysFromNow(1), - front: 'Checks and Balances', - id: 'checks-and-balances', - progress: 71, - reviewedAt: daysAgo(4), - status: 'mastered', - title: 'Checks and Balances', - updatedAt: daysAgo(4), - }), - clozeNote({ - body: - 'A coalition becomes durable when the {{c1::policy bargain}} is credible and {{c2::incentives}} are transparent.', - deckId: 'political-thought', - cards: [ - { - dueAt: daysFromNow(1), - id: 'c1', - progress: 43, - reviewedAt: daysAgo(2), - title: 'Policy Bargain', - }, - { - dueAt: daysFromNow(4), - id: 'c2', - progress: 24, - reviewedAt: daysAgo(1), - title: 'Incentives', - }, - ], - id: 'coalition-building', - progress: 43, - reviewedAt: daysAgo(2), - status: 'in-progress', - title: 'Coalition Building', - updatedAt: daysAgo(2), - }), - basicNote({ - back: 'Institutional design defines the rules, offices, and procedures that govern authority.', - deckId: 'political-thought', - dueAt: daysFromNow(2), - front: 'Institutional Design', - id: 'institutional-design', - progress: 39, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Institutional Design', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Anchoring pulls judgment toward the first number or frame that enters a decision.', - deckId: 'cognitive-biases', - dueAt: daysFromNow(1), - front: 'Anchoring', - id: 'anchoring', - progress: 64, - reviewedAt: hoursAgo(6), - status: 'in-progress', - title: 'Anchoring', - updatedAt: hoursAgo(6), - }), - clozeNote({ - body: - 'Availability bias makes {{c1::vivid examples}} feel more common than they really are.', - deckId: 'cognitive-biases', - cards: [ - { - dueAt: daysFromNow(2), - id: 'c1', - progress: 53, - reviewedAt: hoursAgo(3), - title: 'Vivid Examples', - }, - ], - id: 'availability-heuristic', - progress: 53, - reviewedAt: hoursAgo(3), - status: 'in-progress', - title: 'Availability Heuristic', - updatedAt: hoursAgo(3), - }), - basicNote({ - back: 'People usually feel losses more strongly than equivalent gains.', - deckId: 'cognitive-biases', - dueAt: daysFromNow(2), - front: 'Loss Aversion', - id: 'loss-aversion', - progress: 69, - reviewedAt: isoNow(), - status: 'mastered', - title: 'Loss Aversion', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Separated powers distribute authority so each branch can restrain the others.', - deckId: 'political-thought', - dueAt: daysFromNow(2), - front: 'Separation of Powers', - id: 'separation-of-powers', - progress: 58, - reviewedAt: hoursAgo(9), - status: 'in-progress', - title: 'Separation of Powers', - updatedAt: hoursAgo(9), - }), - basicNote({ - back: 'Federalism divides governing authority across national and regional institutions.', - deckId: 'political-thought', - dueAt: daysFromNow(2), - front: 'Federalism', - id: 'federalism', - progress: 47, - reviewedAt: daysAgo(1), - status: 'in-progress', - title: 'Federalism', - updatedAt: daysAgo(1), - }), - basicNote({ - back: 'Rule of law means public power operates through known rules rather than personal discretion.', - deckId: 'political-thought', - dueAt: daysFromNow(3), - front: 'Rule of Law', - id: 'rule-of-law', - progress: 57, - reviewedAt: daysAgo(2), - status: 'mastered', - title: 'Rule of Law', - updatedAt: daysAgo(2), - }), - basicNote({ - back: 'A base rate is the background frequency you should account for before focusing on a vivid case.', - deckId: 'statistics-basics', - dueAt: daysFromNow(3), - front: 'Base Rates', - id: 'base-rates', - progress: 48, - reviewedAt: daysAgo(1), - status: 'in-progress', - title: 'Base Rates', - updatedAt: daysAgo(1), - }), - basicNote({ - back: 'A representation uses only a few active units.', - deckId: 'neural-models', - dueAt: daysFromNow(2), - front: 'Sparse Coding', - id: 'sparse-coding', - progress: 31, - reviewedAt: hoursAgo(5), - status: 'in-progress', - title: 'Sparse Coding', - updatedAt: hoursAgo(5), - }), - clozeNote({ - body: 'The {{c1::prediction}} error drives model refinement.', - deckId: 'neural-models', - cards: [ - { - dueAt: daysFromNow(3), - id: 'c1', - progress: 61, - reviewedAt: daysAgo(1), - title: 'Prediction', - }, - { - dueAt: daysFromNow(6), - id: 'c2', - progress: 28, - reviewedAt: isoNow(), - title: 'Refinement', - }, - ], - id: 'predictive-coding', - progress: 61, - reviewedAt: daysAgo(1), - status: 'mastered', - title: 'Predictive Coding', - updatedAt: daysAgo(1), - }), - basicNote({ - back: 'Social structures connect norms, institutions, and networks into stable patterns.', - deckId: 'social-theory', - dueAt: daysFromNow(1), - front: 'Social Structure', - id: 'social-structure-basics', - progress: 70, - reviewedAt: isoNow(), - status: 'mastered', - title: 'Social Structure Basics', - updatedAt: isoNow(), - }), - clozeNote({ - body: - 'The {{c1::method}} archive preserves experimental structure, procedure, and analysis notes for future review.', - deckId: 'method-archives', - cards: [ - { - dueAt: daysFromNow(3), - id: 'c1', - progress: 52, - reviewedAt: isoNow(), - title: 'Method', - }, - ], - id: 'method-archive-overview', - progress: 52, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Method Archive Overview', - updatedAt: isoNow(), - }), - basicNote({ - back: 'Procedure, evaluation, refinement', - deckId: 'applied-analysis', - dueAt: daysFromNow(2), - front: 'Analysis', - id: 'applied-analysis-note', - progress: 56, - reviewedAt: isoNow(), - status: 'in-progress', - title: 'Applied Analysis Note', - updatedAt: isoNow(), - }), - clozeNote({ - body: 'The {{c1::archive}} keeps analysis together for later study.', - deckId: 'archive-studies', - cards: [ - { - dueAt: daysFromNow(4), - id: 'c1', - progress: 47, - reviewedAt: isoNow(), - title: 'Archive', - }, - ], - id: 'archive-studies-note', - progress: 47, - reviewedAt: isoNow(), - status: 'mastered', - title: 'Archive Studies Note', - updatedAt: isoNow(), - }), -] - -const seedNotes = (): NoteRecord[] => seedNoteRecords().map(({ note }) => note) - -const seedState = (): MockState => ({ - activeWorkspaceId: 'independent-study', - deckDetails: { - 'world-history': { dueToday: 9, progress: 71, totalNotes: 7 }, - 'attention-and-memory': { dueToday: 64, progress: 58, totalNotes: 3 }, - 'political-thought': { dueToday: 2, progress: 54, totalNotes: 3 }, - 'cognitive-biases': { dueToday: 6, progress: 62, totalNotes: 3 }, - 'reading-review-queue': { dueToday: 2, progress: 51, totalNotes: 1 }, - 'statistics-basics': { dueToday: 3, progress: 48, totalNotes: 1 }, - 'neural-models': { dueToday: 26, progress: 44, totalNotes: 2 }, - 'social-theory': { dueToday: 18, progress: 55, totalNotes: 1 }, - 'method-archives': { dueToday: 15, progress: 52, totalNotes: 1 }, - 'applied-analysis': { dueToday: 11, progress: 51, totalNotes: 1 }, - 'archive-studies': { dueToday: 8, progress: 47, totalNotes: 1 }, - }, - workspaces: [ - { - description: 'Reading notes, review decks, and reference material for ongoing study.', - icon: 'layers-3', - id: 'independent-study', - title: 'Independent Study', - updatedAt: minutesAgo(2), - }, - { - description: 'Completed decks, older notes, and material worth keeping.', - icon: 'archive', - id: 'reading-archive', - title: 'Reading Archive', - updatedAt: hoursAgo(4), - }, - ], - folders: [ - { - description: 'Topic notes, excerpts, and outlines for active study.', - id: 'reading-notes', - name: 'Reading Notes', - parentId: 'independent-study', - updatedAt: isoNow(), - workspaceId: 'independent-study', - }, - { - description: 'Timelines, turning points, and historical reading notes.', - id: 'history', - name: 'History', - parentId: 'reading-notes', - updatedAt: daysAgo(1), - workspaceId: 'independent-study', - }, - { - description: 'Attention, memory, and judgment topics for spaced review.', - id: 'psychology', - name: 'Psychology', - parentId: 'reading-notes', - updatedAt: daysAgo(2), - workspaceId: 'independent-study', - }, - { - description: 'Long-form ideas and theory notes kept for later review.', - id: 'philosophy', - name: 'Philosophy', - parentId: 'reading-notes', - updatedAt: daysAgo(3), - workspaceId: 'independent-study', - }, - { - description: 'Frameworks, summaries, and reusable study scaffolds.', - id: 'reference', - name: 'Reference', - parentId: 'independent-study', - updatedAt: isoNow(), - workspaceId: 'independent-study', - }, - { - description: 'Argument structure, drafting patterns, and revision aids.', - id: 'writing', - name: 'Writing', - parentId: 'reference', - updatedAt: daysAgo(4), - workspaceId: 'independent-study', - }, - { - description: 'Statistics, measurement, and repeatable study workflows.', - id: 'methods', - name: 'Methods', - parentId: 'reference', - updatedAt: daysAgo(5), - workspaceId: 'independent-study', - }, - ], - decks: [ - { - description: 'Judgment traps worth reviewing until they become visible in the moment.', - dueToday: 6, - parentId: 'independent-study', - icon: 'brain', - id: 'cognitive-biases', - progress: 62, - title: 'Cognitive Biases', - totalNotes: 3, - updatedAt: hoursAgo(5), - workspaceId: 'independent-study', - }, - { - description: 'Core institutions, ideas, and recurring arguments in political theory.', - dueToday: 2, - parentId: 'reading-notes', - icon: 'archive', - id: 'reading-review-queue', - progress: 51, - title: 'Reading Review Queue', - totalNotes: 1, - updatedAt: hoursAgo(9), - workspaceId: 'independent-study', - }, - { - description: 'Base-rate reasoning and small quantitative concepts for everyday study decisions.', - dueToday: 3, - parentId: 'reference', - icon: 'graduation-cap', - id: 'statistics-basics', - progress: 48, - title: 'Statistics Basics', - totalNotes: 1, - updatedAt: daysAgo(1), - workspaceId: 'independent-study', - }, - { - description: 'Turning points, institutions, and broad patterns that repay repeated review.', - dueToday: 9, - parentId: 'independent-study', - icon: 'book-open', - id: 'world-history', - progress: 71, - title: 'World History', - totalNotes: 7, - updatedAt: hoursAgo(9), - workspaceId: 'independent-study', - }, - { - description: 'Attention, working memory, and schema formation in one compact deck.', - dueToday: 5, - parentId: 'psychology', - icon: 'brain', - id: 'attention-and-memory', - progress: 58, - title: 'Attention and Memory', - totalNotes: 3, - updatedAt: daysAgo(1), - workspaceId: 'independent-study', - }, - { - description: 'Institutions, sovereignty, and the structure of political order.', - dueToday: 2, - parentId: 'independent-study', - icon: 'landmark', - id: 'political-thought', - progress: 54, - title: 'Political Thought', - totalNotes: 3, - updatedAt: daysAgo(1), - workspaceId: 'independent-study', - }, - { - description: '', - dueToday: 18, - parentId: 'psychology', - icon: 'brain', - id: 'neural-models', - progress: 31, - title: 'Neural Models', - totalNotes: 2, - updatedAt: hoursAgo(5), - workspaceId: 'independent-study', - }, - { - description: '', - dueToday: 12, - parentId: 'philosophy', - icon: 'network', - id: 'social-theory', - progress: 63, - title: 'Social Theory', - totalNotes: 1, - updatedAt: isoNow(), - workspaceId: 'independent-study', - }, - { - description: '', - dueToday: 9, - parentId: 'writing', - icon: 'shapes', - id: 'method-archives', - progress: 49, - title: 'Method Archives', - totalNotes: 1, - updatedAt: isoNow(), - workspaceId: 'independent-study', - }, - { - description: '', - dueToday: 6, - parentId: 'methods', - icon: 'shapes', - id: 'applied-analysis', - progress: 40, - title: 'Applied Analysis', - totalNotes: 1, - updatedAt: isoNow(), - workspaceId: 'independent-study', - }, - { - description: '', - dueToday: 4, - parentId: 'writing', - icon: 'languages', - id: 'archive-studies', - progress: 36, - title: 'Archive Studies', - totalNotes: 1, - updatedAt: isoNow(), - workspaceId: 'independent-study', - }, - ], - idCounters: { - workspace: 1, - folder: 1, - deck: 1, - note: 1, - review: 1, - card: 1, - }, - notes: seedNotes(), - reviews: [], - settings: defaultSettings(), - trash: { - items: [ - { - deletedAt: daysAgo(5), - id: 'drafting-patterns', - kind: 'deck', - locationPath: ['Independent Study', 'Reference', 'Writing'], - title: 'Drafting Patterns', - }, - { - deletedAt: daysAgo(2), - id: 'sampling-error-notes', - kind: 'note', - locationPath: ['Independent Study', 'Reference', 'Statistics Basics'], - title: 'Sampling Error Notes', - }, - { - deletedAt: daysAgo(7), - id: 'drafts', - kind: 'folder', - locationPath: ['Independent Study'], - title: 'Drafts', - }, - { - deletedAt: daysAgo(8), - id: 'completed-reading-log', - kind: 'note', - locationPath: ['Reading Archive', 'Archive'], - title: 'Completed Reading Log', - }, - { - deletedAt: daysAgo(9), - id: 'linguistic-atlas', - kind: 'workspace', - locationPath: ['Workspaces'], - title: 'Linguistic Atlas', - }, - ], - lastEmptiedAt: daysAgo(2), - }, -}) - -const loadInitialState = (): MockState => { - const fallback = seedState() - - if (typeof window === 'undefined') { - return fallback - } - - const raw = window.localStorage.getItem(storageKey) - - if (!raw) { - writeJson(storageKey, fallback) - return fallback - } - - try { - return JSON.parse(raw) as MockState - } catch { - writeJson(storageKey, fallback) - return fallback - } -} - -const state = loadInitialState() - -const persist = () => writeJson(storageKey, state) - -const touchWorkspace = (workspaceId: string) => { - state.workspaces = state.workspaces.map((workspace) => - workspace.id === workspaceId ? { ...workspace, updatedAt: isoNow() } : workspace, - ) -} - -const workspaceIdForParentFolder = (parentId: string) => - visible(state.workspaces).some((workspace) => workspace.id === parentId) - ? parentId - : visible(state.folders).find((folder) => folder.id === parentId)?.workspaceId ?? - parentId - -const workspaceIdForParent = (parentId: string) => - visible(state.workspaces).some((workspace) => workspace.id === parentId) - ? parentId - : visible(state.folders).find((folder) => folder.id === parentId)?.workspaceId ?? parentId - -const workspaceIdForDeck = (deckId: string) => - visible(state.decks).find((deck) => deck.id === deckId)?.workspaceId ?? activeWorkspace().id - -const addTrashItem = (item: TrashItem) => { - state.trash = { - ...state.trash, - items: [item, ...state.trash.items.filter((candidate) => candidate.id !== item.id)], - } -} - -const visible = (items: readonly T[]) => - items.filter((item) => !item.deletedAt) - -const publicWorkspace = (record: WorkspaceRecord): Workspace => { - const workspace = { ...record } - delete workspace.deletedAt - - return workspace -} - -const publicFolder = (record: FolderRecord): Folder => { - const folder = { ...record } - delete folder.deletedAt - - return folder -} - -const publicDeck = (record: DeckRecord): Deck => { - const deck = { ...record } - delete deck.deletedAt - - return deck -} - -const publicNote = (note: NoteRecord): NoteDetail => { - const publicFields = { ...note } - delete publicFields.deletedAt - - return publicFields as NoteDetail -} - -const publicReviewCard = (card: ReviewSchedulerCard): ReviewCard => { - if (card.kind === 'basic') { - return { - back: card.back, - front: card.front, - id: card.id, - kind: 'basic', - progress: card.progress, - } - } - - return { - body: card.body, - clozeId: card.clozeId, - id: card.id, - kind: 'cloze', - progress: card.progress, - } -} - -const noteCountsByDeck = () => { - const counts = new Map() - - for (const note of visible(state.notes)) { - counts.set(note.deckId, (counts.get(note.deckId) ?? 0) + 1) - } - - return counts -} - -const normalizeDeckTotalNotes = () => { - const counts = noteCountsByDeck() - - state.decks = state.decks.map((deck) => { - const legacy = deck as Deck & { totalCards?: number } - const next = { ...legacy } - delete next.totalCards - - return { - ...next, - totalNotes: counts.get(deck.id) ?? legacy.totalNotes ?? legacy.totalCards ?? 0, - } - }) - state.deckDetails = Object.fromEntries( - Object.entries(state.deckDetails).map(([deckId, detail]) => { - const legacy = detail as typeof detail & { totalCards?: number } - const next = { ...legacy } - delete next.totalCards - - return [ - deckId, - { - ...next, - totalNotes: counts.get(deckId) ?? legacy.totalNotes ?? legacy.totalCards ?? 0, - }, - ] - }), - ) -} - -const normalizeNotes = () => { - state.notes = state.notes.map((note) => { - const legacy = note as NoteDetail & { workspaceId?: string } - const next = { ...legacy } - delete next.workspaceId - - return next - }) -} - -const bumpDeckTotalNotes = (deckId: string, amount: number) => { - state.decks = state.decks.map((deck) => - deck.id === deckId - ? { - ...deck, - totalNotes: Math.max(0, deck.totalNotes + amount), - updatedAt: isoNow(), - } - : deck, - ) - const detail = state.deckDetails[deckId] - - if (detail) { - state.deckDetails = { - ...state.deckDetails, - [deckId]: { - ...detail, - totalNotes: Math.max(0, detail.totalNotes + amount), - }, - } - } -} - -normalizeNotes() -normalizeDeckTotalNotes() - -const sortByPreference = < - T extends { dueToday?: number; name?: string; title?: string; updatedAt: string }, ->( - items: readonly T[], - sort: SortPreference = { direction: 'asc', field: 'title' }, -) => - [...items].sort((left, right) => { - const direction = sort.direction === 'asc' ? 1 : -1 - let value: number - - if (sort.field === 'updated') { - value = new Date(left.updatedAt).getTime() - new Date(right.updatedAt).getTime() - } else if (sort.field === 'dueToday') { - value = (left.dueToday ?? 0) - (right.dueToday ?? 0) - } else { - value = (left.title ?? left.name ?? '').localeCompare(right.title ?? right.name ?? '') - } - - return value * direction - }) - -const clozeMarkersFromBody = (body: string) => { - const markers: Array<{ clozeId: string; text: string }> = [] - const pattern = /\{\{(c\d+)::(.*?)\}\}/g - const seen = new Set() - let match: RegExpExecArray | null - - while ((match = pattern.exec(body)) !== null) { - if (seen.has(match[1])) { - continue - } - - seen.add(match[1]) - markers.push({ - clozeId: match[1], - text: match[2], - }) - } - - return markers -} - -const noteSearchText = (note: NoteRecord) => - [ - note.title, - note.kind === 'basic' ? note.editor.front : note.editor.body, - note.kind === 'basic' ? note.editor.back : '', - ] - .join(' ') - .toLowerCase() - -const folderPathSegments = (folderId: string): string[] => { - const folder = state.folders.find((candidate) => candidate.id === folderId) - - if (!folder) { - return [] - } - - if (folder.parentId === folder.workspaceId) { - return [folder.name] - } - - return [...folderPathSegments(folder.parentId), folder.name] -} - -const workspaceTitle = (workspaceId: string) => - state.workspaces.find((workspace) => workspace.id === workspaceId)?.title ?? 'Workspace' - -const deckLocationPath = (deck: DeckRecord) => [ - workspaceTitle(deck.workspaceId), - ...(deck.parentId === deck.workspaceId ? [] : folderPathSegments(deck.parentId)), -] - -const folderContainerLocationPath = (folder: FolderRecord) => [ - workspaceTitle(folder.workspaceId), - ...(folder.parentId === folder.workspaceId - ? [] - : folderPathSegments(folder.parentId)), -] - -const searchResultGroups = (results: SearchResult[]): SearchResultGroup[] => { - const folderResults = sortSearchResults( - results.filter((result): result is FolderSearchResult => result.kind === 'folder'), - ) - const deckResults = sortSearchResults( - results.filter((result): result is DeckSearchResult => result.kind === 'deck'), - ) - const noteResults = sortSearchResults( - results.filter((result): result is NoteSearchResult => result.kind === 'note'), - ) - const groups: SearchResultGroup[] = [] - - if (folderResults.length > 0) { - groups.push({ kind: 'folder', results: folderResults }) - } - - if (deckResults.length > 0) { - groups.push({ kind: 'deck', results: deckResults }) - } - - if (noteResults.length > 0) { - groups.push({ kind: 'note', results: noteResults }) - } - - return groups -} - -const sortSearchResults = (results: T[]) => - results.sort((left, right) => left.title.localeCompare(right.title)) - -const descendantFolderIds = (folderId: string): string[] => { - const children = visible(state.folders).filter( - (folder) => folder.parentId === folderId, - ) - - return children.flatMap((folder) => [folder.id, ...descendantFolderIds(folder.id)]) -} - -const activeWorkspace = () => { - const workspaces = visible(state.workspaces) - const active = - workspaces.find((workspace) => workspace.id === state.activeWorkspaceId) ?? - workspaces[0] ?? - state.workspaces[0] - - if (active) { - state.activeWorkspaceId = active.id - } - - return active -} - -const deckDetail = (deck: DeckRecord): DeckDetail => ({ - ...publicDeck(deck), - ...(state.deckDetails[deck.id] ?? { - dueToday: deck.dueToday, - progress: deck.progress, - totalNotes: deck.totalNotes, - }), -}) - -const clozeCardsFromBody = ({ - body, - currentCards = [], - idAllocator, - newCardDueAt, - newCardReviewedAt, -}: { - body: string - currentCards?: ClozeNoteCard[] - idAllocator: ReturnType - newCardDueAt: string - newCardReviewedAt: string -}): ClozeNoteCard[] => { - const currentByClozeId = new Map(currentCards.map((card) => [card.clozeId, card])) - - return clozeMarkersFromBody(body) - .map((marker) => { - const clozeId = marker.clozeId - const current = currentByClozeId.get(clozeId) - - if (current) { - return { - ...current, - title: marker.text, - } - } - - return { - clozeId, - dueAt: newCardDueAt, - id: idAllocator.next('card'), - progress: 0, - reviewedAt: newCardReviewedAt, - status: 'in-progress', - title: marker.text, - } - }) -} - -const updateReviewedClozeCard = ({ - cardId, - cards, - dueAt, - progress, - reviewedAt, - status, -}: { - cardId: string - cards: ClozeNoteCard[] - dueAt: string - progress: number - reviewedAt: string - status: NoteDetail['status'] -}): ClozeNoteCard[] => - cards.map((card) => - card.id === cardId - ? { - ...card, - dueAt, - progress, - reviewedAt, - status, - } - : card, - ) - -const noteListItem = (note: NoteRecord): NoteListItem => ({ - dueAt: note.dueAt, - id: note.id, - kind: note.kind, - progress: note.progress, - reviewedAt: note.reviewedAt, - status: note.status, - title: note.title, - updatedAt: note.updatedAt, -}) - -const noteRef = (note: NoteRecord): NoteRef => ({ - deckId: note.deckId, - id: note.id, -}) - -const reviewCardsForNote = (note: NoteRecord): ReviewSchedulerCard[] => { - const workspaceId = workspaceIdForDeck(note.deckId) - - if (note.kind === 'basic') { - return [ - { - back: note.editor.back, - deckId: note.deckId, - dueAt: note.dueAt, - front: note.editor.front, - id: basicCardId(note.id), - kind: 'basic', - noteId: note.id, - progress: note.progress, - reviewedAt: note.reviewedAt, - status: note.status, - title: note.title, - workspaceId, - }, - ] - } - - return note.cards.map((card) => ({ - body: note.editor.body, - clozeId: card.clozeId, - deckId: note.deckId, - dueAt: card.dueAt, - id: card.id, - kind: 'cloze', - noteId: note.id, - progress: card.progress, - reviewedAt: card.reviewedAt, - status: card.status, - title: note.title, - workspaceId, - })) -} - -const listReviewCards = (deckId: string) => - visible(state.notes) - .filter((note) => note.deckId === deckId) - .flatMap((note) => reviewCardsForNote(note)) - -const dueReviewCards = (deckId: string) => - listReviewCards(deckId) - .filter((card) => card.dueAt <= isoNow()) - .sort(compareDueCards) - -const practiceReviewCards = (deckId: string) => - listReviewCards(deckId).sort(comparePracticeCards) - -const durationSeconds = (startedAt: string, endedAt: string) => - Math.max(0, Math.floor((Date.parse(endedAt) - Date.parse(startedAt)) / 1000)) - -const reviewSession = (review: ReviewSessionRecord): ReviewSession | undefined => - review.mode === 'due' ? dueReviewSession(review) : practiceReviewSession(review) - -const dueReviewSession = (review: DueReviewSessionRecord): DueReviewSession => { - const currentCardId = - review.status === 'active' - ? review.cardIds[review.reviewedCards.length] - : undefined - const currentCard = currentCardId - ? listReviewCards(review.deckId).find((card) => card.id === currentCardId) - : undefined - - return { - ...(review.completedAt ? { completedAt: review.completedAt } : {}), - ...(currentCard ? { currentCard: publicReviewCard(currentCard) } : {}), - deckId: review.deckId, - durationSeconds: durationSeconds(review.startedAt, review.completedAt ?? isoNow()), - id: review.id, - mode: 'due', - plannedCount: review.cardIds.length, - reviewedCount: review.reviewedCards.length, - startedAt: review.startedAt, - status: review.status, - } -} - -const practiceReviewSession = ( - review: PracticeReviewSessionRecord, -): PracticeReviewSession | undefined => { - const currentCard = listReviewCards(review.deckId).find( - (card) => card.id === review.currentCardId, - ) - - return currentCard - ? { - currentCard: publicReviewCard(currentCard), - deckId: review.deckId, - durationSeconds: durationSeconds(review.startedAt, isoNow()), - id: review.id, - mode: 'practice', - reviewedCount: review.reviewedCards.length, - startedAt: review.startedAt, - } - : undefined -} - -const compareDueCards = (left: ReviewSchedulerCard, right: ReviewSchedulerCard) => - left.dueAt.localeCompare(right.dueAt) || - left.reviewedAt.localeCompare(right.reviewedAt) || - left.id.localeCompare(right.id) - -const comparePracticeCards = (left: ReviewSchedulerCard, right: ReviewSchedulerCard) => - left.reviewedAt.localeCompare(right.reviewedAt) || - left.progress - right.progress || - left.id.localeCompare(right.id) - -export const mockAppDataStore = { - activeWorkspace, - reset() { - Object.assign(state, seedState()) - persist() - }, - createDeck(draft: DeckDraft) { - const id = createIdAllocator(state.idCounters).next('deck') - const workspaceId = workspaceIdForParent(draft.parentId) - const deck: DeckRecord = { - ...draft, - dueToday: 0, - id, - progress: 0, - totalNotes: 0, - updatedAt: isoNow(), - workspaceId, - } - - state.decks = [deck, ...state.decks] - state.deckDetails = { - ...state.deckDetails, - [id]: { dueToday: 0, progress: 0, totalNotes: 0 }, - } - touchWorkspace(workspaceId) - persist() - - return publicDeck(deck) - }, - createFolder(draft: FolderDraft) { - const id = createIdAllocator(state.idCounters).next('folder') - const workspaceId = workspaceIdForParentFolder(draft.parentId) - const folder: FolderRecord = { ...draft, id, updatedAt: isoNow(), workspaceId } - - state.folders = [folder, ...state.folders] - touchWorkspace(workspaceId) - persist() - - return publicFolder(folder) - }, - createNote(draft: NoteDraft) { - const ids = createIdAllocator(state.idCounters) - const id = ids.next('note') - const workspaceId = workspaceIdForDeck(draft.deckId) - const base = { - deckId: draft.deckId, - dueAt: daysFromNow(1), - id, - progress: 0, - reviewedAt: isoNow(), - status: 'in-progress' as const, - title: draft.title, - updatedAt: isoNow(), - } - const dueAt = daysFromNow(1) - const reviewedAt = isoNow() - const cards = - draft.kind === 'cloze' - ? clozeCardsFromBody({ - body: draft.editor.body, - idAllocator: ids, - newCardDueAt: dueAt, - newCardReviewedAt: reviewedAt, - }) - : [] - const aggregate = aggregateClozeCards(cards, base) - const note: NoteDetail = - draft.kind === 'basic' - ? { - ...base, - dueAt, - editor: draft.editor, - kind: 'basic', - reviewedAt, - } - : { - ...base, - cards, - dueAt: aggregate.dueAt, - editor: draft.editor, - kind: 'cloze', - progress: aggregate.progress, - reviewedAt, - status: aggregate.status, - } - - state.notes = [note, ...state.notes] - bumpDeckTotalNotes(draft.deckId, 1) - touchWorkspace(workspaceId) - persist() - - return noteRef(note) - }, - createWorkspace(draft: WorkspaceDraft) { - const id = createIdAllocator(state.idCounters).next('workspace') - const workspace: WorkspaceRecord = { - ...draft, - id, - updatedAt: isoNow(), - } - - state.workspaces = [workspace, ...state.workspaces] - persist() - - return publicWorkspace(workspace) - }, - deleteDeck(deckId: string) { - const deck = state.decks.find((candidate) => candidate.id === deckId) - - if (!deck) { - return - } - - state.decks = state.decks.map((candidate) => - candidate.id === deckId ? { ...candidate, deletedAt: isoNow() } : candidate, - ) - addTrashItem({ - deletedAt: isoNow(), - id: deck.id, - kind: 'deck', - locationPath: deckLocationPath(deck), - title: deck.title, - }) - touchWorkspace(deck.workspaceId) - persist() - }, - deleteFolder(folderId: string) { - const folder = state.folders.find((candidate) => candidate.id === folderId) - - if (!folder) { - return - } - - state.folders = state.folders.map((candidate) => - candidate.id === folderId ? { ...candidate, deletedAt: isoNow() } : candidate, - ) - addTrashItem({ - deletedAt: isoNow(), - id: folder.id, - kind: 'folder', - locationPath: folderContainerLocationPath(folder), - title: folder.name, - }) - touchWorkspace(folder.workspaceId) - persist() - }, - deleteNote(noteId: string) { - const note = state.notes.find((candidate) => candidate.id === noteId) - const deck = note ? state.decks.find((candidate) => candidate.id === note.deckId) : undefined - - if (!note) { - return - } - - state.notes = state.notes.map((candidate) => - candidate.id === noteId ? { ...candidate, deletedAt: isoNow() } : candidate, - ) - bumpDeckTotalNotes(note.deckId, -1) - addTrashItem({ - deletedAt: isoNow(), - id: note.id, - kind: 'note', - locationPath: deck - ? [...deckLocationPath(deck), deck.title] - : [note.deckId], - title: note.title, - }) - persist() - }, - deleteWorkspace(workspaceId: string) { - const workspace = state.workspaces.find((candidate) => candidate.id === workspaceId) - - if (!workspace || visible(state.workspaces).length <= 1) { - return null - } - - state.workspaces = state.workspaces.map((candidate) => - candidate.id === workspaceId ? { ...candidate, deletedAt: isoNow() } : candidate, - ) - addTrashItem({ - deletedAt: isoNow(), - id: workspace.id, - kind: 'workspace', - locationPath: ['Workspaces'], - title: workspace.title, - }) - - const nextWorkspace = activeWorkspace() - state.activeWorkspaceId = - nextWorkspace.id === workspaceId - ? visible(state.workspaces).find((candidate) => candidate.id !== workspaceId)?.id ?? - nextWorkspace.id - : nextWorkspace.id - persist() - - return state.activeWorkspaceId - }, - emptyTrash() { - state.trash = { items: [], lastEmptiedAt: isoNow() } - persist() - - return state.trash - }, - getActiveWorkspaceId() { - return activeWorkspace().id - }, - getDeckById(deckId: string) { - const deck = visible(state.decks).find((candidate) => candidate.id === deckId) - - return deck ? deckDetail(deck) : undefined - }, - getFolderById(folderId: string) { - const folder = visible(state.folders).find((candidate) => candidate.id === folderId) - - return folder ? publicFolder(folder) : undefined - }, - getFolderPath: folderPathSegments, - getNoteById(_deckId: string, noteId: string) { - const note = visible(state.notes).find((candidate) => candidate.id === noteId) - - return note ? publicNote(note) : undefined - }, - getSettings() { - return state.settings - }, - getReviewById(reviewId: string) { - const review = state.reviews.find((candidate) => candidate.id === reviewId) - - return review ? reviewSession(review) : undefined - }, - getWorkspaceById(workspaceId: string) { - const workspace = visible(state.workspaces).find( - (candidate) => candidate.id === workspaceId, - ) - - return workspace ? publicWorkspace(workspace) : undefined - }, - startReview(deckId: string): ReviewStartResult | undefined { - const deck = visible(state.decks).find((candidate) => candidate.id === deckId) - - if (!deck) { - return undefined - } - - const startedAt = isoNow() - const dueCards = dueReviewCards(deckId) - - if (dueCards.length > 0) { - const ids = createIdAllocator(state.idCounters) - const review: DueReviewSessionRecord = { - cardIds: dueCards.map((card) => card.id), - deckId, - id: ids.next('review'), - mode: 'due', - reviewedCards: [], - startedAt, - status: 'active', - } - - state.reviews = [review, ...state.reviews] - persist() - - return dueReviewSession(review) - } - - const practiceCard = practiceReviewCards(deckId)[0] - - if (!practiceCard) { - return { - mode: 'unavailable', - reason: 'empty-deck', - } - } - - const review: PracticeReviewSessionRecord = { - currentCardId: practiceCard.id, - deckId, - id: createIdAllocator(state.idCounters).next('review'), - mode: 'practice', - reviewedCards: [], - startedAt, - } - - state.reviews = [review, ...state.reviews] - persist() - - return practiceReviewSession(review) - }, - grade(reviewId: string, cardId: string, grade: ReviewGrade): ReviewSession | undefined { - const review = state.reviews.find((candidate) => candidate.id === reviewId) - const currentCard = visible(state.notes) - .flatMap((note) => reviewCardsForNote(note)) - .find((card) => card.id === cardId) - - if (!review || !currentCard || currentCard.deckId !== review.deckId) { - return undefined - } - - const note = visible(state.notes).find((candidate) => candidate.id === currentCard.noteId) - - if (!note) { - return undefined - } - - const expectedCardId = - review.mode === 'due' - ? review.cardIds[review.reviewedCards.length] - : review.currentCardId - - if ( - expectedCardId !== cardId || - (review.mode === 'due' && review.status === 'completed') - ) { - return undefined - } - - const increment = grade === 'again' ? -8 : grade === 'hard' ? 4 : grade === 'good' ? 9 : 14 - const nextDueAt = daysFromNow( - grade === 'again' ? 1 : grade === 'hard' ? 2 : grade === 'good' ? 4 : 7, - ) - const nextProgress = Math.max(0, Math.min(100, currentCard.progress + increment)) - const nextReviewedAt = isoNow() - const nextStatus = nextProgress >= 80 ? 'mastered' : 'in-progress' - - if (note.kind === 'basic') { - state.notes = state.notes.map((candidate) => - candidate.id === note.id - ? { - ...candidate, - dueAt: nextDueAt, - progress: nextProgress, - reviewedAt: nextReviewedAt, - status: nextStatus, - updatedAt: nextReviewedAt, - } - : candidate, - ) - } else { - const cards = updateReviewedClozeCard({ - cardId, - cards: note.cards, - dueAt: nextDueAt, - progress: nextProgress, - reviewedAt: nextReviewedAt, - status: nextStatus, - }) - const aggregate = aggregateClozeCards(cards, note) - - state.notes = state.notes.map((candidate) => { - if (candidate.id !== note.id || candidate.kind !== 'cloze') { - return candidate - } - - return { - ...candidate, - cards, - dueAt: aggregate.dueAt, - progress: aggregate.progress, - reviewedAt: aggregate.reviewedAt, - status: aggregate.status, - updatedAt: nextReviewedAt, - } - }) - } - - state.reviews = state.reviews.map((candidate) => { - if (candidate.id !== review.id) { - return candidate - } - - const reviewedCards = [ - ...candidate.reviewedCards, - { - cardId, - grade, - noteId: note.id, - reviewedAt: nextReviewedAt, - }, - ] - - if (candidate.mode === 'due') { - return { - ...candidate, - ...(reviewedCards.length >= candidate.cardIds.length - ? { completedAt: nextReviewedAt, status: 'completed' as const } - : {}), - reviewedCards, - } - } - - return { - ...candidate, - currentCardId: practiceReviewCards(candidate.deckId)[0]?.id ?? candidate.currentCardId, - reviewedCards, - } - }) - persist() - - return mockAppDataStore.getReviewById(review.id) - }, - listDecksInFolder(folderId: string, sort?: SortPreference) { - return sortByPreference( - visible(state.decks).filter( - (deck) => deck.parentId === folderId, - ), - sort, - ).map(publicDeck) - }, - listFoldersInFolder(folderId: string, sort?: SortPreference) { - return sortByPreference( - visible(state.folders).filter( - (folder) => folder.parentId === folderId, - ), - sort, - ).map(publicFolder) - }, - listWorkspaceDecks(workspaceId: string, sort?: SortPreference) { - return sortByPreference( - visible(state.decks).filter( - (deck) => deck.workspaceId === workspaceId && deck.parentId === workspaceId, - ), - sort, - ).map(publicDeck) - }, - listWorkspaceFolders(workspaceId: string, sort?: SortPreference) { - return sortByPreference( - visible(state.folders).filter( - (folder) => folder.workspaceId === workspaceId && folder.parentId === workspaceId, - ), - sort, - ).map(publicFolder) - }, - listNotes(deckId: string, sort?: SortPreference) { - const notes = visible(state.notes).filter((note) => note.deckId === deckId) - const sortedNotes = sort ? sortByPreference(notes, sort) : notes - - return sortedNotes.map(noteListItem) - }, - listReviewCards(deckId: string) { - return listReviewCards(deckId).map(publicReviewCard) - }, - listTrash() { - return state.trash - }, - listWorkspaces() { - activeWorkspace() - - return visible(state.workspaces).map(publicWorkspace) - }, - resetSettings() { - const settings = defaultSettings() - state.settings = settings - persist() - - return settings - }, - restoreTrashItem(itemId: string) { - const item = state.trash.items.find((candidate) => candidate.id === itemId) - - if (!item) { - return - } - - if (item.kind === 'workspace') { - state.workspaces = state.workspaces.map((workspace) => - workspace.id === itemId ? { ...workspace, deletedAt: undefined } : workspace, - ) - } - if (item.kind === 'folder') { - state.folders = state.folders.map((folder) => - folder.id === itemId ? { ...folder, deletedAt: undefined } : folder, - ) - } - if (item.kind === 'deck') { - state.decks = state.decks.map((deck) => - deck.id === itemId ? { ...deck, deletedAt: undefined } : deck, - ) - } - if (item.kind === 'note') { - const note = state.notes.find((candidate) => candidate.id === itemId) - - state.notes = state.notes.map((note) => - note.id === itemId ? { ...note, deletedAt: undefined } : note, - ) - if (note) { - bumpDeckTotalNotes(note.deckId, 1) - } - } - - state.trash = { - ...state.trash, - items: state.trash.items.filter((candidate) => candidate.id !== itemId), - } - persist() - }, - deleteTrashItem(itemId: string) { - state.trash = { - ...state.trash, - items: state.trash.items.filter((item) => item.id !== itemId), - } - persist() - }, - search(scope: SearchScope, query: string) { - const normalized = query.trim().toLowerCase() - - if (!normalized) { - return [] as SearchResultGroup[] - } - - const folderIds = - scope.kind === 'folder' ? new Set([scope.folderId, ...descendantFolderIds(scope.folderId)]) : null - - const folders = - scope.kind === 'deck' - ? [] - : visible(state.folders).filter((folder) => { - const inScope = - scope.kind === 'workspace' - ? folder.workspaceId === scope.workspaceId - : folderIds?.has(folder.id) - return inScope && folder.name.toLowerCase().includes(normalized) - }) - const decks = - scope.kind === 'deck' - ? [] - : visible(state.decks).filter((deck) => { - const inScope = - scope.kind === 'workspace' - ? deck.workspaceId === scope.workspaceId - : (folderIds?.has(deck.parentId) ?? false) - return inScope && deck.title.toLowerCase().includes(normalized) - }) - const notes = visible(state.notes).flatMap((note) => { - const deck = state.decks.find((candidate) => candidate.id === note.deckId) - - if (!deck) { - return [] - } - - if (scope.kind === 'deck') { - return note.deckId === scope.deckId && noteSearchText(note).includes(normalized) - ? [{ deck, note }] - : [] - } - - const inScope = - scope.kind === 'workspace' - ? deck.workspaceId === scope.workspaceId - : (folderIds?.has(deck.parentId) ?? false) - - return inScope && noteSearchText(note).includes(normalized) ? [{ deck, note }] : [] - }) - - return searchResultGroups([ - ...folders.map((folder) => ({ - id: folder.id, - kind: 'folder', - locationPath: folderContainerLocationPath(folder), - title: folder.name, - updatedAt: folder.updatedAt, - workspaceId: folder.workspaceId, - })), - ...decks.map((deck) => ({ - deckIcon: deck.icon, - id: deck.id, - kind: 'deck', - locationPath: deckLocationPath(deck), - title: deck.title, - updatedAt: deck.updatedAt, - workspaceId: deck.workspaceId, - })), - ...notes.map(({ deck, note }) => ({ - deckId: note.deckId, - id: note.id, - kind: 'note', - locationPath: deckLocationPath(deck), - noteKind: note.kind, - title: note.title, - updatedAt: note.updatedAt, - workspaceId: deck.workspaceId, - })), - ]) - }, - setActiveWorkspaceId(workspaceId: string) { - if (state.activeWorkspaceId === workspaceId) { - return - } - - state.activeWorkspaceId = workspaceId - persist() - }, - updateDeck(deckId: string, draft: DeckDraft) { - const current = state.decks.find((deck) => deck.id === deckId) - - if (!current) { - return undefined - } - - const workspaceId = workspaceIdForParent(draft.parentId) - const next = { - ...current, - ...draft, - updatedAt: isoNow(), - workspaceId, - } - - state.decks = state.decks.map((deck) => (deck.id === deckId ? next : deck)) - touchWorkspace(workspaceId) - if (current.workspaceId !== workspaceId) { - touchWorkspace(current.workspaceId) - } - persist() - - return deckDetail(next) - }, - updateFolder(folderId: string, draft: FolderDraft) { - const current = state.folders.find((folder) => folder.id === folderId) - - if (!current) { - return undefined - } - - const workspaceId = workspaceIdForParentFolder(draft.parentId) - const next = { - ...current, - ...draft, - updatedAt: isoNow(), - workspaceId, - } - - state.folders = state.folders.map((folder) => (folder.id === folderId ? next : folder)) - touchWorkspace(workspaceId) - if (current.workspaceId !== workspaceId) { - touchWorkspace(current.workspaceId) - } - persist() - - return next - }, - updateNote(noteId: string, draft: NoteDraft) { - const current = state.notes.find((note) => note.id === noteId) - - if (!current) { - return undefined - } - - const workspaceId = workspaceIdForDeck(draft.deckId) - const dueAt = current.dueAt - const ids = createIdAllocator(state.idCounters) - const cards = - draft.kind === 'cloze' - ? clozeCardsFromBody({ - body: draft.editor.body, - currentCards: current.kind === 'cloze' ? current.cards : [], - idAllocator: ids, - newCardDueAt: daysFromNow(1), - newCardReviewedAt: isoNow(), - }) - : [] - const aggregate = aggregateClozeCards(cards, { - ...current, - progress: 0, - status: 'in-progress', - }) - const next: NoteRecord = - draft.kind === 'basic' - ? { - deckId: draft.deckId, - deletedAt: current.deletedAt, - dueAt, - editor: draft.editor, - id: current.id, - kind: 'basic', - progress: current.progress, - reviewedAt: current.reviewedAt, - status: current.status, - title: draft.title, - updatedAt: isoNow(), - } - : { - cards, - deckId: draft.deckId, - deletedAt: current.deletedAt, - dueAt: aggregate.dueAt, - editor: draft.editor, - id: current.id, - kind: 'cloze', - progress: aggregate.progress, - reviewedAt: aggregate.reviewedAt, - status: aggregate.status, - title: draft.title, - updatedAt: isoNow(), - } - - state.notes = state.notes.map((note) => (note.id === noteId ? next : note)) - if (current.deckId !== draft.deckId) { - bumpDeckTotalNotes(current.deckId, -1) - bumpDeckTotalNotes(draft.deckId, 1) - } - touchWorkspace(workspaceId) - const currentWorkspaceId = workspaceIdForDeck(current.deckId) - if (currentWorkspaceId !== workspaceId) { - touchWorkspace(currentWorkspaceId) - } - persist() - - return noteRef(next) - }, - updateWorkspace(workspaceId: string, draft: WorkspaceDraft) { - const current = state.workspaces.find((workspace) => workspace.id === workspaceId) - const next = current - ? { - ...current, - ...draft, - updatedAt: isoNow(), - } - : undefined - - if (!next) { - return undefined - } - - state.workspaces = state.workspaces.map((workspace) => - workspace.id === workspaceId ? next : workspace, - ) - persist() - - return next - }, - writeSettings(settings: Settings) { - state.settings = settings - persist() - - return settings - }, -} - -export { defaultSettings } diff --git a/ui/src/platform/mock/mockDomainResult.ts b/ui/src/platform/mock/mockDomainResult.ts new file mode 100644 index 0000000..f48c34a --- /dev/null +++ b/ui/src/platform/mock/mockDomainResult.ts @@ -0,0 +1,89 @@ +import { + MockHttpError, + type ReviewSession as ApiReviewSession, + type ReviewStartResult as ApiReviewStartResult, +} from '@local/mock-server/browser' + +import { + domainError, + err, + ok, + type DomainError, + type Result, +} from '@shared/errors' +import type { + DueReviewSession, + ReviewSession, + ReviewStartResult, +} from '@features/review/types/review.types' + +export const toMockDomainResult = async ( + operation: () => TValue | Promise, + mapValue: (value: TValue) => TResult = (value) => value as unknown as TResult, +): Promise> => { + try { + return ok(mapValue(await operation())) + } catch (error) { + return err(toMockDomainError(error)) + } +} + +export const toMockVoidDomainResult = (operation: () => void | Promise) => + toMockDomainResult(operation, () => undefined) + +export const toReviewStartResult = ( + result: ApiReviewStartResult, +): ReviewStartResult => { + if (result.mode === 'unavailable') { + return result + } + + return toReviewSession(result) +} + +export const toReviewSession = (session: ApiReviewSession): ReviewSession => { + if (session.mode === 'practice') { + return session as ReviewSession + } + + const { currentCard, ...rest } = session + const dueSession: DueReviewSession = { + ...rest, + ...(currentCard ? { currentCard } : {}), + } as DueReviewSession + + return dueSession +} + +const toMockDomainError = (error: unknown): DomainError => { + if (!(error instanceof MockHttpError)) { + return domainError.unexpected('Mock service failed.') + } + + switch (error.status) { + case 400: + return domainError.unexpected(error.message) + case 401: + return domainError.unauthorized(error.message) + case 403: + return domainError.forbidden(error.message) + case 404: + return toNotFoundDomainError(error) + case 409: + return domainError.conflict(error.message) + case 422: + return domainError.validation(error.message, {}) + case 502: + case 503: + case 504: + return domainError.unavailable(error.message) + default: + return domainError.unexpected(error.message) + } +} + +const toNotFoundDomainError = (error: MockHttpError): DomainError => { + const match = /^(.+) (\S+) was not found$/.exec(error.message) + + return domainError.notFound(error.message, match?.[1], match?.[2]) +} diff --git a/ui/src/platform/runtime.ts b/ui/src/platform/runtime.ts index c37a3bd..be4fcde 100644 --- a/ui/src/platform/runtime.ts +++ b/ui/src/platform/runtime.ts @@ -2,16 +2,17 @@ import { getRuntimeKind } from '@shared/lib/runtime-profile' export type ServiceMode = 'auto' | 'mock' | 'tauri' | 'web' export type ResolvedServiceMode = Exclude +export type ConfiguredServiceMode = Exclude -const serviceModes = ['auto', 'mock', 'tauri', 'web'] as const +const configuredServiceModes = ['auto', 'tauri', 'web'] as const -const isValidServiceMode = (value: string | undefined): value is ServiceMode => - typeof value === 'string' && serviceModes.includes(value as ServiceMode) +const isValidConfiguredServiceMode = (value: string | undefined): value is ConfiguredServiceMode => + typeof value === 'string' && configuredServiceModes.includes(value as ConfiguredServiceMode) -export const getConfiguredServiceMode = (): ServiceMode => { +export const getConfiguredServiceMode = (): ConfiguredServiceMode => { const value = import.meta.env.VITE_SERVICE_MODE?.trim().toLowerCase() - return isValidServiceMode(value) ? value : 'auto' + return isValidConfiguredServiceMode(value) ? value : 'auto' } export const resolveServiceMode = ( diff --git a/ui/src/platform/services/content-search/mock/contentSearchService.ts b/ui/src/platform/services/content-search/mock/contentSearchService.ts index 47650b1..3a0a16f 100644 --- a/ui/src/platform/services/content-search/mock/contentSearchService.ts +++ b/ui/src/platform/services/content-search/mock/contentSearchService.ts @@ -1,9 +1,13 @@ import type { ContentSearchService } from '@features/content-search/services/contentSearchService' -import { ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { SearchResultGroup } from '@features/content-search/types/search.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult } from '@platform/mock/mockDomainResult' export const mockContentSearchService: ContentSearchService = { async search(scope, query) { - return ok(mockAppDataStore.search(scope, query)) + return toMockDomainResult( + () => mockApi.searchService.searchContent({ query, scope }), + (groups) => groups as SearchResultGroup[], + ) }, } diff --git a/ui/src/platform/services/decks/mock/deckService.ts b/ui/src/platform/services/decks/mock/deckService.ts index df42317..c7fc3a4 100644 --- a/ui/src/platform/services/decks/mock/deckService.ts +++ b/ui/src/platform/services/decks/mock/deckService.ts @@ -1,30 +1,47 @@ import type { DeckService } from '@features/decks/services/deckService' -import { domainError, err, ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { Deck, DeckDetail, DeckDraft } from '@features/decks/types/deck.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult, toMockVoidDomainResult } from '@platform/mock/mockDomainResult' +import { toSortQuery } from '@shared/services/api/adapters/sortQuery' export const mockDeckService: DeckService = { async create(draft) { - return ok(mockAppDataStore.createDeck(draft)) + return toMockDomainResult( + () => mockApi.decksService.createDeck(toDeckDraft(draft)), + toDeck, + ) }, async delete(deckId) { - mockAppDataStore.deleteDeck(deckId) - - return ok(undefined) + return toMockVoidDomainResult(() => mockApi.decksService.deleteDeck(deckId)) }, async getById(deckId) { - const deck = mockAppDataStore.getDeckById(deckId) - - return deck ? ok(deck) : err(domainError.notFound('Deck not found.', 'deck', deckId)) + return toMockDomainResult( + () => mockApi.decksService.getDeck(deckId), + toDeckDetail, + ) }, async listFolderChildren(folderId, sort) { - return ok(mockAppDataStore.listDecksInFolder(folderId, sort)) + return toMockDomainResult( + () => mockApi.decksService.listFolderDecks(folderId, toSortQuery(sort)), + (decks) => decks.map(toDeck), + ) }, async listWorkspaceRoot(workspaceId, sort) { - return ok(mockAppDataStore.listWorkspaceDecks(workspaceId, sort)) + return toMockDomainResult( + () => mockApi.decksService.listWorkspaceDecks(workspaceId, toSortQuery(sort)), + (decks) => decks.map(toDeck), + ) }, async update(deckId, draft) { - const deck = mockAppDataStore.updateDeck(deckId, draft) - - return deck ? ok(deck) : err(domainError.notFound('Deck not found.', 'deck', deckId)) + return toMockDomainResult( + () => mockApi.decksService.updateDeck(deckId, toDeckDraft(draft)), + toDeckDetail, + ) }, } + +const toDeck = (deck: unknown): Deck => deck as Deck + +const toDeckDetail = (deck: unknown): DeckDetail => deck as DeckDetail + +const toDeckDraft = (draft: DeckDraft) => draft diff --git a/ui/src/platform/services/folders/mock/folderService.ts b/ui/src/platform/services/folders/mock/folderService.ts index 0948f84..6286cd9 100644 --- a/ui/src/platform/services/folders/mock/folderService.ts +++ b/ui/src/platform/services/folders/mock/folderService.ts @@ -1,37 +1,51 @@ import type { FolderService } from '@features/folders/services/folderService' -import { domainError, err, ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { Folder, FolderDraft } from '@features/folders/types/folder.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult, toMockVoidDomainResult } from '@platform/mock/mockDomainResult' +import { toSortQuery } from '@shared/services/api/adapters/sortQuery' export const mockFolderService: FolderService = { async create(draft) { - return ok(mockAppDataStore.createFolder(draft)) + return toMockDomainResult( + () => mockApi.foldersService.createFolder(toFolderDraft(draft)), + toFolder, + ) }, async delete(folderId) { - mockAppDataStore.deleteFolder(folderId) - - return ok(undefined) + return toMockVoidDomainResult(() => mockApi.foldersService.deleteFolder(folderId)) }, async getById(folderId) { - const folder = mockAppDataStore.getFolderById(folderId) - - return folder - ? ok(folder) - : err(domainError.notFound('Folder not found.', 'folder', folderId)) + return toMockDomainResult( + () => mockApi.foldersService.getFolder(folderId), + toFolder, + ) }, async getPath(folderId) { - return ok(mockAppDataStore.getFolderPath(folderId)) + return toMockDomainResult( + () => mockApi.foldersService.getFolderPath(folderId), + ({ segments }) => segments, + ) }, async listFolderChildren(folderId, sort) { - return ok(mockAppDataStore.listFoldersInFolder(folderId, sort)) + return toMockDomainResult( + () => mockApi.foldersService.listFolderFolders(folderId, toSortQuery(sort)), + (folders) => folders.map(toFolder), + ) }, async listWorkspaceRoot(workspaceId, sort) { - return ok(mockAppDataStore.listWorkspaceFolders(workspaceId, sort)) + return toMockDomainResult( + () => mockApi.foldersService.listWorkspaceFolders(workspaceId, toSortQuery(sort)), + (folders) => folders.map(toFolder), + ) }, async update(folderId, draft) { - const folder = mockAppDataStore.updateFolder(folderId, draft) - - return folder - ? ok(folder) - : err(domainError.notFound('Folder not found.', 'folder', folderId)) + return toMockDomainResult( + () => mockApi.foldersService.updateFolder(folderId, toFolderDraft(draft)), + toFolder, + ) }, } + +const toFolder = (folder: unknown): Folder => folder as Folder + +const toFolderDraft = (draft: FolderDraft) => draft diff --git a/ui/src/platform/services/notes/mock/noteService.ts b/ui/src/platform/services/notes/mock/noteService.ts index 3ef749d..28f1169 100644 --- a/ui/src/platform/services/notes/mock/noteService.ts +++ b/ui/src/platform/services/notes/mock/noteService.ts @@ -1,27 +1,50 @@ import type { NoteService } from '@features/notes/services/noteService' -import { domainError, err, ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { + NoteDetail, + NoteDraft, + NoteListItem, + NoteRef, +} from '@features/notes/types/note.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult, toMockVoidDomainResult } from '@platform/mock/mockDomainResult' +import { toSortQuery } from '@shared/services/api/adapters/sortQuery' export const mockNoteService: NoteService = { async create(draft) { - return ok(mockAppDataStore.createNote(draft)) + return toMockDomainResult( + () => mockApi.notesService.createNote(toNoteDraft(draft)), + toNoteRef, + ) }, async delete(noteId) { - mockAppDataStore.deleteNote(noteId) - - return ok(undefined) + return toMockVoidDomainResult(() => mockApi.notesService.deleteNote(noteId)) }, async getById(deckId, noteId) { - const note = mockAppDataStore.getNoteById(deckId, noteId) + void deckId - return note ? ok(note) : err(domainError.notFound('Note not found.', 'note', noteId)) + return toMockDomainResult( + () => mockApi.notesService.getNote(noteId), + toNoteDetail, + ) }, async listByDeck(deckId, sort) { - return ok(mockAppDataStore.listNotes(deckId, sort)) + return toMockDomainResult( + () => mockApi.notesService.listNotesByDeck(deckId, toSortQuery(sort)), + (notes) => notes.map(toNoteListItem), + ) }, async update(noteId, draft) { - const note = mockAppDataStore.updateNote(noteId, draft) - - return note ? ok(note) : err(domainError.notFound('Note not found.', 'note', noteId)) + return toMockDomainResult( + () => mockApi.notesService.updateNote(noteId, toNoteDraft(draft)), + toNoteRef, + ) }, } + +const toNoteDetail = (note: unknown): NoteDetail => note as NoteDetail + +const toNoteDraft = (draft: NoteDraft) => draft + +const toNoteListItem = (note: unknown): NoteListItem => note as NoteListItem + +const toNoteRef = (note: unknown): NoteRef => note as NoteRef diff --git a/ui/src/platform/services/review/mock/reviewService.ts b/ui/src/platform/services/review/mock/reviewService.ts index cb7a53e..3d6857d 100644 --- a/ui/src/platform/services/review/mock/reviewService.ts +++ b/ui/src/platform/services/review/mock/reviewService.ts @@ -1,21 +1,28 @@ import type { ReviewService } from '@features/review/services/reviewService' -import { domainError, err, ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import { mockApi } from '@platform/mock/mockApi' +import { + toMockDomainResult, + toReviewSession, + toReviewStartResult, +} from '@platform/mock/mockDomainResult' export const mockReviewService: ReviewService = { async start(deckId) { - const result = mockAppDataStore.startReview(deckId) - - return result ? ok(result) : err(domainError.notFound('Deck not found.')) + return toMockDomainResult( + () => mockApi.reviewService.startReviewSession(deckId), + toReviewStartResult, + ) }, async get(reviewId) { - const review = mockAppDataStore.getReviewById(reviewId) - - return review ? ok(review) : err(domainError.notFound('Review not found.')) + return toMockDomainResult( + () => mockApi.reviewService.getReviewSession(reviewId), + toReviewSession, + ) }, async grade(reviewId, cardId, grade) { - const result = mockAppDataStore.grade(reviewId, cardId, grade) - - return result ? ok(result) : err(domainError.notFound('Review card not found.')) + return toMockDomainResult( + () => mockApi.reviewService.gradeReviewSessionCard(reviewId, cardId, grade), + toReviewSession, + ) }, } diff --git a/ui/src/platform/services/settings/mock/settingsService.ts b/ui/src/platform/services/settings/mock/settingsService.ts index 1dea7c5..df016f5 100644 --- a/ui/src/platform/services/settings/mock/settingsService.ts +++ b/ui/src/platform/services/settings/mock/settingsService.ts @@ -1,18 +1,27 @@ import type { SettingsService } from '@features/settings/services/settingsService' -import { ok } from '@shared/errors' -import { defaultSettings, mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { Settings } from '@features/settings/types/settings.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult } from '@platform/mock/mockDomainResult' export const mockSettingsService: SettingsService = { async getDefaults() { - return ok(defaultSettings()) + return toMockDomainResult( + () => mockApi.settingsService.getDefaultSettings(), + toSettings, + ) }, async read() { - return ok(mockAppDataStore.getSettings()) + return toMockDomainResult(() => mockApi.settingsService.getSettings(), toSettings) }, async reset() { - return ok(mockAppDataStore.resetSettings()) + return toMockDomainResult(() => mockApi.settingsService.resetSettings(), toSettings) }, async write(settings) { - return ok(mockAppDataStore.writeSettings(settings)) + return toMockDomainResult( + () => mockApi.settingsService.updateSettings(settings), + toSettings, + ) }, } + +const toSettings = (settings: unknown): Settings => settings as Settings diff --git a/ui/src/platform/services/trash/mock/trashService.ts b/ui/src/platform/services/trash/mock/trashService.ts index 6922ece..6ad8f82 100644 --- a/ui/src/platform/services/trash/mock/trashService.ts +++ b/ui/src/platform/services/trash/mock/trashService.ts @@ -1,22 +1,21 @@ import type { TrashService } from '@features/trash/services/trashService' -import { ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { TrashState } from '@features/trash/types/trash.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult, toMockVoidDomainResult } from '@platform/mock/mockDomainResult' export const mockTrashService: TrashService = { async deleteItem(itemId) { - mockAppDataStore.deleteTrashItem(itemId) - - return ok(undefined) + return toMockVoidDomainResult(() => mockApi.trashService.deleteTrashItem(itemId)) }, async empty() { - return ok(mockAppDataStore.emptyTrash()) + return toMockDomainResult(() => mockApi.trashService.emptyTrash(), toTrashState) }, async list() { - return ok(mockAppDataStore.listTrash()) + return toMockDomainResult(() => mockApi.trashService.getTrash(), toTrashState) }, async restoreItem(itemId) { - mockAppDataStore.restoreTrashItem(itemId) - - return ok(undefined) + return toMockVoidDomainResult(() => mockApi.trashService.restoreTrashItem(itemId)) }, } + +const toTrashState = (trash: unknown): TrashState => trash as TrashState diff --git a/ui/src/platform/services/workspaces/mock/workspaceService.ts b/ui/src/platform/services/workspaces/mock/workspaceService.ts index cdb89f9..b65f336 100644 --- a/ui/src/platform/services/workspaces/mock/workspaceService.ts +++ b/ui/src/platform/services/workspaces/mock/workspaceService.ts @@ -1,48 +1,55 @@ import type { WorkspaceService } from '@features/workspaces/services/workspaceService' -import { domainError, err, ok } from '@shared/errors' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import type { Workspace, WorkspaceDraft } from '@features/workspaces/types/workspace.types' +import { mockApi } from '@platform/mock/mockApi' +import { toMockDomainResult, toMockVoidDomainResult } from '@platform/mock/mockDomainResult' export const mockWorkspaceService: WorkspaceService = { async create(draft) { - return ok(mockAppDataStore.createWorkspace(draft)) + return toMockDomainResult( + () => mockApi.workspacesService.createWorkspace(toWorkspaceDraft(draft)), + toWorkspace, + ) }, async delete(workspaceId) { - return ok(mockAppDataStore.deleteWorkspace(workspaceId)) + return toMockDomainResult( + () => mockApi.workspacesService.deleteWorkspace(workspaceId), + ({ activeWorkspaceId }) => activeWorkspaceId, + ) }, async getActiveId() { - return ok(mockAppDataStore.getActiveWorkspaceId()) + return toMockDomainResult( + () => mockApi.workspacesService.getActiveWorkspace(), + ({ workspaceId }) => workspaceId, + ) }, async getById(workspaceId) { - const workspace = mockAppDataStore.getWorkspaceById(workspaceId) - - return workspace - ? ok(workspace) - : err(domainError.notFound('Workspace not found.', 'workspace', workspaceId)) + return toMockDomainResult( + () => mockApi.workspacesService.getWorkspace(workspaceId), + toWorkspace, + ) }, async list() { - const workspaces = mockAppDataStore.listWorkspaces() - - return ok({ - activeWorkspaceId: workspaces.length > 0 ? mockAppDataStore.getActiveWorkspaceId() : null, - workspaces, - }) + return toMockDomainResult( + () => mockApi.workspacesService.listWorkspaces(), + (result) => ({ + activeWorkspaceId: result.activeWorkspaceId, + workspaces: result.workspaces.map(toWorkspace), + }), + ) }, async setActiveId(workspaceId) { - const workspace = mockAppDataStore.getWorkspaceById(workspaceId) - - if (!workspace) { - return err(domainError.notFound('Workspace not found.', 'workspace', workspaceId)) - } - - mockAppDataStore.setActiveWorkspaceId(workspaceId) - - return ok(undefined) + return toMockVoidDomainResult(() => + mockApi.workspacesService.setActiveWorkspace(workspaceId), + ) }, async update(workspaceId, draft) { - const workspace = mockAppDataStore.updateWorkspace(workspaceId, draft) - - return workspace - ? ok(workspace) - : err(domainError.notFound('Workspace not found.', 'workspace', workspaceId)) + return toMockDomainResult( + () => mockApi.workspacesService.updateWorkspace(workspaceId, toWorkspaceDraft(draft)), + toWorkspace, + ) }, } + +const toWorkspace = (workspace: unknown): Workspace => workspace as Workspace + +const toWorkspaceDraft = (draft: WorkspaceDraft) => draft diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts index 7bb0903..c2f9bab 100644 --- a/ui/src/test/setup.ts +++ b/ui/src/test/setup.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, vi } from 'vitest' import { queryClient } from '@core/query/query-client' import { resetThemeStoreForTests } from '@core/theme' import { appI18n, defaultLocale } from '@core/i18n' -import { mockAppDataStore } from '@platform/mock/mockAppDataStore' +import { mockStateRepository } from '@platform/mock/mockApi' vi.mock('lucide-react/dynamic', async () => { const React = await import('react') @@ -69,7 +69,7 @@ beforeEach(async () => { window.sessionStorage.clear() resetThemeStoreForTests() await appI18n.changeLanguage(defaultLocale) - mockAppDataStore.reset() + await mockStateRepository.reset() queryClient.clear() }) diff --git a/ui/src/test/storybook/page-services.ts b/ui/src/test/storybook/page-services.ts index 152e2e5..3c8391a 100644 --- a/ui/src/test/storybook/page-services.ts +++ b/ui/src/test/storybook/page-services.ts @@ -1,3 +1,5 @@ +import { DEFAULT_SETTINGS } from '@local/mock-server/browser' + import type { ContentSearchService } from '@features/content-search/services/contentSearchService' import type { BootstrapService } from '@features/bootstrap' import type { SearchResultGroup } from '@features/content-search/types/search.types' @@ -40,7 +42,6 @@ import { defaultSortPreference, type SortPreference, } from '@shared/types/sort.types' -import { defaultSettings } from '@platform/mock/mockAppDataStore' import { baseBasicNoteDetail, @@ -109,7 +110,7 @@ export const createTrashItem = (item: Partial = {}): TrashItem => ({ }) export const createSettings = (settings: Partial = {}): Settings => ({ - ...defaultSettings(), + ...(structuredClone(DEFAULT_SETTINGS) as Settings), ...settings, }) diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index 67d3818..6d709bc 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -2,7 +2,7 @@ interface ImportMetaEnv { readonly VITE_CLEAR_API_BASE_URL?: string - readonly VITE_SERVICE_MODE?: 'auto' | 'web' | 'tauri' | 'mock' + readonly VITE_SERVICE_MODE?: 'auto' | 'web' | 'tauri' } interface ImportMeta {