diff --git a/frontend/src/app/models/index.ts b/frontend/src/app/models/index.ts index 1f0af8b..083f09d 100644 --- a/frontend/src/app/models/index.ts +++ b/frontend/src/app/models/index.ts @@ -9,6 +9,8 @@ export interface Block { tag: string; description: string; is_done: boolean; + /** How many squares this block draws in the tower (>= 1). */ + difficulty: number; created_at: number; } @@ -38,7 +40,7 @@ export type SaveStatus = | 'saving' | 'saved' | 'retrying' - | 'error' // generic / network — will keep trying - | 'too-large' // 413 — payload exceeds the server cap, won't retry + | 'error' // generic / network — retries exhausted until the next mutation + | 'too-large' // 413 — payload exceeds the server cap, will not retry | 'rate-limited' // 429 — will retry after Retry-After - | 'invalid'; // 400 — server rejected the body, won't retry + | 'invalid'; // 400 — server rejected the body, will not retry diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 2e9b8e8..58bf117 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -23,9 +23,9 @@ export class ApiService { ); } - putData(token: string, tree: TreeDto): Promise { - return firstValueFrom( - this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }), + async putData(token: string, tree: TreeDto): Promise { + await firstValueFrom( + this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }), ); } diff --git a/frontend/src/app/services/api.service.vitest.ts b/frontend/src/app/services/api.service.vitest.ts index d8a681f..4b9b4e3 100644 --- a/frontend/src/app/services/api.service.vitest.ts +++ b/frontend/src/app/services/api.service.vitest.ts @@ -1,28 +1,44 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ApiService } from './api.service'; +import type { TreeDto } from '../models'; -// Mock environment -vi.mock('../../environments/environment', () => ({ - environment: { apiBase: 'http://test-api', production: false }, -})); +describe('ApiService', () => { + let service: ApiService; + let http: HttpTestingController; -describe('ApiService URL patterns', () => { - const baseUrl = 'http://test-api'; - - it('constructs correct health URL', () => { - expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health'); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), ApiService], + }); + service = TestBed.inject(ApiService); + http = TestBed.inject(HttpTestingController); }); - it('constructs correct register URL', () => { - expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register'); + afterEach(() => { + http.verify(); }); - it('constructs correct data URL', () => { - expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data'); + it('gets data with a bearer token', async () => { + const tree: TreeDto = { pages: [] }; + const promise = service.getData('token-1'); + const req = http.expectOne('/api/v1/data'); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe('Bearer token-1'); + req.flush(tree); + await expect(promise).resolves.toEqual(tree); }); - it('formats Authorization header correctly', () => { - const token = 'abc-def-123'; - const header = `Bearer ${token}`; - expect(header).toBe('Bearer abc-def-123'); + it('puts data with a bearer token', async () => { + const tree: TreeDto = { pages: [] }; + const promise = service.putData('token-1', tree); + const req = http.expectOne('/api/v1/data'); + expect(req.request.method).toBe('PUT'); + expect(req.request.headers.get('Authorization')).toBe('Bearer token-1'); + expect(req.request.body).toBe(tree); + req.flush(null); + await expect(promise).resolves.toBeUndefined(); }); }); diff --git a/frontend/src/app/services/modal-state.service.ts b/frontend/src/app/services/modal-state.service.ts index c1565ed..eb78a78 100644 --- a/frontend/src/app/services/modal-state.service.ts +++ b/frontend/src/app/services/modal-state.service.ts @@ -5,7 +5,7 @@ import { Injectable, computed, signal } from '@angular/core'; * mount and decrements on destroy. Consumers read `anyOpen` to react. * * Used by `page.component` to disable tower drag-and-drop while any modal - * (block-edit carousel, page-settings, tower-settings, confirm-delete, + * (block-edit carousel, settings, tower-settings, confirm-delete, * settings) is on screen — otherwise the user can drag towers from behind * the open card. */ diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index a1e3e5b..71589bc 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -1,9 +1,11 @@ -import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core'; +import { Injectable, inject, signal, OnDestroy } from '@angular/core'; import { ApiService } from './api.service'; +import { AnalyticsService } from './analytics.service'; import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models'; const TOKEN_KEY = 'life-towers.token.v4'; -const CACHE_KEY = 'life-towers.cache.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; @@ -28,7 +30,7 @@ function uuidV4(): string { function isUuidV4(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( - value, + value.toLowerCase(), ); } @@ -49,15 +51,47 @@ 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 { + tag: string; + desc: string; + done: boolean; + difficulty?: number; + ageHrs: number; +} + +interface ExampleDonePattern { + tag: string; + desc: (sequence: number) => string; +} + +const EXAMPLE_DONE_BLOCKS_PER_TOWER = 12; + @Injectable({ providedIn: 'root' }) export class StoreService implements OnDestroy { private readonly api = inject(ApiService); + private readonly analytics = inject(AnalyticsService); // ── State ────────────────────────────────────────────────────────────────── private readonly _pages = signal([]); @@ -70,26 +104,22 @@ export class StoreService implements OnDestroy { readonly loading = this._loading.asReadonly(); readonly token = this._token.asReadonly(); - readonly pageCount = computed(() => this._pages().length); - // ── Debounce / retry ─────────────────────────────────────────────────────── private debounceTimer: ReturnType | null = null; private retryTimer: ReturnType | null = null; + private retryResolver: (() => void) | null = null; 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) => { if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) { - // Another tab switched accounts. Re-init with the new token instead of - // continuing to sync the old account's state to the new one. - this._token.set(e.newValue); - this._pages.set([]); - this._loading.set(true); - this.cancelPendingWrites(); - void this.init(); - } else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) { + this.switchToken(e.newValue); + } else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) { // Another tab just wrote a fresh cache; adopt it if we're not mid-save // (to avoid clobbering our own state with the other tab's older view). try { @@ -103,17 +133,21 @@ export class StoreService implements OnDestroy { // ── Single-flight init ───────────────────────────────────────────────────── private initPromise: Promise | null = null; + private initGeneration = 0; // ── Init ─────────────────────────────────────────────────────────────────── async init(): Promise { if (this.initPromise) return this.initPromise; - this.initPromise = this.doInit().finally(() => { - this.initPromise = null; + const generation = ++this.initGeneration; + this.initPromise = this.doInit(generation).finally(() => { + if (this.initGeneration === generation) { + this.initPromise = null; + } }); return this.initPromise; } - private async doInit(): Promise { + private async doInit(generation: number): Promise { if (typeof window !== 'undefined') { // (idempotent — adding the same listener twice is a no-op) window.addEventListener('storage', this.storageListener); @@ -123,6 +157,9 @@ export class StoreService implements OnDestroy { if (stored && !isUuidV4(stored)) { // Garbage in localStorage from a buggy past version — refuse it. stored = null; + } else if (stored) { + stored = stored.toLowerCase(); + safeSet(TOKEN_KEY, stored); } const token = stored ?? uuidV4(); if (!stored) { @@ -140,7 +177,7 @@ export class StoreService implements OnDestroy { try { const tree = await this.api.getData(token); - this.adoptServerTree(tree); + if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token); } catch (err: unknown) { const status = (err as { status?: number })?.status; if (status === 401) { @@ -148,57 +185,73 @@ export class StoreService implements OnDestroy { try { await this.api.register(token); const tree = await this.api.getData(token); - this.adoptServerTree(tree); + if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token); } catch { - this.loadFromCache(); + if (this.isCurrentInit(generation, token)) this.loadFromCache(token); } } else { - this.loadFromCache(); + if (this.isCurrentInit(generation, token)) this.loadFromCache(token); } } finally { - this._loading.set(false); + if (this.initGeneration === generation) { + this._loading.set(false); + } } } + private isCurrentInit(generation: number, token: string): boolean { + return this.initGeneration === generation && this._token() === token; + } + /** * Apply a freshly-fetched server tree. If the server is empty but our local * cache holds data, the cache wins and we schedule a push — otherwise the * "server forgot me" recovery would silently wipe offline edits. */ - private adoptServerTree(tree: TreeDto): void { + 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(CACHE_KEY); - 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); - this.updateCache(tree); + this.updateCache(token, tree); } - private loadFromCache(): void { - const raw = safeGet(CACHE_KEY); - if (raw) { - try { - const tree: TreeDto = JSON.parse(raw); - this._pages.set(tree.pages); - } catch { - /* ignore */ - } + 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) return null; + try { + return JSON.parse(raw) as TreeDto; + } catch { + return null; } } - private updateCache(tree: TreeDto): void { - safeSet(CACHE_KEY, JSON.stringify(tree)); + 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 { @@ -210,6 +263,11 @@ export class StoreService implements OnDestroy { clearTimeout(this.retryTimer); this.retryTimer = null; } + if (this.retryResolver !== null) { + const resolve = this.retryResolver; + this.retryResolver = null; + resolve(); + } this.dirtyDuringFlush = false; } @@ -226,6 +284,8 @@ export class StoreService implements OnDestroy { towers: [], }; this._pages.update((pages) => [...pages, page]); + this.analytics.trackStart(); + this.analytics.trackPageCreated(); this.scheduleSave(); } @@ -242,13 +302,13 @@ export class StoreService implements OnDestroy { } reorderPages(fromIndex: number, toIndex: number): void { + let changed = false; this._pages.update((pages) => { - const arr = [...pages]; - const [item] = arr.splice(fromIndex, 1); - arr.splice(toIndex, 0, item); - return arr; + const reordered = reorder(pages, fromIndex, toIndex); + changed = reordered !== null; + return reordered ?? pages; }); - this.scheduleSave(); + if (changed) this.scheduleSave(); } addTower(pageId: string, name: string, base_color: HslColor): void { @@ -256,6 +316,8 @@ export class StoreService implements OnDestroy { this._pages.update((pages) => pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)), ); + this.analytics.trackStart(); + this.analytics.trackTowerCreated(); this.scheduleSave(); } @@ -280,16 +342,17 @@ export class StoreService implements OnDestroy { } reorderTowers(pageId: string, fromIndex: number, toIndex: number): void { + let changed = false; this._pages.update((pages) => pages.map((p) => { if (p.id !== pageId) return p; - const towers = [...p.towers]; - const [item] = towers.splice(fromIndex, 1); - towers.splice(toIndex, 0, item); + const towers = reorder(p.towers, fromIndex, toIndex); + if (towers === null) return p; + changed = true; return { ...p, towers }; }), ); - this.scheduleSave(); + if (changed) this.scheduleSave(); } addBlock( @@ -298,26 +361,30 @@ export class StoreService implements OnDestroy { tag: string, description: string, is_done = false, + difficulty = 1, ): void { const block: Block = { id: uuidV4(), tag, description, is_done, + difficulty, created_at: Math.floor(Date.now() / 1000), }; this._pages.update((pages) => pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t, + ), + } : p, ), ); + this.analytics.trackStart(); + this.analytics.trackBlockCreated({ isDone: is_done }); this.scheduleSave(); } @@ -327,23 +394,29 @@ export class StoreService implements OnDestroy { blockId: string, patch: Partial>, ): void { + let becameDone = false; this._pages.update((pages) => pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? { - ...t, - blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)), - } - : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId + ? (() => { + const result = this.patchBlockList(t.blocks, blockId, patch); + becameDone = result.becameDone; + return { ...t, blocks: result.blocks }; + })() + : t, + ), + } : p, ), ); + if (becameDone) { + this.analytics.trackStart(); + this.analytics.trackBlockCompleted(); + } this.scheduleSave(); } @@ -352,40 +425,49 @@ export class StoreService implements OnDestroy { pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } - : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId + ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } + : t, + ), + } : p, ), ); this.scheduleSave(); } - toggleBlock(pageId: string, towerId: string, blockId: string): void { - this._pages.update((pages) => - pages.map((p) => - p.id === pageId - ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? { - ...t, - blocks: t.blocks.map((b) => - b.id === blockId ? { ...b, is_done: !b.is_done } : b, - ), - } - : t, - ), - } - : p, - ), - ); - this.scheduleSave(); + private patchBlockList( + blocks: Block[], + blockId: string, + patch: Partial>, + ): { blocks: Block[]; becameDone: boolean } { + const nextBlocks: Block[] = []; + let completedBlock: Block | null = null; + let becameDone = false; + + for (const block of blocks) { + if (block.id !== blockId) { + nextBlocks.push(block); + continue; + } + + const updated: Block = { ...block, ...patch }; + becameDone = !block.is_done && updated.is_done; + if (becameDone) { + // Display newly completed todos as the newest square, without changing + // created_at because the date slider still filters on creation time. + completedBlock = updated; + } else { + nextBlocks.push(updated); + } + } + + return { + blocks: completedBlock ? [...nextBlocks, completedBlock] : nextBlocks, + becameDone, + }; } /** @@ -395,19 +477,27 @@ export class StoreService implements OnDestroy { * if the timing flipped). */ switchToken(newToken: string): void { - if (!isUuidV4(newToken)) return; + const token = newToken.toLowerCase(); + if (!isUuidV4(token)) return; this.cancelPendingWrites(); - safeSet(TOKEN_KEY, newToken); - this._token.set(newToken); + this.initGeneration++; + this.initPromise = null; + safeSet(TOKEN_KEY, token); + this._token.set(token); 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; @@ -435,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) { @@ -443,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. @@ -459,7 +550,13 @@ export class StoreService implements OnDestroy { try { await this.api.putData(put.token, put.tree); this._saveStatus.set('saved'); - this.updateCache(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; @@ -503,8 +600,10 @@ export class StoreService implements OnDestroy { } await new Promise((resolve) => { + this.retryResolver = resolve; this.retryTimer = setTimeout(() => { this.retryTimer = null; + this.retryResolver = null; resolve(); }, delayMs); }); @@ -512,13 +611,16 @@ 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, + ); } } // ── Example data ────────────────────────────────────────────────────────── - loadExample(): void { + loadExample(): string { const now = Math.floor(Date.now() / 1000); const page: Page = { @@ -529,38 +631,83 @@ export class StoreService implements OnDestroy { default_date_from: null, default_date_to: null, towers: [ + // Done blocks are listed oldest-first so they fill the falling stack + // oldest → newest (left → right), matching the slider's old → new + // labels; pending tasks stay on top for the accordion. this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [ - { tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 }, - { tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 }, - { tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 }, - { tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 }, - { tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 }, + { + tag: 'novel', + desc: 'Finish The Brothers Karamazov', + done: false, + difficulty: 4, + ageHrs: 0, + }, + ...this.makeExampleDoneBlocks( + [ + { tag: 'novel', desc: (n) => `Read chapter ${n}` }, + { tag: 'paper', desc: (n) => `Annotated paper ${n}` }, + { tag: 'article', desc: (n) => `Saved article notes ${n}` }, + { tag: 'essay', desc: (n) => `Drafted reading response ${n}` }, + ], + 2, + 8, + ), ]), this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [ - { tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 }, - { tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 }, - { tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 }, - { tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 }, + { + tag: 'angular', + desc: 'Modernise the towers app', + done: false, + difficulty: 3, + ageHrs: 0, + }, + { + tag: 'rust', + desc: 'Port the sync layer to Tauri', + done: false, + difficulty: 5, + ageHrs: 1, + }, + ...this.makeExampleDoneBlocks( + [ + { tag: 'angular', desc: (n) => `Refined UI pass ${n}` }, + { tag: 'rust', desc: (n) => `Completed systems spike ${n}` }, + { tag: 'infra', desc: (n) => `Tuned deploy workflow ${n}` }, + { tag: 'docs', desc: (n) => `Captured project note ${n}` }, + ], + 4, + 9, + ), ]), this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [ - { tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 }, - { tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 }, - { tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 }, - { tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 }, - { tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 }, + { tag: 'run', desc: '10k Sunday', done: false, difficulty: 2, ageHrs: 0 }, + { tag: 'climb', desc: 'Lead 6a outdoors', done: false, difficulty: 4, ageHrs: 4 }, + ...this.makeExampleDoneBlocks( + [ + { tag: 'run', desc: (n) => `Easy run ${n}` }, + { tag: 'climb', desc: (n) => `Bouldering session ${n}` }, + { tag: 'mobility', desc: (n) => `Mobility circuit ${n}` }, + { tag: 'strength', desc: (n) => `Strength session ${n}` }, + ], + 6, + 11, + ), ]), ], }; this._pages.update((pages) => [...pages, page]); + this.analytics.trackStart(); + this.analytics.trackExampleLoaded(); this.scheduleSave(); + return page.id; } private makeExampleTower( name: string, base_color: HslColor, nowSec: number, - blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>, + blocks: ExampleBlockSeed[], ): Tower { return { id: uuidV4(), @@ -571,11 +718,30 @@ export class StoreService implements OnDestroy { tag: b.tag, description: b.desc, is_done: b.done, + difficulty: b.difficulty ?? 1, created_at: nowSec - Math.floor(b.ageHrs * 3600), })), }; } + private makeExampleDoneBlocks( + patterns: ExampleDonePattern[], + newestAgeHrs: number, + spacingHrs: number, + ): ExampleBlockSeed[] { + return Array.from({ length: EXAMPLE_DONE_BLOCKS_PER_TOWER }, (_, i) => { + const pattern = patterns[i % patterns.length]; + const sequence = Math.floor(i / patterns.length) + 1; + return { + tag: pattern.tag, + desc: pattern.desc(sequence), + done: true, + difficulty: 1 + ((i + (i % patterns.length) * 2) % 5), + ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs, + }; + }); + } + ngOnDestroy(): void { this.cancelPendingWrites(); if (typeof window !== 'undefined') { @@ -583,3 +749,22 @@ export class StoreService implements OnDestroy { } } } + +function reorder(items: readonly T[], fromIndex: number, toIndex: number): T[] | null { + if ( + fromIndex === toIndex || + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length + ) { + return null; + } + + const next = [...items]; + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +} diff --git a/frontend/src/app/services/store.service.vitest.ts b/frontend/src/app/services/store.service.vitest.ts index 0836e9b..2ecbe76 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { StoreService } from './store.service'; import { ApiService } from './api.service'; +import { AnalyticsService } from './analytics.service'; import type { TreeDto } from '../models'; // ── localStorage stub ──────────────────────────────────────────────────────── @@ -63,7 +64,10 @@ 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'; +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}`; // ── Helpers ────────────────────────────────────────────────────────────────── function configure(api: MockApi): StoreService { @@ -72,6 +76,18 @@ function configure(api: MockApi): StoreService { providers: [ provideZonelessChangeDetection(), { provide: ApiService, useValue: api }, + { + provide: AnalyticsService, + useValue: { + init: vi.fn(), + trackStart: vi.fn(), + trackExampleLoaded: vi.fn(), + trackPageCreated: vi.fn(), + trackTowerCreated: vi.fn(), + trackBlockCreated: vi.fn(), + trackBlockCompleted: vi.fn(), + }, + }, StoreService, ], }); @@ -137,6 +153,18 @@ describe('StoreService', () => { expect(store.token()).toBe(FIXED_UUID); }); + it('canonicalizes a stored uppercase token', async () => { + storage[TOKEN_KEY] = FIXED_UUID.toUpperCase(); + const api = makeMockApi(); + const store = configure(api); + + await store.init(); + + expect(storage[TOKEN_KEY]).toBe(FIXED_UUID); + expect(api.getData).toHaveBeenCalledWith(FIXED_UUID); + expect(store.token()).toBe(FIXED_UUID); + }); + it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => { storage[TOKEN_KEY] = 'not-a-uuid'; const api = makeMockApi(); @@ -218,6 +246,98 @@ describe('StoreService', () => { expect(api.getData).toHaveBeenCalledTimes(1); }); + it('moves a pending block to the end when it becomes done', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + api.getData.mockResolvedValue({ + pages: [ + { + id: 'page-1', + name: 'Page', + hide_create_tower_button: false, + keep_tasks_open: false, + default_date_from: null, + default_date_to: null, + towers: [ + { + id: 'tower-1', + name: 'Tower', + base_color: { h: 0.5, s: 0.5, l: 0.5 }, + blocks: [ + { + id: 'old-pending', + tag: 'a', + description: 'Created first, completed last', + is_done: false, + difficulty: 1, + created_at: 100, + }, + { + id: 'newer-pending', + tag: 'b', + description: 'Still pending', + is_done: false, + difficulty: 1, + created_at: 300, + }, + { + id: 'existing-done', + tag: 'c', + description: 'Already done', + is_done: true, + difficulty: 1, + created_at: 200, + }, + ], + }, + ], + }, + ], + } satisfies TreeDto); + const store = configure(api); + await store.init(); + + store.updateBlock('page-1', 'tower-1', 'old-pending', { is_done: true }); + + const blocks = store.pages()[0].towers[0].blocks; + expect(blocks.map((b) => b.id)).toEqual([ + 'newer-pending', + 'existing-done', + 'old-pending', + ]); + expect(blocks[2].created_at).toBe(100); + }); + + it('loads welcome example data with a stack of completed squares per tower', () => { + const api = makeMockApi(); + const store = configure(api); + + const pageId = store.loadExample(); + + const [page] = store.pages(); + expect(page.id).toBe(pageId); + expect(page.name).toBe('Hobbies'); + expect(page.towers).toHaveLength(3); + + const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); + const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0); + expect(doneSquares).toBeGreaterThanOrEqual(90); + expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size) + .toBeGreaterThan(1); + + for (const tower of page.towers) { + const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at); + const doneSquareCount = tower.blocks + .filter((b) => b.is_done) + .reduce((sum, block) => sum + block.difficulty, 0); + expect(doneSquareCount).toBeGreaterThanOrEqual(30); + expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); + expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1); + } + + store.ngOnDestroy(); + }); + // ── Debounced save ───────────────────────────────────────────────────────── it('debounces saves: multiple mutations within 750ms → one PUT', async () => { @@ -233,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); }); @@ -263,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 () => { @@ -350,15 +562,73 @@ describe('StoreService', () => { // Mutate, then switch BEFORE the debounce fires. store.addPage('old-account'); - const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff'; api.getData.mockResolvedValue({ pages: [] }); - store.switchToken(newToken); + store.switchToken(OTHER_TOKEN); // Run all timers — the OLD debounce must have been cancelled, // so no PUT should have happened. await vi.advanceTimersByTimeAsync(2000); expect(api.putData).not.toHaveBeenCalled(); - expect(store.token()).toBe(newToken); + expect(store.token()).toBe(OTHER_TOKEN); + }); + + it('switchToken invalidates an init already in flight', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + let resolveFirstGet: ((v: TreeDto) => void) | null = null; + api.getData + .mockReturnValueOnce(new Promise((res) => (resolveFirstGet = res))) + .mockResolvedValueOnce({ pages: [mkPage('new-account')] }); + const store = configure(api); + + const firstInit = store.init(); + store.switchToken(OTHER_TOKEN); + resolveFirstGet!({ pages: [mkPage('old-account')] }); + await firstInit; + + expect(api.getData).toHaveBeenCalledWith(OTHER_TOKEN); + expect(store.token()).toBe(OTHER_TOKEN); + expect(store.pages()[0].name).toBe('new-account'); + }); + + it('switchToken cancels a pending retry without wedging future saves', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + api.putData + .mockRejectedValueOnce(httpError(429, { 'Retry-After': '30' })) + .mockResolvedValue(undefined); + const store = configure(api); + await store.init(); + + store.addPage('old-account'); + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(1); + + api.getData.mockResolvedValue({ pages: [] }); + store.switchToken(OTHER_TOKEN); + await vi.advanceTimersByTimeAsync(0); + + store.addPage('new-account'); + await vi.advanceTimersByTimeAsync(750); + + expect(api.putData).toHaveBeenCalledTimes(2); + expect(api.putData.mock.calls[1][0]).toBe(OTHER_TOKEN); + expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].name).toBe('new-account'); + }); + + it('does not load another account cache after switching tokens', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('old-cache')] } satisfies TreeDto); + const api = makeMockApi(); + const store = configure(api); + await store.init(); + + api.getData.mockRejectedValue(httpError(0)); + store.switchToken(OTHER_TOKEN); + await vi.advanceTimersByTimeAsync(0); + + expect(storage[OTHER_CACHE_KEY]).toBeUndefined(); + expect(store.pages()).toHaveLength(0); }); it('switchToken rejects a non-UUIDv4 input', () => { @@ -370,6 +640,19 @@ describe('StoreService', () => { expect(store.token()).toBe(''); // never initialized }); + it('switchToken canonicalizes uppercase UUID input', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + const store = configure(api); + await store.init(); + + store.switchToken(OTHER_TOKEN.toUpperCase()); + await vi.advanceTimersByTimeAsync(0); + + expect(store.token()).toBe(OTHER_TOKEN); + expect(storage[TOKEN_KEY]).toBe(OTHER_TOKEN); + }); + // ── Cross-tab sync ──────────────────────────────────────────────────────── it('adopts a fresh cache written by another tab via the storage event', async () => { diff --git a/frontend/src/app/utils/hash.ts b/frontend/src/app/utils/hash.ts index fffe090..d95c6da 100644 --- a/frontend/src/app/utils/hash.ts +++ b/frontend/src/app/utils/hash.ts @@ -16,5 +16,5 @@ export function hash(s: string): number { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } // Map the signed int32 to [0, 1) — same formula as legacy - return h / (Math.pow(2, 32) - 2) + 0.5; + return h / (2 ** 32 - 2) + 0.5; }