getting there
This commit is contained in:
parent
f87480e79d
commit
5ac6633d40
20 changed files with 898 additions and 112 deletions
|
|
@ -20,7 +20,7 @@ backend/ # FastAPI + SQLite (WAL)
|
||||||
limits.py # slowapi limiter + payload-size middleware
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0
|
|
||||||
CHECK (keep_tasks_open IN (0, 1));
|
|
||||||
|
|
@ -27,6 +27,7 @@ class BlockIn(BaseModel):
|
||||||
tag: str = Field(max_length=200)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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>(() => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
95
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { StyledBlock } from './tower.component';
|
||||||
|
import { selectVisibleStyledBlocks } from './tower.component';
|
||||||
|
|
||||||
|
function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
tag: 'tag',
|
||||||
|
description: id,
|
||||||
|
is_done: true,
|
||||||
|
difficulty,
|
||||||
|
created_at: 1,
|
||||||
|
_anim: '',
|
||||||
|
_transform: opacity === '1' ? 'translateY(0)' : 'translateY(500%)',
|
||||||
|
_opacity: opacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectVisibleStyledBlocks', () => {
|
||||||
|
it('reserves a capped visible slot for the newly completed block', () => {
|
||||||
|
const styled = [
|
||||||
|
block('newly-done'),
|
||||||
|
block('done-1'),
|
||||||
|
block('done-2'),
|
||||||
|
block('done-3'),
|
||||||
|
block('done-4'),
|
||||||
|
block('done-5'),
|
||||||
|
block('done-6'),
|
||||||
|
block('done-7'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||||
|
|
||||||
|
expect(result.hiddenCount).toBe(2);
|
||||||
|
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||||
|
'newly-done',
|
||||||
|
'done-3',
|
||||||
|
'done-4',
|
||||||
|
'done-5',
|
||||||
|
'done-6',
|
||||||
|
'done-7',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the normal capped window when no new block is entering', () => {
|
||||||
|
const styled = [
|
||||||
|
block('done-0'),
|
||||||
|
block('done-1'),
|
||||||
|
block('done-2'),
|
||||||
|
block('done-3'),
|
||||||
|
block('done-4'),
|
||||||
|
block('done-5'),
|
||||||
|
block('done-6'),
|
||||||
|
block('done-7'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||||
|
|
||||||
|
expect(result.hiddenCount).toBe(2);
|
||||||
|
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||||
|
'done-2',
|
||||||
|
'done-3',
|
||||||
|
'done-4',
|
||||||
|
'done-5',
|
||||||
|
'done-6',
|
||||||
|
'done-7',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps by square cost (difficulty), not by raw block count', () => {
|
||||||
|
// Each block draws 2 squares, so only 3 blocks fit in 6 square slots.
|
||||||
|
const hard = (id: string): StyledBlock => block(id, '1', 2);
|
||||||
|
const styled = [hard('a'), hard('b'), hard('c'), hard('d'), hard('e')];
|
||||||
|
|
||||||
|
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||||
|
|
||||||
|
expect(result.visibleStyled.map((b) => b.id)).toEqual(['c', 'd', 'e']);
|
||||||
|
expect(result.hiddenCount).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts hidden squares when reserving the entering block', () => {
|
||||||
|
const styled = [
|
||||||
|
block('newly-done', '1', 3),
|
||||||
|
block('done-1', '1', 2),
|
||||||
|
block('done-2', '1', 2),
|
||||||
|
block('done-3', '1', 2),
|
||||||
|
block('done-4', '1', 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||||
|
|
||||||
|
expect(result.hiddenCount).toBe(6);
|
||||||
|
expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,31 +1,106 @@
|
||||||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
import { 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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue