diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index f9bb312..2493014 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -1,4 +1,21 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; + +async function expectTaskListOpen(page: Page, description: string): Promise { + const tasks = page.locator('lt-tasks', { hasText: description }).first(); + const taskBody = tasks.locator('.all-task'); + const taskRow = tasks.locator('.task-container', { hasText: description }).first(); + + await expect(tasks.locator('.header')).toHaveCount(0); + await expect + .poll(async () => + taskBody.evaluate((el) => { + const height = el.getBoundingClientRect().height; + return height / Math.max(1, el.scrollHeight); + }), + ) + .toBeGreaterThan(0.9); + await taskRow.locator('.tickbox').click({ trial: true }); +} /** * Smoke test: drives the legacy-styled UI end-to-end. @@ -77,4 +94,90 @@ test.describe('Life Towers smoke test', () => { await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects'); await expect(page.locator('lt-tower lt-block').first()).toBeVisible(); }); + + test('keep tasks open shows new pending tasks and survives immediate reload', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: 'Start empty' }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('lt-select-add .top').first().click(); + await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Work'); + await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter'); + + await page.locator('img[alt="Add tower"]').click(); + await page.locator('input[placeholder="New tower"]').fill('Today'); + await page.locator('lt-tower-settings button[type="submit"]').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByText('Keep tasks open').click(); + await page.locator('lt-settings .exit').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('img[alt="Add block"]').first().click(); + const createCard = page.locator('lt-block-edit .create-card'); + await expect(createCard.getByLabel('Already done')).not.toBeChecked(); + await createCard.locator('lt-select-add .top').click(); + await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('ops'); + await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter'); + await createCard + .locator('textarea[placeholder="Write a description here…"]') + .fill('Review deploy notes'); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await expectTaskListOpen(page, 'Review deploy notes'); + + await page.reload(); + + await expect(page.locator('lt-select-add .top').first()).toContainText('Work', { + timeout: 15000, + }); + await expectTaskListOpen(page, 'Review deploy notes'); + + await page.locator('img[alt="Add block"]').first().click(); + await expect(page.locator('lt-block-edit .create-card').getByLabel('Already done')).not.toBeChecked(); + }); + + test('keep tasks open expands existing pending tasks after reload', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: 'Start empty' }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('lt-select-add .top').first().click(); + await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Ops'); + await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter'); + + await page.locator('img[alt="Add tower"]').click(); + await page.locator('input[placeholder="New tower"]').fill('Queue'); + await page.locator('lt-tower-settings button[type="submit"]').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('img[alt="Add block"]').first().click(); + await page.locator('lt-block-edit lt-select-add .top').click(); + await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').fill('triage'); + await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').press('Enter'); + await page + .locator('textarea[placeholder="Write a description here…"]') + .fill('Clean up alerts'); + await page.getByLabel('Already done').uncheck(); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByText('Keep tasks open').click(); + await page.locator('lt-settings .exit').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.reload(); + + await expect(page.locator('lt-select-add .top').first()).toContainText('Ops', { + timeout: 15000, + }); + await expectTaskListOpen(page, 'Clean up alerts'); + }); }); diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts index 51cb5eb..58cad90 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -35,6 +35,10 @@ function clampDifficulty(value: number): number { return Math.max(1, Math.min(100, value)); } +export function createDoneValue(defaultDone: boolean, currentDone: boolean, edited: boolean): boolean { + return edited ? currentDone : defaultDone; +} + @Component({ selector: 'lt-block-edit', standalone: true, @@ -586,6 +590,7 @@ export class BlockEditComponent implements AfterViewInit { is_done: true, difficulty: 1, }); + private newDoneEdited = false; // 1-based index of the centered card. 0/N+2 are placeholders. readonly activeIdx = signal(1); @@ -617,11 +622,20 @@ export class BlockEditComponent implements AfterViewInit { this.newValue.set({ ...cur, tag: t.length > 0 ? t[0] : '', - is_done: this.defaultDone(), }); } }); }); + + effect(() => { + const isDone = this.defaultDone(); + untracked(() => { + this.newValue.update((v) => ({ + ...v, + is_done: createDoneValue(isDone, v.is_done, this.newDoneEdited), + })); + }); + }); } ngAfterViewInit(): void { @@ -745,6 +759,7 @@ export class BlockEditComponent implements AfterViewInit { } updateNewDone(is_done: boolean): void { + this.newDoneEdited = true; this.newValue.update((v) => ({ ...v, is_done })); } diff --git a/frontend/src/app/components/modal/block-edit.component.vitest.ts b/frontend/src/app/components/modal/block-edit.component.vitest.ts new file mode 100644 index 0000000..b69ca31 --- /dev/null +++ b/frontend/src/app/components/modal/block-edit.component.vitest.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { createDoneValue } from './block-edit.component'; + +describe('createDoneValue', () => { + it('uses the create-card default before the user edits the checkbox', () => { + expect(createDoneValue(false, true, false)).toBe(false); + expect(createDoneValue(true, false, false)).toBe(true); + }); + + it('keeps the user-edited checkbox value', () => { + expect(createDoneValue(false, true, true)).toBe(true); + expect(createDoneValue(true, false, true)).toBe(false); + }); +}); diff --git a/frontend/src/app/components/tasks/tasks.component.ts b/frontend/src/app/components/tasks/tasks.component.ts index f348a17..183da91 100644 --- a/frontend/src/app/components/tasks/tasks.component.ts +++ b/frontend/src/app/components/tasks/tasks.component.ts @@ -1,10 +1,28 @@ -import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + computed, + effect, + input, + output, + signal, + untracked, +} from '@angular/core'; import { Block, HslColor } from '../../models'; import { getColorOfTag } from '../../utils/color'; +export function shouldExpandTasks(keepTasksOpen: boolean, manuallyExpanded: boolean): boolean { + return keepTasksOpen || manuallyExpanded; +} + +export function taskListMaxHeight(expanded: boolean): string { + return expanded ? 'none' : '0px'; +} + /** * Tasks accordion — shows pending (not-done) blocks inside a tower. - * Sits ABOVE the falling-blocks area. Clicking the header expands/collapses. + * Sits ABOVE the falling-blocks area. Clicking the header expands/collapses + * unless the page setting is keeping tasks open. * Clicking the colored tickbox marks the task done. * Clicking the description opens the block-edit modal via the `edit` output. */ @@ -21,23 +39,25 @@ import { getColorOfTag } from '../../utils/color'; (mousedown)="$event.stopPropagation()" (touchstart)="$event.stopPropagation()" > - + @if (!initiallyOpen()) { + + }
@for (b of pending(); track b.id) {
@@ -111,12 +131,9 @@ import { getColorOfTag } from '../../utils/color'; transition: max-height $long-animation-time; /* - * Clip during the open/close animation. We animate to the element's - * exact content height (read off #all in the template) so tall lists - * simply scroll inside the outer .container (max-height: 30vh) — - * .all-task itself is NOT a nested scroller. A nested overflow-y:auto - * here pops a scrollbar the instant a tickbox grows on hover, because - * its scale(1.05) + shadow widen the scrollable-overflow box. + * Clip while collapsed only. When open, let the outer .container own + * scrolling via max-height: 30vh; a nested scroller here pops a + * scrollbar the instant a tickbox grows on hover. */ overflow: hidden; @@ -235,16 +252,18 @@ export class TasksComponent { /** Emitted when the description is clicked — parent opens the block-edit modal. */ readonly edit = output(); - readonly expanded = signal(false); + private readonly manuallyExpanded = signal(false); + readonly expanded = computed(() => + shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()), + ); + readonly taskListMaxHeight = taskListMaxHeight; constructor() { - // Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping - // the "Keep tasks open" page setting expands/collapses the accordion live. - // User clicks (which mutate `expanded` directly) are respected until the - // setting changes again. + // When the page setting switches back to collapsed, discard any older manual + // open state so the setting is reflected immediately. effect(() => { - const open = this.initiallyOpen(); - untracked(() => this.expanded.set(open)); + const keepOpen = this.initiallyOpen(); + if (!keepOpen) untracked(() => this.manuallyExpanded.set(false)); }); } @@ -254,6 +273,7 @@ export class TasksComponent { toggleExpanded(event: Event): void { event.stopPropagation(); - this.expanded.update((v) => !v); + if (this.initiallyOpen()) return; + this.manuallyExpanded.update((v) => !v); } } diff --git a/frontend/src/app/components/tasks/tasks.component.vitest.ts b/frontend/src/app/components/tasks/tasks.component.vitest.ts new file mode 100644 index 0000000..fe12ec6 --- /dev/null +++ b/frontend/src/app/components/tasks/tasks.component.vitest.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { shouldExpandTasks, taskListMaxHeight } from './tasks.component'; + +describe('shouldExpandTasks', () => { + it('expands when tasks should be kept open by page setting', () => { + expect(shouldExpandTasks(true, false)).toBe(true); + }); + + it('expands when the user manually opens the accordion', () => { + expect(shouldExpandTasks(false, true)).toBe(true); + }); + + it('collapses when keep-open is disabled', () => { + expect(shouldExpandTasks(false, false)).toBe(false); + }); +}); + +describe('taskListMaxHeight', () => { + it('does not cap open task lists by a measured height', () => { + expect(taskListMaxHeight(true)).toBe('none'); + }); + + it('clips collapsed task lists', () => { + expect(taskListMaxHeight(false)).toBe('0px'); + }); +}); diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index 72d59d1..4dc87d0 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -21,11 +21,18 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett import { toCss } from '../../utils/color'; /** Tracks which entry path the block-edit modal was opened from. */ -interface EditEntry { +export interface EditEntry { filter: 'done' | 'pending'; activeId: string | null; } +export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry { + return { + filter: keepTasksOpen ? 'pending' : 'done', + activeId: null, + }; +} + /** A done block augmented with per-render animation state. */ export interface StyledBlock extends Block { _anim: '' | 'descend' | 'ascend'; @@ -875,7 +882,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { /** Called by the template "Add block" plus-icon. */ openAddBlock(): void { - this.editEntry.set({ filter: 'done', activeId: null }); + this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen())); } closeEdit(): void { diff --git a/frontend/src/app/components/tower/tower.component.vitest.ts b/frontend/src/app/components/tower/tower.component.vitest.ts index c54cc83..495f141 100644 --- a/frontend/src/app/components/tower/tower.component.vitest.ts +++ b/frontend/src/app/components/tower/tower.component.vitest.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { StyledBlock } from './tower.component'; -import { selectVisibleStyledBlocks } from './tower.component'; +import { editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component'; function block(id: string, opacity = '1', difficulty = 1): StyledBlock { return { @@ -127,3 +127,13 @@ describe('selectVisibleStyledBlocks', () => { expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']); }); }); + +describe('editEntryForNewBlock', () => { + it('opens the create card in the pending task view when tasks are kept open', () => { + expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null }); + }); + + it('keeps the existing completed-task default when tasks are collapsed', () => { + expect(editEntryForNewBlock(false)).toEqual({ filter: 'done', activeId: null }); + }); +}); diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index 280b049..71589bc 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -5,6 +5,7 @@ import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models'; const TOKEN_KEY = 'life-towers.token.v4'; const CACHE_KEY_PREFIX = 'life-towers.cache.v4'; +const PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4'; const DEBOUNCE_MS = 750; const MAX_RETRIES = 5; @@ -50,14 +51,26 @@ function safeSet(key: string, value: string): void { /* ignore */ } } +function safeRemove(key: string): void { + try { + localStorage.removeItem(key); + } catch { + /* ignore */ + } +} function cacheKeyForToken(token: string): string { return `${CACHE_KEY_PREFIX}.${token}`; } +function pendingCacheKeyForToken(token: string): string { + return `${PENDING_CACHE_KEY_PREFIX}.${token}`; +} + interface PendingPut { token: string; tree: TreeDto; + revision: number; } interface ExampleBlockSeed { @@ -98,6 +111,9 @@ export class StoreService implements OnDestroy { private flushInFlight = false; // True while in-flight if a new mutation arrived; we'll re-flush after. private dirtyDuringFlush = false; + // Monotonic local mutation version. Prevents an older in-flight PUT from + // clearing a newer pending cache entry when it completes. + private localMutationRevision = 0; // ── Cross-tab sync ───────────────────────────────────────────────────────── private readonly storageListener = (e: StorageEvent) => { @@ -193,19 +209,21 @@ export class StoreService implements OnDestroy { * "server forgot me" recovery would silently wipe offline edits. */ private adoptServerTree(tree: TreeDto, token: string): void { + if (safeGet(pendingCacheKeyForToken(token))) { + const cachedTree = this.readCachedTree(token); + if (cachedTree?.pages && cachedTree.pages.length > 0) { + this._pages.set(cachedTree.pages); + this.scheduleSave(); + return; + } + } + if (tree.pages.length === 0) { - const cached = safeGet(cacheKeyForToken(token)); - if (cached) { - try { - const cachedTree: TreeDto = JSON.parse(cached); - if (cachedTree.pages && cachedTree.pages.length > 0) { - this._pages.set(cachedTree.pages); - this.scheduleSave(); - return; - } - } catch { - /* fall through to server-empty */ - } + const cachedTree = this.readCachedTree(token); + if (cachedTree?.pages && cachedTree.pages.length > 0) { + this._pages.set(cachedTree.pages); + this.scheduleSave(); + return; } } this._pages.set(tree.pages); @@ -213,19 +231,27 @@ export class StoreService implements OnDestroy { } private loadFromCache(token: string): void { + const tree = this.readCachedTree(token); + if (tree) this._pages.set(tree.pages); + } + + private readCachedTree(token: string): TreeDto | null { const raw = safeGet(cacheKeyForToken(token)); - if (raw) { - try { - const tree: TreeDto = JSON.parse(raw); - this._pages.set(tree.pages); - } catch { - /* ignore */ - } + if (!raw) return null; + try { + return JSON.parse(raw) as TreeDto; + } catch { + return null; } } - private updateCache(token: string, tree: TreeDto): void { + private updateCache(token: string, tree: TreeDto, pending = false): void { safeSet(cacheKeyForToken(token), JSON.stringify(tree)); + if (pending) { + safeSet(pendingCacheKeyForToken(token), '1'); + } else { + safeRemove(pendingCacheKeyForToken(token)); + } } private cancelPendingWrites(): void { @@ -461,12 +487,17 @@ export class StoreService implements OnDestroy { this._pages.set([]); this._loading.set(true); this._saveStatus.set('idle'); + this.localMutationRevision = 0; void this.init(); } // ── Save / sync ──────────────────────────────────────────────────────────── private scheduleSave(): void { + this.localMutationRevision += 1; + const token = this._token(); + if (token) this.updateCache(token, { pages: this._pages() }, true); + if (this.flushInFlight) { // A save is already happening. Mark dirty so we re-flush when it finishes. this.dirtyDuringFlush = true; @@ -494,6 +525,7 @@ export class StoreService implements OnDestroy { this.flushInFlight = true; this.dirtyDuringFlush = false; + const revision = this.localMutationRevision; // Cancel any pending retry — runFlush() supersedes it. if (this.retryTimer !== null) { @@ -502,7 +534,7 @@ export class StoreService implements OnDestroy { } try { - await this.attempt({ token, tree: { pages: this._pages() } }, 0); + await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0); } finally { this.flushInFlight = false; // Coalesce mutations that arrived during the flush into a fresh save. @@ -518,7 +550,13 @@ export class StoreService implements OnDestroy { try { await this.api.putData(put.token, put.tree); this._saveStatus.set('saved'); - this.updateCache(put.token, put.tree); + if ( + this._token() === put.token && + put.revision === this.localMutationRevision && + !this.dirtyDuringFlush + ) { + this.updateCache(put.token, put.tree); + } return; } catch (err: unknown) { const status = (err as { status?: number })?.status; @@ -573,7 +611,10 @@ export class StoreService implements OnDestroy { // The token may have changed during the wait — re-check before retrying. if (this._token() !== put.token) return; // Re-snapshot the latest pages so the retry pushes current state. - await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1); + await this.attempt( + { token: put.token, tree: { pages: this._pages() }, revision: put.revision }, + attempt + 1, + ); } } diff --git a/frontend/src/app/services/store.service.vitest.ts b/frontend/src/app/services/store.service.vitest.ts index bd04358..2ecbe76 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -65,6 +65,7 @@ function makeMockApi(): MockApi { const FIXED_UUID = '11111111-2222-4333-8444-555555555555'; const TOKEN_KEY = 'life-towers.token.v4'; const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`; +const PENDING_CACHE_KEY = `life-towers.cache-pending.v4.${FIXED_UUID}`; const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff'; const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`; @@ -352,6 +353,7 @@ describe('StoreService', () => { expect(api.putData).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(750); expect(api.putData).toHaveBeenCalledTimes(1); + expect(storage[PENDING_CACHE_KEY]).toBeUndefined(); const [, tree] = api.putData.mock.calls[0]; expect((tree as TreeDto).pages).toHaveLength(3); }); @@ -382,6 +384,97 @@ describe('StoreService', () => { expect(lastTree.pages).toHaveLength(2); }); + it('does not let an older in-flight save clear a newer pending cache entry', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + let resolveFirstPut: (() => void) | null = null; + api.putData + .mockReturnValueOnce(new Promise((res) => (resolveFirstPut = () => res()))) + .mockResolvedValueOnce(undefined); + const store = configure(api); + await store.init(); + + store.addPage('first'); + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(1); + + store.addPage('second'); + expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name)) + .toEqual(['first', 'second']); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + resolveFirstPut!(); + await vi.advanceTimersByTimeAsync(0); + + expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name)) + .toEqual(['first', 'second']); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(2); + expect(storage[PENDING_CACHE_KEY]).toBeUndefined(); + }); + + it('keeps a pending local mutation across reload before the debounce saves', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + const serverPage = mkPage('server'); + api.getData.mockResolvedValue({ pages: [serverPage] }); + const store = configure(api); + await store.init(); + + store.updatePage(FIXED_UUID, { keep_tasks_open: true }); + + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + store.ngOnDestroy(); + + const reloadedApi = makeMockApi(); + reloadedApi.getData.mockResolvedValue({ pages: [serverPage] }); + const reloadedStore = configure(reloadedApi); + await reloadedStore.init(); + + expect(reloadedStore.pages()[0].keep_tasks_open).toBe(true); + + await vi.advanceTimersByTimeAsync(750); + expect(reloadedApi.putData).toHaveBeenCalledTimes(1); + const [, tree] = reloadedApi.putData.mock.calls[0]; + expect((tree as TreeDto).pages[0].keep_tasks_open).toBe(true); + }); + + it('does not let a stale in-flight save clear a newer pending settings cache', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + const serverPage = mkPage('server'); + let resolveFirstPut: (() => void) | null = null; + api.getData.mockResolvedValue({ pages: [serverPage] }); + api.putData + .mockReturnValueOnce(new Promise((res) => (resolveFirstPut = () => res()))) + .mockResolvedValueOnce(undefined); + const store = configure(api); + await store.init(); + + store.updatePage(FIXED_UUID, { name: 'renamed' }); + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(1); + expect((api.putData.mock.calls[0][1] as TreeDto).pages[0].keep_tasks_open).toBe(false); + + store.updatePage(FIXED_UUID, { keep_tasks_open: true }); + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + resolveFirstPut!(); + await vi.advanceTimersByTimeAsync(0); + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(2); + expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].keep_tasks_open).toBe(true); + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBeUndefined(); + }); + // ── Error handling ──────────────────────────────────────────────────────── it('marks status "too-large" on 413 and does NOT retry', async () => {