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

This commit is contained in:
Andras Schmelczer 2026-05-31 10:38:56 +01:00
parent 5bf8e752e7
commit 0ba28b0a14
9 changed files with 387 additions and 58 deletions

View file

@ -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');
});
});

View file

@ -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 }));
}

View file

@ -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);
});
});

View file

@ -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">&nbsp;</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">&nbsp;</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);
}
}

View 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');
});
});

View file

@ -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 {

View file

@ -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 });
});
});

View file

@ -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,
);
}
}

View file

@ -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 () => {