not bad
This commit is contained in:
parent
e2a60e71a3
commit
003f38ea60
36 changed files with 1543 additions and 1287 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue