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
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

View file

@ -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,
),

View file

@ -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;

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)
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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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 });

View file

@ -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();
}

View file

@ -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

View file

@ -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)"

View file

@ -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,
);
}

View file

@ -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)"

View file

@ -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>(() => {

View file

@ -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();

View file

@ -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,
},
});
}
}

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 { 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 {

View file

@ -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;
}

View file

@ -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,
};
});

View file

@ -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();