$ emrebener
home personal-projects lifeos

LifeOS

published: updated: type: build

Dashboard

Board

Health

LifeOS is a single-user life-management app I self-host in my homelab: a kanban tracker (Project Category → Project → Task), recurring habit routines, a calendar, a health area with a computed-target energy engine and macro logging, and PARA-style resource and area notes, all served from one Docker container on one port. The backend is FastAPI + SQLModel over SQLite in WAL mode; the frontend is React + TypeScript; and the seam between them is type-checked end to end, because the frontend’s API types are generated from the backend’s OpenAPI schema instead of being hand-written. The load-bearing logic (status transitions, fractional ordering, recurrence expansion, the whole nutrition energy engine) lives in pure functions with 530-plus tests around them, because those are the parts that silently corrupt data when they’re wrong.

1. One app across six domains, one container

The reason it’s one app and not six is the data. A task has a deadline that belongs on the calendar; a routine’s completions feed a streak chart; a diet phase paints a band across the same calendar the tasks live on. Once the domains reference each other, splitting them into separate services means either duplicating the cross-references or building an integration layer to stitch them back together. For a single user on a homelab, both are worse than putting everything behind one schema.

The scope, by area:

AreaWhat it is
BoardsKanban with four fixed columns (todo, doing, done, cancelled); a Project is the board, a Task is one card
RoutinesRecurring, trackable habits with structured recurrence rules and tri-state (done / missed / pending) completion
CalendarA schedule-x view merging routine occurrences, scheduled tasks, and diet-phase bands into one feed
HealthBody metrics, diet phases, a computed-target energy engine, macro-chunk nutrition logging, water tracking
Resources / AreasPARA “R” and “A”: markdown documents organized under categories, edited in a deep-linkable modal
Archive / SearchA per-item archive flag, and a global search that spans every entity above

The single-user constraint is a decision, not a shortcut. There’s no authorization layer, no per-row ownership, no multi-tenant story anywhere in the code, and that absence is what keeps the schema small enough to hold all six domains without the joins turning into a permissions matrix. The cost is that LifeOS can never serve a second user without a rewrite. For a tool I run for myself, that trade is free.

The backend is layered the same way in every domain: a router owns HTTP and the auth dependency, a service owns the logic worth testing, a SQLModel table owns persistence, and a schema shapes the boundary. That uniformity is what lets one person hold all of it in their head.

2. The type-checked seam between Python and TypeScript

The frontend never hand-writes an API type. FastAPI already knows the exact shape of every request and response, because each endpoint declares a response_model, so the frontend’s types are generated from the backend’s OpenAPI document by a single command:

openapi-typescript http://localhost:8000/openapi.json -o src/api/schema.d.ts

That produces a 4,700-line schema.d.ts with a banner that says “Do not make direct changes to the file.” Every hook imports its types from there — components["schemas"]["TaskDetail"], ["TaskCreate"], and so on — so a backend field rename that isn’t matched on the frontend becomes a TypeScript error at build time instead of an undefined at runtime. The alternative I didn’t take was maintaining a parallel set of TypeScript interfaces by hand. That works right up until the day someone changes a Pydantic model and forgets the mirror; then the two drift silently and the bug surfaces in the browser. tRPC would give the same end-to-end safety, but it assumes a TypeScript backend, and this backend is Python. OpenAPI is the bridge that a Python-to-TypeScript stack actually has.

The one manual seam is casing. Python is snake_case, the JSON boundary is camelCase, and I reconcile them at the schema layer rather than anywhere in the transport:

