Some projects look bigger on a résumé than they feel in your hands. FeatherPunch was the opposite.
It is a small, local-first mobile app for tracking time across the roles that make up a life: work, study, family, rest, or anything else worth naming. You create a role, punch in, punch out, and gradually build a record of where your time actually went.
There are no accounts, no cloud sync, and no backend sitting behind the product. That was intentional. FeatherPunch was never meant to become another elaborate productivity system full of streaks, goals, team dashboards, and optimization loops. The idea was quieter: make time visible without immediately turning it into a performance metric.
That simplicity puts unusual weight on the small amount of state the app does own. A session cannot quietly disappear. An active punch has to survive navigation and application restarts. Switching roles should not create overlapping sessions. Historical records need to remain trustworthy because the entire product depends on the user being able to look back and believe what the app remembered.
SQLite therefore became the durable source of truth, while Expo Router handled application structure and client-side state was divided between Redux, Zustand, and local component state. That split also exposed one of the more useful lessons in the project: adding another state-management library is easy. Deciding which layer is actually allowed to own a piece of state is much harder.
Tracking roles instead of tasks
FeatherPunch tracks time by role, not by project or task.
That distinction shaped the tone of the product. Most time-tracking tools are organized around output: projects, deadlines, clients, reports, billable hours, productivity goals, or team management. FeatherPunch starts somewhere else. It treats time as something worth observing before deciding what should be optimized.
A role can represent studying, exercise, family, sleep, work, or anything else the user wants to name. Sessions record when time begins and ends under one of those roles, while the application derives lightweight summaries from that history.
The home screen reflects that restraint. The current role is the dominant piece of state because it answers the most immediate question: what am I doing right now? Recent roles make switching inexpensive, while the "Today at a glance" summary gives the user a sense of how the day accumulated without turning the experience into a dense analytics dashboard.
The bottom navigation keeps the product similarly focused around four surfaces: Home, Roles, Insights, and Settings. There is enough structure to revisit the data, but the main workflow remains one action at a time: choose a role, punch in, let time pass, and punch out.
No backend was both a product decision and an architecture decision
The most important technical choice in FeatherPunch was also one of the clearest product choices: there is no application backend.
Roles, sessions, and application settings live in SQLite on the device. Theme state persists through Redux Persist and AsyncStorage. Onboarding is represented by a local flag in the app_settings table. There are no server routes, auth tokens, controllers, cloud database, or API client sitting between the interface and its source of truth.
That keeps the data model honest. The phone owns the data, so the runtime has to treat local persistence as a first-class concern rather than as a temporary cache waiting for a server to confirm it.
The approach has obvious benefits. There is less deployment infrastructure, the privacy story is simpler, and there is no hidden synchronization model pretending to be easy while carrying most of the complexity somewhere else. But removing a backend does not remove architecture. It relocates it.
In FeatherPunch, SQLite is not "just storage." It is the durable authority for the application. The startup path has to initialize it correctly before the rest of the UI can be trusted. Session transitions have to preserve the single-active-session rule. Screens need a coherent way to read and refresh local data. State held in memory has to remain aligned with what the database says actually exists.
The high-level structure is intentionally small:
The point of the diagram is not sophistication. It is ownership. One application, one local database, one persisted theme slice, a few focused state holders, and screen-level hooks that can read local data without pretending there is a remote service behind them.
The startup path is part of the product
A local-first application cannot treat initialization as incidental.
When FeatherPunch starts, Expo Router takes over from index.js, the root layout mounts, SQLite opens, migrations run, and default roles are seeded if necessary. Redux is provided, persisted theme state rehydrates, the visual wrappers are mounted, and the application finally reaches the route that reads onboarding state from SQLite.
Until the database is ready, the root layout does not proceed as though application state already exists.
That ordering matters because the database is the source of truth. Routing based on local state before that state has been initialized would make startup nondeterministic: the UI could make decisions from incomplete information and correct itself only after persistence catches up.
The implementation does not require a grand abstraction to solve that problem. It simply acknowledges that the durable layer has to be ready before the rest of the application can trust it.
There are smaller runtime details around that path as well. NativeWind participates in the styling pipeline, Reanimated is stubbed for Expo Go and web, and Android-specific plugins adjust edge-to-edge behavior and system-bar appearance. None of those is the headline feature of FeatherPunch, but together they support the feeling that the application was intentionally assembled rather than merely made functional.
One active session is the central invariant
The most important rule in FeatherPunch is simple:
There can only be one active session at a time.
That sentence defines more of the architecture than the size of the app might suggest.
If the user is currently punched into "Building," switching to "Studying" should not leave both sessions running. Punching out should end exactly the current active session. Navigating away from the Home screen should not cause the application to forget what is active. Restarting the app should allow that state to be reconstructed from durable data.
The Home screen makes that invariant visible. In the supplied example, "Building" is shown as the active role with the time at which the user punched in and one clear action: Punch out. A secondary "Switch role" action represents a different state transition rather than requiring the screen to manually perform several database writes.
That logic is centralized in the session store. The Home screen does not directly implement clock-in, clock-out, and role-switch writes independently. Instead, it invokes the store action responsible for the transition. The store performs the corresponding SQLite operation and then reloads the active session from the database.
The flow is deliberately modest, but it gives the most important product transition a single operational owner.
That matters more than it may appear. Without that boundary, separate screens or components could gradually develop slightly different ideas about how session switching should work. One might end the current session before creating the next; another might attempt the operations in the opposite order; another might update UI state optimistically without reconciling it against SQLite.
Centralizing the transition does not make FeatherPunch "enterprise architecture." It simply makes the rule harder to accidentally violate.
SQLite is authoritative; in-memory state is a working copy
The active session exists both durably in SQLite and temporarily in Zustand because those layers serve different purposes.
SQLite answers the long-lived question: what session is actually active?
The Zustand store answers the immediate UI question: what active session should the application currently render and coordinate around?
That distinction is why session mutations reload the active session after writing rather than assuming the in-memory state is automatically correct. The durable representation remains authoritative, while the store provides a convenient runtime view of it.
The rest of the app follows a similar pattern. Historical roles and sessions can be loaded directly into screen-level state because they do not need the same globally coordinated transition logic. Theme state belongs in Redux because it is cross-cutting UI state and already has a persistence path through AsyncStorage.
The architecture is therefore intentionally uneven. Not every value gets promoted into the same global store.
That is a feature, not an inconsistency.
A common failure mode in small applications is to decide that, because Redux or Zustand already exists, all meaningful state should move into it. FeatherPunch instead ended up with a more pragmatic rule: give global ownership only to state that actually needs global coordination.
The small state-management split exposed a bigger design lesson
Using both Redux and Zustand in a small app can sound suspicious at first. It is reasonable to ask whether one state library could have handled everything.
Technically, it probably could.
But the interesting part of FeatherPunch was not proving that two libraries were necessary. It was recognizing that the categories of state were different.
Redux is used narrowly for persisted theme state. Zustand owns active-session behavior and modal coordination. Screen-level hooks keep role lists, session lists, loading state, and other local data close to the views that use them. SQLite owns durable domain data.
That layout is not a universal architecture recommendation. In fact, one lesson from the project is that introducing state tools is cheaper than maintaining clear ownership boundaries between them. The important thing is being able to explain why a value belongs where it does.
For FeatherPunch, the active session earned a centralized owner because several product actions depend on the same invariant. A list of today's completed sessions did not require the same treatment.
The product is intentionally not an optimization machine
The cover captures the product idea better than a conventional productivity pitch would:
"You do not live one life. You live many roles."
The application is built around seeing those roles rather than assigning each one a productivity score. The home screen shows time distribution and recent roles, but the core interaction remains observational: punch into the thing you are doing and later see where the time accumulated.
That constraint matters technically because it prevented the project from growing features simply to appear more complete. There is no backend solely to make the architecture diagram larger. There is no account model without a product need for accounts. There is no sync subsystem quietly multiplying complexity behind a personal utility.
The result is a smaller system, but one whose boundaries are easier to inspect.
What the project taught me
FeatherPunch reminded me that meaningful architecture does not require large scale. Sometimes the interesting constraints come from choosing not to add infrastructure.
A backend could have made the project appear more substantial from a distance, but it would also have changed the problem. FeatherPunch asked what a personal, local-first time tracker needs when the device itself owns the truth. That made startup order, local migrations, durable session state, and ownership of transitions much more important than network architecture.
It also reinforced that state management is fundamentally an ownership problem. Redux, Zustand, React state, and SQLite are only mechanisms. The harder question is which layer gets the final say when two representations disagree.
For FeatherPunch, the answer became relatively clear: SQLite owns durable domain truth, the active-session store owns the transition workflow, and the UI renders a working representation of that state.
That clarity matters more than the number of libraries involved.
Takeaway
FeatherPunch stayed small, and that is part of why I value it.
It did not need microservices, cloud synchronization, a remote database, or a large state architecture. What it did need was a trustworthy local model for something deceptively simple: start tracking one role, stop it later, and remember what happened.
That requirement was enough to force real decisions about persistence, initialization, state ownership, invariants, and product restraint.
The project taught me that small applications still deserve architectural care. The goal is not to make them look like large systems. It is to identify the few promises the product actually makes and structure the code so those promises have clear owners.
For FeatherPunch, the most important promise is simple: your time should not be forgotten or contradicted by the app that is supposed to remember it.
