getting there

This commit is contained in:
Andras Schmelczer 2026-05-30 15:40:53 +01:00
parent f87480e79d
commit 5ac6633d40
20 changed files with 898 additions and 112 deletions

View file

@ -20,7 +20,7 @@ backend/ # FastAPI + SQLite (WAL)
limits.py # slowapi limiter + payload-size middleware limits.py # slowapi limiter + payload-size middleware
models.py # pydantic v2 schemas models.py # pydantic v2 schemas
logging.py # structlog JSON 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 tests/test_api.py # pytest + httpx AsyncClient
pyproject.toml # uv-managed pyproject.toml # uv-managed
frontend/ # Angular 21+ standalone, signals, zoneless frontend/ # Angular 21+ standalone, signals, zoneless

View file

@ -94,7 +94,7 @@ async def get_data(
block_rows = conn.execute( block_rows = conn.execute(
""" """
SELECT id, tag, description, is_done, created_at SELECT id, tag, description, is_done, difficulty, created_at
FROM blocks FROM blocks
WHERE tower_id = ? WHERE tower_id = ?
ORDER BY position ORDER BY position
@ -108,6 +108,7 @@ async def get_data(
tag=b["tag"], tag=b["tag"],
description=b["description"], description=b["description"],
is_done=bool(b["is_done"]), is_done=bool(b["is_done"]),
difficulty=b["difficulty"],
created_at=b["created_at"], created_at=b["created_at"],
) )
for b in block_rows for b in block_rows
@ -213,8 +214,9 @@ async def put_data(
""" """
INSERT INTO blocks INSERT INTO blocks
(id, tower_id, user_id, position, tag, (id, tower_id, user_id, position, tag,
description, is_done, created_at, updated_at) description, is_done, difficulty,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
block.id, block.id,
@ -224,6 +226,7 @@ async def put_data(
block.tag, block.tag,
block.description, block.description,
1 if block.is_done else 0, 1 if block.is_done else 0,
block.difficulty,
created_at, created_at,
now, now,
), ),

View file

@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS pages (
position INTEGER NOT NULL, position INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)), 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_from INTEGER,
default_date_to INTEGER, default_date_to INTEGER,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS blocks (
tag TEXT NOT NULL DEFAULT '', tag TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)), 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, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
) STRICT; ) STRICT;

View file

@ -1,2 +0,0 @@
ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0
CHECK (keep_tasks_open IN (0, 1));

View file

@ -27,6 +27,7 @@ class BlockIn(BaseModel):
tag: str = Field(max_length=200) tag: str = Field(max_length=200)
description: str = Field(max_length=10_000) description: str = Field(max_length=10_000)
is_done: bool is_done: bool
difficulty: int = Field(default=1, ge=1, le=100)
created_at: Optional[int] = None created_at: Optional[int] = None
@field_validator("id") @field_validator("id")
@ -42,6 +43,7 @@ class BlockOut(BaseModel):
tag: str tag: str
description: str description: str
is_done: bool is_done: bool
difficulty: int
created_at: int created_at: int

View file

