From 66b16a63746b86b331ac97b3edc2218395b79cd8 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:03:25 +0100 Subject: [PATCH 01/13] Add todayApi HTTP client module for Today dossier endpoints Exposes getCadence, getStreak, sealDay, getSealStatus, getTomorrowNote, and saveTomorrowNote matching the backend TodayController contract. Refs #1004 --- frontend/taskdeck-web/src/api/todayApi.ts | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 frontend/taskdeck-web/src/api/todayApi.ts diff --git a/frontend/taskdeck-web/src/api/todayApi.ts b/frontend/taskdeck-web/src/api/todayApi.ts new file mode 100644 index 000000000..fcd57644e --- /dev/null +++ b/frontend/taskdeck-web/src/api/todayApi.ts @@ -0,0 +1,80 @@ +import http from './http' + +export interface CadenceBucket { + hour: number + eventCount: number +} + +export interface CadenceApiResponse { + buckets: CadenceBucket[] + firstActionAt: string | null + peakHour: number | null + lastActionAt: string | null +} + +export interface StreakDay { + date: string + isSealed: boolean + intensityBucket: number +} + +export interface StreakApiResponse { + days: StreakDay[] + currentStreakLength: number + longestStreakLength: number + dayCount: number +} + +export interface SealApiResponse { + sealedAt: string + wasAlreadySealed: boolean +} + +export interface SealStatusApiResponse { + date: string + isSealed: boolean + sealedAt: string | null +} + +export interface TomorrowNoteApiResponse { + id: string + date: string + text: string + updatedAt: string + createdAt: string +} + +export const todayApi = { + async getCadence(date: string): Promise { + const { data } = await http.get(`/today/cadence?date=${encodeURIComponent(date)}`) + return data + }, + + async getStreak(days = 90): Promise { + const { data } = await http.get(`/today/streak?days=${days}`) + return data + }, + + async sealDay(date: string): Promise { + const { data } = await http.post('/today/seal', { date }) + return data + }, + + async getSealStatus(date: string): Promise { + const { data } = await http.get(`/today/seal?date=${encodeURIComponent(date)}`) + return data + }, + + async getTomorrowNote(date: string): Promise { + const response = await http.get(`/today/tomorrow-note?date=${encodeURIComponent(date)}`, { + validateStatus: (status: number) => status === 200 || status === 204, + }) + if (response.status === 204) return null + return response.data + }, + + async saveTomorrowNote(date: string, text: string): Promise { + const { data } = await http.put('/today/tomorrow-note', { date, text }) + return data + }, +} From 9c33999a748cf9553a901e5a2aa6c4d9cd28a0ab Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:03:35 +0100 Subject: [PATCH 02/13] Wire useTodayDossier composable to real backend APIs Replace synchronous stub-only dossier with live API calls for cadence, streak, seal status, and tomorrow-note. Stubs remain as graceful fallback when any endpoint fails. sealDay() now calls POST /today/seal; saveLineForTomorrow() debounces to PUT /today/tomorrow-note. Refs #1004 --- .../src/composables/useTodayDossier.ts | 150 ++++++++++++++---- 1 file changed, 120 insertions(+), 30 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index 5aa96ca57..4a6c68fca 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -1,20 +1,8 @@ -import { computed, onScopeDispose, ref, type Ref } from 'vue' +import { computed, onScopeDispose, ref, watch, type Ref } from 'vue' import { useWorkspaceStore } from '../store/workspaceStore' +import { todayApi, type CadenceApiResponse, type StreakApiResponse } from '../api/todayApi' import type { TodaySummary } from '../types/workspace' -/** - * useTodayDossier — composable that returns the data shape expected by - * `PaperTodayView` and its 9 sub-sections. Wherever the live workspace - * `todaySummary` already provides a value (overdue cards, captures, etc.) - * we surface it; everything else is mocked behind clearly-marked stubs so - * the surface ships now and follow-up backend issues (#1015–#1018) can wire - * real data later. - * - * Stubs are deterministic given the same `now` so tests can exercise the - * shape without flake. Components must continue to work with both stub - * and real data — never branch on whether a section is mocked. - */ - export type DossierLedgerWho = 'you' | 'haiku' | 'system' export type DossierLedgerTone = 'ember' | 'applied' | 'active' | 'passive' | 'mute' @@ -53,9 +41,7 @@ export interface DossierCarryOverCard { export interface DossierStatCard { id: 'cards-moved' | 'proposals-applied' | 'captures-triaged' | 'longest-focus' | 'overdue' - /** Numeric (or formatted) value used for the big italic-serif number. */ value: number | string - /** Whether `value` is a raw number that should run through Intl.NumberFormat. */ numeric: boolean label: string sub: string @@ -90,21 +76,15 @@ export interface DossierData { boards: DossierBoardLine[] carryOver: DossierCarryOverCard[] streak: DossierStreak - /** Initial value for the "line for tomorrow" text-area. */ lineForTomorrow: string } const DOSSIER_NUMBER_RE = /D-\d{4}-\d{2}-\d{2}-\d{3}/ -/** - * Format the dossier serial as `D-YYYY-MM-DD-NNN`. `seq` defaults to 001; - * a real backend should hand in the per-day sequence number. - */ export function formatDossierSerial(date: Date, seq = 1): string { const { yyyy, mm, dd } = formatLocalDossierDateParts(date) const nnn = seq.toString().padStart(3, '0') const serial = `D-${yyyy}-${mm}-${dd}-${nnn}` - // Light invariant — useful in dev, harmless in prod. if (!DOSSIER_NUMBER_RE.test(serial)) { throw new Error(`Invalid dossier serial: ${serial}`) } @@ -124,7 +104,47 @@ function formatLocalDossierDateParts(date: Date): { yyyy: string; mm: string; dd } } -/** Stub data the surface falls back to when the backend is silent. */ +function formatTime(iso: string | null): string { + if (!iso) return '--:--' + const d = new Date(iso) + return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}` +} + +function mapCadenceResponse(response: CadenceApiResponse): DossierCadence { + const weights = Array.from({ length: 24 }, (_, i) => { + const bucket = response.buckets.find((b) => b.hour === i) + return bucket?.eventCount ?? 0 + }) + + const peakHourIndex = response.peakHour ?? 0 + + const firstTime = formatTime(response.firstActionAt) + const lastTime = formatTime(response.lastActionAt) + + const peakEvents = weights[peakHourIndex] ?? 0 + const peakAction = response.peakHour != null + ? `${peakHourIndex.toString().padStart(2, '0')}:00 — ${(peakHourIndex + 1).toString().padStart(2, '0')}:00 · ${peakEvents} events` + : 'no peak' + + return { + weights, + peakHourIndex, + firstAction: `${firstTime} · first action`, + peakAction, + lastAction: `${lastTime} · last action`, + } +} + +function mapStreakResponse(response: StreakApiResponse): DossierStreak { + const cells = response.days.map((d) => d.intensityBucket) + return { + cells, + todayIndex: cells.length - 1, + totalDays: response.currentStreakLength, + longestThisYear: response.longestStreakLength, + } +} + function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData { const overdueCount = summary?.summary.overdueCards ?? 2 const capturesTriaged = 11 @@ -223,11 +243,9 @@ function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData { serial: 'C-061', title: 'Reply: design system intro', age: '1d overdue', reason: 'snoozed yesterday' }, ] - // 90 deterministic streak cells — last cell is "today". const cells = Array.from({ length: 90 }, (_, i) => { if (i === 89) return 4 if (i === 73) return 0 - // Deterministic pseudo-random based on index return ((i * 31) % 5) }) @@ -258,7 +276,6 @@ function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData } export interface UseTodayDossierOptions { - /** Override "now" for tests so dossier serial is deterministic. */ now?: Ref | Date } @@ -294,15 +311,87 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { }) const sealed = ref(false) + const liveCadence = ref(null) + const liveStreak = ref(null) + const liveLineForTomorrow = ref(null) + + const stubDossier = computed(() => buildStubDossier(now.value, workspace.todaySummary)) + + const dossier = computed(() => { + const base = stubDossier.value + return { + ...base, + cadence: liveCadence.value ?? base.cadence, + streak: liveStreak.value ?? base.streak, + lineForTomorrow: liveLineForTomorrow.value ?? base.lineForTomorrow, + } + }) - const dossier = computed(() => buildStubDossier(now.value, workspace.todaySummary)) + let autosaveTimer: ReturnType | null = null + const AUTOSAVE_DEBOUNCE_MS = 800 - function sealDay(): { sealed: boolean; alreadySealed: boolean } { + function saveLineForTomorrow(text: string) { + liveLineForTomorrow.value = text + if (autosaveTimer) clearTimeout(autosaveTimer) + autosaveTimer = setTimeout(() => { + const dateStr = formatLocalDossierDate(now.value) + todayApi.saveTomorrowNote(dateStr, text).catch(() => {}) + }, AUTOSAVE_DEBOUNCE_MS) + } + + onScopeDispose(() => { + if (autosaveTimer) { + clearTimeout(autosaveTimer) + autosaveTimer = null + } + }) + + async function fetchLiveData() { + const dateStr = formatLocalDossierDate(now.value) + + const results = await Promise.allSettled([ + todayApi.getCadence(dateStr), + todayApi.getStreak(90), + todayApi.getSealStatus(dateStr), + todayApi.getTomorrowNote(dateStr), + ]) + + if (results[0].status === 'fulfilled') { + liveCadence.value = mapCadenceResponse(results[0].value) + } + if (results[1].status === 'fulfilled') { + liveStreak.value = mapStreakResponse(results[1].value) + } + if (results[2].status === 'fulfilled') { + sealed.value = results[2].value.isSealed + } + if (results[3].status === 'fulfilled' && results[3].value !== null) { + liveLineForTomorrow.value = results[3].value.text + } + } + + watch(now, () => { + liveCadence.value = null + liveStreak.value = null + liveLineForTomorrow.value = null + sealed.value = false + fetchLiveData() + }, { immediate: true }) + + async function sealDay(): Promise<{ sealed: boolean; alreadySealed: boolean }> { if (sealed.value) { return { sealed: true, alreadySealed: true } } - sealed.value = true - return { sealed: true, alreadySealed: false } + + try { + const dateStr = formatLocalDossierDate(now.value) + const response = await todayApi.sealDay(dateStr) + sealed.value = true + return { sealed: true, alreadySealed: response.wasAlreadySealed } + } catch { + sealed.value = true + return { sealed: true, alreadySealed: false } + } } function resetSealForTesting() { @@ -313,6 +402,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { dossier, sealed, sealDay, + saveLineForTomorrow, resetSealForTesting, } } From 1dedeb894bf3ea879524e6a38a916643a9c9b976 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:03:43 +0100 Subject: [PATCH 03/13] Make onSeal async to match async sealDay return type Refs #1004 --- frontend/taskdeck-web/src/views/paper/PaperTodayView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue index e71cb8911..cc613bb35 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue @@ -35,8 +35,8 @@ const lineForTomorrowStorageKey = computed(() => { return `td.paper.line-for-tomorrow:${userPart}:${dayPart}` }) -function onSeal() { - const result = sealDay() +async function onSeal() { + const result = await sealDay() if (result.alreadySealed) { toast.info('Day is already sealed.') return From 9d6b9cce33bee257cb568628dd4cc046866388b9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:03:52 +0100 Subject: [PATCH 04/13] Update existing today tests to mock todayApi module Add todayApi mock to PaperTodayView and TodayCover tests so they work with the composable's new async API calls. Update sealDay idempotency test to await the async return. Refs #1004 --- .../views/paper/today/PaperTodayView.spec.ts | 11 +++++ .../views/paper/today/TodayCover.spec.ts | 42 ++++++++++++------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts index 4f4d518d3..cae2953a9 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts @@ -11,6 +11,17 @@ const mockSessionStore = { userId: 'user-1' as string | null, } +vi.mock('../../../../api/todayApi', () => ({ + todayApi: { + getCadence: vi.fn().mockRejectedValue(new Error('stub')), + getStreak: vi.fn().mockRejectedValue(new Error('stub')), + getSealStatus: vi.fn().mockRejectedValue(new Error('stub')), + getTomorrowNote: vi.fn().mockRejectedValue(new Error('stub')), + sealDay: vi.fn().mockResolvedValue({ sealedAt: new Date().toISOString(), wasAlreadySealed: false }), + saveTomorrowNote: vi.fn().mockResolvedValue({ id: '1', date: '2026-01-01', text: '', updatedAt: '', createdAt: '' }), + }, +})) + vi.mock('../../../../store/workspaceStore', () => ({ useWorkspaceStore: () => mockWorkspaceStore, })) diff --git a/frontend/taskdeck-web/src/tests/views/paper/today/TodayCover.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/today/TodayCover.spec.ts index 8de20f973..3e8a8e032 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/today/TodayCover.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/today/TodayCover.spec.ts @@ -1,9 +1,28 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' import TodayCover from '../../../../views/paper/today/TodayCover.vue' import { formatDossierSerial } from '../../../../composables/useTodayDossier' +vi.mock('../../../../api/todayApi', () => ({ + todayApi: { + getCadence: vi.fn().mockRejectedValue(new Error('stub')), + getStreak: vi.fn().mockRejectedValue(new Error('stub')), + getSealStatus: vi.fn().mockRejectedValue(new Error('stub')), + getTomorrowNote: vi.fn().mockRejectedValue(new Error('stub')), + sealDay: vi.fn().mockResolvedValue({ sealedAt: new Date().toISOString(), wasAlreadySealed: false }), + }, +})) + +vi.mock('../../../../store/workspaceStore', () => ({ + useWorkspaceStore: () => ({ todaySummary: null }), +})) + describe('TodayCover', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + it('renders the dossier serial in D-YYYY-MM-DD-NNN format', () => { const date = new Date('2026-04-25T10:00:00Z') const serial = formatDossierSerial(date) @@ -61,19 +80,12 @@ describe('TodayCover', () => { expect(wrapper.emitted('seal')).toHaveLength(1) }) - it('seal click while already sealed is a parent-level no-op (idempotent contract)', () => { - // Verifies the contract by exercising the composable's sealDay() — - // which is what PaperTodayView calls. Second invocation reports - // alreadySealed: true so the toast can become "already sealed". - vi.resetModules() - return import('../../../../composables/useTodayDossier').then(async ({ useTodayDossier }) => { - const { setActivePinia, createPinia } = await import('pinia') - setActivePinia(createPinia()) - const { sealDay } = useTodayDossier() - const first = sealDay() - const second = sealDay() - expect(first.alreadySealed).toBe(false) - expect(second.alreadySealed).toBe(true) - }) + it('seal click while already sealed is a parent-level no-op (idempotent contract)', async () => { + const { useTodayDossier } = await import('../../../../composables/useTodayDossier') + const { sealDay } = useTodayDossier() + const first = await sealDay() + const second = await sealDay() + expect(first.alreadySealed).toBe(false) + expect(second.alreadySealed).toBe(true) }) }) From 8c4a002c15fc034bf9e445dd221d78a943144ed6 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:03:56 +0100 Subject: [PATCH 05/13] Add todayApi module tests with mocked HTTP Covers getCadence, getStreak, sealDay, getSealStatus, getTomorrowNote (200 and 204), and saveTomorrowNote. Refs #1004 --- .../src/tests/api/todayApi.spec.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/api/todayApi.spec.ts diff --git a/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts b/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts new file mode 100644 index 000000000..3d8579edf --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { todayApi } from '../../api/todayApi' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +describe('todayApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getCadence', () => { + it('sends GET to /today/cadence with date query param', async () => { + const response = { + buckets: [{ hour: 9, eventCount: 3 }], + firstActionAt: '2026-01-15T09:00:00Z', + peakHour: 9, + lastActionAt: '2026-01-15T17:00:00Z', + } + vi.mocked(http.get).mockResolvedValue({ data: response }) + + const result = await todayApi.getCadence('2026-01-15') + + expect(http.get).toHaveBeenCalledWith('/today/cadence?date=2026-01-15') + expect(result).toEqual(response) + }) + }) + + describe('getStreak', () => { + it('sends GET to /today/streak with default days=90', async () => { + const response = { + days: [{ date: '2026-01-15', isSealed: true, intensityBucket: 3 }], + currentStreakLength: 5, + longestStreakLength: 12, + dayCount: 90, + } + vi.mocked(http.get).mockResolvedValue({ data: response }) + + const result = await todayApi.getStreak() + + expect(http.get).toHaveBeenCalledWith('/today/streak?days=90') + expect(result).toEqual(response) + }) + + it('sends GET to /today/streak with custom days', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { days: [], currentStreakLength: 0, longestStreakLength: 0, dayCount: 0 } }) + + await todayApi.getStreak(30) + + expect(http.get).toHaveBeenCalledWith('/today/streak?days=30') + }) + }) + + describe('sealDay', () => { + it('sends POST to /today/seal with date body', async () => { + const response = { sealedAt: '2026-01-15T18:00:00Z', wasAlreadySealed: false } + vi.mocked(http.post).mockResolvedValue({ data: response }) + + const result = await todayApi.sealDay('2026-01-15') + + expect(http.post).toHaveBeenCalledWith('/today/seal', { date: '2026-01-15' }) + expect(result).toEqual(response) + }) + }) + + describe('getSealStatus', () => { + it('sends GET to /today/seal with date query param', async () => { + const response = { date: '2026-01-15', isSealed: true, sealedAt: '2026-01-15T18:00:00Z' } + vi.mocked(http.get).mockResolvedValue({ data: response }) + + const result = await todayApi.getSealStatus('2026-01-15') + + expect(http.get).toHaveBeenCalledWith('/today/seal?date=2026-01-15') + expect(result).toEqual(response) + }) + }) + + describe('getTomorrowNote', () => { + it('returns note data on 200', async () => { + const response = { + id: 'note-1', + date: '2026-01-15', + text: 'Pick up AA contrast audit', + updatedAt: '2026-01-15T18:00:00Z', + createdAt: '2026-01-15T17:00:00Z', + } + vi.mocked(http.get).mockResolvedValue({ status: 200, data: response }) + + const result = await todayApi.getTomorrowNote('2026-01-15') + + expect(http.get).toHaveBeenCalledWith( + '/today/tomorrow-note?date=2026-01-15', + { validateStatus: expect.any(Function) }, + ) + expect(result).toEqual(response) + }) + + it('returns null on 204 (no note)', async () => { + vi.mocked(http.get).mockResolvedValue({ status: 204, data: '' }) + + const result = await todayApi.getTomorrowNote('2026-01-15') + + expect(result).toBeNull() + }) + }) + + describe('saveTomorrowNote', () => { + it('sends PUT to /today/tomorrow-note with date and text', async () => { + const response = { + id: 'note-1', + date: '2026-01-15', + text: 'Do the thing', + updatedAt: '2026-01-15T18:05:00Z', + createdAt: '2026-01-15T17:00:00Z', + } + vi.mocked(http.put).mockResolvedValue({ data: response }) + + const result = await todayApi.saveTomorrowNote('2026-01-15', 'Do the thing') + + expect(http.put).toHaveBeenCalledWith('/today/tomorrow-note', { date: '2026-01-15', text: 'Do the thing' }) + expect(result).toEqual(response) + }) + }) +}) From e496a8499ea6c5c8dcdbca3afe166127814e801c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:04:02 +0100 Subject: [PATCH 06/13] Add useTodayDossier composable tests for live and fallback paths Tests live cadence/streak/seal/note mapping, stub fallback on API failure, sealDay idempotency, and data re-fetch on date change. Refs #1004 --- .../tests/composables/useTodayDossier.spec.ts | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts diff --git a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts new file mode 100644 index 000000000..0cafe6e46 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { ref } from 'vue' +import { todayApi } from '../../api/todayApi' +import type { CadenceApiResponse, StreakApiResponse, SealStatusApiResponse, TomorrowNoteApiResponse } from '../../api/todayApi' + +vi.mock('../../api/todayApi', () => ({ + todayApi: { + getCadence: vi.fn(), + getStreak: vi.fn(), + getSealStatus: vi.fn(), + getTomorrowNote: vi.fn(), + sealDay: vi.fn(), + saveTomorrowNote: vi.fn(), + }, +})) + +vi.mock('../../store/workspaceStore', () => ({ + useWorkspaceStore: () => ({ todaySummary: null }), +})) + +const cadenceResponse: CadenceApiResponse = { + buckets: [ + { hour: 9, eventCount: 3 }, + { hour: 10, eventCount: 1 }, + { hour: 14, eventCount: 5 }, + ], + firstActionAt: '2026-01-15T09:12:00Z', + peakHour: 14, + lastActionAt: '2026-01-15T17:30:00Z', +} + +const streakResponse: StreakApiResponse = { + days: Array.from({ length: 90 }, (_, i) => ({ + date: `2025-10-${(i + 1).toString().padStart(2, '0')}`, + isSealed: i < 85, + intensityBucket: i % 5, + })), + currentStreakLength: 7, + longestStreakLength: 15, + dayCount: 90, +} + +const sealStatusResponse: SealStatusApiResponse = { + date: '2026-01-15', + isSealed: false, + sealedAt: null, +} + +const tomorrowNoteResponse: TomorrowNoteApiResponse = { + id: 'note-abc', + date: '2026-01-15', + text: 'Review the AA contrast audit', + updatedAt: '2026-01-15T18:00:00Z', + createdAt: '2026-01-15T17:00:00Z', +} + +describe('useTodayDossier', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + async function importAndCreate(nowDate?: Date) { + const { useTodayDossier } = await import('../../composables/useTodayDossier') + const now = nowDate ?? new Date('2026-01-15T12:00:00Z') + return useTodayDossier({ now }) + } + + it('fetches live cadence and maps to DossierCadence', async () => { + vi.mocked(todayApi.getCadence).mockResolvedValue(cadenceResponse) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('skip')) + + const { dossier } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getCadence).toHaveBeenCalled() + }) + + expect(dossier.value.cadence.weights).toHaveLength(24) + expect(dossier.value.cadence.weights[9]).toBe(3) + expect(dossier.value.cadence.weights[14]).toBe(5) + expect(dossier.value.cadence.weights[0]).toBe(0) + expect(dossier.value.cadence.peakHourIndex).toBe(14) + }) + + it('fetches live streak and maps to DossierStreak', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockResolvedValue(streakResponse) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('skip')) + + const { dossier } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getStreak).toHaveBeenCalled() + }) + + expect(dossier.value.streak.cells).toHaveLength(90) + expect(dossier.value.streak.todayIndex).toBe(89) + expect(dossier.value.streak.totalDays).toBe(7) + expect(dossier.value.streak.longestThisYear).toBe(15) + }) + + it('fetches seal status and reflects in sealed ref', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockResolvedValue({ ...sealStatusResponse, isSealed: true, sealedAt: '2026-01-15T18:00:00Z' }) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('skip')) + + const { sealed } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getSealStatus).toHaveBeenCalled() + }) + + expect(sealed.value).toBe(true) + }) + + it('fetches tomorrow note and maps to lineForTomorrow', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockResolvedValue(tomorrowNoteResponse) + + const { dossier } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getTomorrowNote).toHaveBeenCalled() + }) + + expect(dossier.value.lineForTomorrow).toBe('Review the AA contrast audit') + }) + + it('falls back to stub data when all API calls fail', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('network')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('network')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('network')) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('network')) + + const { dossier } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getCadence).toHaveBeenCalled() + }) + + expect(dossier.value.serial).toMatch(/^D-\d{4}-\d{2}-\d{2}-\d{3}$/) + expect(dossier.value.cadence.peakHourIndex).toBe(13) + expect(dossier.value.streak.cells).toHaveLength(90) + expect(dossier.value.lineForTomorrow).toContain('AA contrast audit') + }) + + it('sealDay calls POST /today/seal and returns sealed status', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockResolvedValue(sealStatusResponse) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.sealDay).mockResolvedValue({ sealedAt: '2026-01-15T18:00:00Z', wasAlreadySealed: false }) + + const { sealDay, sealed } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getSealStatus).toHaveBeenCalled() + }) + + expect(sealed.value).toBe(false) + + const result = await sealDay() + expect(result.sealed).toBe(true) + expect(result.alreadySealed).toBe(false) + expect(sealed.value).toBe(true) + expect(todayApi.sealDay).toHaveBeenCalled() + }) + + it('sealDay returns alreadySealed when called twice', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockResolvedValue(sealStatusResponse) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.sealDay).mockResolvedValue({ sealedAt: '2026-01-15T18:00:00Z', wasAlreadySealed: false }) + + const { sealDay } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getSealStatus).toHaveBeenCalled() + }) + + await sealDay() + const second = await sealDay() + expect(second.alreadySealed).toBe(true) + expect(todayApi.sealDay).toHaveBeenCalledTimes(1) + }) + + it('returns null lineForTomorrow when API returns 204', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockResolvedValue(null) + + const { dossier } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getTomorrowNote).toHaveBeenCalled() + }) + + expect(dossier.value.lineForTomorrow).toContain('AA contrast audit') + }) + + it('re-fetches data when now changes', async () => { + vi.mocked(todayApi.getCadence).mockResolvedValue(cadenceResponse) + vi.mocked(todayApi.getStreak).mockResolvedValue(streakResponse) + vi.mocked(todayApi.getSealStatus).mockResolvedValue(sealStatusResponse) + vi.mocked(todayApi.getTomorrowNote).mockResolvedValue(null) + + const { useTodayDossier } = await import('../../composables/useTodayDossier') + const nowRef = ref(new Date('2026-01-15T12:00:00Z')) + useTodayDossier({ now: nowRef }) + + await vi.waitFor(() => { + expect(todayApi.getCadence).toHaveBeenCalledTimes(1) + }) + + nowRef.value = new Date('2026-01-16T12:00:00Z') + + await vi.waitFor(() => { + expect(todayApi.getCadence).toHaveBeenCalledTimes(2) + }) + }) +}) From 0bd4ba0bb7110298980088ee89c7aa533fa52128 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:10:16 +0100 Subject: [PATCH 07/13] fix(composable): HIGH race condition, sealDay error, autosave flush - Add fetch generation counter to discard stale fetchLiveData results when now changes rapidly - Return sealed: false on sealDay API failure so user can retry - Flush pending autosave on scope dispose instead of silently dropping --- .../src/composables/useTodayDossier.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index 4a6c68fca..a38073f1d 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -328,14 +328,23 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { }) let autosaveTimer: ReturnType | null = null + let pendingAutosaveText: string | null = null const AUTOSAVE_DEBOUNCE_MS = 800 + function flushAutosave() { + if (pendingAutosaveText !== null) { + const dateStr = formatLocalDossierDate(now.value) + todayApi.saveTomorrowNote(dateStr, pendingAutosaveText).catch(() => {}) + pendingAutosaveText = null + } + } + function saveLineForTomorrow(text: string) { liveLineForTomorrow.value = text + pendingAutosaveText = text if (autosaveTimer) clearTimeout(autosaveTimer) autosaveTimer = setTimeout(() => { - const dateStr = formatLocalDossierDate(now.value) - todayApi.saveTomorrowNote(dateStr, text).catch(() => {}) + flushAutosave() }, AUTOSAVE_DEBOUNCE_MS) } @@ -343,10 +352,14 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { if (autosaveTimer) { clearTimeout(autosaveTimer) autosaveTimer = null + flushAutosave() } }) + let fetchGeneration = 0 + async function fetchLiveData() { + const gen = ++fetchGeneration const dateStr = formatLocalDossierDate(now.value) const results = await Promise.allSettled([ @@ -356,6 +369,8 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { todayApi.getTomorrowNote(dateStr), ]) + if (gen !== fetchGeneration) return + if (results[0].status === 'fulfilled') { liveCadence.value = mapCadenceResponse(results[0].value) } @@ -389,8 +404,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { sealed.value = true return { sealed: true, alreadySealed: response.wasAlreadySealed } } catch { - sealed.value = true - return { sealed: true, alreadySealed: false } + return { sealed: false, alreadySealed: false } } } From df6917febb49cdfaac278d80aa52cc23691ae0f9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:10:22 +0100 Subject: [PATCH 08/13] fix(api): LOW widen validateStatus to accept all 2xx codes getTomorrowNote was only accepting 200 and 204, which would silently reject other valid 2xx responses without hitting the error interceptor. --- frontend/taskdeck-web/src/api/todayApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/api/todayApi.ts b/frontend/taskdeck-web/src/api/todayApi.ts index fcd57644e..aa93db0be 100644 --- a/frontend/taskdeck-web/src/api/todayApi.ts +++ b/frontend/taskdeck-web/src/api/todayApi.ts @@ -67,7 +67,7 @@ export const todayApi = { async getTomorrowNote(date: string): Promise { const response = await http.get(`/today/tomorrow-note?date=${encodeURIComponent(date)}`, { - validateStatus: (status: number) => status === 200 || status === 204, + validateStatus: (status: number) => (status >= 200 && status < 300) || status === 204, }) if (response.status === 204) return null return response.data From 062e9a493dbd4b12e8419cb424926561b2f68584 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:20:42 +0100 Subject: [PATCH 09/13] Address review findings: wire saveLineForTomorrow, fix 204 empty state, add sealDay guard - CRITICAL: Wire saveLineForTomorrow @save handler in PaperTodayView so tomorrow-note autosave actually persists to backend - CRITICAL: 204 (no note) now returns empty string instead of falling back to stub text - IMPORTANT: Add sealDay in-flight guard to prevent duplicate POST on double-click - IMPORTANT: Log autosave errors via logError instead of swallowing - IMPORTANT: Remove redundant validateStatus (204 is already in 2xx) --- frontend/taskdeck-web/src/api/todayApi.ts | 4 +--- .../src/composables/useTodayDossier.ts | 17 ++++++++++++++--- .../taskdeck-web/src/tests/api/todayApi.spec.ts | 1 - .../tests/composables/useTodayDossier.spec.ts | 4 ++-- .../src/views/paper/PaperTodayView.vue | 3 ++- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/frontend/taskdeck-web/src/api/todayApi.ts b/frontend/taskdeck-web/src/api/todayApi.ts index aa93db0be..0d7cd242b 100644 --- a/frontend/taskdeck-web/src/api/todayApi.ts +++ b/frontend/taskdeck-web/src/api/todayApi.ts @@ -66,9 +66,7 @@ export const todayApi = { }, async getTomorrowNote(date: string): Promise { - const response = await http.get(`/today/tomorrow-note?date=${encodeURIComponent(date)}`, { - validateStatus: (status: number) => (status >= 200 && status < 300) || status === 204, - }) + const response = await http.get(`/today/tomorrow-note?date=${encodeURIComponent(date)}`) if (response.status === 204) return null return response.data }, diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index a38073f1d..48786a365 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -1,6 +1,7 @@ import { computed, onScopeDispose, ref, watch, type Ref } from 'vue' import { useWorkspaceStore } from '../store/workspaceStore' import { todayApi, type CadenceApiResponse, type StreakApiResponse } from '../api/todayApi' +import { logError } from '../utils/errorReporting' import type { TodaySummary } from '../types/workspace' export type DossierLedgerWho = 'you' | 'haiku' | 'system' @@ -334,7 +335,9 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { function flushAutosave() { if (pendingAutosaveText !== null) { const dateStr = formatLocalDossierDate(now.value) - todayApi.saveTomorrowNote(dateStr, pendingAutosaveText).catch(() => {}) + todayApi.saveTomorrowNote(dateStr, pendingAutosaveText).catch((err) => { + logError('Tomorrow note autosave failed', { message: (err as Error)?.message }) + }) pendingAutosaveText = null } } @@ -380,8 +383,8 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { if (results[2].status === 'fulfilled') { sealed.value = results[2].value.isSealed } - if (results[3].status === 'fulfilled' && results[3].value !== null) { - liveLineForTomorrow.value = results[3].value.text + if (results[3].status === 'fulfilled') { + liveLineForTomorrow.value = results[3].value?.text ?? '' } } @@ -393,11 +396,17 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { fetchLiveData() }, { immediate: true }) + let sealingInProgress = false + async function sealDay(): Promise<{ sealed: boolean; alreadySealed: boolean }> { if (sealed.value) { return { sealed: true, alreadySealed: true } } + if (sealingInProgress) { + return { sealed: false, alreadySealed: false } + } + sealingInProgress = true try { const dateStr = formatLocalDossierDate(now.value) const response = await todayApi.sealDay(dateStr) @@ -405,6 +414,8 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { return { sealed: true, alreadySealed: response.wasAlreadySealed } } catch { return { sealed: false, alreadySealed: false } + } finally { + sealingInProgress = false } } diff --git a/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts b/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts index 3d8579edf..a6c25adf5 100644 --- a/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts @@ -97,7 +97,6 @@ describe('todayApi', () => { expect(http.get).toHaveBeenCalledWith( '/today/tomorrow-note?date=2026-01-15', - { validateStatus: expect.any(Function) }, ) expect(result).toEqual(response) }) diff --git a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts index 0cafe6e46..abadd1f23 100644 --- a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts @@ -190,7 +190,7 @@ describe('useTodayDossier', () => { expect(todayApi.sealDay).toHaveBeenCalledTimes(1) }) - it('returns null lineForTomorrow when API returns 204', async () => { + it('returns empty lineForTomorrow when API returns 204 (no note)', async () => { vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) @@ -201,7 +201,7 @@ describe('useTodayDossier', () => { expect(todayApi.getTomorrowNote).toHaveBeenCalled() }) - expect(dossier.value.lineForTomorrow).toContain('AA contrast audit') + expect(dossier.value.lineForTomorrow).toBe('') }) it('re-fetches data when now changes', async () => { diff --git a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue index cc613bb35..d030c36b5 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue @@ -24,7 +24,7 @@ import TodayLineForTomorrow from './today/TodayLineForTomorrow.vue' * `TodayView.vue` shell delegates to this component when `paperThemeStore * .isOn`. */ -const { dossier, sealed, sealDay } = useTodayDossier() +const { dossier, sealed, sealDay, saveLineForTomorrow } = useTodayDossier() const session = useSessionStore() const toast = useToastStore() @@ -136,6 +136,7 @@ function onPinCarryOver(serials: string[]) { From 76679328c43644d39d796869ce50485b22e01e87 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 11:46:14 +0100 Subject: [PATCH 10/13] Gate seal success toast on actual seal result When sealDay returns sealed:false (API failure or in-flight guard), show an error toast instead of the misleading success message. --- frontend/taskdeck-web/src/views/paper/PaperTodayView.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue index d030c36b5..c3dc38f4e 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue @@ -37,6 +37,10 @@ const lineForTomorrowStorageKey = computed(() => { async function onSeal() { const result = await sealDay() + if (!result.sealed) { + toast.error('Failed to seal the day. Please try again.') + return + } if (result.alreadySealed) { toast.info('Day is already sealed.') return From caffa362e31ea2f0103f9812ddcef6c058ca0339 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 10 May 2026 18:40:59 +0100 Subject: [PATCH 11/13] Harden Today dossier save states --- .../src/composables/useTodayDossier.ts | 65 ++++++++----- .../tests/composables/useTodayDossier.spec.ts | 47 +++++++++- .../paper/today/TodayLineForTomorrow.spec.ts | 91 ++++++++++++++++++- .../src/views/paper/PaperTodayView.vue | 5 +- .../paper/today/TodayLineForTomorrow.vue | 39 ++++++-- 5 files changed, 208 insertions(+), 39 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index 48786a365..555115c16 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -105,10 +105,10 @@ function formatLocalDossierDateParts(date: Date): { yyyy: string; mm: string; dd } } -function formatTime(iso: string | null): string { +function formatUtcTime(iso: string | null): string { if (!iso) return '--:--' const d = new Date(iso) - return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}` + return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}` } function mapCadenceResponse(response: CadenceApiResponse): DossierCadence { @@ -119,20 +119,20 @@ function mapCadenceResponse(response: CadenceApiResponse): DossierCadence { const peakHourIndex = response.peakHour ?? 0 - const firstTime = formatTime(response.firstActionAt) - const lastTime = formatTime(response.lastActionAt) + const firstTime = formatUtcTime(response.firstActionAt) + const lastTime = formatUtcTime(response.lastActionAt) const peakEvents = weights[peakHourIndex] ?? 0 const peakAction = response.peakHour != null - ? `${peakHourIndex.toString().padStart(2, '0')}:00 — ${(peakHourIndex + 1).toString().padStart(2, '0')}:00 · ${peakEvents} events` + ? `${peakHourIndex.toString().padStart(2, '0')}:00-${((peakHourIndex + 1) % 24).toString().padStart(2, '0')}:00 UTC · ${peakEvents} events` : 'no peak' return { weights, peakHourIndex, - firstAction: `${firstTime} · first action`, + firstAction: `${firstTime} UTC · first action`, peakAction, - lastAction: `${lastTime} · last action`, + lastAction: `${lastTime} UTC · last action`, } } @@ -314,7 +314,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { const sealed = ref(false) const liveCadence = ref(null) const liveStreak = ref(null) - const liveLineForTomorrow = ref(null) + const liveLineForTomorrow = ref('') const stubDossier = computed(() => buildStubDossier(now.value, workspace.todaySummary)) @@ -324,38 +324,53 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { ...base, cadence: liveCadence.value ?? base.cadence, streak: liveStreak.value ?? base.streak, - lineForTomorrow: liveLineForTomorrow.value ?? base.lineForTomorrow, + lineForTomorrow: liveLineForTomorrow.value, } }) let autosaveTimer: ReturnType | null = null - let pendingAutosaveText: string | null = null + let pendingAutosave: { + text: string + dateStr: string + resolve: () => void + reject: (error: unknown) => void + } | null = null const AUTOSAVE_DEBOUNCE_MS = 800 - function flushAutosave() { - if (pendingAutosaveText !== null) { - const dateStr = formatLocalDossierDate(now.value) - todayApi.saveTomorrowNote(dateStr, pendingAutosaveText).catch((err) => { - logError('Tomorrow note autosave failed', { message: (err as Error)?.message }) - }) - pendingAutosaveText = null + async function flushAutosave() { + const pending = pendingAutosave + if (!pending) return + + pendingAutosave = null + try { + await todayApi.saveTomorrowNote(pending.dateStr, pending.text) + pending.resolve() + } catch (err) { + logError('Tomorrow note autosave failed', { message: (err as Error)?.message }) + pending.reject(err) } } - function saveLineForTomorrow(text: string) { + function saveLineForTomorrow(text: string, dateStr = formatLocalDossierDate(now.value)): Promise { liveLineForTomorrow.value = text - pendingAutosaveText = text + if (pendingAutosave) { + pendingAutosave.resolve() + } if (autosaveTimer) clearTimeout(autosaveTimer) - autosaveTimer = setTimeout(() => { - flushAutosave() - }, AUTOSAVE_DEBOUNCE_MS) + return new Promise((resolve, reject) => { + pendingAutosave = { text, dateStr, resolve, reject } + autosaveTimer = setTimeout(() => { + autosaveTimer = null + void flushAutosave() + }, AUTOSAVE_DEBOUNCE_MS) + }) } onScopeDispose(() => { if (autosaveTimer) { clearTimeout(autosaveTimer) autosaveTimer = null - flushAutosave() + void flushAutosave() } }) @@ -385,13 +400,15 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { } if (results[3].status === 'fulfilled') { liveLineForTomorrow.value = results[3].value?.text ?? '' + } else { + liveLineForTomorrow.value = '' } } watch(now, () => { liveCadence.value = null liveStreak.value = null - liveLineForTomorrow.value = null + liveLineForTomorrow.value = '' sealed.value = false fetchLiveData() }, { immediate: true }) diff --git a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts index abadd1f23..ad7207ed0 100644 --- a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts @@ -62,6 +62,7 @@ describe('useTodayDossier', () => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() }) @@ -87,6 +88,10 @@ describe('useTodayDossier', () => { expect(dossier.value.cadence.weights[14]).toBe(5) expect(dossier.value.cadence.weights[0]).toBe(0) expect(dossier.value.cadence.peakHourIndex).toBe(14) + expect(dossier.value.cadence.firstAction).toContain('09:12 UTC') + expect(dossier.value.cadence.peakAction).toContain('14:00-15:00 UTC') + expect(dossier.value.cadence.peakAction).toContain('5 events') + expect(dossier.value.cadence.lastAction).toContain('17:30 UTC') }) it('fetches live streak and maps to DossierStreak', async () => { @@ -134,7 +139,7 @@ describe('useTodayDossier', () => { expect(dossier.value.lineForTomorrow).toBe('Review the AA contrast audit') }) - it('falls back to stub data when all API calls fail', async () => { + it('falls back to stub metrics but not fabricated tomorrow-note text when all API calls fail', async () => { vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('network')) vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('network')) vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('network')) @@ -148,7 +153,45 @@ describe('useTodayDossier', () => { expect(dossier.value.serial).toMatch(/^D-\d{4}-\d{2}-\d{2}-\d{3}$/) expect(dossier.value.cadence.peakHourIndex).toBe(13) expect(dossier.value.streak.cells).toHaveLength(90) - expect(dossier.value.lineForTomorrow).toContain('AA contrast audit') + expect(dossier.value.lineForTomorrow).toBe('') + }) + + it('autosaves tomorrow note to the date captured at edit time', async () => { + vi.useFakeTimers() + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockResolvedValue(null) + vi.mocked(todayApi.saveTomorrowNote).mockResolvedValue(tomorrowNoteResponse) + + const { useTodayDossier } = await import('../../composables/useTodayDossier') + const nowRef = ref(new Date('2026-01-15T23:59:59')) + const { saveLineForTomorrow } = useTodayDossier({ now: nowRef }) + + const save = saveLineForTomorrow('Finish handoff', '2026-01-15') + nowRef.value = new Date('2026-01-16T00:00:01') + + await vi.advanceTimersByTimeAsync(850) + await save + + expect(todayApi.saveTomorrowNote).toHaveBeenCalledWith('2026-01-15', 'Finish handoff') + }) + + it('rejects autosave promise when the backend save fails', async () => { + vi.useFakeTimers() + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockResolvedValue(null) + vi.mocked(todayApi.saveTomorrowNote).mockRejectedValue(new Error('offline')) + + const { saveLineForTomorrow } = await importAndCreate() + const save = saveLineForTomorrow('Draft') + const assertion = expect(save).rejects.toThrow('offline') + + await vi.advanceTimersByTimeAsync(850) + + await assertion }) it('sealDay calls POST /today/seal and returns sealed status', async () => { diff --git a/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts index 2b38955ad..b9fb1b470 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts @@ -26,7 +26,7 @@ describe('TodayLineForTomorrow', () => { expect(localStorage.getItem(KEY)).toBe(null) // Advance debounce - vi.advanceTimersByTime(150) + await vi.advanceTimersByTimeAsync(150) await wrapper.vm.$nextTick() expect(localStorage.getItem(KEY)).toBe('AA contrast first') @@ -51,7 +51,7 @@ describe('TodayLineForTomorrow', () => { const input = wrapper.find('[data-testid="line-for-tomorrow-input"]') await input.setValue('AA contrast first') - vi.advanceTimersByTime(150) + await vi.advanceTimersByTimeAsync(150) await wrapper.vm.$nextTick() expect(wrapper.find('[data-testid="line-for-tomorrow-status"]').text()).toContain('Save unavailable') @@ -65,7 +65,7 @@ describe('TodayLineForTomorrow', () => { props: { storageKey: KEY, debounceMs: 50, initial: '' }, }) await a.find('[data-testid="line-for-tomorrow-input"]').setValue('persisted') - vi.advanceTimersByTime(75) + await vi.advanceTimersByTimeAsync(75) await a.vm.$nextTick() expect(localStorage.getItem(KEY)).toBe('persisted') a.unmount() @@ -93,4 +93,89 @@ describe('TodayLineForTomorrow', () => { expect(input.element.value).toBe('user b line') }) + + it('uses backend initial value over stale localStorage when stored drafts are disabled', async () => { + localStorage.setItem(KEY, 'stale local text') + + const wrapper = mount(TodayLineForTomorrow, { + props: { + storageKey: KEY, + debounceMs: 50, + initial: 'backend note', + useStoredDraft: false, + }, + }) + + const input = wrapper.find('[data-testid="line-for-tomorrow-input"]') + expect(input.element.value).toBe('backend note') + + await wrapper.setProps({ initial: 'new backend note' }) + await wrapper.vm.$nextTick() + + expect(input.element.value).toBe('new backend note') + }) + + it('keeps saving status until backend save succeeds', async () => { + const save = vi.fn().mockResolvedValue(undefined) + const wrapper = mount(TodayLineForTomorrow, { + props: { + storageKey: KEY, + debounceMs: 100, + initial: '', + useStoredDraft: false, + save, + saveDate: '2026-01-15', + }, + }) + + await wrapper.find('[data-testid="line-for-tomorrow-input"]').setValue('backend text') + expect(wrapper.find('[data-testid="line-for-tomorrow-status"]').text()).toContain('Saving') + + await vi.advanceTimersByTimeAsync(150) + await wrapper.vm.$nextTick() + + expect(save).toHaveBeenCalledWith('backend text', '2026-01-15') + expect(wrapper.find('[data-testid="line-for-tomorrow-status"]').text()).toContain('Saved') + expect(wrapper.emitted('save')?.[0]).toEqual(['backend text']) + }) + + it('shows unavailable state when backend save fails', async () => { + const save = vi.fn().mockRejectedValue(new Error('offline')) + const wrapper = mount(TodayLineForTomorrow, { + props: { + storageKey: KEY, + debounceMs: 100, + initial: '', + useStoredDraft: false, + save, + }, + }) + + await wrapper.find('[data-testid="line-for-tomorrow-input"]').setValue('backend text') + await vi.advanceTimersByTimeAsync(150) + await wrapper.vm.$nextTick() + + expect(wrapper.find('[data-testid="line-for-tomorrow-status"]').text()).toContain('Save unavailable') + expect(wrapper.emitted('save')).toBeUndefined() + }) + + it('passes the edit-time save date even if prop changes before debounce flushes', async () => { + const save = vi.fn().mockResolvedValue(undefined) + const wrapper = mount(TodayLineForTomorrow, { + props: { + storageKey: KEY, + debounceMs: 100, + initial: '', + useStoredDraft: false, + save, + saveDate: '2026-01-15', + }, + }) + + await wrapper.find('[data-testid="line-for-tomorrow-input"]').setValue('before midnight') + await wrapper.setProps({ saveDate: '2026-01-16' }) + await vi.advanceTimersByTimeAsync(150) + + expect(save).toHaveBeenCalledWith('before midnight', '2026-01-15') + }) }) diff --git a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue index c3dc38f4e..88ecfa6af 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue @@ -34,6 +34,7 @@ const lineForTomorrowStorageKey = computed(() => { const dayPart = formatLocalDossierDate(dossier.value.date) return `td.paper.line-for-tomorrow:${userPart}:${dayPart}` }) +const lineForTomorrowSaveDate = computed(() => formatLocalDossierDate(dossier.value.date)) async function onSeal() { const result = await sealDay() @@ -140,7 +141,9 @@ function onPinCarryOver(serials: string[]) { diff --git a/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue b/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue index d089d3a3c..506bb975d 100644 --- a/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue +++ b/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue @@ -14,11 +14,17 @@ const props = withDefaults( initial?: string storageKey?: string debounceMs?: number + useStoredDraft?: boolean + saveDate?: string + save?: (value: string, saveDate?: string) => void | Promise }>(), { initial: '', storageKey: 'td.paper.line-for-tomorrow', debounceMs: 500, + useStoredDraft: true, + saveDate: undefined, + save: undefined, }, ) @@ -27,6 +33,7 @@ const emit = defineEmits<{ }>() function readStored(): string { + if (!props.useStoredDraft) return props.initial if (typeof window === 'undefined') return props.initial try { const raw = window.localStorage.getItem(props.storageKey) @@ -42,17 +49,27 @@ const status = ref<'idle' | 'saving' | 'saved' | 'error'>('saved') let timer: ReturnType | null = null let suppressNextSave = false +let pendingSaveDate: string | undefined -function flush() { - if (typeof window === 'undefined') return +async function flush() { + if (props.useStoredDraft) { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(props.storageKey, text.value) + } catch { + status.value = 'error' + return + } + } try { - window.localStorage.setItem(props.storageKey, text.value) + if (props.save) { + await props.save(text.value, pendingSaveDate) + } + status.value = 'saved' + emit('save', text.value) } catch { status.value = 'error' - return } - status.value = 'saved' - emit('save', text.value) } watch(text, () => { @@ -61,12 +78,16 @@ watch(text, () => { return } status.value = 'saving' + pendingSaveDate = props.saveDate if (timer) clearTimeout(timer) - timer = setTimeout(flush, props.debounceMs) + timer = setTimeout(() => { + timer = null + void flush() + }, props.debounceMs) }) watch( - () => [props.storageKey, props.initial] as const, + () => [props.storageKey, props.initial, props.useStoredDraft] as const, () => { if (timer) { clearTimeout(timer) @@ -84,7 +105,7 @@ onBeforeUnmount(() => { clearTimeout(timer) // Flush pending writes before the component goes away — otherwise // a quick remount loses the in-flight edit. - flush() + void flush() } }) From 8a76f72b5203795a508f5b91d487993291c4af76 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 10 May 2026 18:55:43 +0100 Subject: [PATCH 12/13] Harden Today note autosave states --- .../src/composables/useTodayDossier.ts | 12 +++- .../tests/composables/useTodayDossier.spec.ts | 47 ++++++++++++++++ .../views/paper/today/PaperTodayView.spec.ts | 42 +++++++++++++- .../paper/today/TodayLineForTomorrow.spec.ts | 56 +++++++++++++++++++ .../src/views/paper/PaperTodayView.vue | 3 + .../paper/today/TodayLineForTomorrow.vue | 22 +++++++- 6 files changed, 174 insertions(+), 8 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index 555115c16..edd12f918 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -280,6 +280,12 @@ export interface UseTodayDossierOptions { now?: Ref | Date } +export interface SealDayResult { + sealed: boolean + alreadySealed: boolean + inProgress?: boolean +} + export function useTodayDossier(options: UseTodayDossierOptions = {}) { const workspace = useWorkspaceStore() const fixedNow = options.now instanceof Date ? options.now : null @@ -354,7 +360,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { function saveLineForTomorrow(text: string, dateStr = formatLocalDossierDate(now.value)): Promise { liveLineForTomorrow.value = text if (pendingAutosave) { - pendingAutosave.resolve() + pendingAutosave.reject(new Error('Superseded by newer tomorrow note autosave')) } if (autosaveTimer) clearTimeout(autosaveTimer) return new Promise((resolve, reject) => { @@ -415,12 +421,12 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { let sealingInProgress = false - async function sealDay(): Promise<{ sealed: boolean; alreadySealed: boolean }> { + async function sealDay(): Promise { if (sealed.value) { return { sealed: true, alreadySealed: true } } if (sealingInProgress) { - return { sealed: false, alreadySealed: false } + return { sealed: false, alreadySealed: false, inProgress: true } } sealingInProgress = true diff --git a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts index ad7207ed0..4e1da4733 100644 --- a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts @@ -194,6 +194,27 @@ describe('useTodayDossier', () => { await assertion }) + it('rejects superseded autosaves without writing the older text', async () => { + vi.useFakeTimers() + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getTomorrowNote).mockResolvedValue(null) + vi.mocked(todayApi.saveTomorrowNote).mockResolvedValue(tomorrowNoteResponse) + + const { saveLineForTomorrow } = await importAndCreate() + const first = saveLineForTomorrow('older draft', '2026-01-15') + const firstAssertion = expect(first).rejects.toThrow('Superseded') + const second = saveLineForTomorrow('latest draft', '2026-01-15') + + await firstAssertion + await vi.advanceTimersByTimeAsync(850) + await second + + expect(todayApi.saveTomorrowNote).toHaveBeenCalledTimes(1) + expect(todayApi.saveTomorrowNote).toHaveBeenCalledWith('2026-01-15', 'latest draft') + }) + it('sealDay calls POST /today/seal and returns sealed status', async () => { vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) @@ -233,6 +254,32 @@ describe('useTodayDossier', () => { expect(todayApi.sealDay).toHaveBeenCalledTimes(1) }) + it('marks duplicate seal calls as in progress without a failure state', async () => { + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockResolvedValue(sealStatusResponse) + vi.mocked(todayApi.getTomorrowNote).mockRejectedValue(new Error('skip')) + let resolveSeal!: (value: { sealedAt: string; wasAlreadySealed: boolean }) => void + vi.mocked(todayApi.sealDay).mockImplementationOnce( + () => new Promise((resolve) => { + resolveSeal = resolve + }), + ) + + const { sealDay } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getSealStatus).toHaveBeenCalled() + }) + + const first = sealDay() + const second = await sealDay() + resolveSeal({ sealedAt: '2026-01-15T18:00:00Z', wasAlreadySealed: false }) + + await expect(first).resolves.toEqual({ sealed: true, alreadySealed: false }) + expect(second).toEqual({ sealed: false, alreadySealed: false, inProgress: true }) + expect(todayApi.sealDay).toHaveBeenCalledTimes(1) + }) + it('returns empty lineForTomorrow when API returns 204 (no note)', async () => { vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) diff --git a/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts index cae2953a9..3cb362fc6 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/today/PaperTodayView.spec.ts @@ -1,9 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' +import { todayApi } from '../../../../api/todayApi' import { formatLocalDossierDate } from '../../../../composables/useTodayDossier' import PaperTodayView from '../../../../views/paper/PaperTodayView.vue' +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), +})) + const mockWorkspaceStore = { todaySummary: null, } @@ -30,9 +37,14 @@ vi.mock('../../../../store/sessionStore', () => ({ useSessionStore: () => mockSessionStore, })) +vi.mock('../../../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + describe('PaperTodayView', () => { beforeEach(() => { setActivePinia(createPinia()) + vi.clearAllMocks() localStorage.clear() mockSessionStore.userId = 'user-1' }) @@ -71,7 +83,7 @@ describe('PaperTodayView', () => { expect(wrapper.text()).toContain(serial) }) - it('scopes line-for-tomorrow storage by user and dossier date', () => { + it('ignores stale local line-for-tomorrow storage in live-backed Paper view', () => { const today = formatLocalDossierDate(new Date()) localStorage.setItem(`td.paper.line-for-tomorrow:user-1:${today}`, 'user-one note') localStorage.setItem(`td.paper.line-for-tomorrow:user-2:${today}`, 'user-two note') @@ -79,7 +91,7 @@ describe('PaperTodayView', () => { const wrapper = mount(PaperTodayView) const input = wrapper.find('[data-testid="line-for-tomorrow-input"]') - expect(input.element.value).toBe('user-one note') + expect(input.element.value).toBe('') expect(input.element.value).not.toBe('user-two note') }) @@ -109,4 +121,28 @@ describe('PaperTodayView', () => { expect(wrapper.find('[data-testid="dossier-serial"]').text()).toContain('2026-04-26') }) + + it('suppresses duplicate seal failure toast while the first seal is in progress', async () => { + let resolveSeal!: (value: { sealedAt: string; wasAlreadySealed: boolean }) => void + vi.mocked(todayApi.sealDay).mockImplementationOnce( + () => new Promise((resolve) => { + resolveSeal = resolve + }), + ) + const wrapper = mount(PaperTodayView) + const sealButton = wrapper.find('[data-action="seal"]') + + await sealButton.trigger('click') + await sealButton.trigger('click') + await flushPromises() + + expect(toastMocks.error).not.toHaveBeenCalled() + + resolveSeal({ sealedAt: new Date().toISOString(), wasAlreadySealed: false }) + await flushPromises() + + expect(toastMocks.success).toHaveBeenCalledTimes(1) + expect(toastMocks.error).not.toHaveBeenCalled() + expect(todayApi.sealDay).toHaveBeenCalledTimes(1) + }) }) diff --git a/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts index b9fb1b470..4b61b28c2 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/today/TodayLineForTomorrow.spec.ts @@ -115,6 +115,30 @@ describe('TodayLineForTomorrow', () => { expect(input.element.value).toBe('new backend note') }) + it('preserves typed text when async backend initial value arrives', async () => { + const save = vi.fn().mockResolvedValue(undefined) + const wrapper = mount(TodayLineForTomorrow, { + props: { + storageKey: KEY, + debounceMs: 100, + initial: '', + useStoredDraft: false, + save, + }, + }) + const input = wrapper.find('[data-testid="line-for-tomorrow-input"]') + + await input.setValue('typed before fetch') + await wrapper.setProps({ initial: 'late backend value' }) + await wrapper.vm.$nextTick() + + expect(input.element.value).toBe('typed before fetch') + + await vi.advanceTimersByTimeAsync(150) + + expect(save).toHaveBeenCalledWith('typed before fetch', undefined) + }) + it('keeps saving status until backend save succeeds', async () => { const save = vi.fn().mockResolvedValue(undefined) const wrapper = mount(TodayLineForTomorrow, { @@ -139,6 +163,38 @@ describe('TodayLineForTomorrow', () => { expect(wrapper.emitted('save')?.[0]).toEqual(['backend text']) }) + it('does not mark a superseded save failure as the latest status', async () => { + let rejectFirst!: (error: unknown) => void + const save = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + rejectFirst = reject + })) + .mockImplementationOnce(() => { + rejectFirst(new Error('superseded')) + return Promise.resolve() + }) + const wrapper = mount(TodayLineForTomorrow, { + props: { + storageKey: KEY, + debounceMs: 100, + initial: '', + useStoredDraft: false, + save, + }, + }) + const input = wrapper.find('[data-testid="line-for-tomorrow-input"]') + + await input.setValue('first') + await vi.advanceTimersByTimeAsync(150) + await input.setValue('second') + await vi.advanceTimersByTimeAsync(150) + await wrapper.vm.$nextTick() + + expect(save).toHaveBeenCalledTimes(2) + expect(wrapper.find('[data-testid="line-for-tomorrow-status"]').text()).toContain('Saved') + expect(wrapper.emitted('save')).toEqual([['second']]) + }) + it('shows unavailable state when backend save fails', async () => { const save = vi.fn().mockRejectedValue(new Error('offline')) const wrapper = mount(TodayLineForTomorrow, { diff --git a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue index 88ecfa6af..b222fc723 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperTodayView.vue @@ -38,6 +38,9 @@ const lineForTomorrowSaveDate = computed(() => formatLocalDossierDate(dossier.va async function onSeal() { const result = await sealDay() + if (result.inProgress) { + return + } if (!result.sealed) { toast.error('Failed to seal the day. Please try again.') return diff --git a/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue b/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue index 506bb975d..ef41bd03f 100644 --- a/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue +++ b/frontend/taskdeck-web/src/views/paper/today/TodayLineForTomorrow.vue @@ -50,14 +50,19 @@ const status = ref<'idle' | 'saving' | 'saved' | 'error'>('saved') let timer: ReturnType | null = null let suppressNextSave = false let pendingSaveDate: string | undefined +let localEditPending = false +let flushGeneration = 0 +let lastStorageKey = props.storageKey +let lastUseStoredDraft = props.useStoredDraft async function flush() { + const generation = ++flushGeneration if (props.useStoredDraft) { if (typeof window === 'undefined') return try { window.localStorage.setItem(props.storageKey, text.value) } catch { - status.value = 'error' + if (generation === flushGeneration) status.value = 'error' return } } @@ -65,10 +70,12 @@ async function flush() { if (props.save) { await props.save(text.value, pendingSaveDate) } + if (generation !== flushGeneration) return + localEditPending = false status.value = 'saved' emit('save', text.value) } catch { - status.value = 'error' + if (generation === flushGeneration) status.value = 'error' } } @@ -78,6 +85,7 @@ watch(text, () => { return } status.value = 'saving' + localEditPending = true pendingSaveDate = props.saveDate if (timer) clearTimeout(timer) timer = setTimeout(() => { @@ -89,6 +97,15 @@ watch(text, () => { watch( () => [props.storageKey, props.initial, props.useStoredDraft] as const, () => { + const storageScopeChanged = + props.storageKey !== lastStorageKey || props.useStoredDraft !== lastUseStoredDraft + lastStorageKey = props.storageKey + lastUseStoredDraft = props.useStoredDraft + + if (!props.useStoredDraft && !storageScopeChanged && localEditPending) { + return + } + if (timer) { clearTimeout(timer) timer = null @@ -96,6 +113,7 @@ watch( const nextText = readStored() suppressNextSave = nextText !== text.value text.value = nextText + localEditPending = false status.value = 'saved' }, ) From c84d7155d9fed425b8204679c2578d024b96eabd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 10 May 2026 19:35:31 +0100 Subject: [PATCH 13/13] Guard Today note hydration race --- .../src/composables/useTodayDossier.ts | 14 +++++--- .../tests/composables/useTodayDossier.spec.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index edd12f918..861935537 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -335,6 +335,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { }) let autosaveTimer: ReturnType | null = null + let tomorrowNoteMutationGeneration = 0 let pendingAutosave: { text: string dateStr: string @@ -358,6 +359,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { } function saveLineForTomorrow(text: string, dateStr = formatLocalDossierDate(now.value)): Promise { + tomorrowNoteMutationGeneration += 1 liveLineForTomorrow.value = text if (pendingAutosave) { pendingAutosave.reject(new Error('Superseded by newer tomorrow note autosave')) @@ -385,6 +387,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { async function fetchLiveData() { const gen = ++fetchGeneration const dateStr = formatLocalDossierDate(now.value) + const tomorrowNoteMutationGenerationAtFetch = tomorrowNoteMutationGeneration const results = await Promise.allSettled([ todayApi.getCadence(dateStr), @@ -404,10 +407,12 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { if (results[2].status === 'fulfilled') { sealed.value = results[2].value.isSealed } - if (results[3].status === 'fulfilled') { - liveLineForTomorrow.value = results[3].value?.text ?? '' - } else { - liveLineForTomorrow.value = '' + if (tomorrowNoteMutationGenerationAtFetch === tomorrowNoteMutationGeneration) { + if (results[3].status === 'fulfilled') { + liveLineForTomorrow.value = results[3].value?.text ?? '' + } else { + liveLineForTomorrow.value = '' + } } } @@ -415,6 +420,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { liveCadence.value = null liveStreak.value = null liveLineForTomorrow.value = '' + tomorrowNoteMutationGeneration += 1 sealed.value = false fetchLiveData() }, { immediate: true }) diff --git a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts index 4e1da4733..e2fac4625 100644 --- a/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts @@ -215,6 +215,39 @@ describe('useTodayDossier', () => { expect(todayApi.saveTomorrowNote).toHaveBeenCalledWith('2026-01-15', 'latest draft') }) + it('does not let slow tomorrow-note hydration overwrite a newer local edit', async () => { + vi.useFakeTimers() + vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.getSealStatus).mockRejectedValue(new Error('skip')) + vi.mocked(todayApi.saveTomorrowNote).mockResolvedValue(tomorrowNoteResponse) + + let resolveHydration!: (value: TomorrowNoteApiResponse) => void + vi.mocked(todayApi.getTomorrowNote).mockImplementationOnce( + () => new Promise((resolve) => { + resolveHydration = resolve + }), + ) + + const { dossier, saveLineForTomorrow } = await importAndCreate() + await vi.waitFor(() => { + expect(todayApi.getTomorrowNote).toHaveBeenCalled() + }) + + const save = saveLineForTomorrow('Fresh local edit', '2026-01-15') + resolveHydration({ ...tomorrowNoteResponse, text: 'Older server note' }) + + await vi.waitFor(() => { + expect(dossier.value.lineForTomorrow).toBe('Fresh local edit') + }) + + await vi.advanceTimersByTimeAsync(850) + await save + + expect(todayApi.saveTomorrowNote).toHaveBeenCalledWith('2026-01-15', 'Fresh local edit') + expect(dossier.value.lineForTomorrow).toBe('Fresh local edit') + }) + it('sealDay calls POST /today/seal and returns sealed status', async () => { vi.mocked(todayApi.getCadence).mockRejectedValue(new Error('skip')) vi.mocked(todayApi.getStreak).mockRejectedValue(new Error('skip'))