diff --git a/frontend/taskdeck-web/src/api/todayApi.ts b/frontend/taskdeck-web/src/api/todayApi.ts new file mode 100644 index 000000000..0d7cd242b --- /dev/null +++ b/frontend/taskdeck-web/src/api/todayApi.ts @@ -0,0 +1,78 @@ +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)}`) + 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 + }, +} diff --git a/frontend/taskdeck-web/src/composables/useTodayDossier.ts b/frontend/taskdeck-web/src/composables/useTodayDossier.ts index 5aa96ca57..861935537 100644 --- a/frontend/taskdeck-web/src/composables/useTodayDossier.ts +++ b/frontend/taskdeck-web/src/composables/useTodayDossier.ts @@ -1,20 +1,9 @@ -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 { logError } from '../utils/errorReporting' 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 +42,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 +77,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 +105,47 @@ function formatLocalDossierDateParts(date: Date): { yyyy: string; mm: string; dd } } -/** Stub data the surface falls back to when the backend is silent. */ +function formatUtcTime(iso: string | null): string { + if (!iso) return '--:--' + const d = new Date(iso) + return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().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 = 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) % 24).toString().padStart(2, '0')}:00 UTC · ${peakEvents} events` + : 'no peak' + + return { + weights, + peakHourIndex, + firstAction: `${firstTime} UTC · first action`, + peakAction, + lastAction: `${lastTime} UTC · 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 +244,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,10 +277,15 @@ function buildStubDossier(now: Date, summary: TodaySummary | null): DossierData } export interface UseTodayDossierOptions { - /** Override "now" for tests so dossier serial is deterministic. */ 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 @@ -294,15 +318,134 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { }) const sealed = ref(false) + const liveCadence = ref(null) + const liveStreak = ref(null) + const liveLineForTomorrow = ref('') + + 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, + } + }) + + let autosaveTimer: ReturnType | null = null + let tomorrowNoteMutationGeneration = 0 + let pendingAutosave: { + text: string + dateStr: string + resolve: () => void + reject: (error: unknown) => void + } | null = null + const AUTOSAVE_DEBOUNCE_MS = 800 + + 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, dateStr = formatLocalDossierDate(now.value)): Promise { + tomorrowNoteMutationGeneration += 1 + liveLineForTomorrow.value = text + if (pendingAutosave) { + pendingAutosave.reject(new Error('Superseded by newer tomorrow note autosave')) + } + if (autosaveTimer) clearTimeout(autosaveTimer) + 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 + void flushAutosave() + } + }) + + let fetchGeneration = 0 - const dossier = computed(() => buildStubDossier(now.value, workspace.todaySummary)) + async function fetchLiveData() { + const gen = ++fetchGeneration + const dateStr = formatLocalDossierDate(now.value) + const tomorrowNoteMutationGenerationAtFetch = tomorrowNoteMutationGeneration - function sealDay(): { sealed: boolean; alreadySealed: boolean } { + const results = await Promise.allSettled([ + todayApi.getCadence(dateStr), + todayApi.getStreak(90), + todayApi.getSealStatus(dateStr), + todayApi.getTomorrowNote(dateStr), + ]) + + if (gen !== fetchGeneration) return + + 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 (tomorrowNoteMutationGenerationAtFetch === tomorrowNoteMutationGeneration) { + if (results[3].status === 'fulfilled') { + liveLineForTomorrow.value = results[3].value?.text ?? '' + } else { + liveLineForTomorrow.value = '' + } + } + } + + watch(now, () => { + liveCadence.value = null + liveStreak.value = null + liveLineForTomorrow.value = '' + tomorrowNoteMutationGeneration += 1 + sealed.value = false + fetchLiveData() + }, { immediate: true }) + + let sealingInProgress = false + + async function sealDay(): Promise { if (sealed.value) { return { sealed: true, alreadySealed: true } } - sealed.value = true - return { sealed: true, alreadySealed: false } + if (sealingInProgress) { + return { sealed: false, alreadySealed: false, inProgress: true } + } + + sealingInProgress = true + try { + const dateStr = formatLocalDossierDate(now.value) + const response = await todayApi.sealDay(dateStr) + sealed.value = true + return { sealed: true, alreadySealed: response.wasAlreadySealed } + } catch { + return { sealed: false, alreadySealed: false } + } finally { + sealingInProgress = false + } } function resetSealForTesting() { @@ -313,6 +456,7 @@ export function useTodayDossier(options: UseTodayDossierOptions = {}) { dossier, sealed, sealDay, + saveLineForTomorrow, resetSealForTesting, } } 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..a6c25adf5 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/todayApi.spec.ts @@ -0,0 +1,130 @@ +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', + ) + 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) + }) + }) +}) 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..e2fac4625 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/useTodayDossier.spec.ts @@ -0,0 +1,350 @@ +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.useRealTimers() + 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) + 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 () => { + 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 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')) + 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).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('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('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')) + 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('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')) + 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).toBe('') + }) + + 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) + }) + }) +}) 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..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, } @@ -11,6 +18,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, })) @@ -19,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' }) @@ -60,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') @@ -68,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') }) @@ -98,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/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) }) }) 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..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 @@ -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,145 @@ 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('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, { + 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('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, { + 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 e71cb8911..b222fc723 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() @@ -34,9 +34,17 @@ const lineForTomorrowStorageKey = computed(() => { const dayPart = formatLocalDossierDate(dossier.value.date) return `td.paper.line-for-tomorrow:${userPart}:${dayPart}` }) +const lineForTomorrowSaveDate = computed(() => formatLocalDossierDate(dossier.value.date)) -function onSeal() { - const result = sealDay() +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 + } if (result.alreadySealed) { toast.info('Day is already sealed.') return @@ -136,6 +144,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..ef41bd03f 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,34 @@ 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 -function flush() { - if (typeof window === 'undefined') return +async function flush() { + const generation = ++flushGeneration + if (props.useStoredDraft) { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(props.storageKey, text.value) + } catch { + if (generation === flushGeneration) status.value = 'error' + return + } + } try { - window.localStorage.setItem(props.storageKey, text.value) + 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' - return + if (generation === flushGeneration) status.value = 'error' } - status.value = 'saved' - emit('save', text.value) } watch(text, () => { @@ -61,13 +85,27 @@ watch(text, () => { return } status.value = 'saving' + localEditPending = true + 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, () => { + 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 @@ -75,6 +113,7 @@ watch( const nextText = readStored() suppressNextSave = nextText !== text.value text.value = nextText + localEditPending = false status.value = 'saved' }, ) @@ -84,7 +123,7 @@ onBeforeUnmount(() => { clearTimeout(timer) // Flush pending writes before the component goes away — otherwise // a quick remount loses the in-flight edit. - flush() + void flush() } })