diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 686d91361..55d802c5c 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -2,6 +2,8 @@ Last Updated: 2026-09-10 +Finish delivery of #2895 and its bounded #2896 reminder-save ownership follow-up. The latter replaces a component-wide boolean with a request identity so an older account's completion cannot discard a newer account's unsaved hours. Its reproduced interleavings, full frontend suite, typecheck/build and lint pass. Keep the final #2808 completion record tied to the merged delivery receipts. + The #2893/#2894 recovery follow-up is implemented in [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895): preserve reminder drafts, report concurrent question writes accurately and align model payloads with the evidence preview. Keep the existing privacy/revision/usage boundaries, prove the reproduced cases and publish one reviewed follow-up. Local full suites, focused final payload checks, browser recovery and bounded independent review pass. Merged #2892 as 93eab3443 after required run 34453924959 passed at reviewed head 42965edde. The reminder-hours slice under #2808 delivers: optional named-zone weekly windows, explicit save/recovery, existing shared budgets and server-side eligibility. Local full backend/frontend, combined privacy/recovery and cross-browser proof pass. The final active-account write condition prevents erased private hours from being restored by an in-flight request. Old opted-in users retain unrestricted behavior; reminders remain default off. diff --git a/docs/STATUS.md b/docs/STATUS.md index 2d0e3abd6..c5fbf54a0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,8 @@ Last Updated: 2026-09-10 +Reminder save ownership (#2896): a delayed completion from a previous account can no longer release the guard for a newer account's enable save. Both success and failure interleavings were reproduced before repair; 22 targeted component cases and the full 6,438-test frontend suite pass, with three existing skips. Typecheck, production build and scoped lint pass. This is a focused follow-up to #2895; the delivery tracker records its hosted gate and merge receipt. + Final overhaul recovery follow-up (#2893/#2894, [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895)): retain an unsaved hours draft across enable-only saves and confirmed validation rejections, distinguish competing question writes from source changes, and send the model exactly the previewed excerpt. Final frontend passes 6,436 tests with three existing skips; full backend passes 9,315 with 34 existing skips, followed by 17 API and 19 application tests for the final payload correction. Build/typecheck, scoped lint, browser recovery and bounded independent review pass. Required hosted runs 34458797270 and 34462673996 passed at reviewed heads f39cc6cf3 and d25edf2a5, including both operating-system suites and browser smoke. The PR records subsequent base qualification and the final merge receipt. The source and draft regressions were reproduced before repair; the validation ledger records their scope. Optional reminder hours (#2808, merged #2892): explicit weekday and time windows in an IANA zone, including overnight and daylight-saving behavior. Server checks precede question lookup and budget admission; preference edits preserve the shared UTC allowance, old-client toggles retain the window, and exports include it without a new migration. The slice passed 9,270 backend and 6,421 frontend tests, with 34/three existing skips respectively. After parent integration, 56 API tests, 56 component tests, typecheck/build and five Chromium journeys pass, including a reproduced and fixed account-erasure race. Firefox and mobile Grove/Grove Night evidence are recorded in the validation ledger. Required CI run 34453924959 passed at reviewed head 42965edde; #2892 merged as 93eab3443. [Policy](product/WORKSPACE_ATTENTION.md). diff --git a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md index 9520a0044..9125c4a68 100644 --- a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md +++ b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md @@ -1,5 +1,18 @@ # Workspace overhaul validation and follow-through +## Reminder save ownership follow-up (2026-09-10) + +Issue #2896 covers a late-review account-switch interleaving: account A's save is pending, account B +loads settings and starts another enable save, then A settles before B. Both resolved and rejected A +responses reproduced replacement of B's unsaved zone with its saved zone. A unique request identity +now lets only the owning completion release the component guard. Both regressions pass within 22 +attention component tests and assert an explicit later hours save using B's returned revision. + +Full frontend qualification passes 6,438 tests with three existing skips across 416 files; typecheck, +production build and scoped ESLint pass. The backend is unchanged. The deterministic component tests +exercise the actual shared store and account-generation checks; no new physical-device or live-provider +claim is made. The follow-up PR records its independent review and hosted delivery receipt. + ## Final recovery follow-up (2026-09-10) The hours draft regression failed in both enable-toggle directions before the repair. A confirmed diff --git a/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue b/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue index a0fb45d73..bd8fbc37b 100644 --- a/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue +++ b/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue @@ -9,7 +9,7 @@ const restricted = ref(false) const zone = ref('UTC') const days = ref([1, 2, 3, 4, 5]) const start = ref('09:00'); const end = ref('17:00') -let savingEnablement = false +let savingEnablement: symbol | null = null const dayOptions = [{ value: 1, name: 'Monday' }, { value: 2, name: 'Tuesday' }, { value: 3, name: 'Wednesday' }, { value: 4, name: 'Thursday' }, { value: 5, name: 'Friday' }, { value: 6, name: 'Saturday' }, { value: 0, name: 'Sunday' }] const formatTime = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}` @@ -17,7 +17,7 @@ const minutes = (value: string) => { const [hour, minute] = value.split(':').map watch(() => attention.settings, settings => { // An enable-only receipt must not replace an independent, unsaved hours draft. if (settings && savingEnablement) return - savingEnablement = false + savingEnablement = null const window = settings?.window restricted.value = !!window zone.value = window?.timeZoneId ?? 'UTC' @@ -29,9 +29,10 @@ const valid = computed(() => !restricted.value || (zone.value.trim().length > 0 function localZone() { zone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' } async function saveEnabled(enabled: boolean) { if (attention.busy || !attention.settings) return - savingEnablement = true + const request = Symbol() + savingEnablement = request try { await attention.save(enabled) } - finally { savingEnablement = false } + finally { if (savingEnablement === request) savingEnablement = null } } function saveHours() { if (!attention.settings || !valid.value) return diff --git a/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts b/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts index dc324d700..9e4c1f436 100644 --- a/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts @@ -86,6 +86,40 @@ describe('optional quiet reminders', () => { expect(useWorkspaceAttentionStore().settings?.enabled).toBe(true) }) + it.each(['resolve', 'reject'])('keeps the newer account save guarded when the old save settles by %s', async outcome => { + wrapper = mount(WorkspaceAttentionSettings); await flushPromises() + let completeOld!: (value: typeof settings) => void + let rejectOld!: (reason: Error) => void + vi.mocked(workspaceAttentionApi.save).mockReturnValueOnce(new Promise((resolve, reject) => { + completeOld = resolve; rejectOld = reject + })) + await wrapper.get('input').setValue(false); await flushPromises() + session.userId = 'different-owner'; await flushPromises() + const savedWindow = { timeZoneId: 'Europe/London', daysMask: 62, startMinute: 540, endMinute: 1020 } + vi.mocked(workspaceAttentionApi.get).mockResolvedValueOnce({ ...settings, revision: 10, window: savedWindow }) + await wrapper.get('button').trigger('click'); await flushPromises() + await wrapper.get('input[type=text]').setValue('America/New_York') + await wrapper.findAll('input[type=time]')[0]!.setValue('22:00') + await wrapper.findAll('input[type=time]')[1]!.setValue('02:00') + let completeNew!: (value: typeof settings & { window: typeof savedWindow }) => void + vi.mocked(workspaceAttentionApi.save).mockReturnValueOnce(new Promise(resolve => { completeNew = resolve })) + await wrapper.get('input').setValue(false); await flushPromises() + if (outcome === 'resolve') completeOld({ ...settings, enabled: false, revision: 2 }) + else rejectOld(new Error('Old account save response was lost')) + await flushPromises() + expect(useWorkspaceAttentionStore().busy).toBe(true) + expect(useWorkspaceAttentionStore().settings?.revision).toBe(10) + completeNew({ ...settings, enabled: false, revision: 11, window: savedWindow }); await flushPromises() + expect((wrapper.get('input[type=text]').element as HTMLInputElement).value).toBe('America/New_York') + expect((wrapper.findAll('input[type=time]')[0]!.element as HTMLInputElement).value).toBe('22:00') + expect((wrapper.findAll('input[type=time]')[1]!.element as HTMLInputElement).value).toBe('02:00') + const window = { timeZoneId: 'America/New_York', daysMask: 62, startMinute: 1320, endMinute: 120 } + vi.mocked(workspaceAttentionApi.save).mockResolvedValueOnce({ ...settings, enabled: false, revision: 12, window }) + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(workspaceAttentionApi.save).toHaveBeenCalledTimes(3) + expect(workspaceAttentionApi.save).toHaveBeenLastCalledWith(11, false, window) + }) + it('saves an explicit weekly window and disables controls until its receipt arrives', async () => { wrapper = mount(WorkspaceAttentionSettings); await flushPromises() const restrict = wrapper.findAll('input[type=checkbox]')[1]!