class FoodLogEntryCreate(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    date: datetime.date
    name: Optional[str] = None
    protein_g: float = Field(alias="proteinG")
    carbs_g: float = Field(alias="carbsG")
    fat_g: float = Field(alias="fatG")
    alcohol_g: Optional[float] = Field(default=None, alias="alcoholG")
    fiber_g: Optional[float] = Field(default=None, alias="fiberG")
    kcal_override: Optional[float] = Field(default=None, alias="kcalOverride")

populate_by_name=True means the service code keeps writing entry.protein_g while the wire stays proteinG, and the alias is what lands in the generated TypeScript. The casing convention lives in exactly one place per DTO and the generator carries it the rest of the way.

The other half of the boundary is cache coherence. LifeOS uses TanStack Query with structured, factory-built keys, and the interesting problem is that one write touches many views. Moving a task changes the board, the dashboard summary, the deadline panel, the analytics rollups, the calendar, and the search index. So a task mutation fans its invalidation out across all of them:

export async function invalidateAfterTaskMutation(
  queryClient: QueryClient,
  projectId: string,
  id?: string,
): Promise<void> {
  const promises = [
    queryClient.invalidateQueries({ queryKey: ["tasks", projectId], exact: false }),
    queryClient.invalidateQueries({ queryKey: qk.summary() }),
    queryClient.invalidateQueries({ queryKey: qk.deadlines() }),
    queryClient.invalidateQueries({ queryKey: ["analytics"], exact: false }),
    queryClient.invalidateQueries({ queryKey: ["calendar"], exact: false }),
    queryClient.invalidateQueries({ queryKey: ["search"], exact: false }),
  ];
  if (id) promises.push(queryClient.invalidateQueries({ queryKey: qk.task(id) }));
  await Promise.all(promises);
}

Drags and completion toggles go a step further and update the cache optimistically: an onMutate handler snapshots the affected columns and applies the move before the request leaves, onError rolls back to the snapshot if the server refuses, and onSettled runs the fan-out above to reconcile. The card moves the instant you drop it, and it snaps back only if the write actually failed. Getting the invalidation set wrong is a subtle bug in either direction: too narrow and a stale panel lingers, too broad and the whole UI refetches on every keystroke. Naming it once, per entity, in a helper the hooks share is how I keep that set honest.

One task mutation invalidates six viewstask mutation(drag / edit)boarddashboard summarydeadline panelanalytics rollupscalendarsearch index["tasks", projectId]qk.summary()qk.deadlines()["analytics"]["calendar"]["search"]One task mutation invalidates six viewstask mutation(drag / edit)boarddashboard summarydeadline panelanalytics rollupscalendarsearch index["tasks", projectId]qk.summary()qk.deadlines()["analytics"]["calendar"]["search"]

3. Search is a SQLite feature, not a separate service

Global search runs on SQLite’s own full-text engine, FTS5, not a LIKE scan and not a search server bolted on the side. The whole index is one virtual table:

CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
  entity_type UNINDEXED, entity_id UNINDEXED, title, body,
  tokenize = 'unicode61 remove_diacritics 2'
);

Nine source tables feed it — tasks, projects, resources, areas, routines, and the four category tables — and the table stays in sync through 27 triggers, three per source (after insert, after delete, after update), generated from one definition so the index can never fall behind a write. Standing up Elasticsearch or Meilisearch for a single-user homelab app would mean a second process, a second thing to back up, and a second failure mode, all to search a database that already ships a competent full-text engine inside the file I’m already storing. FTS5 costs nothing extra to run and the index lives in the same SQLite file as the data it indexes.

User input becomes a prefix-AND MATCH expression. Every word is required and every word matches as a prefix, so typing kuber dock finds a document containing “kubernetes” and “docker” before either word is finished:

def build_fts_query(q: str) -> str | None:
    cleaned = _FTS_SPECIALS.sub(" ", q)
    terms = [t for t in cleaned.split() if t.upper() not in _BOOLEAN_WORDS]
    if not terms:
        return None
    return " AND ".join(f'"{t}"*' for t in terms)

