🔄 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

  1. Extend your app with useful features (edit, filter, stats).

  2. Improve persistence (read/write JSON safely).

  3. Provide clear output (tables for CLI; JSON schemas for API).

  4. 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 stats

Expected 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] = None

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

Create 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"}' | jq

List pending tasks:

curl -s 'http://127.0.0.1:8000/tasks?status=pending&limit=10&offset=0' | jq

Mark done:

curl -s -X PATCH http://127.0.0.1:8000/tasks/1 -H 'Content-Type: application/json' \
  -d '{"done":true}' | jq

Edit title:

curl -s -X PATCH http://127.0.0.1:8000/tasks/1 -H 'Content-Type: application/json' \
  -d '{"title":"Study 15 minutes"}' | jq

Delete:

curl -i -X DELETE http://127.0.0.1:8000/tasks/1

Expected 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

  1. Implement one extra feature:

    • CLI: export command that writes a CSV of all habits to data/habits.csv.

    • API: add GET /stats returning counts {total, done, pending}.

  2. Add helpful errors (e.g., prevent empty titles).

  3. (Optional) Add a --data flag 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.

Keep Reading