A personal operating console for time. Renders a human lifespan as navigable grids of weeks, months, years, and decades. Weekly logging follows a fixed flow — mood, mission, note — and everything persists locally. No account, no backend, no sync.
The app is a client-only React SPA with no backend, no database, and no account system. A single persisted Zustand store is the center of the architecture — configuration, notes, moods, missions, sealed archives, and UI flow state all live there. Derived life metrics are computed from three durable inputs: birthDate, lifespan, and a set of keyed records.
Record keys are string-encoded by time unit: w123 for week 123, m42 for month 42, similar patterns for years. The lifecycle grid, sheet navigation, counters, and derived metrics all build on these keys. The encoding made multi-scale navigation straightforward early in the project; it also made key parsing load-bearing across the app, which created friction as later features needed to reason about key shape.
An earlier version of the app used the Guardian API for modern historical context and the Wikipedia REST API for older eras. The idea was that a logged week becomes richer when you can see what was happening in the world at the same time. That version worked technically. The product question it exposed: was the app about world context, or about building a coherent internal instrument?
External feeds were replaced with locally computed celestial signals — moon phase, illumination, zodiac, season. The computation is deterministic, requires no network call, has no API dependency, and works indefinitely. The architectural side effects: simpler codebase, no API keys, no rate limits, no breaking changes from upstream providers. The product side effect: the app became quieter and more self-contained.
persist middleware)_headers CSP policytsc --noEmit as type-check gate
w123 m42 y8birthDate + lifespanFileReader / Blob / object URL
localStorage persistence via ZustandNo backend means no auth, no synchronization logic, no hosting cost, and no data leaving the device. It also shifts persistence responsibility entirely to the client: without deliberate export, data durability is bounded by browser profile health. The backup and import system isn't optional — it's the contract with the user.
The live counters use per-digit DOM mutation rather than React state updates to create a physical split-flap effect. Driving this through React's rendering cycle would either re-render large UI regions on every tick or require memoization scaffolding that obscures the intent. The DOM update path is an uncommon choice in a React app, but it matches the problem: isolated, high-frequency, visually specific animation with no downstream data dependencies.
The ordering isn't arbitrary and isn't configurable. It reflects the intended ritual: affect before intent, intent before record. This sequence influenced Zustand store design, the sheet interaction model, current-week logic, and relog flows. New features that touch the log flow are routed through existing sheet interactions rather than allowed to modify the sequence. Sealed future archives, for example, operate through sheet state without touching the core log path.
The lifecycle week grid can render thousands of interactive cells for a full lifespan view. Handling hover and search highlight state through per-cell React state produces unnecessary re-renders at that scale. Both behaviors are implemented through CSS class toggling and sibling selectors. The approach skips the React rendering path for these interactions and keeps the grid responsive without virtualization.
One store made early iteration fast — all state is co-located, all selectors are in one place. As the app grew, the same design created coupling between domain state and UI flow state. Sealed future archives, for example, had to respect backward compatibility with the original key model. Separating persisted domain state from transient UI state is the correct next step; it wasn't done early because the shape of the domain wasn't yet stable.
String-encoded record keys (w123, m42) seemed like a lightweight choice early. As the app grew, key parsing appeared in more and more places — the grid, sheets, counters, sealed archive logic, import validation, migration paths. A future record that needed different semantics from the key string created backward-compatibility concerns immediately. The encoding that was convenient in week one became a constraint that later features had to design around.
An app with no backend that stores personal history has one serious failure mode: a bad import overwrites data the user can't recover. Import validation checks schema version, key shape, entry count, file type, and file size before touching the store. An import that passes all checks but references an incompatible schema version is rejected and surfaced with a migration prompt. Getting this right took several iterations because the edge cases are not obvious until you test them with real backup files from earlier versions of the app.
A full lifespan at week resolution can involve thousands of cells. React re-renders on hover or search state would produce visible lag at that count without careful memoization. The solution was to remove those behaviors from React state entirely — CSS handles hover via sibling selector, search results are toggled as classes. The tradeoff is less React-idiomatic code in those paths, but the interaction is fast and the rendering model is simpler than the memoization alternative would have been.
Planning documents were written early and were accurate at the time. As the codebase moved — the external feed pivot, the sealed archive addition, UI refactors — the documents didn't always follow. In a solo project this is survivable but creates friction when you want to explain, extend, or hand off the work. The cost of documentation drift compounds when the project becomes something you want to reason about over a longer time span.