@ -215,6 +215,7 @@ def _make_tree() -> dict:
"tag": f"tag-{pi}-{ti}-{bi}", "tag": f"tag-{pi}-{ti}-{bi}",
"description": f"desc-{pi}-{ti}-{bi}", "description": f"desc-{pi}-{ti}-{bi}",
"is_done": False, "is_done": False,
"difficulty": bi + 1,
"created_at": 1700000000 + bi, "created_at": 1700000000 + bi,
} }
) )
@ -266,6 +267,42 @@ async def test_round_trip(client: AsyncClient) -> None:
for bi, block in enumerate(tower["blocks"]): for bi, block in enumerate(tower["blocks"]):
assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"] assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"]
assert block["tag"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["tag"] 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -11,8 +11,8 @@ test.describe('Life Towers visuals', () => {
await page.waitForTimeout(350); // let the welcome modal finish fade-in await page.waitForTimeout(350); // let the welcome modal finish fade-in
await page.screenshot({ path: 'visuals/01-welcome-modal.png', fullPage: true }); await page.screenshot({ path: 'visuals/01-welcome-modal.png', fullPage: true });
// Dismiss the welcome modal with Start fresh, then continue. // Dismiss the welcome modal with Start empty, then continue.
await page.getByRole('button', { name: 'Start fresh' }).click(); await page.getByRole('button', { name: 'Start empty' }).click();
await page.waitForSelector('section.modal', { state: 'detached' }); await page.waitForSelector('section.modal', { state: 'detached' });
await page.screenshot({ path: 'visuals/01b-empty-state-after-dismiss.png', fullPage: true }); 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 }); 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.goto('/');
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 }); await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
await page.waitForTimeout(350); 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.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(1800); await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/12-example-data.png', fullPage: true }); 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.waitForTimeout(350);
await page.screenshot({ path: 'visuals/13-mobile-welcome.png', fullPage: true }); 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.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(1800); await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true }); await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });

View file

