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