Add task toggle
Some checks failed
CI / Backend tests (pull_request) Has been cancelled
CI / Frontend lint (pull_request) Has been cancelled
CI / Frontend unit tests (pull_request) Has been cancelled
CI / Frontend build (pull_request) Has been cancelled
CI / Playwright e2e (pull_request) Has been cancelled
Some checks failed
CI / Backend tests (pull_request) Has been cancelled
CI / Frontend lint (pull_request) Has been cancelled
CI / Frontend unit tests (pull_request) Has been cancelled
CI / Frontend build (pull_request) Has been cancelled
CI / Playwright e2e (pull_request) Has been cancelled
This commit is contained in:
parent
5bf8e752e7
commit
0ba28b0a14
9 changed files with 387 additions and 58 deletions
|
|
@ -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<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="header"
|
||||
(click)="toggleExpanded($event)"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
@if (pending().length === 0) {
|
||||
<span aria-hidden="true"> </span>
|
||||
} @else {
|
||||
{{ pending().length === 1 ? 'task' : 'tasks' }}
|
||||
}
|
||||
</button>
|
||||
@if (!initiallyOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="header"
|
||||
(click)="toggleExpanded($event)"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
@if (pending().length === 0) {
|
||||
<span aria-hidden="true"> </span>
|
||||
} @else {
|
||||
{{ pending().length === 1 ? 'task' : 'tasks' }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
#all
|
||||
class="all-task"
|
||||
[style.max-height.px]="expanded() ? all.scrollHeight : 0"
|
||||
[style.max-height]="taskListMaxHeight(expanded())"
|
||||
>
|
||||
@for (b of pending(); track b.id) {
|
||||
<div class="task-container">
|
||||
|
|
@ -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<Block>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal file
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>((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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue