getting there
This commit is contained in:
parent
f87480e79d
commit
5ac6633d40
20 changed files with 898 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (keep_tasks_open IN (0, 1));
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="difficulty">
|
||||
<span class="label">Difficulty</span>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Decrease difficulty"
|
||||
[disabled]="editedFor(b.id).difficulty <= 1"
|
||||
(click)="updateDifficulty(b.id, -1); $event.stopPropagation()"
|
||||
>−</button>
|
||||
<span class="value">{{ editedFor(b.id).difficulty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
|
||||
</div>
|
||||
|
|
@ -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 {
|
|||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="difficulty">
|
||||
<span class="label">Difficulty</span>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Decrease difficulty"
|
||||
[disabled]="newValue().difficulty <= 1"
|
||||
(click)="updateNewDifficulty(-1); $event.stopPropagation()"
|
||||
>−</button>
|
||||
<span class="value">{{ newValue().difficulty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button
|
||||
(click)="submitNew(); $event.stopPropagation()"
|
||||
|
|
@ -394,6 +434,68 @@ interface EditedValue {
|
|||
}
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
@include medium-text();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--small-padding);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto var(--medium-padding);
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--small-padding);
|
||||
|
||||
.value {
|
||||
min-width: 1.5em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
button.step {
|
||||
all: unset; // strip native + global button styles
|
||||
@include square(22px);
|
||||
@include center-child();
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
background: $light-color;
|
||||
box-shadow: $shadow-border;
|
||||
cursor: pointer;
|
||||
// all:unset drops the font to serif and the global button's hover
|
||||
// underline (button::after) survives the reset — re-assert both.
|
||||
font: bold 18px/1 $normal-font;
|
||||
color: $text-color;
|
||||
user-select: none;
|
||||
transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time;
|
||||
|
||||
&::after { content: none; }
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
@include square(26px);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) {
|
||||
|
|
@ -454,6 +556,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: '',
|
||||
description: '',
|
||||
is_done: true,
|
||||
difficulty: 1,
|
||||
});
|
||||
|
||||
// 1-based index of the centered card. 0/N+2 are placeholders.
|
||||
|
|
@ -471,6 +574,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: b.tag,
|
||||
description: b.description,
|
||||
is_done: b.is_done,
|
||||
difficulty: b.difficulty ?? 1,
|
||||
});
|
||||
}
|
||||
untracked(() => this.editedValues.set(m));
|
||||
|
|
@ -510,6 +614,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: '',
|
||||
description: '',
|
||||
is_done: false,
|
||||
difficulty: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -519,6 +624,10 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
|
||||
}
|
||||
|
||||
tagPlaceholder(fallback: string): string {
|
||||
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
||||
}
|
||||
|
||||
colorOfNewTag = computed(() => {
|
||||
const t = this.newValue().tag;
|
||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
||||
|
|
@ -561,6 +670,12 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
updateDifficulty(id: string, delta: number): void {
|
||||
const next = Math.max(1, this.editedFor(id).difficulty + delta);
|
||||
this.patchEdited(id, { difficulty: next });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
private patchEdited(id: string, patch: Partial<EditedValue>): void {
|
||||
this.editedValues.update((m) => {
|
||||
const v = m.get(id);
|
||||
|
|
@ -574,7 +689,13 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
flushExisting(id: string): void {
|
||||
const v = this.editedFor(id);
|
||||
if (!v.tag) return; // Skip empty saves
|
||||
this.save.emit({ id, tag: v.tag, description: v.description, is_done: v.is_done });
|
||||
this.save.emit({
|
||||
id,
|
||||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(id: string): void {
|
||||
|
|
@ -595,6 +716,10 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
this.newValue.update((v) => ({ ...v, is_done }));
|
||||
}
|
||||
|
||||
updateNewDifficulty(delta: number): void {
|
||||
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
|
||||
}
|
||||
|
||||
submitNew(): void {
|
||||
const v = this.newValue();
|
||||
if (!v.tag) return;
|
||||
|
|
@ -603,6 +728,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
this.close.emit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
output,
|
||||
input,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
|
|
@ -23,7 +24,17 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
[class.active]="active()"
|
||||
(click)="onBackdropClick($event)"
|
||||
>
|
||||
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
|
||||
<div
|
||||
class="modal__dialog"
|
||||
#dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="labelledBy()"
|
||||
[attr.aria-describedby]="describedBy()"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
(keydown.escape)="onClose()"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -73,6 +84,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
`,
|
||||
})
|
||||
export class ModalComponent implements AfterViewInit, OnDestroy {
|
||||
readonly labelledBy = input<string | null>(null);
|
||||
readonly describedBy = input<string | null>(null);
|
||||
readonly close = output<void>();
|
||||
|
||||
// The active signal starts false; AfterViewInit flips it true on next tick
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
[tower]="tower"
|
||||
[dateRange]="dateRange()"
|
||||
[keepTasksOpen]="page().keep_tasks_open"
|
||||
[animateInitialStack]="animateInitialStack()"
|
||||
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
||||
(updateTower)="onUpdateTower(tower.id, $event)"
|
||||
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface BlockPatch {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
/** Minimum blocks before the date-range slider becomes visible. */
|
||||
|
|
@ -61,6 +62,7 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
|
|||
})
|
||||
export class PageComponent {
|
||||
readonly page = input.required<Page>();
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
readonly dragHappening = output<boolean>();
|
||||
|
||||
protected readonly store = inject(StoreService);
|
||||
|
|
@ -157,6 +159,7 @@ export class PageComponent {
|
|||
result.tag,
|
||||
result.description,
|
||||
result.is_done,
|
||||
result.difficulty,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
<div class="page-container">
|
||||
@if (selectedPage(); as page) {
|
||||
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
|
||||
<lt-page
|
||||
[page]="page"
|
||||
[animateInitialStack]="page.id === animateInitialStackPageId()"
|
||||
(dragHappening)="dragHappening.set($event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>Add a new page to get started!</p>
|
||||
}
|
||||
|
|
@ -53,7 +57,11 @@
|
|||
}
|
||||
|
||||
@if (showWelcome()) {
|
||||
<lt-modal (close)="showWelcome.set(false)">
|
||||
<lt-modal
|
||||
labelledBy="welcome-title"
|
||||
describedBy="welcome-description"
|
||||
(close)="showWelcome.set(false)"
|
||||
>
|
||||
<lt-welcome
|
||||
(close)="showWelcome.set(false)"
|
||||
(startFresh)="showWelcome.set(false)"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
signal,
|
||||
computed,
|
||||
effect,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { PageComponent } from '../page/page.component';
|
||||
|
|
@ -22,7 +23,7 @@ import { Page } from '../../models';
|
|||
templateUrl: './pages.component.html',
|
||||
styleUrl: './pages.component.scss',
|
||||
})
|
||||
export class PagesComponent {
|
||||
export class PagesComponent implements OnDestroy {
|
||||
protected readonly store = inject(StoreService);
|
||||
|
||||
/** ID of currently selected page within store.pages(). */
|
||||
|
|
@ -32,6 +33,8 @@ export class PagesComponent {
|
|||
readonly dragHappening = signal(false);
|
||||
readonly showWelcome = signal(false);
|
||||
readonly confirmDeletePageId = signal<string | null>(null);
|
||||
readonly animateInitialStackPageId = signal<string | null>(null);
|
||||
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
|
@ -44,10 +47,27 @@ export class PagesComponent {
|
|||
}
|
||||
|
||||
onLoadExample(): void {
|
||||
this.store.loadExample();
|
||||
const pageId = this.store.loadExample();
|
||||
this.selectedPageId.set(pageId);
|
||||
this.animateInitialStackPageId.set(pageId);
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
this.exampleAnimationTimer = setTimeout(() => {
|
||||
if (this.animateInitialStackPageId() === pageId) {
|
||||
this.animateInitialStackPageId.set(null);
|
||||
}
|
||||
this.exampleAnimationTimer = null;
|
||||
}, 2500);
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
}
|
||||
|
||||
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
|
||||
|
||||
readonly selectedPage = computed<Page | null>(() => {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ import {
|
|||
</div>
|
||||
<div class="bottom-container">
|
||||
<div class="bottom" [class.open]="open()">
|
||||
@if (resolvedItems().length === 0 && emptyHint()) {
|
||||
<p class="empty-hint">{{ emptyHint() }}</p>
|
||||
}
|
||||
@for (item of resolvedItems(); track item) {
|
||||
@if (editing()) {
|
||||
<input
|
||||
|
|
@ -204,14 +201,6 @@ import {
|
|||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
@include medium-text();
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: rgba($text-color, 0.72);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
@include sub-title-text();
|
||||
width: 100%;
|
||||
|
|
@ -384,7 +373,6 @@ export class SelectAddComponent implements OnChanges {
|
|||
readonly selected = input<string | null>(null);
|
||||
readonly editable = input<boolean>(false);
|
||||
readonly placeholder = input<string>('Select…');
|
||||
readonly emptyHint = input<string>('');
|
||||
readonly alwaysDropShadow = input<boolean>(false);
|
||||
readonly onlyShadowBorder = input<boolean>(false);
|
||||
|
||||
|
|
@ -419,7 +407,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
protected resolvedSelected(): string | null {
|
||||
// New API: string
|
||||
const s = this.selected();
|
||||
if (s != null) return s;
|
||||
if (s != null && s.trim()) return s;
|
||||
// Legacy API: index into options
|
||||
const idx = this.selectedIndex();
|
||||
const opts = this.resolvedItems();
|
||||
|
|
|
|||
|
|
@ -21,24 +21,100 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
|
|||
import { toCss } from '../../utils/color';
|
||||
|
||||
/** Tracks which entry path the block-edit modal was opened from. */
|
||||
type EditEntry = { filter: 'done' | 'pending'; activeId: string | null };
|
||||
interface EditEntry {
|
||||
filter: 'done' | 'pending';
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
/** A done block augmented with per-render animation state. */
|
||||
interface StyledBlock extends Block {
|
||||
export interface StyledBlock extends Block {
|
||||
_anim: '' | 'descend' | 'ascend';
|
||||
_transform: string;
|
||||
_opacity: string;
|
||||
}
|
||||
|
||||
/** One rendered square. A block draws `difficulty` squares, all sharing the
|
||||
* block's color + animation state. */
|
||||
interface RenderSquare {
|
||||
key: string;
|
||||
block: StyledBlock;
|
||||
}
|
||||
|
||||
const BLOCKS_PER_ROW = 6;
|
||||
|
||||
/** How many squares a block draws (its difficulty, clamped to >= 1). */
|
||||
function squareCount(block: Block): number {
|
||||
const n = Math.floor(block.difficulty ?? 1);
|
||||
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
function totalSquares(blocks: Block[]): number {
|
||||
return blocks.reduce((sum, block) => sum + squareCount(block), 0);
|
||||
}
|
||||
|
||||
/** Pick the newest blocks (array tail) whose cumulative square-count (each
|
||||
* block costs `difficulty` squares) fits within `limit`, preserving order. */
|
||||
function fitNewestBySquares(blocks: StyledBlock[], limit: number): StyledBlock[] {
|
||||
const chosen: StyledBlock[] = [];
|
||||
let used = 0;
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const cost = squareCount(blocks[i]);
|
||||
if (used + cost > limit) break;
|
||||
used += cost;
|
||||
chosen.unshift(blocks[i]);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function selectVisibleStyledBlocks(
|
||||
styled: StyledBlock[],
|
||||
visibleLimit: number,
|
||||
enteringInRangeId: string | null,
|
||||
): { visibleStyled: StyledBlock[]; hiddenCount: number } {
|
||||
// `visibleLimit` is a number of SQUARE slots. A block draws `difficulty`
|
||||
// squares, so we cap by cumulative square cost, not by raw block count.
|
||||
const normalizedLimit = Math.max(0, visibleLimit);
|
||||
const restingBlocks = styled.filter((b) => b._opacity === '1');
|
||||
let shownRestingBlocks = fitNewestBySquares(restingBlocks, normalizedLimit);
|
||||
const enteringBlock =
|
||||
enteringInRangeId === null ? undefined : restingBlocks.find((b) => b.id === enteringInRangeId);
|
||||
|
||||
if (enteringBlock && !shownRestingBlocks.some((b) => b.id === enteringBlock.id)) {
|
||||
// Guarantee the just-completed block a slot, then fill the remaining
|
||||
// square budget with the newest of the others.
|
||||
const reservedBudget = Math.max(0, normalizedLimit - squareCount(enteringBlock));
|
||||
const others = restingBlocks.filter((b) => b.id !== enteringBlock.id);
|
||||
shownRestingBlocks = [enteringBlock, ...fitNewestBySquares(others, reservedBudget)];
|
||||
}
|
||||
|
||||
const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks));
|
||||
const usedSquares = totalSquares(shownRestingBlocks);
|
||||
const remainingBudget = Math.max(0, normalizedLimit - usedSquares);
|
||||
const transitioningBlocks =
|
||||
remainingBudget > 0
|
||||
? fitNewestBySquares(
|
||||
styled.filter((b) => b._opacity !== '1'),
|
||||
remainingBudget,
|
||||
)
|
||||
: [];
|
||||
const shownIds = new Set([
|
||||
...shownRestingBlocks.map((b) => b.id),
|
||||
...transitioningBlocks.map((b) => b.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
visibleStyled: styled.filter((b) => shownIds.has(b.id)),
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-tower',
|
||||
standalone: true,
|
||||
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="tower">
|
||||
<div #towerRoot class="tower">
|
||||
<div class="tower-header">
|
||||
<input
|
||||
#nameInput
|
||||
|
|
@ -76,26 +152,31 @@ const BLOCKS_PER_ROW = 6;
|
|||
<div
|
||||
#stackZone
|
||||
class="stack-zone"
|
||||
[class.empty]="tower().blocks.length === 0"
|
||||
[style.--block-stack-height]="blockStackHeight()"
|
||||
>
|
||||
<img
|
||||
src="assets/plus-sign.svg"
|
||||
class="add-block"
|
||||
alt="Add block"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); openAddBlock()"
|
||||
(keydown.enter)="$event.stopPropagation(); openAddBlock()"
|
||||
(keydown.space)="$event.preventDefault(); $event.stopPropagation(); openAddBlock()"
|
||||
/>
|
||||
|
||||
<div class="block-container-container">
|
||||
<div class="block-container">
|
||||
@for (b of visibleBlocks(); track b.id) {
|
||||
@for (sq of squares(); track sq.key) {
|
||||
<lt-block
|
||||
[block]="b"
|
||||
[block]="sq.block"
|
||||
[baseColor]="tower().base_color"
|
||||
[class]="b._anim"
|
||||
[style.transform]="b._transform"
|
||||
[style.opacity]="b._opacity"
|
||||
(clicked)="onEditBlock(b)"
|
||||
[class]="sq.block._anim"
|
||||
[style.transform]="sq.block._transform"
|
||||
[style.opacity]="sq.block._opacity"
|
||||
(clicked)="onEditBlock(sq.block)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -209,6 +290,7 @@ const BLOCKS_PER_ROW = 6;
|
|||
--block-stack-height: 0px;
|
||||
--add-block-size: 48px;
|
||||
--add-block-clearance: var(--medium-padding);
|
||||
--empty-add-block-center-offset: 0px;
|
||||
|
||||
@include card();
|
||||
overflow: hidden;
|
||||
|
|
@ -297,6 +379,16 @@ const BLOCKS_PER_ROW = 6;
|
|||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&.empty img.add-block {
|
||||
top: max(
|
||||
0px,
|
||||
min(
|
||||
calc(50% - var(--add-block-size) / 2 - var(--empty-add-block-center-offset)),
|
||||
calc(100% - var(--add-block-size))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.block-container-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -431,16 +523,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||
/** When true, the tasks accordion starts expanded on load. */
|
||||
readonly keepTasksOpen = input<boolean>(false);
|
||||
/** When true, completed blocks descend on this tower's first measured render. */
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
|
||||
// ── Outputs ────────────────────────────────────────────────────────────────
|
||||
readonly updateTower = output<TowerSettingsResult>();
|
||||
readonly deleteTowerRequest = output<void>();
|
||||
/** Emitted when a new block is created from the carousel's "Create now" card. */
|
||||
readonly addBlock = output<{ tag: string; description: string; is_done: boolean }>();
|
||||
readonly addBlock = output<{ tag: string; description: string; is_done: boolean; difficulty: number }>();
|
||||
/** Emitted when an existing block is patched from the carousel. */
|
||||
readonly saveBlock = output<{
|
||||
blockId: string;
|
||||
result: { tag: string; description: string; is_done: boolean };
|
||||
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||
}>();
|
||||
readonly deleteBlock = output<string>();
|
||||
|
||||
|
|
@ -452,12 +546,13 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
readonly hiddenBlockCount = signal(0);
|
||||
|
||||
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
|
||||
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
readonly pending = computed(() => this.tower().blocks.filter(b => !b.is_done));
|
||||
readonly pending = computed(() => this.tower().blocks.filter((b) => !b.is_done));
|
||||
|
||||
/** CSS color string for the tower name input. */
|
||||
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
||||
|
|
@ -467,7 +562,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
const entry = this.editEntry();
|
||||
if (!entry) return [];
|
||||
const isDone = entry.filter === 'done';
|
||||
return this.tower().blocks.filter(b => b.is_done === isDone);
|
||||
return this.tower().blocks.filter((b) => b.is_done === isDone);
|
||||
});
|
||||
|
||||
readonly editViewTitle = computed(() => {
|
||||
|
|
@ -492,8 +587,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
||||
|
||||
/** Flat list of squares to render: each visible block expands into
|
||||
* `difficulty` adjacent squares that wrap (via the flex container) into
|
||||
* the row above. All squares of a block share its animation state. */
|
||||
readonly squares = computed<RenderSquare[]>(() => {
|
||||
const out: RenderSquare[] = [];
|
||||
for (const b of this.visibleBlocks()) {
|
||||
const n = squareCount(b);
|
||||
for (let k = 0; k < n; k++) {
|
||||
out.push({ key: `${b.id}#${k}`, block: b });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
readonly blockStackHeight = computed(() => {
|
||||
const rows = Math.ceil(this.visibleBlocks().length / BLOCKS_PER_ROW);
|
||||
const rows = Math.ceil(this.squares().length / BLOCKS_PER_ROW);
|
||||
const cqw = rows * (100 / BLOCKS_PER_ROW);
|
||||
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
|
||||
});
|
||||
|
|
@ -505,10 +615,11 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
effect(() => {
|
||||
const range = this.dateRange();
|
||||
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||
const animateInitialStack = this.animateInitialStack();
|
||||
// Reconcile all done blocks, then cap the rendered stack to the rows
|
||||
// that fit below the tasks and add button.
|
||||
const allDone = this.tower().blocks.filter((b) => b.is_done);
|
||||
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks));
|
||||
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +656,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
const styles = getComputedStyle(stackZone);
|
||||
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
|
||||
this.measureEmptyAddBlockOffset(stackZone);
|
||||
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
|
||||
const clearance = this.parseCssPixels(
|
||||
styles.getPropertyValue('--add-block-clearance'),
|
||||
|
|
@ -557,6 +669,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
|
||||
}
|
||||
|
||||
private measureEmptyAddBlockOffset(stackZone: HTMLElement): void {
|
||||
const towerRoot = this.towerRoot()?.nativeElement;
|
||||
if (!towerRoot) return;
|
||||
|
||||
const stackRect = stackZone.getBoundingClientRect();
|
||||
const towerRect = towerRoot.getBoundingClientRect();
|
||||
const stackCenter = stackRect.top + stackRect.height / 2;
|
||||
const towerCenter = towerRect.top + towerRect.height / 2;
|
||||
const offset = Math.max(0, stackCenter - towerCenter);
|
||||
stackZone.style.setProperty('--empty-add-block-center-offset', `${offset}px`);
|
||||
}
|
||||
|
||||
private parseCssPixels(value: string, fallback: number): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
|
|
@ -566,16 +690,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
allDone: Block[],
|
||||
range: { from: number; to: number } | null,
|
||||
maxVisibleBlocks: number | null,
|
||||
animateInitialStack: boolean,
|
||||
): void {
|
||||
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
||||
this._visibleBlocks.set([]);
|
||||
this.hiddenBlockCount.set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = allDone.map((b) => b.id);
|
||||
const prev = this.prevDoneIds;
|
||||
const prevSet = new Set(prev);
|
||||
const newIds = ids.filter(id => !prevSet.has(id));
|
||||
const newIds = ids.filter((id) => !prevSet.has(id));
|
||||
const grewByOne =
|
||||
!this.isFirstRun &&
|
||||
ids.length === prev.length + 1 &&
|
||||
newIds.length === 1 &&
|
||||
prev.every(id => ids.includes(id)); // no IDs disappeared
|
||||
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||
|
||||
const inRange = (b: Block) =>
|
||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
||||
|
|
@ -605,47 +736,37 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
const newInRangeId = grewByOne ? newIds[0] : null;
|
||||
const visibleLimit =
|
||||
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
|
||||
const restingBlocks = styled.filter((b) => b._opacity === '1');
|
||||
const hiddenCount = Number.isFinite(visibleLimit)
|
||||
? Math.max(0, restingBlocks.length - visibleLimit)
|
||||
: 0;
|
||||
let visibleStyled = styled;
|
||||
let hiddenCount = 0;
|
||||
if (Number.isFinite(visibleLimit)) {
|
||||
const shownRestingBlocks =
|
||||
hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks;
|
||||
const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length);
|
||||
const transitioningBlocks =
|
||||
remainingSlots > 0
|
||||
? styled.filter((b) => b._opacity !== '1').slice(-remainingSlots)
|
||||
: [];
|
||||
const shownIds = new Set([
|
||||
...shownRestingBlocks.map((b) => b.id),
|
||||
...transitioningBlocks.map((b) => b.id),
|
||||
]);
|
||||
visibleStyled = styled.filter((b) => shownIds.has(b.id));
|
||||
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
|
||||
styled,
|
||||
visibleLimit,
|
||||
newInRangeId,
|
||||
));
|
||||
}
|
||||
this.hiddenBlockCount.set(hiddenCount);
|
||||
|
||||
if (this.isFirstRun && animateInitialStack) {
|
||||
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
|
||||
if (initialBlocks.length > 0) {
|
||||
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (grewByOne) {
|
||||
const newId = newIds[0];
|
||||
const newBlock = visibleStyled.find(b => b.id === newId);
|
||||
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||
if (newBlock && inRange(newBlock)) {
|
||||
// Snap newly-added in-range block to start position, then on the next
|
||||
// paint flip it back to rest — that's what makes it visibly fall.
|
||||
newBlock._anim = '';
|
||||
newBlock._transform = 'translateY(500%)';
|
||||
newBlock._opacity = '0';
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
newBlock._anim = 'descend';
|
||||
newBlock._transform = 'translateY(0)';
|
||||
newBlock._opacity = '1';
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
});
|
||||
});
|
||||
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
|
|
@ -658,6 +779,25 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
this.isFirstRun = false;
|
||||
}
|
||||
|
||||
private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
block._anim = '';
|
||||
block._transform = 'translateY(500%)';
|
||||
block._opacity = '0';
|
||||
}
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
onRename(event: Event): void {
|
||||
|
|
@ -676,7 +816,12 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
onMarkTaskDone(block: Block): void {
|
||||
this.saveBlock.emit({
|
||||
blockId: block.id,
|
||||
result: { tag: block.tag, description: block.description, is_done: true },
|
||||
result: {
|
||||
tag: block.tag,
|
||||
description: block.description,
|
||||
is_done: true,
|
||||
difficulty: block.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -691,11 +836,21 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
onBlockSave(ev: BlockEditSave): void {
|
||||
if (ev.id === null) {
|
||||
this.addBlock.emit({ tag: ev.tag, description: ev.description, is_done: ev.is_done });
|
||||
this.addBlock.emit({
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
});
|
||||
} else {
|
||||
this.saveBlock.emit({
|
||||
blockId: ev.id,
|
||||
result: { tag: ev.tag, description: ev.description, is_done: ev.is_done },
|
||||
result: {
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
95
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { StyledBlock } from './tower.component';
|
||||
import { selectVisibleStyledBlocks } from './tower.component';
|
||||
|
||||
function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
|
||||
return {
|
||||
id,
|
||||
tag: 'tag',
|
||||
description: id,
|
||||
is_done: true,
|
||||
difficulty,
|
||||
created_at: 1,
|
||||
_anim: '',
|
||||
_transform: opacity === '1' ? 'translateY(0)' : 'translateY(500%)',
|
||||
_opacity: opacity,
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectVisibleStyledBlocks', () => {
|
||||
it('reserves a capped visible slot for the newly completed block', () => {
|
||||
const styled = [
|
||||
block('newly-done'),
|
||||
block('done-1'),
|
||||
block('done-2'),
|
||||
block('done-3'),
|
||||
block('done-4'),
|
||||
block('done-5'),
|
||||
block('done-6'),
|
||||
block('done-7'),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||
|
||||
expect(result.hiddenCount).toBe(2);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||
'newly-done',
|
||||
'done-3',
|
||||
'done-4',
|
||||
'done-5',
|
||||
'done-6',
|
||||
'done-7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the normal capped window when no new block is entering', () => {
|
||||
const styled = [
|
||||
block('done-0'),
|
||||
block('done-1'),
|
||||
block('done-2'),
|
||||
block('done-3'),
|
||||
block('done-4'),
|
||||
block('done-5'),
|
||||
block('done-6'),
|
||||
block('done-7'),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||
|
||||
expect(result.hiddenCount).toBe(2);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||
'done-2',
|
||||
'done-3',
|
||||
'done-4',
|
||||
'done-5',
|
||||
'done-6',
|
||||
'done-7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('caps by square cost (difficulty), not by raw block count', () => {
|
||||
// Each block draws 2 squares, so only 3 blocks fit in 6 square slots.
|
||||
const hard = (id: string): StyledBlock => block(id, '1', 2);
|
||||
const styled = [hard('a'), hard('b'), hard('c'), hard('d'), hard('e')];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['c', 'd', 'e']);
|
||||
expect(result.hiddenCount).toBe(4);
|
||||
});
|
||||
|
||||
it('counts hidden squares when reserving the entering block', () => {
|
||||
const styled = [
|
||||
block('newly-done', '1', 3),
|
||||
block('done-1', '1', 2),
|
||||
block('done-2', '1', 2),
|
||||
block('done-3', '1', 2),
|
||||
block('done-4', '1', 2),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||
|
||||
expect(result.hiddenCount).toBe(6);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +1,106 @@
|
|||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-welcome',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [A11yModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="welcome-card">
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
<h2 id="welcome-title" tabindex="-1" cdkFocusInitial>Welcome to Life Towers</h2>
|
||||
|
||||
<h2>Welcome to Life Towers</h2>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
(click)="close.emit()"
|
||||
aria-label="Dismiss welcome"
|
||||
></button>
|
||||
|
||||
<p class="lead">
|
||||
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
|
||||
Each <strong>tower</strong> 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.
|
||||
<p class="lead" id="welcome-description">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p class="muted">
|
||||
Everything you write is saved to a small remote database, keyed to a private UUID token shown
|
||||
under <em>Settings → Account</em>. Copy that token to recover your data on another device, or
|
||||
paste a friend's token to look at theirs.
|
||||
<p class="sr-only">
|
||||
Preview showing three towers with pending task bars at the top and completed task
|
||||
blocks stacked below.
|
||||
</p>
|
||||
<div class="tower-preview" aria-hidden="true">
|
||||
<div class="preview-shell">
|
||||
<div class="preview-page-tab"></div>
|
||||
<div class="preview-towers">
|
||||
<div class="preview-tower preview-tower--reading">
|
||||
<span class="preview-task"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-tower preview-tower--projects">
|
||||
<span class="preview-task"></span>
|
||||
<span class="preview-task preview-task--short"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-tower preview-tower--exercise">
|
||||
<span class="preview-task"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sr-only" id="welcome-basics-title">How Life Towers works</h3>
|
||||
<dl class="basics" aria-labelledby="welcome-basics-title">
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Pages</dt>
|
||||
<dd class="basic__text">Keep each area separate.</dd>
|
||||
</div>
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Towers</dt>
|
||||
<dd class="basic__text">Stack related tasks together.</dd>
|
||||
</div>
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Blocks</dt>
|
||||
<dd class="basic__text">Finished tasks become colored blocks.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="account-note">
|
||||
Your towers save with a private recovery token in Settings, then Account. Use it to
|
||||
restore your workspace on another device.
|
||||
</p>
|
||||
|
||||
<p class="choice-note">
|
||||
Start empty if you know what to track, or load sample towers you can edit or delete.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="startFresh.emit()">Start fresh</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
|
||||
<button type="button" (click)="startFresh.emit()">Start empty</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue