From 5ac6633d402cca8c47187d8312bff9480e8b4a2c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 30 May 2026 15:40:53 +0100 Subject: [PATCH] getting there --- CLAUDE.md | 2 +- backend/src/life_towers/api.py | 9 +- .../life_towers/migrations/001_initial.sql | 2 + .../migrations/002_keep_tasks_open.sql | 2 - backend/src/life_towers/models.py | 2 + backend/tests/test_api.py | 37 ++ frontend/e2e/visuals.spec.ts | 10 +- .../components/modal/block-edit.component.ts | 136 ++++++- .../app/components/modal/modal.component.ts | 15 +- .../app/components/page/page.component.html | 1 + .../src/app/components/page/page.component.ts | 3 + .../app/components/pages/pages.component.html | 12 +- .../app/components/pages/pages.component.ts | 24 +- .../shared/select-add/select-add.component.ts | 14 +- .../app/components/tower/tower.component.ts | 253 ++++++++++--- .../tower/tower.component.vitest.ts | 95 +++++ .../components/welcome/welcome.component.ts | 346 ++++++++++++++++-- frontend/src/app/models/index.ts | 2 + frontend/src/app/services/store.service.ts | 36 +- .../src/app/services/store.service.vitest.ts | 9 +- 20 files changed, 898 insertions(+), 112 deletions(-) delete mode 100644 backend/src/life_towers/migrations/002_keep_tasks_open.sql create mode 100644 frontend/src/app/components/tower/tower.component.vitest.ts diff --git a/CLAUDE.md b/CLAUDE.md index 91799c9..091d70c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ backend/ # FastAPI + SQLite (WAL) limits.py # slowapi limiter + payload-size middleware models.py # pydantic v2 schemas logging.py # structlog JSON - migrations/ # 001_initial.sql, 002_keep_tasks_open.sql, … + migrations/ # 001_initial.sql consolidated schema tests/test_api.py # pytest + httpx AsyncClient pyproject.toml # uv-managed frontend/ # Angular 21+ standalone, signals, zoneless diff --git a/backend/src/life_towers/api.py b/backend/src/life_towers/api.py index 3a8fd4a..9fcee2a 100644 --- a/backend/src/life_towers/api.py +++ b/backend/src/life_towers/api.py @@ -94,7 +94,7 @@ async def get_data( block_rows = conn.execute( """ - SELECT id, tag, description, is_done, created_at + SELECT id, tag, description, is_done, difficulty, created_at FROM blocks WHERE tower_id = ? ORDER BY position @@ -108,6 +108,7 @@ async def get_data( tag=b["tag"], description=b["description"], is_done=bool(b["is_done"]), + difficulty=b["difficulty"], created_at=b["created_at"], ) for b in block_rows @@ -213,8 +214,9 @@ async def put_data( """ INSERT INTO blocks (id, tower_id, user_id, position, tag, - description, is_done, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + description, is_done, difficulty, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( block.id, @@ -224,6 +226,7 @@ async def put_data( block.tag, block.description, 1 if block.is_done else 0, + block.difficulty, created_at, now, ), diff --git a/backend/src/life_towers/migrations/001_initial.sql b/backend/src/life_towers/migrations/001_initial.sql index 14ab170..b8d3761 100644 --- a/backend/src/life_towers/migrations/001_initial.sql +++ b/backend/src/life_towers/migrations/001_initial.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS pages ( position INTEGER NOT NULL, name TEXT NOT NULL, hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)), + keep_tasks_open INTEGER NOT NULL DEFAULT 0 CHECK (keep_tasks_open IN (0, 1)), default_date_from INTEGER, default_date_to INTEGER, created_at INTEGER NOT NULL, @@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS blocks ( tag TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)), + difficulty INTEGER NOT NULL DEFAULT 1 CHECK (difficulty >= 1), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) STRICT; diff --git a/backend/src/life_towers/migrations/002_keep_tasks_open.sql b/backend/src/life_towers/migrations/002_keep_tasks_open.sql deleted file mode 100644 index f9417ad..0000000 --- a/backend/src/life_towers/migrations/002_keep_tasks_open.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0 - CHECK (keep_tasks_open IN (0, 1)); diff --git a/backend/src/life_towers/models.py b/backend/src/life_towers/models.py index d848ad4..4fe8694 100644 --- a/backend/src/life_towers/models.py +++ b/backend/src/life_towers/models.py @@ -27,6 +27,7 @@ class BlockIn(BaseModel): tag: str = Field(max_length=200) description: str = Field(max_length=10_000) is_done: bool + difficulty: int = Field(default=1, ge=1, le=100) created_at: Optional[int] = None @field_validator("id") @@ -42,6 +43,7 @@ class BlockOut(BaseModel): tag: str description: str is_done: bool + difficulty: int created_at: int diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a23603b..0037c88 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -215,6 +215,7 @@ def _make_tree() -> dict: "tag": f"tag-{pi}-{ti}-{bi}", "description": f"desc-{pi}-{ti}-{bi}", "is_done": False, + "difficulty": bi + 1, "created_at": 1700000000 + bi, } ) @@ -266,6 +267,42 @@ async def test_round_trip(client: AsyncClient) -> None: for bi, block in enumerate(tower["blocks"]): assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"] assert block["tag"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["tag"] + assert ( + block["difficulty"] + == tree["pages"][pi]["towers"][ti]["blocks"][bi]["difficulty"] + ) + + +@pytest.mark.asyncio +async def test_difficulty_defaults_to_one_when_omitted(client: AsyncClient) -> None: + """A block sent without `difficulty` is stored and returned as 1.""" + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + tree = _make_tree() + # Drop difficulty from the very first block. + del tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"] + + put_resp = await client.put("/api/v1/data", json=tree, headers=headers) + assert put_resp.status_code == 204 + + data = (await client.get("/api/v1/data", headers=headers)).json() + assert data["pages"][0]["towers"][0]["blocks"][0]["difficulty"] == 1 + + +@pytest.mark.asyncio +async def test_difficulty_must_be_positive(client: AsyncClient) -> None: + """difficulty < 1 is rejected by validation.""" + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + tree = _make_tree() + tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"] = 0 + + resp = await client.put("/api/v1/data", json=tree, headers=headers) + assert resp.status_code == 400 # --------------------------------------------------------------------------- diff --git a/frontend/e2e/visuals.spec.ts b/frontend/e2e/visuals.spec.ts index 8b5429c..a1e3384 100644 --- a/frontend/e2e/visuals.spec.ts +++ b/frontend/e2e/visuals.spec.ts @@ -11,8 +11,8 @@ test.describe('Life Towers visuals', () => { await page.waitForTimeout(350); // let the welcome modal finish fade-in await page.screenshot({ path: 'visuals/01-welcome-modal.png', fullPage: true }); - // Dismiss the welcome modal with Start fresh, then continue. - await page.getByRole('button', { name: 'Start fresh' }).click(); + // Dismiss the welcome modal with Start empty, then continue. + await page.getByRole('button', { name: 'Start empty' }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); await page.screenshot({ path: 'visuals/01b-empty-state-after-dismiss.png', fullPage: true }); @@ -171,11 +171,11 @@ test.describe('Life Towers visuals', () => { await page.screenshot({ path: 'visuals/11-settings-modal.png', fullPage: true }); }); - test('"Try an example" populates a sample page', async ({ page }) => { + test('"Load sample towers" populates a sample page', async ({ page }) => { await page.goto('/'); await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 }); await page.waitForTimeout(350); - await page.getByRole('button', { name: 'Try an example' }).click(); + await page.getByRole('button', { name: 'Load sample towers' }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); await page.waitForTimeout(1800); await page.screenshot({ path: 'visuals/12-example-data.png', fullPage: true }); @@ -190,7 +190,7 @@ test.describe('Life Towers visuals', () => { await page.waitForTimeout(350); await page.screenshot({ path: 'visuals/13-mobile-welcome.png', fullPage: true }); - await page.getByRole('button', { name: 'Try an example' }).click(); + await page.getByRole('button', { name: 'Load sample towers' }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); await page.waitForTimeout(1800); await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true }); diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts index 63296ae..cd50b5b 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -23,12 +23,14 @@ export interface BlockEditSave { tag: string; description: string; is_done: boolean; + difficulty: number; } interface EditedValue { tag: string; description: string; is_done: boolean; + difficulty: number; } @Component({ @@ -73,8 +75,7 @@ interface EditedValue { [selected]="editedFor(b.id).tag" [alwaysDropShadow]="true" [onlyShadowBorder]="true" - placeholder="Tag this item…" - emptyHint="No tags yet. Type one below to create it." + [placeholder]="tagPlaceholder('Tag this item…')" (select)="updateTag(b.id, $event)" (add)="updateTag(b.id, $event)" /> @@ -96,6 +97,26 @@ interface EditedValue { Already done +
+ Difficulty +
+ + {{ editedFor(b.id).difficulty }} + +
+
+
@@ -125,8 +146,7 @@ interface EditedValue { [selected]="newValue().tag" [alwaysDropShadow]="true" [onlyShadowBorder]="true" - placeholder="Set a category…" - emptyHint="No tags yet. Type one below to create it." + [placeholder]="tagPlaceholder('Set a category…')" (select)="updateNewTag($event)" (add)="updateNewTag($event)" /> @@ -147,6 +167,26 @@ interface EditedValue { Already done +
+ Difficulty +
+ + {{ newValue().difficulty }} + +
+
+
+

Welcome to Life Towers

-

Welcome to Life Towers

+ -

- A visual TODO with bite. Each page is a context (work, hobbies, a project). - Each tower is a stack of related tasks. As you finish a task, it falls into the - tower as a colored square — the more you do, the taller it grows. +

+ Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies, + home, or any project. Add towers for task groups, then check off tasks to build them + block by block.

-

- Everything you write is saved to a small remote database, keyed to a private UUID token shown - under Settings → Account. Copy that token to recover your data on another device, or - paste a friend's token to look at theirs. +

+ Preview showing three towers with pending task bars at the top and completed task + blocks stacked below. +

+ + +

How Life Towers works

+
+
+
Pages
+
Keep each area separate.
+
+
+
Towers
+
Stack related tasks together.
+
+
+
Blocks
+
Finished tasks become colored blocks.
+
+
+ + + +

+ Start empty if you know what to track, or load sample towers you can edit or delete.

- - + +
`, @@ -34,12 +109,27 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; :host { display: block; } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + .welcome-card { @include card(); - width: 66vw; - max-width: 480px; + width: min(560px, calc(100vw - (2 * var(--large-padding)))); + max-width: 560px; + max-height: calc(100svh - (2 * var(--medium-padding))); + overflow-y: auto; @media (max-width: $mobile-width) { - width: 88vw; + width: min(88vw, calc(100vw - (2 * var(--medium-padding)))); max-width: 88vw; padding: var(--medium-padding); } @@ -48,8 +138,20 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; position: relative; box-shadow: $shadow; text-align: left; + font-family: $normal-font; + font-weight: 300; + font-size: var(--medium-font-size); + line-height: 1.45; @include inner-spacing(var(--medium-padding)); + h2, + h3, + p, + dl, + dd { + margin: 0; + } + .exit { position: absolute; top: var(--medium-padding); @@ -58,31 +160,231 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; } h2 { + font-family: $title-font; + font-weight: 400; + font-size: var(--larger-font-size); text-align: center; - margin-bottom: var(--medium-padding); padding: 0 36px; + line-height: 1.25; @media (max-width: $mobile-width) { padding: 0 28px; } } - p.lead { color: $text-color; } + .lead { + color: $text-color; + font-family: inherit; + font-weight: inherit; + font-size: inherit; + line-height: inherit; + max-width: 46ch; + margin-inline: auto; + text-align: center; + } - p.muted { - color: rgba($text-color, 0.7); - font-size: var(--medium-font-size); - em { font-style: italic; } + .tower-preview { + box-sizing: border-box; + padding: var(--medium-padding) 0; + border-top: 1px solid rgba($text-color, 0.08); + border-bottom: 1px solid rgba($text-color, 0.08); + + @media (max-width: $mobile-width) { + padding-block: var(--small-padding); + } + } + + .preview-shell { + width: min(100%, 370px); + margin: 0 auto; + } + + .preview-page-tab { + width: 96px; + height: 14px; + margin: 0 auto var(--small-padding); + border-radius: var(--border-radius); + background: rgba($text-color, 0.08); + box-shadow: $shadow-border; + } + + .preview-towers { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: end; + gap: var(--small-padding); + } + + .preview-tower { + display: flex; + flex-direction: column; + min-height: 128px; + padding: 6px; + box-sizing: border-box; + border-radius: var(--border-radius); + background: rgba($text-color, 0.025); + box-shadow: $shadow-border; + } + + .preview-task { + display: block; + width: 74%; + height: 5px; + margin-bottom: 4px; + border-radius: var(--border-radius); + background: var(--task-color); + } + + .preview-task--short { + width: 48%; + } + + .preview-stack { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-content: end; + gap: 2px; + min-height: 86px; + margin-top: auto; + + span { + display: block; + aspect-ratio: 1; + border-radius: 2px; + background: var(--block-color); + box-shadow: $shadow-border; + } + + span:nth-child(2n) { + filter: saturate(0.9) brightness(1.06); + } + + span:nth-child(3n) { + filter: saturate(1.08) brightness(0.96); + } + } + + @media (prefers-reduced-motion: no-preference) { + @keyframes preview-task-pulse { + 0%, 100% { + opacity: 0.42; + transform: scaleX(0.86); + } + 45%, 70% { + opacity: 1; + transform: scaleX(1); + } + } + + @keyframes preview-block-fall { + 0% { + opacity: 0; + transform: translateY(-220%); + } + 60%, 100% { + opacity: 1; + transform: translateY(0); + } + } + + .preview-task { + transform-origin: left center; + animation: preview-task-pulse 2600ms ease-in-out infinite; + } + + .preview-stack span { + opacity: 0; + animation: preview-block-fall 1200ms cubic-bezier(0.5, 0, 1, 0) forwards; + animation-delay: calc(160ms + var(--fall-index, 0) * 95ms); + } + + .preview-stack span:nth-child(1) { --fall-index: 0; } + .preview-stack span:nth-child(2) { --fall-index: 1; } + .preview-stack span:nth-child(3) { --fall-index: 2; } + .preview-stack span:nth-child(4) { --fall-index: 3; } + .preview-stack span:nth-child(5) { --fall-index: 4; } + .preview-stack span:nth-child(6) { --fall-index: 5; } + .preview-stack span:nth-child(7) { --fall-index: 6; } + .preview-stack span:nth-child(8) { --fall-index: 7; } + .preview-stack span:nth-child(9) { --fall-index: 8; } + } + + .preview-tower--reading { + --block-color: hsl(18, 70%, 58%); + --task-color: hsla(18, 70%, 58%, 0.26); + } + + .preview-tower--projects { + --block-color: hsl(209, 65%, 52%); + --task-color: hsla(209, 65%, 52%, 0.24); + } + + .preview-tower--exercise { + --block-color: hsl(130, 45%, 44%); + --task-color: hsla(130, 45%, 44%, 0.22); + } + + .basics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--small-padding); + + @media (max-width: $mobile-width) { + grid-template-columns: 1fr; + } + } + + .basic { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .basic__label { + font-family: inherit; + font-weight: inherit; + color: $text-color; + font-size: inherit; + line-height: inherit; + } + + .basic__text, + .account-note, + .choice-note { + font-family: inherit; + font-weight: inherit; + color: rgba($text-color, 0.82); + font-size: inherit; + line-height: inherit; + } + + .account-note, + .choice-note { + max-width: 54ch; + margin-inline: auto; + } + + .choice-note { + text-align: center; } .actions { display: flex; justify-content: space-around; + align-items: flex-end; + flex-wrap: wrap; gap: var(--large-padding); margin-top: var(--large-padding); button { + font-family: inherit; + font-weight: inherit; + font-size: inherit; + margin: 0; max-width: 100%; + line-height: inherit; + text-align: center; } button.primary { diff --git a/frontend/src/app/models/index.ts b/frontend/src/app/models/index.ts index 1f0af8b..e9216d0 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; } diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index f158445..ad5b41d 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -60,6 +60,7 @@ interface ExampleBlockSeed { tag: string; desc: string; done: boolean; + difficulty?: number; ageHrs: number; } @@ -318,12 +319,14 @@ 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) => @@ -578,7 +581,7 @@ export class StoreService implements OnDestroy { // ── Example data ────────────────────────────────────────────────────────── - loadExample(): void { + loadExample(): string { const now = Math.floor(Date.now() / 1000); const page: Page = { @@ -593,7 +596,13 @@ export class StoreService implements OnDestroy { // 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: 'Finish The Brothers Karamazov', + done: false, + difficulty: 4, + ageHrs: 0, + }, ...this.makeExampleDoneBlocks( [ { tag: 'novel', desc: (n) => `Read chapter ${n}` }, @@ -606,8 +615,20 @@ export class StoreService implements OnDestroy { ), ]), 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: '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}` }, @@ -620,8 +641,8 @@ export class StoreService implements OnDestroy { ), ]), 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: '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}` }, @@ -640,6 +661,7 @@ export class StoreService implements OnDestroy { this.analytics.trackStart(); this.analytics.trackExampleLoaded(); this.scheduleSave(); + return page.id; } private makeExampleTower( @@ -657,6 +679,7 @@ 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), })), }; @@ -674,6 +697,7 @@ export class StoreService implements OnDestroy { 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, }; }); diff --git a/frontend/src/app/services/store.service.vitest.ts b/frontend/src/app/services/store.service.vitest.ts index 75d3f30..3081883 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -241,6 +241,7 @@ describe('StoreService', () => { tag: 'a', description: 'Created first, completed last', is_done: false, + difficulty: 1, created_at: 100, }, { @@ -248,6 +249,7 @@ describe('StoreService', () => { tag: 'b', description: 'Still pending', is_done: false, + difficulty: 1, created_at: 300, }, { @@ -255,6 +257,7 @@ describe('StoreService', () => { tag: 'c', description: 'Already done', is_done: true, + difficulty: 1, created_at: 200, }, ], @@ -281,19 +284,23 @@ describe('StoreService', () => { const api = makeMockApi(); const store = configure(api); - store.loadExample(); + 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)); expect(doneBlocks.length).toBeGreaterThanOrEqual(300); + 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); expect(doneDates.length).toBeGreaterThanOrEqual(100); expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); + expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1); } store.ngOnDestroy();