@ -23,12 +23,14 @@ export interface BlockEditSave {
tag: string; tag: string;
description: string; description: string;
is_done: boolean; is_done: boolean;
difficulty: number;
} }
interface EditedValue { interface EditedValue {
tag: string; tag: string;
description: string; description: string;
is_done: boolean; is_done: boolean;
difficulty: number;
} }
@Component({ @Component({
@ -73,8 +75,7 @@ interface EditedValue {
[selected]="editedFor(b.id).tag" [selected]="editedFor(b.id).tag"
[alwaysDropShadow]="true" [alwaysDropShadow]="true"
[onlyShadowBorder]="true" [onlyShadowBorder]="true"
placeholder="Tag this item…" [placeholder]="tagPlaceholder('Tag this item…')"
emptyHint="No tags yet. Type one below to create it."
(select)="updateTag(b.id, $event)" (select)="updateTag(b.id, $event)"
(add)="updateTag(b.id, $event)" (add)="updateTag(b.id, $event)"
/> />
@ -96,6 +97,26 @@ interface EditedValue {
<span>Already done</span> <span>Already done</span>
</label> </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"> <div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button> <button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
</div> </div>
@ -125,8 +146,7 @@ interface EditedValue {
[selected]="newValue().tag" [selected]="newValue().tag"
[alwaysDropShadow]="true" [alwaysDropShadow]="true"
[onlyShadowBorder]="true" [onlyShadowBorder]="true"
placeholder="Set a category…" [placeholder]="tagPlaceholder('Set a category…')"
emptyHint="No tags yet. Type one below to create it."
(select)="updateNewTag($event)" (select)="updateNewTag($event)"
(add)="updateNewTag($event)" (add)="updateNewTag($event)"
/> />
@ -147,6 +167,26 @@ interface EditedValue {
<span>Already done</span> <span>Already done</span>
</label> </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"> <div class="bottom">
<button <button
(click)="submitNew(); $event.stopPropagation()" (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 { .bottom {
height: 32px; height: 32px;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
@ -454,6 +556,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: '', tag: '',
description: '', description: '',
is_done: true, is_done: true,
difficulty: 1,
}); });
// 1-based index of the centered card. 0/N+2 are placeholders. // 1-based index of the centered card. 0/N+2 are placeholders.
@ -471,6 +574,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: b.tag, tag: b.tag,
description: b.description, description: b.description,
is_done: b.is_done, is_done: b.is_done,
difficulty: b.difficulty ?? 1,
}); });
} }
untracked(() => this.editedValues.set(m)); untracked(() => this.editedValues.set(m));
@ -510,6 +614,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: '', tag: '',
description: '', description: '',
is_done: false, is_done: false,
difficulty: 1,
} }
); );
} }
@ -519,6 +624,10 @@ export class BlockEditComponent implements AfterViewInit {
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent'; 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(() => { colorOfNewTag = computed(() => {
const t = this.newValue().tag; const t = this.newValue().tag;
return t ? getColorOfTag(t, this.baseColor()) : 'transparent'; return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
@ -561,6 +670,12 @@ export class BlockEditComponent implements AfterViewInit {
this.flushExisting(id); 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 { private patchEdited(id: string, patch: Partial<EditedValue>): void {
this.editedValues.update((m) => { this.editedValues.update((m) => {
const v = m.get(id); const v = m.get(id);
@ -574,7 +689,13 @@ export class BlockEditComponent implements AfterViewInit {
flushExisting(id: string): void { flushExisting(id: string): void {
const v = this.editedFor(id); const v = this.editedFor(id);
if (!v.tag) return; // Skip empty saves 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 { onDelete(id: string): void {
@ -595,6 +716,10 @@ export class BlockEditComponent implements AfterViewInit {
this.newValue.update((v) => ({ ...v, is_done })); 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 { submitNew(): void {
const v = this.newValue(); const v = this.newValue();
if (!v.tag) return; if (!v.tag) return;
@ -603,6 +728,7 @@ export class BlockEditComponent implements AfterViewInit {
tag: v.tag, tag: v.tag,
description: v.description, description: v.description,
is_done: v.is_done, is_done: v.is_done,
difficulty: v.difficulty,
}); });
this.close.emit(); this.close.emit();
} }

View file

@ -2,6 +2,7 @@ import {
Component, Component,
ChangeDetectionStrategy, ChangeDetectionStrategy,
output, output,
input,
signal, signal,
AfterViewInit, AfterViewInit,
OnDestroy, OnDestroy,
@ -23,7 +24,17 @@ import { ModalStateService } from '../../services/modal-state.service';
[class.active]="active()" [class.active]="active()"
(click)="onBackdropClick($event)" (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> <ng-content></ng-content>
</div> </div>
</section> </section>
@ -73,6 +84,8 @@ import { ModalStateService } from '../../services/modal-state.service';
`, `,
}) })
export class ModalComponent implements AfterViewInit, OnDestroy { export class ModalComponent implements AfterViewInit, OnDestroy {
readonly labelledBy = input<string | null>(null);
readonly describedBy = input<string | null>(null);
readonly close = output<void>(); readonly close = output<void>();
// The active signal starts false; AfterViewInit flips it true on next tick // The active signal starts false; AfterViewInit flips it true on next tick

View file

@ -11,6 +11,7 @@
[tower]="tower" [tower]="tower"
[dateRange]="dateRange()" [dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open" [keepTasksOpen]="page().keep_tasks_open"
[animateInitialStack]="animateInitialStack()"
(cdkDragStarted)="onTowerDragStart(tower.id)" (cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)" (updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)" (deleteTowerRequest)="onDeleteTower(tower.id)"

View file

@ -39,6 +39,7 @@ interface BlockPatch {
tag: string; tag: string;
description: string; description: string;
is_done: boolean; is_done: boolean;
difficulty: number;
} }
/** Minimum blocks before the date-range slider becomes visible. */ /** Minimum blocks before the date-range slider becomes visible. */
@ -61,6 +62,7 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
}) })
export class PageComponent { export class PageComponent {
readonly page = input.required<Page>(); readonly page = input.required<Page>();
readonly animateInitialStack = input<boolean>(false);
readonly dragHappening = output<boolean>(); readonly dragHappening = output<boolean>();
protected readonly store = inject(StoreService); protected readonly store = inject(StoreService);
@ -157,6 +159,7 @@ export class PageComponent {
result.tag, result.tag,
result.description, result.description,
result.is_done, result.is_done,
result.difficulty,
); );
} }

View file

@ -10,7 +10,11 @@
<div class="page-container"> <div class="page-container">
@if (selectedPage(); as page) { @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 { } @else {
<p>Add a new page to get started!</p> <p>Add a new page to get started!</p>
} }
@ -53,7 +57,11 @@
} }
@if (showWelcome()) { @if (showWelcome()) {
<lt-modal (close)="showWelcome.set(false)"> <lt-modal
labelledBy="welcome-title"
describedBy="welcome-description"
(close)="showWelcome.set(false)"
>
<lt-welcome <lt-welcome
(close)="showWelcome.set(false)" (close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)" (startFresh)="showWelcome.set(false)"

View file

@ -5,6 +5,7 @@ import {
signal, signal,
computed, computed,
effect, effect,
OnDestroy,
} from '@angular/core'; } from '@angular/core';
import { StoreService } from '../../services/store.service'; import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component'; import { PageComponent } from '../page/page.component';
@ -22,7 +23,7 @@ import { Page } from '../../models';
templateUrl: './pages.component.html', templateUrl: './pages.component.html',
styleUrl: './pages.component.scss', styleUrl: './pages.component.scss',
}) })
export class PagesComponent { export class PagesComponent implements OnDestroy {
protected readonly store = inject(StoreService); protected readonly store = inject(StoreService);
/** ID of currently selected page within store.pages(). */ /** ID of currently selected page within store.pages(). */
@ -32,6 +33,8 @@ export class PagesComponent {
readonly dragHappening = signal(false); readonly dragHappening = signal(false);
readonly showWelcome = signal(false); readonly showWelcome = signal(false);
readonly confirmDeletePageId = signal<string | null>(null); readonly confirmDeletePageId = signal<string | null>(null);
readonly animateInitialStackPageId = signal<string | null>(null);
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
effect(() => { effect(() => {
@ -44,10 +47,27 @@ export class PagesComponent {
} }
onLoadExample(): void { 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); this.showWelcome.set(false);
} }
ngOnDestroy(): void {
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
}
readonly pageNames = computed(() => this.store.pages().map((p) => p.name)); readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
readonly selectedPage = computed<Page | null>(() => { readonly selectedPage = computed<Page | null>(() => {

View file

@ -28,9 +28,6 @@ import {
</div> </div>
<div class="bottom-container"> <div class="bottom-container">
<div class="bottom" [class.open]="open()"> <div class="bottom" [class.open]="open()">
@if (resolvedItems().length === 0 && emptyHint()) {
<p class="empty-hint">{{ emptyHint() }}</p>
}
@for (item of resolvedItems(); track item) { @for (item of resolvedItems(); track item) {
@if (editing()) { @if (editing()) {
<input <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'] { input[type='text'] {
@include sub-title-text(); @include sub-title-text();
width: 100%; width: 100%;
@ -384,7 +373,6 @@ export class SelectAddComponent implements OnChanges {
readonly selected = input<string | null>(null); readonly selected = input<string | null>(null);
readonly editable = input<boolean>(false); readonly editable = input<boolean>(false);
readonly placeholder = input<string>('Select…'); readonly placeholder = input<string>('Select…');
readonly emptyHint = input<string>('');
readonly alwaysDropShadow = input<boolean>(false); readonly alwaysDropShadow = input<boolean>(false);
readonly onlyShadowBorder = input<boolean>(false); readonly onlyShadowBorder = input<boolean>(false);
@ -419,7 +407,7 @@ export class SelectAddComponent implements OnChanges {
protected resolvedSelected(): string | null { protected resolvedSelected(): string | null {
// New API: string // New API: string
const s = this.selected(); const s = this.selected();
if (s != null) return s; if (s != null && s.trim()) return s;
// Legacy API: index into options // Legacy API: index into options
const idx = this.selectedIndex(); const idx = this.selectedIndex();
const opts = this.resolvedItems(); const opts = this.resolvedItems();

View file

@ -21,24 +21,100 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
import { toCss } from '../../utils/color'; import { toCss } from '../../utils/color';
/** Tracks which entry path the block-edit modal was opened from. */ /** 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. */ /** A done block augmented with per-render animation state. */
interface StyledBlock extends Block { export interface StyledBlock extends Block {
_anim: '' | 'descend' | 'ascend'; _anim: '' | 'descend' | 'ascend';
_transform: string; _transform: string;
_opacity: 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; 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({ @Component({
selector: 'lt-tower', selector: 'lt-tower',
standalone: true, standalone: true,
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent], imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="tower"> <div #towerRoot class="tower">
<div class="tower-header"> <div class="tower-header">
<input <input
#nameInput #nameInput
@ -76,26 +152,31 @@ const BLOCKS_PER_ROW = 6;
<div <div
#stackZone #stackZone
class="stack-zone" class="stack-zone"
[class.empty]="tower().blocks.length === 0"
[style.--block-stack-height]="blockStackHeight()" [style.--block-stack-height]="blockStackHeight()"
> >
<img <img
src="assets/plus-sign.svg" src="assets/plus-sign.svg"
class="add-block" class="add-block"
alt="Add block" alt="Add block"
role="button"
tabindex="0"
(pointerdown)="$event.stopPropagation()" (pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation(); openAddBlock()" (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-container">
<div class="block-container"> <div class="block-container">
@for (b of visibleBlocks(); track b.id) { @for (sq of squares(); track sq.key) {
<lt-block <lt-block
[block]="b" [block]="sq.block"
[baseColor]="tower().base_color" [baseColor]="tower().base_color"
[class]="b._anim" [class]="sq.block._anim"
[style.transform]="b._transform" [style.transform]="sq.block._transform"
[style.opacity]="b._opacity" [style.opacity]="sq.block._opacity"
(clicked)="onEditBlock(b)" (clicked)="onEditBlock(sq.block)"
/> />
} }
</div> </div>
@ -209,6 +290,7 @@ const BLOCKS_PER_ROW = 6;
--block-stack-height: 0px; --block-stack-height: 0px;
--add-block-size: 48px; --add-block-size: 48px;
--add-block-clearance: var(--medium-padding); --add-block-clearance: var(--medium-padding);
--empty-add-block-center-offset: 0px;
@include card(); @include card();
overflow: hidden; overflow: hidden;
@ -297,6 +379,16 @@ const BLOCKS_PER_ROW = 6;
transform: translateX(-50%); 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 { .block-container-container {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -431,16 +523,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
readonly dateRange = input<{ from: number; to: number } | null>(null); readonly dateRange = input<{ from: number; to: number } | null>(null);
/** When true, the tasks accordion starts expanded on load. */ /** When true, the tasks accordion starts expanded on load. */
readonly keepTasksOpen = input<boolean>(false); readonly keepTasksOpen = input<boolean>(false);
/** When true, completed blocks descend on this tower's first measured render. */
readonly animateInitialStack = input<boolean>(false);
// ── Outputs ──────────────────────────────────────────────────────────────── // ── Outputs ────────────────────────────────────────────────────────────────
readonly updateTower = output<TowerSettingsResult>(); readonly updateTower = output<TowerSettingsResult>();
readonly deleteTowerRequest = output<void>(); readonly deleteTowerRequest = output<void>();
/** Emitted when a new block is created from the carousel's "Create now" card. */ /** 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. */ /** Emitted when an existing block is patched from the carousel. */
readonly saveBlock = output<{ readonly saveBlock = output<{
blockId: string; blockId: string;
result: { tag: string; description: string; is_done: boolean }; result: { tag: string; description: string; is_done: boolean; difficulty: number };
}>(); }>();
readonly deleteBlock = output<string>(); readonly deleteBlock = output<string>();
@ -452,12 +546,13 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
readonly hiddenBlockCount = signal(0); readonly hiddenBlockCount = signal(0);
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone'); private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
private readonly maxVisibleBlocks = signal<number | null>(null); private readonly maxVisibleBlocks = signal<number | null>(null);
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */ /** 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. */ /** CSS color string for the tower name input. */
readonly towerNameCss = computed(() => toCss(this.tower().base_color)); readonly towerNameCss = computed(() => toCss(this.tower().base_color));
@ -467,7 +562,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
const entry = this.editEntry(); const entry = this.editEntry();
if (!entry) return []; if (!entry) return [];
const isDone = entry.filter === 'done'; 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(() => { readonly editViewTitle = computed(() => {
@ -492,8 +587,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
private readonly _visibleBlocks = signal<StyledBlock[]>([]); private readonly _visibleBlocks = signal<StyledBlock[]>([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly(); 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(() => { 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); const cqw = rows * (100 / BLOCKS_PER_ROW);
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`; return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
}); });
@ -505,10 +615,11 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
effect(() => { effect(() => {
const range = this.dateRange(); const range = this.dateRange();
const maxVisibleBlocks = this.maxVisibleBlocks(); const maxVisibleBlocks = this.maxVisibleBlocks();
const animateInitialStack = this.animateInitialStack();
// Reconcile all done blocks, then cap the rendered stack to the rows // Reconcile all done blocks, then cap the rendered stack to the rows
// that fit below the tasks and add button. // that fit below the tasks and add button.
const allDone = this.tower().blocks.filter((b) => b.is_done); 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 styles = getComputedStyle(stackZone);
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48); const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
this.measureEmptyAddBlockOffset(stackZone);
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15; const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
const clearance = this.parseCssPixels( const clearance = this.parseCssPixels(
styles.getPropertyValue('--add-block-clearance'), 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); 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 { private parseCssPixels(value: string, fallback: number): number {
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
@ -566,16 +690,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
allDone: Block[], allDone: Block[],
range: { from: number; to: number } | null, range: { from: number; to: number } | null,
maxVisibleBlocks: number | null, maxVisibleBlocks: number | null,
animateInitialStack: boolean,
): void { ): void {
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
this._visibleBlocks.set([]);
this.hiddenBlockCount.set(0);
return;
}
const ids = allDone.map((b) => b.id); const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds; const prev = this.prevDoneIds;
const prevSet = new Set(prev); const prevSet = new Set(prev);
const newIds = ids.filter(id => !prevSet.has(id)); const newIds = ids.filter((id) => !prevSet.has(id));
const grewByOne = const grewByOne =
!this.isFirstRun && !this.isFirstRun &&
ids.length === prev.length + 1 && ids.length === prev.length + 1 &&
newIds.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) => const inRange = (b: Block) =>
!range || (b.created_at >= range.from && b.created_at <= range.to); !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 = const visibleLimit =
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks); 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 visibleStyled = styled;
let hiddenCount = 0;
if (Number.isFinite(visibleLimit)) { if (Number.isFinite(visibleLimit)) {
const shownRestingBlocks = ({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks; styled,
const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length); visibleLimit,
const transitioningBlocks = newInRangeId,
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));
} }
this.hiddenBlockCount.set(hiddenCount); 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) { if (grewByOne) {
const newId = newIds[0]; const newId = newIds[0];
const newBlock = visibleStyled.find(b => b.id === newId); const newBlock = visibleStyled.find((b) => b.id === newId);
if (newBlock && inRange(newBlock)) { if (newBlock && inRange(newBlock)) {
// Snap newly-added in-range block to start position, then on the next // 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. // paint flip it back to rest — that's what makes it visibly fall.
newBlock._anim = ''; this.startDescendAnimation(visibleStyled, [newBlock]);
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.prevDoneIds = ids; this.prevDoneIds = ids;
this.isFirstRun = false; this.isFirstRun = false;
return; return;
@ -658,6 +779,25 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
this.isFirstRun = false; 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 ───────────────────────────────────────────────────────── // ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void { onRename(event: Event): void {
@ -676,7 +816,12 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
onMarkTaskDone(block: Block): void { onMarkTaskDone(block: Block): void {
this.saveBlock.emit({ this.saveBlock.emit({
blockId: block.id, 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 { onBlockSave(ev: BlockEditSave): void {
if (ev.id === null) { 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 { } else {
this.saveBlock.emit({ this.saveBlock.emit({
blockId: ev.id, 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,
},
}); });
} }
} }

View 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']);
});
});

View file

@ -1,31 +1,106 @@
import { Component, ChangeDetectionStrategy, output } from '@angular/core'; import { Component, ChangeDetectionStrategy, output } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
@Component({ @Component({
selector: 'lt-welcome', selector: 'lt-welcome',
standalone: true, standalone: true,
imports: [], imports: [A11yModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="welcome-card"> <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"> <p class="lead" id="welcome-description">
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project). Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies,
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the home, or any project. Add towers for task groups, then check off tasks to build them
tower as a colored square the more you do, the taller it grows. block by block.
</p> </p>
<p class="muted"> <p class="sr-only">
Everything you write is saved to a small remote database, keyed to a private UUID token shown Preview showing three towers with pending task bars at the top and completed task
under <em>Settings Account</em>. Copy that token to recover your data on another device, or blocks stacked below.
paste a friend's token to look at theirs. </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> </p>
<div class="actions"> <div class="actions">
<button type="button" (click)="startFresh.emit()">Start fresh</button> <button type="button" (click)="startFresh.emit()">Start empty</button>
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button> <button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
</div> </div>
</div> </div>
`, `,
@ -34,12 +109,27 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
:host { display: block; } :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 { .welcome-card {
@include card(); @include card();
width: 66vw; width: min(560px, calc(100vw - (2 * var(--large-padding))));
max-width: 480px; max-width: 560px;
max-height: calc(100svh - (2 * var(--medium-padding)));
overflow-y: auto;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
width: 88vw; width: min(88vw, calc(100vw - (2 * var(--medium-padding))));
max-width: 88vw; max-width: 88vw;
padding: var(--medium-padding); padding: var(--medium-padding);
} }
@ -48,8 +138,20 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
position: relative; position: relative;
box-shadow: $shadow; box-shadow: $shadow;
text-align: left; 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)); @include inner-spacing(var(--medium-padding));
h2,
h3,
p,
dl,
dd {
margin: 0;
}
.exit { .exit {
position: absolute; position: absolute;
top: var(--medium-padding); top: var(--medium-padding);
@ -58,31 +160,231 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
} }
h2 { h2 {
font-family: $title-font;
font-weight: 400;
font-size: var(--larger-font-size);
text-align: center; text-align: center;
margin-bottom: var(--medium-padding);
padding: 0 36px; padding: 0 36px;
line-height: 1.25;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
padding: 0 28px; 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 { .tower-preview {
color: rgba($text-color, 0.7); box-sizing: border-box;
font-size: var(--medium-font-size); padding: var(--medium-padding) 0;
em { font-style: italic; } 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 { .actions {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--large-padding); gap: var(--large-padding);
margin-top: var(--large-padding); margin-top: var(--large-padding);
button { button {
font-family: inherit;
font-weight: inherit;
font-size: inherit;
margin: 0;
max-width: 100%; max-width: 100%;
line-height: inherit;
text-align: center;
} }
button.primary { button.primary {

View file

@ -9,6 +9,8 @@ export interface Block {
tag: string; tag: string;
description: string; description: string;
is_done: boolean; is_done: boolean;
/** How many squares this block draws in the tower (>= 1). */
difficulty: number;
created_at: number; created_at: number;
} }

View file

@ -60,6 +60,7 @@ interface ExampleBlockSeed {
tag: string; tag: string;
desc: string; desc: string;
done: boolean; done: boolean;
difficulty?: number;
ageHrs: number; ageHrs: number;
} }
@ -318,12 +319,14 @@ export class StoreService implements OnDestroy {
tag: string, tag: string,
description: string, description: string,
is_done = false, is_done = false,
difficulty = 1,
): void { ): void {
const block: Block = { const block: Block = {
id: uuidV4(), id: uuidV4(),
tag, tag,
description, description,
is_done, is_done,
difficulty,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}; };
this._pages.update((pages) => this._pages.update((pages) =>
@ -578,7 +581,7 @@ export class StoreService implements OnDestroy {
// ── Example data ────────────────────────────────────────────────────────── // ── Example data ──────────────────────────────────────────────────────────
loadExample(): void { loadExample(): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const page: Page = { const page: Page = {
@ -593,7 +596,13 @@ export class StoreService implements OnDestroy {
// oldest → newest (left → right), matching the slider's old → new // oldest → newest (left → right), matching the slider's old → new
// labels; pending tasks stay on top for the accordion. // labels; pending tasks stay on top for the accordion.
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [ 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( ...this.makeExampleDoneBlocks(
[ [
{ tag: 'novel', desc: (n) => `Read chapter ${n}` }, { 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, [ 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( ...this.makeExampleDoneBlocks(
[ [
{ tag: 'angular', desc: (n) => `Refined UI pass ${n}` }, { 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, [ this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 }, { tag: 'run', desc: '10k Sunday', done: false, difficulty: 2, ageHrs: 0 },
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 }, { tag: 'climb', desc: 'Lead 6a outdoors', done: false, difficulty: 4, ageHrs: 4 },
...this.makeExampleDoneBlocks( ...this.makeExampleDoneBlocks(
[ [
{ tag: 'run', desc: (n) => `Easy run ${n}` }, { tag: 'run', desc: (n) => `Easy run ${n}` },
@ -640,6 +661,7 @@ export class StoreService implements OnDestroy {
this.analytics.trackStart(); this.analytics.trackStart();
this.analytics.trackExampleLoaded(); this.analytics.trackExampleLoaded();
this.scheduleSave(); this.scheduleSave();
return page.id;
} }
private makeExampleTower( private makeExampleTower(
@ -657,6 +679,7 @@ export class StoreService implements OnDestroy {
tag: b.tag, tag: b.tag,
description: b.desc, description: b.desc,
is_done: b.done, is_done: b.done,
difficulty: b.difficulty ?? 1,
created_at: nowSec - Math.floor(b.ageHrs * 3600), created_at: nowSec - Math.floor(b.ageHrs * 3600),
})), })),
}; };
@ -674,6 +697,7 @@ export class StoreService implements OnDestroy {
tag: pattern.tag, tag: pattern.tag,
desc: pattern.desc(sequence), desc: pattern.desc(sequence),
done: true, done: true,
difficulty: 1 + ((i + (i % patterns.length) * 2) % 5),
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs, ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
}; };
}); });

View file

@ -241,6 +241,7 @@ describe('StoreService', () => {
tag: 'a', tag: 'a',
description: 'Created first, completed last', description: 'Created first, completed last',
is_done: false, is_done: false,
difficulty: 1,
created_at: 100, created_at: 100,
}, },
{ {
@ -248,6 +249,7 @@ describe('StoreService', () => {
tag: 'b', tag: 'b',
description: 'Still pending', description: 'Still pending',
is_done: false, is_done: false,
difficulty: 1,
created_at: 300, created_at: 300,
}, },
{ {
@ -255,6 +257,7 @@ describe('StoreService', () => {
tag: 'c', tag: 'c',
description: 'Already done', description: 'Already done',
is_done: true, is_done: true,
difficulty: 1,
created_at: 200, created_at: 200,
}, },
], ],
@ -281,19 +284,23 @@ describe('StoreService', () => {
const api = makeMockApi(); const api = makeMockApi();
const store = configure(api); const store = configure(api);
store.loadExample(); const pageId = store.loadExample();
const [page] = store.pages(); const [page] = store.pages();
expect(page.id).toBe(pageId);
expect(page.name).toBe('Hobbies'); expect(page.name).toBe('Hobbies');
expect(page.towers).toHaveLength(3); expect(page.towers).toHaveLength(3);
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
expect(doneBlocks.length).toBeGreaterThanOrEqual(300); 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) { for (const tower of page.towers) {
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at); const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
expect(doneDates.length).toBeGreaterThanOrEqual(100); expect(doneDates.length).toBeGreaterThanOrEqual(100);
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
} }
store.ngOnDestroy(); store.ngOnDestroy();