This commit is contained in:
Andras Schmelczer 2026-05-30 14:33:39 +01:00
parent e2a60e71a3
commit 003f38ea60
36 changed files with 1543 additions and 1287 deletions

View file

@ -1,5 +1,6 @@
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';
@ -55,9 +56,24 @@ interface PendingPut {
tree: TreeDto;
}
interface ExampleBlockSeed {
tag: string;
desc: string;
done: boolean;
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<Page[]>([]);
@ -226,6 +242,8 @@ export class StoreService implements OnDestroy {
towers: [],
};
this._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackPageCreated();
this.scheduleSave();
}
@ -256,6 +274,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();
}
@ -318,6 +338,8 @@ export class StoreService implements OnDestroy {
: p,
),
);
this.analytics.trackStart();
this.analytics.trackBlockCreated({ isDone: is_done });
this.scheduleSave();
}
@ -336,7 +358,7 @@ export class StoreService implements OnDestroy {
t.id === towerId
? {
...t,
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
}
: t,
),
@ -366,6 +388,7 @@ export class StoreService implements OnDestroy {
}
toggleBlock(pageId: string, towerId: string, blockId: string): void {
let becameDone = false;
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId
@ -373,21 +396,58 @@ export class StoreService implements OnDestroy {
...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,
),
}
? (() => {
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<Omit<Block, 'id' | 'created_at'>>,
): { 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
@ -529,30 +589,56 @@ 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, 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, ageHrs: 0 },
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, 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, ageHrs: 0 },
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, 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();
}
@ -560,7 +646,7 @@ export class StoreService implements OnDestroy {
name: string,
base_color: HslColor,
nowSec: number,
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
blocks: ExampleBlockSeed[],
): Tower {
return {
id: uuidV4(),
@ -576,6 +662,23 @@ export class StoreService implements OnDestroy {
};
}
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,
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
};
});
}
ngOnDestroy(): void {
this.cancelPendingWrites();
if (typeof window !== 'undefined') {