Stripping the FTS operator characters first (", *, (, ), ^, :) matters because those aren’t user intent, they’re a way to crash the query parser; quoting each term and appending * is what turns raw words into a safe prefix match.

The design decision I’m most careful about is where the row cap sits relative to the filters. Search can be narrowed by type, by category, and by whether to include archived items, and each type is capped at 50 results. If you cap first and filter second, the filter can empty out a page that had real matches sitting just past the limit. So the WHERE clause is assembled before the LIMIT, and the cap is the last thing SQLite applies:

where = ["search_index.entity_type = :etype", "search_index MATCH :q"]
if cfg.archivable and not include_archived:
    where.append("e.is_archived = 0")
if category_ids:
    where.append(f"e.{cfg.cat_fk} IN :cat_ids")
sql = (
    f"SELECT {cols} FROM search_index "
    f"JOIN {cfg.entity_table} e ON e.id = search_index.entity_id "
    f"JOIN {cfg.cat_table} c ON c.id = e.{cfg.cat_fk} "
    f"WHERE {' AND '.join(where)} "
    f"ORDER BY rank LIMIT :cap"
)

Filters constrain the set, ORDER BY rank sorts by FTS5’s relevance score, and the 50-row cap comes off the already-filtered, already-ranked result. The cap stays correct no matter how the search is narrowed.

The one place this design fought back was the migration tooling. Alembic’s autogenerate compares the live database against the models, and an FTS5 virtual table plus the five shadow tables SQLite materializes for it aren’t in the model metadata, so autogenerate reads them as tables that ought to be dropped and helpfully writes a migration to delete the search index. The fix is a filter that hides them from the comparison:

def include_name(name, type_, parent_names):
    if type_ == "table" and name is not None and (
        name == "search_index" or name.startswith("search_index_")
    ):
        return False
    return True

Without it, the next auto-generated migration silently carries a drop_table for the entire search subsystem. That’s the kind of failure that passes review because the migration looks unrelated, so the guard lives right next to the autogenerate config with a comment explaining why it’s there.

Nine source tables feed one FTS5 indextasksprojectsresourcesareasroutinesproject categoriesresource categoriesarea categoriesroutine categoriessearch_index(FTS5 virtual table)27 triggers keep it in sync9 sources x 3(after insert / delete /update)Nine source tables feed one FTS5 indextasksprojectsresourcesareasroutinesproject categoriesresource categoriesarea categoriesroutine categoriessearch_index(FTS5 virtual table)27 triggers keep it in sync9 sources x 3(after insert / delete /update)

4. The risky logic lives in pure, tested functions

The rule I hold everywhere is that anything that can quietly corrupt data gets pulled out of the router, written as a pure function with no database access, and tested directly. The services layer is where that logic lives, and it’s why the backend has 534 test functions against roughly 6,600 lines of application code.

The sharpest example is status transitions. A task’s status can change two ways — dragged between columns, or edited in the detail modal — and both paths converge on one function that is the only place closed_at is ever set or cleared:

def apply_status_change(task, new_status: str, session) -> None:
    if new_status == task.status:
        return
    was_open = task.status in OPEN
    now_closed = new_status not in OPEN
    if was_open and now_closed:
        task.closed_at = utcnow()
    elif (not was_open) and (not now_closed):
        task.closed_at = None
    # closed -> closed: leave closed_at unchanged
    new_pos = top_of_column(session, task.project_id, new_status)
    task.status = new_status
    task.position = new_pos

todo and doing are open, done and cancelled are closed. Moving open-to-closed stamps the completion time; moving closed-to-open clears it; moving between two closed states leaves it untouched, so re-categorizing a finished task from done to cancelled doesn’t rewrite when it closed. If that logic were copy-pasted into the drag handler and the modal handler separately, the two would drift and half the analytics would quietly disagree with the other half. There’s one function and nine tests pinning its exact behavior.

Ordering within a column uses fractional positions rather than integer indices, so reordering a card touches one row instead of renumbering the column. A new card between two neighbors takes (above + below) / 2.0; the top of a column is min_pos - 1.0. Floats eventually run out of precision between two very close neighbors, so a needs_rebalance check watches the gap and a rebalance pass renumbers the column to evenly-spaced values when it gets too small.

The health engine is pure arithmetic with every constant named and sourced. Basal metabolic rate is a one-liner:

def bmr_mifflin(*, weight_kg, height_cm, age, sex) -> float:
    return 10 * weight_kg + 6.25 * height_cm - 5 * age + (5 if sex == "male" else -161)

Calories from a logged meal use the full Atwater factors — 4 kcal/g for protein and carbs, 9 for fat, 7 for alcohol — and the engine has 46 tests of its own covering the formula variants, the macro derivations, and the warning thresholds that fire when a computed target would drop below BMR.

Recurrence expansion is the one I most wanted to be pure, because it’s the easiest place to get a subtle date bug. Given a rule, a start date, and a window, it returns the occurrence dates by walking the window one day at a time and asking a predicate whether each day matches:

def _matches(rec, start_date, d) -> bool:
    if d < start_date:
        return False
    t = rec["type"]
    if t == "daily":
        return (d - start_date).days % rec["interval"] == 0
    if t == "weekly":
        if WEEKDAYS[d.weekday()] not in rec["weekdays"]:
            return False
        anchor_monday = start_date - timedelta(days=start_date.weekday())
        d_monday = d - timedelta(days=d.weekday())
        return ((d_monday - anchor_monday).days // 7) % rec["interval"] == 0
    # monthly / yearly ...

A day-by-day scan looks naive next to date arithmetic that jumps straight to the next occurrence, and for an unbounded range it would be. But the windows here are capped at 366 days, so the scan is cheap, and it buys correctness for free: as the code comment notes, “a Feb date never has day==31; Feb 29 only exists in leap years.” An “every month on the 31st” rule simply produces no date in November because November 31st never comes up in the walk, and no special case has to remember that. The skip rules fall out of the calendar instead of being encoded by hand.

5. The calendar feed, and schedule-x’s runtime traps

The calendar is one endpoint returning a discriminated union, and one frontend library that diverges from its own documentation in ways that only surface at runtime.

The backend never stores an occurrence. GET /api/calendar/occurrences?from&to expands every routine over the requested window on the fly, merges in the tasks and diet phases that fall inside it, and returns a single list tagged by kind:

class CalendarOccurrence(BaseModel):
    kind: Literal["routine"] = "routine"
    # routineId, date, title, status, ...

class TaskEvent(BaseModel):
    kind: Literal["task"] = "task"
    # taskId, start, end, allDay, isDeadline, ...

class DietPhaseSpan(BaseModel):
    kind: Literal["dietPhase"] = "dietPhase"
    # phaseId, mode, start, end (clipped to the window)

CalendarEntry = Annotated[
    Union[CalendarOccurrence, TaskEvent, DietPhaseSpan], Field(discriminator="kind")
]

The kind discriminator is what makes the union safe on both ends: FastAPI validates each member against the right model, and the generated TypeScript narrows on the same field so the frontend’s switch over kind is exhaustively type-checked. Expanding occurrences per request instead of materializing them means there’s no stored table to keep in sync when a routine’s rule changes. Editing the recurrence just changes what the next expansion produces. The router loops the routines and calls the same pure expand from the previous section:

routines = session.exec(select(Routine)).all()
out: list[CalendarEntry] = []
for r in routines:
    dates = expand(r.recurrence, r.start_date, r.end_date, win_from, win_to)

The window is validated to at most 366 days, which is the same bound that keeps the day-by-day scan cheap.

The frontend renders this with schedule-x v4, and its real API differs from the published docs in ways that cost time. Events are Temporal values, not date strings, and need the polyfill imported both as a value and as a global. Plugins are the second argument to useCalendarApp, not a config key. The app is created once with its config frozen, so anything that changes later — the event list, the theme, the calendar colors — has to be pushed imperatively through service methods rather than by re-rendering, which means callbacks read fresh React state through refs instead of closures. setTheme lives on the app instance, not on the controls plugin that looks like it should own it.

The trap that actually crashes the page is event IDs. schedule-x feeds each event’s id straight into document.querySelector, so an id containing a colon throws "... is not a valid id" on first render. A composite key like routineId:date is the obvious way to make an occurrence’s identity unique, and it detonates immediately. The whole app uses a double-underscore separator instead:

const ID_SEP = "__";

function makeEventId(routineId: string, date: string): string {
  return `${routineId}${ID_SEP}${date}`;
}

Routine occurrences are <routineId>__<date>, tasks are task__<taskId>__<span|deadline>, and diet phases are dietphase__<phaseId>, all colon-free, all mutually non-colliding, all parseable back into their parts. The code comment states the constraint plainly: the separator “keeps IDs valid CSS idents (colons are not allowed by document.querySelector / schedule-x’s id validation).” What makes this one dangerous is that it passes typecheck, lint, and the unit tests, and only fails when a browser tries to render the event. It’s the one part of the calendar that has to be verified in an actual browser, and the ID grammar is documented next to the code so the next colon never gets written.

6. Single writer, single container, by design

LifeOS ships as one multi-stage Docker image: the Vite build compiles the frontend to static files, and FastAPI serves them with an SPA fallback alongside the /api routes, so the whole app is one process on one port. There’s no separate frontend host, no reverse proxy to configure, no CORS to reason about. For a homelab service that one person deploys, a single container is the entire operations story.

SQLite is the deliberate ceiling. Running Postgres would mean a database server to provision, secure, and back up next to an app whose entire dataset fits comfortably in one file. The price of SQLite is exactly one writable replica, and the whole deployment is shaped to honor it: the Kubernetes Deployment is replicas: 1 with a Recreate strategy so a rollout never briefly runs two writers, the volume is ReadWriteOnce, and the database runs in WAL mode with foreign keys enforced. There’s no horizontal scaling, and for this workload there’s nothing to scale, since a single user generates writes a laptop wouldn’t notice. The container runs as a non-root user, which forces one real constraint worth naming: the database volume has to be pre-owned by that user, because a root-created bind mount would leave the process unable to write its own database.

Deletes are hard and cascade at the database level, which for a personal tool is the honest default: no trash to manage and no soft-delete flag threading through every query. The one guard I added on top is that a board, resource, or area has to be archived before it can be deleted: the archive flag hides it from every list, and only from the archive page can it be permanently removed. That two-step exists because a single-user app has no “are you sure” safety net from a second person noticing, so the destructive action is deliberately made to take two decisions instead of one.