import { Injectable, inject, signal, computed, 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 DEBOUNCE_MS = 750; const MAX_RETRIES = 5; // RFC 4122 v4 UUID. Prefers crypto.randomUUID (secure contexts only) and // falls back to crypto.getRandomValues — which works on plain http origins // behind a non-localhost reverse proxy too. function uuidV4(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { try { return crypto.randomUUID(); } catch { /* fall through */ } } const b = new Uint8Array(16); crypto.getRandomValues(b); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; } 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, ); } // localStorage can throw (private mode, quota exceeded, disabled). All // access goes through these helpers so a transient failure never bubbles // up and breaks app init. function safeGet(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } function safeSet(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { /* ignore */ } } interface PendingPut { token: string; tree: TreeDto; } 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 = 120; @Injectable({ providedIn: 'root' }) export class StoreService implements OnDestroy { private readonly api = inject(ApiService); private readonly analytics = inject(AnalyticsService); // ── State ────────────────────────────────────────────────────────────────── private readonly _pages = signal([]); private readonly _saveStatus = signal('idle'); private readonly _token = signal(''); private readonly _loading = signal(true); readonly pages = this._pages.asReadonly(); readonly saveStatus = this._saveStatus.asReadonly(); 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 flushInFlight = false; // True while in-flight if a new mutation arrived; we'll re-flush after. private dirtyDuringFlush = false; // ── 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) { // 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 { const tree: TreeDto = JSON.parse(e.newValue); this._pages.set(tree.pages); } catch { /* ignore */ } } }; // ── Single-flight init ───────────────────────────────────────────────────── private initPromise: Promise | null = null; // ── Init ─────────────────────────────────────────────────────────────────── async init(): Promise { if (this.initPromise) return this.initPromise; this.initPromise = this.doInit().finally(() => { this.initPromise = null; }); return this.initPromise; } private async doInit(): Promise { if (typeof window !== 'undefined') { // (idempotent — adding the same listener twice is a no-op) window.addEventListener('storage', this.storageListener); } let stored = safeGet(TOKEN_KEY); if (stored && !isUuidV4(stored)) { // Garbage in localStorage from a buggy past version — refuse it. stored = null; } const token = stored ?? uuidV4(); if (!stored) { safeSet(TOKEN_KEY, token); } this._token.set(token); if (!stored) { try { await this.api.register(token); } catch { // Non-fatal; the 401 path below will re-attempt registration. } } try { const tree = await this.api.getData(token); this.adoptServerTree(tree); } catch (err: unknown) { const status = (err as { status?: number })?.status; if (status === 401) { // Token unknown to server — re-register (idempotent) and retry. try { await this.api.register(token); const tree = await this.api.getData(token); this.adoptServerTree(tree); } catch { this.loadFromCache(); } } else { this.loadFromCache(); } } finally { this._loading.set(false); } } /** * 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 { 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 */ } } } this._pages.set(tree.pages); this.updateCache(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 updateCache(tree: TreeDto): void { safeSet(CACHE_KEY, JSON.stringify(tree)); } private cancelPendingWrites(): void { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.retryTimer !== null) { clearTimeout(this.retryTimer); this.retryTimer = null; } this.dirtyDuringFlush = false; } // ── Mutations ────────────────────────────────────────────────────────────── addPage(name: string): void { const page: Page = { id: uuidV4(), name, hide_create_tower_button: false, keep_tasks_open: false, default_date_from: null, default_date_to: null, towers: [], }; this._pages.update((pages) => [...pages, page]); this.analytics.trackStart(); this.analytics.trackPageCreated(); this.scheduleSave(); } updatePage(id: string, patch: Partial>): void { this._pages.update((pages) => pages.map((p) => (p.id === id ? { ...p, ...patch } : p)), ); this.scheduleSave(); } deletePage(id: string): void { this._pages.update((pages) => pages.filter((p) => p.id !== id)); this.scheduleSave(); } reorderPages(fromIndex: number, toIndex: number): void { this._pages.update((pages) => { const arr = [...pages]; const [item] = arr.splice(fromIndex, 1); arr.splice(toIndex, 0, item); return arr; }); this.scheduleSave(); } addTower(pageId: string, name: string, base_color: HslColor): void { const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] }; this._pages.update((pages) => pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)), ); this.analytics.trackStart(); this.analytics.trackTowerCreated(); this.scheduleSave(); } updateTower(pageId: string, towerId: string, patch: Partial>): void { this._pages.update((pages) => pages.map((p) => p.id === pageId ? { ...p, towers: p.towers.map((t) => (t.id === towerId ? { ...t, ...patch } : t)) } : p, ), ); this.scheduleSave(); } deleteTower(pageId: string, towerId: string): void { this._pages.update((pages) => pages.map((p) => p.id === pageId ? { ...p, towers: p.towers.filter((t) => t.id !== towerId) } : p, ), ); this.scheduleSave(); } reorderTowers(pageId: string, fromIndex: number, toIndex: number): void { 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); return { ...p, towers }; }), ); this.scheduleSave(); } addBlock( pageId: string, towerId: string, 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, ), ); this.analytics.trackStart(); this.analytics.trackBlockCreated({ isDone: is_done }); this.scheduleSave(); } updateBlock( pageId: string, towerId: string, blockId: string, patch: Partial>, ): void { this._pages.update((pages) => pages.map((p) => p.id === pageId ? { ...p, towers: p.towers.map((t) => t.id === towerId ? { ...t, blocks: this.patchBlockList(t.blocks, blockId, patch).blocks, } : t, ), } : p, ), ); this.scheduleSave(); } deleteBlock(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.filter((b) => b.id !== blockId) } : t, ), } : p, ), ); this.scheduleSave(); } toggleBlock(pageId: string, towerId: string, blockId: string): void { let becameDone = false; this._pages.update((pages) => pages.map((p) => p.id === pageId ? { ...p, towers: p.towers.map((t) => t.id === towerId ? (() => { const block = t.blocks.find((b) => b.id === blockId); if (!block) return t; const result = this.patchBlockList(t.blocks, blockId, { is_done: !block.is_done, }); becameDone = result.becameDone; return { ...t, blocks: result.blocks }; })() : t, ), } : p, ), ); this.analytics.trackStart(); if (becameDone) this.analytics.trackBlockCompleted(); 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, }; } /** * Switch to a different user's token. Any pending writes for the OLD * account must be cancelled first — otherwise a queued PUT could fire * against the NEW account with the old account's data (or vice versa, * if the timing flipped). */ switchToken(newToken: string): void { if (!isUuidV4(newToken)) return; this.cancelPendingWrites(); safeSet(TOKEN_KEY, newToken); this._token.set(newToken); this._pages.set([]); this._loading.set(true); this._saveStatus.set('idle'); void this.init(); } // ── Save / sync ──────────────────────────────────────────────────────────── private scheduleSave(): void { if (this.flushInFlight) { // A save is already happening. Mark dirty so we re-flush when it finishes. this.dirtyDuringFlush = true; return; } if (this.debounceTimer !== null) clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.debounceTimer = null; void this.runFlush(); }, DEBOUNCE_MS); } /** * One save attempt with bounded retries. Captures the token and tree * snapshot up front so a mid-flight switchToken can't redirect this * write to a different account. */ private async runFlush(): Promise { if (this.flushInFlight) { this.dirtyDuringFlush = true; return; } const token = this._token(); if (!token) return; this.flushInFlight = true; this.dirtyDuringFlush = false; // Cancel any pending retry — runFlush() supersedes it. if (this.retryTimer !== null) { clearTimeout(this.retryTimer); this.retryTimer = null; } try { await this.attempt({ token, tree: { pages: this._pages() } }, 0); } finally { this.flushInFlight = false; // Coalesce mutations that arrived during the flush into a fresh save. if (this.dirtyDuringFlush) { this.dirtyDuringFlush = false; this.scheduleSave(); } } } private async attempt(put: PendingPut, attempt: number): Promise { this._saveStatus.set(attempt === 0 ? 'saving' : 'retrying'); try { await this.api.putData(put.token, put.tree); this._saveStatus.set('saved'); this.updateCache(put.tree); return; } catch (err: unknown) { const status = (err as { status?: number })?.status; const headers = (err as { headers?: { get(name: string): string | null } })?.headers; // Permanent failures: no point retrying. if (status === 400) { this._saveStatus.set('invalid'); return; } if (status === 413) { this._saveStatus.set('too-large'); return; } // 401 mid-PUT: server forgot us. Re-register (idempotent) and retry. if (status === 401) { try { await this.api.register(put.token); } catch { // fall through to retry/backoff } } if (attempt >= MAX_RETRIES) { this._saveStatus.set('error'); return; } // Honor server's Retry-After when present (429 in particular). let delayMs = Math.min(1000 * 2 ** attempt, 30000); if (status === 429) { this._saveStatus.set('rate-limited'); const ra = headers?.get('Retry-After') ?? headers?.get('retry-after'); if (ra) { const seconds = parseInt(ra, 10); if (Number.isFinite(seconds) && seconds > 0) { delayMs = Math.min(seconds * 1000, 60_000); } } } await new Promise((resolve) => { this.retryTimer = setTimeout(() => { this.retryTimer = null; resolve(); }, delayMs); }); // 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); } } // ── Example data ────────────────────────────────────────────────────────── loadExample(): string { const now = Math.floor(Date.now() / 1000); const page: Page = { id: uuidV4(), name: 'Hobbies', hide_create_tower_button: false, keep_tasks_open: true, 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, 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, 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, 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: ExampleBlockSeed[], ): Tower { return { id: uuidV4(), name, base_color, blocks: blocks.map((b) => ({ id: uuidV4(), 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') { window.removeEventListener('storage', this.storageListener); } } }