🔄 Quick Recap (Day 28)
You chose a track (CLI Habit Tracker or Web/API Pocket Tasks), set up your project, and shipped a running skeleton.
🎯 What You’ll Learn Today
Extend your app with useful features (edit, filter, stats).
Improve persistence (read/write JSON safely).
Provide clear output (tables for CLI; JSON schemas for API).
Add basic validation and friendly error messages.
Pick the track you started yesterday and complete today’s build steps.
🧩 Track A: CLI Habit Tracker — Feature Build
We’ll add: filters, edit, stats, and nicer output with rich.
Update app/storage.py
No change needed if you used yesterday’s file—keep JSON load()/save().
Replace app/main.py with this
# app/main.py
from __future__ import annotations
import argparse
from typing import List, Dict, Optional
from datetime import date
from app import storage
try:
from rich.console import Console
from rich.table import Table
except ImportError:
Console = None
Table = None
def _print_table(items: List[Dict]) -> None:
if not items:
print("No habits yet. Try: add <title>")
return
if Console and Table:
table = Table(title="Habits")
for col in ("id", "title", "done", "due"):
table.add_column(col)
for i in items:
table.add_row(str(i.get("id")), i.get("title", ""), "✔" if i.get("done") else "✗", (i.get("due") or "-") )
Console().print(table)
else:
for i in items:
mark = "✔" if i.get("done") else "✗"
print(f"[{mark}] {i['id']}: {i['title']} (due: {i.get('due') or '-'})")
def add(title: str, due: Optional[str]) -> None:
items: List[Dict] = storage.load()
new_id = (max([i["id"] for i in items]) + 1) if items else 1
# validate due (YYYY-MM-DD) if provided
if due:
try:
date.fromisoformat(due)
except ValueError:
print("Invalid --due format. Use YYYY-MM-DD.")
return
items.append({"id": new_id, "title": title.strip(), "done": False, "due": due})
storage.save(items)
print(f"Added #{new_id}: {title}")
def ls(status: str) -> None:
items = storage.load()
if status == "pending":
items = [i for i in items if not i.get("done")]
elif status == "done":
items = [i for i in items if i.get("done")]
_print_table(items)
def edit(item_id: int, title: Optional[str], due: Optional[str]) -> None:
items = storage.load()
for i in items:
if i["id"] == item_id:
if title:
i["title"] = title.strip()
if due is not None:
if due:
try:
date.fromisoformat(due)
except ValueError:
print("Invalid --due format. Use YYYY-MM-DD.")
return
i["due"] = due or None
storage.save(items)
print(f"Updated #{item_id}")
return
print(f"No item with id {item_id}")
def done(item_id: int) -> None:
items = storage.load()
for i in items:
if i["id"] == item_id:
i["done"] = True
storage.save(items)
print(f"Marked #{item_id} as done")
return
print(f"No item with id {item_id}")
def delete(item_id: int) -> None:
items = storage.load()
new_items = [i for i in items if i["id"] != item_id]
storage.save(new_items)
print(f"Deleted #{item_id}" if len(new_items) != len(items) else f"No item with id {item_id}")
def stats() -> None:
items = storage.load()
total = len(items)
done_count = sum(1 for i in items if i.get("done"))
pending = total - done_count
print(f"Total: {total} | Done: {done_count} | Pending: {pending}")
def main() -> None:
p = argparse.ArgumentParser(prog="habits", description="Habit Tracker")
sub = p.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add", help="Add a new habit")
p_add.add_argument("title")
p_add.add_argument("--due", help="YYYY-MM-DD", default=None)
p_list = sub.add_parser("list", help="List habits")
p_list.add_argument("--status", choices=["all", "pending", "done"], default="all")
p_edit = sub.add_parser("edit", help="Edit title or due date")
p_edit.add_argument("id", type=int)
p_edit.add_argument("--title")
p_edit.add_argument("--due", help="YYYY-MM-DD or empty to clear", default=None)
p_done = sub.add_parser("done", help="Mark as done")
p_done.add_argument("id", type=int)
p_del = sub.add_parser("del", help="Delete an item")
p_del.add_argument("id", type=int)
sub.add_parser("stats", help="Show totals")
args = p.parse_args()
if args.cmd == "add":
add(args.title, args.due)
elif args.cmd == "list":
ls(args.status)
elif args.cmd == "edit":
edit(args.id, args.title, args.due)
elif args.cmd == "done":
done(args.id)
elif args.cmd == "del":
delete(args.id)
elif args.cmd == "stats":
stats()
if __name__ == "__main__":
main()Try these commands
python -m app.main add "Study 20 minutes" --due 2025-08-31
python -m app.main add "Walk 3km"
python -m app.main list --status all
python -m app.main done 1
python -m app.main edit 2 --title "Walk 2km" --due 2025-09-01
python -m app.main list --status pending
python -m app.main statsExpected output (abridged):
Added #1: Study 20 minutes
Added #2: Walk 3km
[✗] 1: Study 20 minutes (due: 2025-08-31)
[✗] 2: Walk 3km (due: -)
Marked #1 as done
Updated #2
[✗] 2: Walk 2km (due: 2025-09-01)
Total: 2 | Done: 1 | Pending: 1🌐 Track B: Web/API Pocket Tasks — Feature Build
We’ll add: JSON persistence, input/output models, filters + pagination, and partial updates.
Create app/storage.py
# app/storage.py
from __future__ import annotations
from pathlib import Path
import json
from typing import List, Dict
DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "tasks.json"
DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
def load() -> List[Dict]:
if not DATA_PATH.exists():
return []
return json.loads(DATA_PATH.read_text(encoding="utf-8"))
def save(items: List[Dict]) -> None:
DATA_PATH.write_text(json.dumps(items, indent=2), encoding="utf-8")Replace app/models.py
# app/models.py
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
class Task(BaseModel):
id: int
title: str = Field(min_length=1)
done: bool = False
created_at: str = datetime.now().isoformat(timespec="seconds")
due: Optional[str] = None # YYYY-MM-DD
class TaskCreate(BaseModel):
id: int
title: str = Field(min_length=1)
due: Optional[str] = None
class TaskUpdate(BaseModel):
title: Optional[str] = None
done: Optional[bool] = None
due: Optional[str] = NoneReplace app/main.py (FastAPI)
# app/main.py
from __future__ import annotations
from fastapi import FastAPI, HTTPException, Query
from typing import List, Optional
from app.models import Task, TaskCreate, TaskUpdate
from app import storage
app = FastAPI(title="Pocket Tasks")
@app.get("/")
async def root():
return {"message": "Pocket Tasks API"}
@app.get("/tasks", response_model=List[Task])
async def list_tasks(status: str = Query("all", pattern="^(all|pending|done)$"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0)):
items = storage.load()
if status == "pending":
items = [i for i in items if not i.get("done")]
elif status == "done":
items = [i for i in items if i.get("done")]
return items[offset:offset+limit]
@app.post("/tasks", status_code=201, response_model=Task)
async def add_task(new: TaskCreate):
items = storage.load()
if any(i.get("id") == new.id for i in items):
raise HTTPException(status_code=400, detail="id already exists")
item = Task(id=new.id, title=new.title, due=new.due)
items.append(item.dict())
storage.save(items)
return item
@app.patch("/tasks/{task_id}", response_model=Task)
async def update_task(task_id: int, patch: TaskUpdate):
items = storage.load()
for i in items:
if i.get("id") == task_id:
if patch.title is not None:
i["title"] = patch.title
if patch.done is not None:
i["done"] = patch.done
if patch.due is not None:
i["due"] = patch.due or None
storage.save(items)
return i
raise HTTPException(status_code=404, detail="not found")
@app.delete("/tasks/{task_id}", status_code=204)
async def delete_task(task_id: int):
items = storage.load()
new_items = [i for i in items if i.get("id") != task_id]
if len(new_items) == len(items):
raise HTTPException(status_code=404, detail="not found")
storage.save(new_items)Run & Try
uvicorn app.main:app --reloadCreate a task:
curl -s -X POST http://127.0.0.1:8000/tasks -H 'Content-Type: application/json' \
-d '{"id":1, "title":"Study 20 minutes", "due":"2025-09-01"}' | jqList pending tasks:
curl -s 'http://127.0.0.1:8000/tasks?status=pending&limit=10&offset=0' | jqMark done:
curl -s -X PATCH http://127.0.0.1:8000/tasks/1 -H 'Content-Type: application/json' \
-d '{"done":true}' | jqEdit title:
curl -s -X PATCH http://127.0.0.1:8000/tasks/1 -H 'Content-Type: application/json' \
-d '{"title":"Study 15 minutes"}' | jqDelete:
curl -i -X DELETE http://127.0.0.1:8000/tasks/1Expected responses (abridged):
{"id":1,"title":"Study 20 minutes","done":false,"created_at":"YYYY-MM-DDTHH:MM:SS","due":"2025-09-01"}
[{"id":1,"title":"Study 20 minutes","done":false,"created_at":"...","due":"2025-09-01"}]
{"id":1,"title":"Study 20 minutes","done":true,"created_at":"...","due":"2025-09-01"}
{"id":1,"title":"Study 15 minutes","done":true,"created_at":"...","due":"2025-09-01"}
HTTP/1.1 204 No Content✅ Definition of Done
CLI: supports add/list (with filters)/edit/done/delete/stats; output is readable.
API: supports create/list (filters & pagination)/patch/delete with JSON persistence.
Friendly error messages for invalid input or missing ids.
Your app runs end‑to‑end and matches the expected outputs above.
🧙♂️ Take the Wand and Try Yourself
Implement one extra feature:
CLI:
exportcommand that writes a CSV of all habits todata/habits.csv.API: add
GET /statsreturning counts{total, done, pending}.
Add helpful errors (e.g., prevent empty titles).
(Optional) Add a
--dataflag to choose a different JSON path for testing.
Expected outcome:
Running your extra feature produces a CSV (CLI) or a JSON stats response (API).
Input validation prevents empty titles and bad dates.
Up next: Day 30: Wrap‑Up & Next Steps — recap, best practices, and building your learning roadmap.