Houseplant Watering Tracker
A minimal single-user web app for tracking houseplant watering schedules and reminders.
11,615 lines · 105,303 words · 32 sections · Aug 6, 2026
Houseplant Watering Tracker — Product & Engineering Specification #
A minimal single-user web app for tracking houseplant watering schedules and reminders.
Overview #
Houseplant owners with several plants on different watering schedules struggle to remember which plants need water and when, which leads to over- and under-watering. This specification describes a deliberately small web app that solves exactly that problem and nothing else: a list of plants, each with a watering interval in days, a one-tap "Watered today" action, and clear highlighting of what is due or overdue right now.
The product is single-user by design. There are no accounts, no photos, no plant species database, and no notifications. Restraint is the design, not a limitation to be engineered around later.
This document is written to be executed cold by an AI coding agent or a small engineering team, without clarifying questions. Every field limit, edge case, error message, and default is decided here. Where a topic is cross-cutting, exactly one section owns it and every other section references it by number; Section 30.2 lists those canonical owners and is the tie-breaker when two passages appear to disagree.
How to read it. Sections are numbered ## and referenced by number throughout. Tables are
normative. Code blocks are literal and intended to be used as written. "Must" marks a requirement;
"may" marks a permitted option. Section 29 tells the executing agent how to work through the build,
and Section 27 gives the milestone order to build it in.
What gets built. One npm workspaces monorepo producing one Docker image: a single Node process serving a React single-page app and a JSON API, backed by one SQLite file on a mounted volume. No managed database, no external service, no third-party network request at runtime.
Table of Contents #
- Before You Start — Customization Decisions
- Project Overview and Vision
- Scope Boundaries
- Users, Personas and Core Scenarios
- Technology Stack and Architecture
- Repository Layout and Coding Conventions
- Data Model and Database Schema
- Watering Schedule Domain Logic
- Validation Rules and Limits
- Feature: Plant Management
- Feature: Watering Actions and History
- Feature: Due and Overdue Status Display
- API Design and Contract
- Frontend Information Architecture and Routing
- Design System and Visual Language
- Component Architecture and State Management
- Screen Specifications
- Interaction States: Loading, Empty, Error, Offline
- Accessibility Requirements
- Security, Privacy and Threat Model
- Configuration and Environment Variables
- Observability, Logging and Health
- Performance Budgets and Targets
- Data Durability: Backup, Export and Import
- Testing Strategy and Test Matrix
- Build, Deployment and Operations
- Milestones and Execution Plan
- Acceptance Criteria Master Checklist
- Executor Instructions
- Appendices
1. Before You Start — Customization Decisions #
This section lists every decision an operator may reasonably want to change before the executing agent begins building. Every decision already has a working default, so none of them block the build. Change any of them by editing the named file or environment variable before first boot; none require a code review beyond that single edit.
| # | Decision | Default | Takes effect in | How to change |
|---|---|---|---|---|
| 1 | Display timezone | UTC |
Section 8 (day-boundary math), Section 21 (env var definition) | Set APP_TIMEZONE to an IANA name (e.g. America/Chicago) in .env or the container environment before first boot. |
| 2 | Optional access-code unlock screen | Disabled (APP_ACCESS_CODE unset) |
Section 20 (threat model), Section 21 | Set APP_ACCESS_CODE (min 8 characters) in the environment. Unset it to disable again. |
| 3 | HTTP listen port | 8080 |
Section 21, Section 26 (deployment) | Set PORT in the environment. |
| 4 | Database file location | ./data/app.db |
Section 7 (schema), Section 21 | Set APP_DATABASE_PATH to an absolute or relative path. The parent directory must exist and be writable; the process creates the file itself. |
| 5 | Plant count cap | 500 active (non-deleted) plants |
Section 9 (validation limits) | Edit MAX_ACTIVE_PLANTS in packages/shared/src/constants.ts and rebuild. Not exposed as an env var because it is a data-integrity limit, not a deployment concern. |
| 6 | Default sort order | Urgency first (overdue → due_today → due_soon → upcoming), then daysUntilDue ascending, then name ascending |
Section 12 (due/overdue display), Section 16 (state management) | Edit compareByUrgency in packages/shared/src/domain/schedule.ts and rebuild both apps. The user may also change the active sort per session from the UI; that choice persists in localStorage under hpt.sort (Section 14.4). |
| 7 | Theme default | Follows OS prefers-color-scheme; no forced light or dark default |
Section 15 (design tokens) | The user's own OS setting drives it. To force a default, set the initial value written to localStorage['hpt.theme'] in apps/web/src/lib/theme.ts. |
| 8 | Backup on/off and retention | Enabled, 14 daily snapshots retained | Section 24 (backup/export/import) | Set APP_BACKUP_ENABLED and APP_BACKUP_RETAIN in the environment. |
| 9 | Rate limit values | 300 requests per 60,000 ms per IP | Section 13 (API contract), Section 20 (threat model) | Set APP_RATE_LIMIT_MAX and APP_RATE_LIMIT_WINDOW_MS in the environment. |
| 10 | Log level | info |
Section 22 (observability) | Set APP_LOG_LEVEL to any valid pino level (fatal, error, warn, info, debug, trace, silent). |
| 11 | "Due soon" threshold | Exactly 1 day (daysUntilDue === 1) |
Section 8 (status enum) | Not an environment variable — it is a domain constant (DUE_SOON_THRESHOLD_DAYS = 1) in packages/shared/src/constants.ts. Changing it requires editing the constant and rebuilding both apps/api and apps/web, since the shared package is imported by both. |
| 12 | Soft-delete purge window | 30 days after deleted_at |
Section 9 (delete policy), Section 10 (plant management feature) | Edit PURGE_AFTER_DAYS in packages/shared/src/constants.ts and rebuild. |
| 13 | CORS origin | Disabled (same-origin only) | Section 13 | Set APP_CORS_ORIGIN to a single origin URL to enable cross-origin requests from that origin only. |
| 14 | Trust proxy header (X-Forwarded-For) |
false |
Section 21, Section 26 | Set APP_TRUST_PROXY=true only when the app is deployed behind a reverse proxy that sets this header, so that rate limiting and logging see the real client IP. |
1.1 If you change nothing #
The operator gets a single-user app that opens directly to the plant list with no login screen,
reachable only by whoever has the URL. It runs on port 8080, computes all watering due-dates in
UTC, stores its data in a SQLite file at ./data/app.db inside the container's mounted volume,
takes a nightly backup snapshot retained for 14 days, and accepts up to 500 active plants. This is a
complete, working deployment — no further configuration is required to use the product as specified
in Section 2.
1.2 Prerequisites #
Building and running this project requires exactly:
- Node.js 22 LTS
- npm 10 (ships with Node 22)
- Docker 24 or newer — optional, only needed for the container build and deploy path described in Section 26; the app also runs directly under Node for local development.
- A machine or container with at least 256 MB of RAM free at runtime.
Nothing else. No cloud account, no external service subscription, no third-party API key, and no network access is required to build, test, or run the application.
2. Project Overview and Vision #
2.1 What the product is #
The Houseplant Watering Tracker is a minimal single-user web application that tracks watering intervals for a personal houseplant collection and shows which plants need water today. It replaces memory and guesswork with a short list an owner can check in seconds from a phone. It does one job — answering "which plants need water right now" — and deliberately does nothing else.
2.2 The problem #
An owner with more than a handful of plants is tracking several independent schedules at once: a fern that wants water every four days, a succulent that wants it every three weeks, a fiddle-leaf fig somewhere in between. Nothing about a shelf of plants signals which of them is due. The owner is left relying on memory, a rough sense of "it's been a while," or a habit of watering everything on the same day regardless of what each plant actually needs. The failure modes are symmetric and both damaging: forgetting a plant leads to under-watering and eventual wilting or death; watering out of caution when nothing is due leads to over-watering, root rot, and the same outcome by a different path. The underlying problem is not a lack of care — it is a lack of a simple, glanceable reference that says, per plant, "due in 3 days" or "2 days overdue." This product is that reference and nothing more.
2.3 The product principle: restraint #
The customer asked for a deliberately small application, and the smallness is the specification, not a starting point to be expanded. Every feature in Section 3.1 is fully specified end to end; every capability in Section 3.2 is out of scope and must stay out of scope. An executing agent must not add accounts, notifications, photo storage, species databases, analytics dashboards, or any other feature that was deliberately excluded, even if it seems like a natural or low-cost extension. Where a requirement is silent on a point, the correct instinct is to choose the smallest option consistent with the stated rules (see Section 3.4), never the most feature-complete one. Restraint is a deliverable, and depth of specification for the small feature set is preferred over breadth across a larger one.
2.4 Success definition #
The build is successful when all of the following are true and verifiable:
- A new plant can be added, with name and interval, in under 15 seconds on a phone-sized screen, measured from tapping "Add plant" to the plant appearing in the list.
- The answer to "what needs water today" is readable within 3 seconds of the list finishing its first render, without scrolling, on a 360px-wide viewport, for a collection of up to 20 plants.
- Marking a plant watered is a single tap and requires no confirmation dialog, no navigation away from the list, and completes its visible state change within 250 ms of the tap under normal network conditions (the hard limit in Section 23.2).
- The system holds zero server-side state beyond one SQLite file; deleting that file and restarting the process returns the app to a genuinely empty first-run state with no orphaned state elsewhere.
- A plant's overdue status never silently clears itself; it changes only in response to an explicit watering action recorded against that plant (see Section 8.6).
- The full test suite (Section 25) passes in CI on every commit to the main branch before a build is considered releasable.
- The entire application — API and static frontend — runs from a single Docker image and a single Node process, with no additional service, database server, or background worker to operate.
- An operator can go from a clean checkout to a running instance using only the steps in Section 26, with no manual step outside the documented build and start commands.
2.5 The shipping artifact #
The product ships as a single Docker image built from the multi-stage Dockerfile at the repository
root (Section 26). Running that image starts one Node.js process that serves both the JSON API under
/api/v1 and the built static frontend from the same HTTP listener on the configured PORT
(Section 21). All persistent state lives in one SQLite database file at APP_DATABASE_PATH, which the
operator mounts as a volume so it survives container restarts. There is no second container, no
managed database, and no external dependency required at runtime.
2.6 What this product is not #
The product deliberately does not provide, and must never be extended to provide:
- Multi-user accounts, logins, roles, or any form of shared or collaborative plant list.
- Photo uploads or an image gallery for plants.
- A built-in plant species database, care guide content, or species-specific watering suggestions.
- Email, push, or SMS notifications of any kind.
- Weather data or environmental sensor integrations (soil moisture, humidity, light).
- A native iOS or Android application, or any app-store presence.
- Watering history analytics, charts, trends, or reporting beyond the current due/overdue status shown in the list.
3. Scope Boundaries #
3.1 In scope #
Every core feature is built completely. The table below maps each one, plus the supporting capabilities the product needs to operate as a deployable service, to the section that specifies it in full.
| # | Capability | Specified in |
|---|---|---|
| 1 | Plant list management (add, edit, delete plants; name and watering interval) | Section 7 (data model), Section 10 (feature behaviour) |
| 2 | Optional free-text notes field per plant | Section 9 (validation), Section 10 |
| 3 | Due and overdue tracking, computed from interval and last-watered date | Section 8 (domain logic), Section 12 (display) |
| 4 | One-tap "Watered today" action | Section 8 (domain rule), Section 11 (feature behaviour) |
| 5 | Visual due-status highlighting in the list | Section 12, Section 15 (design tokens) |
| 6 | Watering history storage (last-watered date as the source of truth for due calculation) | Section 7 (schema), Section 11 (history feature) |
| 7 | No-login single-owner access | Section 4.2 (access model), Section 20 (security posture) |
| 8 | Data durability: backup, export, import | Section 24 |
| 9 | Configuration via environment variables | Section 21 |
| 10 | Observability: structured logs, health endpoint | Section 22 |
| 11 | Automated test coverage | Section 25 |
| 12 | Containerized build and deployment | Section 26 |
3.2 Out of scope #
The following are never built, and never partially built as a stepping stone toward a future version:
| Item | Why it is excluded | What the executing agent does if tempted to add it |
|---|---|---|
| Multi-user accounts or shared plant lists | This specification defines a single owner with no account system; adding accounts introduces authentication, authorization, and data-isolation concerns the product does not need. | Nothing. Build the single-owner model in Section 4.2 as specified. |
| Photo uploads or plant image galleries | Image storage, resizing, and serving add infrastructure (object storage or disk management at a different scale) disproportionate to a text-based due/overdue tracker. | Nothing. The notes field (Section 9) is the only place for free text such as "the one with variegated leaves." |
| A built-in plant species database or care guides | Species-specific advice is a content and data-maintenance product in its own right; this product is schedule tracking, not plant care education. | Nothing. The user sets the interval themselves; the app does not suggest one. |
| Email, push, or SMS notifications | Due/overdue status is shown only as in-app highlighting; notifications require an outbound delivery integration and user contact data the product never collects. | Nothing. Rely on the visual highlighting in Section 12 and the refresh policy in Section 5.5 / Section 8.9. |
| Weather or environmental sensor integrations | These require external APIs or hardware integrations that contradict the zero-external-dependency architecture in Section 5. | Nothing. Watering intervals are a fixed number of days set by the user. |
| Native iOS or Android apps | This specification calls for a responsive web app; a native app is a separate codebase, build pipeline, and distribution channel. | Nothing. The mobile-first responsive web app in Section 15 is the only client. |
| Watering history analytics or charts beyond basic due/overdue status | Analytics is explicitly out of scope; the history table exists only to compute lastWateredOn, not to be visualized. |
Nothing. Section 11's history list is a flat chronological list, not a chart. |
3.3 Deliberate non-requirements #
These are capabilities a competent engineer might assume belong in a "real" production web application. They are intentionally absent from this one, for the stated reason in each case.
| Non-requirement | Reasoning |
|---|---|
| Multi-device real-time sync | There is one user and, at most, occasional use from a second device; Section 5.6's last-write-wins model with a 5-minute poll and focus-triggered refetch (Section 5.5) is sufficient without a sync protocol. |
| Offline write support | The app requires a live connection to the single API process; there is no local write queue, conflict resolution, or service worker, since offline-first adds meaningful complexity for a feature no core scenario in Section 4.3 requires. |
| Internationalization/localization beyond the timezone setting | The target user is a single individual, not a multi-locale audience; only the display timezone (APP_TIMEZONE) is configurable, and the UI ships in English only. |
| Audit logging (who changed what, when, beyond structured request logs) | There is exactly one user; a change-history/audit trail is a multi-user accountability feature. Section 22's request logs are sufficient for operational debugging. |
| GDPR data-subject tooling (export-my-data portals, right-to-erasure workflows) | The data is plant names, intervals, and optional notes — not personal data about the user beyond what the operator themselves enters; Section 24's export endpoint already provides full data portability without a compliance workflow. |
| Horizontal scaling (multiple app instances, load balancing) | SQLite via a single synchronous connection (Section 5.6) assumes exactly one writer process; the traffic volume of one user never approaches a level requiring more than one instance. |
| CDN delivery of static assets | Total asset size is small and the app is typically deployed close to its single user; Section 5.4's in-process static file serving with long-lived cache headers is sufficient. |
3.4 Scope change protocol #
The executing agent does not add scope. When an implementation decision is not explicitly covered by this specification, the agent resolves it by choosing the simplest option consistent with the restraint principle (Section 2.3), the confirmed out-of-scope list (Section 3.2), and the canonical technical decisions already fixed elsewhere in this document, and records that decision in the decision log (Section 30). The agent never treats an unlisted behavior as an invitation to design a new feature, and never defers a required behavior with a placeholder; every gap is closed by a stated, recorded decision before the milestone containing it is considered complete (Section 27).
4. Users, Personas and Core Scenarios #
4.1 The single persona #
There is exactly one persona, because there is exactly one user role in this product.
Profile: An adult houseplant owner with somewhere between 5 and 40 plants spread around a home. They are not a developer and not technical beyond ordinary smartphone use. They check the app most mornings from their phone, and occasionally from a laptop when adding several new plants at once after a shopping trip.
Goals:
- Know, at a glance, which plants need water today without inspecting each plant individually.
- Record that a plant has been watered with minimal friction, ideally one tap, while their hands may be wet or holding a watering can.
- Trust that an "overdue" plant stays visibly overdue until they actually deal with it.
Frustrations this product removes:
- Forgetting which plants were on a longer cycle and over-watering them out of caution.
- Losing track after a trip and not knowing how overdue anything is.
- Having to open a spreadsheet or note app that was not built for this and requires manual date arithmetic.
Environment of use: Often standing in a hallway or by a windowsill, one hand occupied by a watering can, using a phone one-handed on an ordinary home Wi-Fi connection that is sometimes slow or briefly drops. The interface must be usable at arm's length, in this posture, without precision tapping or multi-step confirmation flows.
4.2 Access model #
There are no roles and no accounts. The single person using the app to check on plants and the single person who deployed it are conceptually the same individual wearing two different hats: the "user" hat when checking or watering plants, and the "operator" hat only at deploy time, when they choose the timezone, the port, and whether to turn on the optional access code. The application itself has no concept of identity — every request that reaches it is treated as the one legitimate user. What that means in practice for exposure and hardening is specified in full in Section 20; this section only establishes that the product's data model and UI never ask "who is this."
4.3 Core scenarios #
Each scenario is a complete, testable, end-to-end journey. "System response" describes observable behavior; "Section reference" points to where that behavior is specified in full.
4.3.1 First run with an empty list #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | Operator finishes deployment and opens the app URL for the first time. | The API boots, runs pending migrations, and creates the SQLite file if absent. The web app loads the plant list route. | Section 26, Section 7 |
| 2 | User views the list. | The list is empty; an empty-state view explains there are no plants yet and presents a prominent "Add plant" action. | Section 18 |
| 3 | User does nothing further this session. | No error, no placeholder rows, no sample data. | Section 18 |
4.3.2 Adding the first plant #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User taps "Add plant" from the empty state. | The add-plant form opens (route or modal per Section 14/17). | Section 14, Section 17 |
| 2 | User types a name, e.g. "Monstera," and a watering interval, e.g. 7 days. Leaves notes blank. | Client-side validation passes as the user types (Section 9). | Section 9 |
| 3 | User submits. | POST /api/v1/plants creates the plant with lastWateredOn defaulted to today; API returns 201 with the plant DTO including computed status. |
Section 7, Section 8, Section 13 |
| 4 | — | The list re-renders with the new plant shown as upcoming (due in 7 days), no longer empty. |
Section 12 |
4.3.3 The daily morning check #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User opens the app on their phone in the morning. | List loads (or refetches if the tab was already open) and displays every plant sorted by urgency. | Section 5.5, Section 12 |
| 2 | User scans the top of the list. | Overdue and due-today plants are visually distinct at the top, each with its status icon, label, and day count. | Section 12, Section 15 |
| 3 | User sees no overdue plants today. | The "needs water" summary reflects zero due, and the user closes the app within seconds. | Section 12 |
4.3.4 Watering one plant #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User finds a due_today plant in the list. |
— | Section 12 |
| 2 | User taps "Watered today" on that plant's row. | POST /api/v1/plants/:plantId/waterings (or equivalent, see Section 13) records today's date; the plant's row updates in place to upcoming with a new due date, no page navigation. |
Section 8, Section 11 |
| 3 | — | A brief, non-blocking confirmation (e.g. a toast) appears and auto-dismisses. | Section 18 |
4.3.5 Watering several plants in a row #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User taps "Watered today" on plant A. | Plant A updates in place; list remains scrolled at the same position. | Section 11, Section 16 |
| 2 | User immediately taps "Watered today" on plant B, then plant C, without waiting between taps. | Each request is independent and idempotent; each plant updates independently even if requests overlap in flight. | Section 8.7 |
| 3 | — | No request is lost, no plant is double-recorded, no UI flicker reorders the list mid-tap in a way that causes a mis-tap on the next plant. | Section 16, Section 23 |
4.3.6 Correcting a mis-tap with Undo #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User taps "Watered today" on the wrong plant. | Plant updates and a toast appears offering "Undo." | Section 11 |
| 2 | User taps "Undo" within the toast's visible window. | The most recent watering entry for that plant is deleted; lastWateredOn recomputes from the plant's remaining history (or created_at if none remains, per Section 9). |
Section 9, Section 11 |
| 3 | — | The plant's row reverts to its prior status without a full page reload. | Section 16 |
4.3.7 Editing an interval after realizing a plant drinks faster #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User opens the edit form for a plant currently upcoming with 5 days left. |
Form pre-fills current name, interval, and notes. | Section 10, Section 17 |
| 2 | User shortens the interval from 10 days to 3 days; lastWateredOn is unchanged and was 4 days ago. |
PATCH /api/v1/plants/:plantId recomputes nextDueOn from the same lastWateredOn; the plant becomes overdue by 1 day immediately. |
Section 8.8 |
| 3 | — | The list reflects the new overdue status on save; nothing clamps or blocks the shortened interval. | Section 8.8, Section 12 |
4.3.8 Deleting a plant that died #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User opens the plant's detail or swipes/taps a delete affordance. | A delete confirmation appears (Section 17). | Section 17 |
| 2 | User confirms deletion. | DELETE /api/v1/plants/:plantId soft-deletes the plant (deleted_at set); it disappears from the list immediately. |
Section 9, Section 10 |
| 3 | User sees an Undo toast. | If the user does nothing for 10 seconds, the toast dismisses and the plant remains soft-deleted, to be purged after 30 days (Section 9). | Section 9 |
4.3.9 Returning after a two-week holiday to several overdue plants #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User opens the app for the first time in 14 days. | List loads; every plant whose interval elapsed shows overdue with an accurate daysOverdue count computed fresh from stored dates, not from any cached client state. |
Section 8, Section 12 |
| 2 | User works down the list watering each overdue plant. | Each watering resets only that plant's countdown from today; unwatered plants keep accruing their overdue count. | Section 8.6, Section 8.7 |
| 3 | User finishes. | The "needs water" summary reaches zero once every overdue and due-today plant has been watered. | Section 12 |
4.3.10 Adding a plant that was last watered three days ago #
| Step | User action | System response | Section reference |
|---|---|---|---|
| 1 | User adds a newly acquired plant they actually watered three days ago at the store. | On the add form, user sets "last watered" to a date three days in the past instead of accepting today's default. | Section 9 |
| 2 | User sets a 7-day interval and submits. | API creates the plant with lastWateredOn = the supplied past date (must not be in the future and not before 1970-01-01, Section 9); nextDueOn computes to 4 days from today. |
Section 7, Section 8, Section 9 |
| 3 | — | The plant appears as upcoming, due in 4 days, not 7 — correctly reflecting the days already elapsed. |
Section 12 |
4.4 Anti-scenarios #
Things the user may try, and what the product deliberately shows instead, because these behaviors are out of scope per Section 3.2:
| What the user tries | What they see instead |
|---|---|
| Looking for a way to attach a photo to a plant | No photo control anywhere in the add/edit form; the notes free-text field is the only place to describe a plant's appearance. |
| Expecting a push or email notification when a plant becomes overdue | No notification is ever sent; the plant simply appears overdue, highlighted, the next time the app is opened (Section 12). |
| Looking for a "share this list" or "invite someone" option | No such option exists anywhere in the UI; the app has no concept of other users (Section 4.2). |
| Expecting species-specific watering suggestions when typing a plant name | No suggestion or autocomplete appears; the interval field is a plain number the user sets themselves. |
| Looking for a chart of watering history over time | The history view (Section 11) is a flat chronological list of dates, with no graph, trend line, or aggregate statistic. |
| Trying to use the app while offline and expecting changes to sync later | Mutating actions fail with a clear network-error state (Section 18) when offline; there is no local queue or background sync. |
5. Technology Stack and Architecture #
This product is built for exactly one concurrent user operating a small, bounded dataset from at most one or two devices at a time. That constraint is the reason for every architectural choice below: a single Node process, a single embedded SQLite file, no cache layer, no queue, and no separate services. Anything heavier than this — a client-server database, a job queue, a second process — would add operational surface area without solving a problem this product actually has. This rationale is stated once here and is not repeated in the sections that reference it.
5.1 Stack table #
| Concern | Decision | Why |
|---|---|---|
| Repo layout | npm workspaces monorepo: apps/web, apps/api, packages/shared |
One repository, one version history, and a shared package that guarantees the frontend and backend validate data identically (Section 9). |
| Language | TypeScript 5.6+, strict: true, everywhere |
End-to-end type safety across the shared schema layer catches mismatches between API and UI at compile time, not in production. |
| Node runtime | Node.js 22 LTS | Current LTS at time of writing with native support for the language features used; long support window for a self-hosted app. |
| Frontend framework | React 19 | Mature, well-documented component model matching the team's shared conventions in Section 6; no server-rendering requirement exists here, so a client-rendered SPA is sufficient. |
| Frontend build | Vite 6 | Fast dev server and a simple, small production build with no framework-specific server runtime needed, matching the static-file serving model in Section 5.4. |
| Frontend routing | React Router 7 (declarative/data router mode) | Small, well-understood route table (Section 14) with data-loading hooks that pair naturally with TanStack Query. |
| Frontend data fetching | TanStack Query v5 | Handles caching, refetch-on-focus, and background polling (Section 5.5) without hand-rolled fetch/cache logic. |
| Styling | Tailwind CSS v4 (CSS-first config), no component library | Utility classes keep the small, mobile-first UI (Section 15) consistent without pulling in a general-purpose component library sized for larger products. |
| Icons | lucide-react |
Single consistent icon set covering the status icons required in Section 15, tree-shakeable, no icon font. |
| Backend framework | Fastify 5 | Low-overhead HTTP framework with a first-class plugin and schema-validation model, appropriate for a small JSON API. |
| Database | SQLite via better-sqlite3 (synchronous driver) |
A single-file embedded database matches a single-user, single-writer workload exactly; the synchronous driver avoids callback/promise overhead for tiny, fast queries. |
| Migrations | Plain numbered .sql files + a tiny in-repo runner; schema_migrations table |
No ORM migration framework is needed for a schema this small (Section 7); plain SQL is fully auditable. |
| Validation | Zod (v3) schemas defined once in packages/shared, imported by both API and web |
Guarantees client and server enforce identical rules (Section 9) from one source of truth. |
| Date/time library | date-fns v4 + @date-fns/tz for IANA timezone handling |
Pure, tree-shakeable date functions with explicit timezone conversion, essential to the calendar-date correctness rules in Section 8. |
| Logging | pino (structured JSON) |
Low-overhead structured logging appropriate for a single process with no log-aggregation infrastructure assumed. |
| Unit/integration tests | Vitest 2 | Fast, Vite-native test runner shared across packages/shared and both apps. |
| API tests | Vitest + fastify.inject() |
In-process HTTP testing with no real network socket, keeping the test suite fast (Section 25). |
| E2E tests | Playwright | Real-browser coverage of the full user journeys in Section 4.3. |
| Accessibility tests | @axe-core/playwright |
Automated a11y checks against the bar defined in Section 19, run inside the same E2E suite. |
| Lint / format | ESLint 9 flat config + Prettier 3 | Single, modern lint configuration (Section 6.5) enforced in CI (Section 26). |
| Package manager | npm 10 (workspaces) | Ships with Node 22; no additional package manager installation required (Section 1.2). |
| Container | Single Docker image, node:22-alpine base, multi-stage build |
Minimal image size and attack surface, matching the single-process runtime topology below. |
| Process model | One Node process serves both the JSON API and the built static frontend | Removes the need for a separate web server or reverse proxy in the base deployment (Section 5.4). |
| Default port | 8080 |
A single conventional, non-privileged port; overridable per Section 1. |
5.2 Runtime topology #
┌───────────────────────────┐
│ Operator's reverse proxy │ (TLS termination, optional —
│ or direct exposure │ out of scope, named in 5.3)
└──────────────┬────────────┘
│ HTTP
▼
┌──────────────────────────────────────────────────────────────────┐
│ Single Node.js 22 process │
│ │
│ ┌───────────────────────────┐ ┌────────────────────────────┐ │
│ │ Fastify HTTP server │ │ In-process maintenance │ │
│ │ - /api/v1/* (JSON API) │ │ timers: │ │
│ │ - /* (static SPA)│ │ - soft-delete purge (24h) │ │
│ └─────────────┬─────────────┘ │ - nightly backup snapshot │ │
│ │ └──────────────┬─────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ better-sqlite3 connection (WAL) │ │
│ └──────────────────────┬───────────────────────┘ │
└────────────────────────────────┼──────────────────────────────────┘
▼
┌───────────────────────────┐
│ SQLite file on a mounted │
│ volume: APP_DATABASE_PATH │
└───────────────────────────┘There is no separate web server, no cache layer (e.g. Redis), no message queue, and no background
worker process distinct from the process shown above. The "maintenance timers" are setInterval/
setTimeout callbacks inside the same event loop, not separate processes (Section 24, Section 9).
5.3 Request lifecycle #
A single request is handled end to end as follows:
- TLS termination happens, if at all, at the operator's own reverse proxy in front of the container; this application never terminates TLS itself and is named here only to be explicit that it is out of scope (Section 26 covers the recommended deployment pattern).
- Connection reaches Fastify on
APP_HOST:PORT. - Request-id assignment: Fastify's built-in
genReqIdassigns a UUIDv7 request id, attached to the logger context and echoed asrequestIdin any error response (Section 13). - Route matching: Fastify matches the method and exact path (
ignoreTrailingSlash: false, Section 13). No match →404 NOT_FOUND. Path matches but method does not →405 METHOD_NOT_ALLOWED. - Content-Type check: for requests with a body, the media type must be
application/json; acharset=utf-8parameter is permitted and any other media type yields →415 UNSUPPORTED_MEDIA_TYPE. - Body size cap enforcement: 64 KB for normal endpoints, 5 MB for the import endpoint
(Section 13, Section 24); exceeding it →
413 PAYLOAD_TOO_LARGE. - JSON parse: malformed JSON →
400 MALFORMED_JSON. - Zod validation against the shared schema for that endpoint (Section 9); unknown fields are
stripped, not rejected; validation failures →
400 VALIDATION_FAILEDwith a populateddetailsarray. - Repository call: the route handler calls a repository function in
apps/api/src/db/, never touching the raw connection directly (Section 6.7). - Synchronous SQLite statement execution:
better-sqlite3executes the prepared statement synchronously within the handler; because this blocks the event loop, statements are kept small and indexed (Section 7) so this never becomes a bottleneck at this data scale. - DTO mapping: the repository or a mapper layer converts
snake_caserows tocamelCaseDTOs (Section 3, Section 6.4); raw rows never reach the route handler's response. - Domain computation: for plant reads, the shared schedule functions (Section 8) compute
status,nextDueOn,daysUntilDue, anddaysOverduefrom the stored dates. - Response envelope construction: success responses use the canonical envelope (Section 13); the handler sets the correct status code (200/201/204).
- Logging: pino logs one structured line per request with
requestId, method, path, status code, and duration in milliseconds (Section 22); request and response bodies containingnotesare never logged (Section 20). - Unhandled errors: any error not raised as an
AppError(Section 6.6) is caught by the global error handler, logged with its stack trace server-side only, and returned to the client as500 INTERNAL_ERRORwith no internal detail leaked in the message.
5.4 Static asset serving #
The same Fastify process serves the built frontend using @fastify/static, pointed at
apps/web/dist. Routing rules:
- Any request path beginning with
/api/is never treated as a static asset request. - Any other
GETrequest whose path does not match a file indistis rewritten toindex.html(SPA fallback), so client-side routes (Section 14) resolve correctly on a hard refresh or direct link. - Non-
GETrequests to non-/apipaths return405 METHOD_NOT_ALLOWED. - Cache headers: hashed, content-addressed build assets (JS/CSS bundles emitted by Vite with a
content hash in the filename) are served with
Cache-Control: public, max-age=31536000, immutable.index.htmlis served withCache-Control: no-cacheso a new deployment is always picked up on next load without requiring the user to hard-refresh.
5.5 Data flow for the schedule computation #
Watering status is always computed server-side, on every read, from the two stored facts —
lastWateredOn and wateringIntervalDays — using the pure functions specified in full in Section 8.
It is never persisted as a column. A stored status value would go stale the instant the clock crossed
local midnight in APP_TIMEZONE without any write happening to the row, so storing it would require a
separate process just to keep it correct — exactly the kind of background worker this architecture
avoids (Section 5.2). Computing it fresh on every read is both simpler and always correct. The client
may recompute the same values optimistically between fetches using the identical shared pure
functions (Section 8.5), but the server's value on the next fetch is authoritative, and the refresh
triggers in Section 16.5 (window focus, visibility change, local-midnight timer, 5-minute background
poll, and post-mutation invalidation) exist specifically to keep the client's optimistic view from
drifting far from that authoritative value.
5.6 Concurrency model #
The product is built for a single user, but its concurrency behavior is fully defined rather than left implicit:
- SQLite runs in WAL mode for better read/write concurrency within the process.
busy_timeoutis set to 5000 ms, so a rare lock contention within the same process waits rather than failing immediately.synchronous = NORMAL— durable enough for this workload without the full fsync cost ofFULL, consistent with the "not sensitive data" classification in Section 20.- The API holds one shared
better-sqlite3connection for the process lifetime; it is not pooled, becausebetter-sqlite3is synchronous and a pool would add complexity without benefit for a single connection. - Because
better-sqlite3executes queries synchronously on the main thread, route handlers must never perform slow synchronous work (large loops, heavy computation) inline; all statements in this product operate on small, indexed result sets (Section 7) so this is never a practical concern at the stated 500-plant cap. - Two-tab / two-device conflict policy: if the same plant is edited from two sessions at nearly
the same time, the last write wins. There is no locking or merge logic. Every plant DTO includes an
updatedAttimestamp; write endpoints accept an optional optimistic-concurrency header as specified in Section 10.5, and a client that detects a conflict simply refetches and re-renders — there is no blocking conflict-resolution UI, consistent with the single-user assumption in Section 4.1.
5.7 Third-party dependency inventory #
Every runtime and development dependency used anywhere in this specification appears here. No later section introduces a package not listed in this table.
| Package | Scope | Purpose | License family |
|---|---|---|---|
react, react-dom |
Runtime (web) | UI rendering | MIT |
react-router-dom |
Runtime (web) | Client-side routing (Section 14) | MIT |
@tanstack/react-query |
Runtime (web) | Server-state caching and refetching (Section 5.5, Section 16) | MIT |
tailwindcss |
Build (web) | Utility-first styling (Section 15) | MIT |
lucide-react |
Runtime (web) | Icon set | ISC |
fastify |
Runtime (api) | HTTP server and routing | MIT |
@fastify/static |
Runtime (api) | Static asset serving (Section 5.4) | MIT |
@fastify/helmet |
Runtime (api) | Security headers (Section 20) | MIT |
@fastify/rate-limit |
Runtime (api) | Rate limiting (Section 13, Section 20) | MIT |
@fastify/cors |
Runtime (api) | Optional single-origin CORS (Section 13) | MIT |
better-sqlite3 |
Runtime (api) | Synchronous SQLite driver | MIT |
zod (v3) |
Runtime (shared) | Schema validation, single source of truth (Section 9) | MIT |
date-fns (v4) |
Runtime (shared) | Calendar date arithmetic (Section 8) | MIT |
@date-fns/tz |
Runtime (shared) | IANA timezone conversion (Section 8) | MIT |
uuidv7 |
Runtime (api) | UUIDv7 primary key generation (Section 7.3) | MIT |
pino |
Runtime (api) | Structured JSON logging (Section 22) | MIT |
pino-pretty |
Dev (api) | Human-readable log formatting in local development only | MIT |
vite |
Build (web) | Frontend build and dev server | MIT |
@vitejs/plugin-react |
Build (web) | React fast refresh support in Vite | MIT |
esbuild |
Build (api) | Bundles apps/api's production build (Section 26) |
MIT |
typescript |
Dev (all) | Static typing across the monorepo | Apache-2.0 |
vitest |
Dev (all) | Unit and integration test runner (Section 25) | MIT |
@playwright/test |
Dev (root/e2e) | End-to-end browser testing (Section 25) | Apache-2.0 |
@axe-core/playwright |
Dev (root/e2e) | Automated accessibility testing (Section 19, Section 25) | MPL-2.0 |
@testing-library/react |
Dev (web) | Component testing (Section 25) | MIT |
@testing-library/user-event |
Dev (web) | Simulated user interaction in component tests (Section 25) | MIT |
rollup-plugin-visualizer |
Dev (web) | Bundle-size visualization for the performance budget gate (Section 23) | MIT |
@lhci/cli |
Dev (root) | Lighthouse CI performance budget enforcement (Section 23) | Apache-2.0 |
@fastify/swagger |
Dev (api) | OpenAPI document generation, development only (Section 13.11) | MIT |
zod-to-json-schema |
Dev (api) | Converts the shared Zod schemas into the OpenAPI document, development only (Section 13.11) | ISC |
eslint |
Dev (all) | Linting (Section 6.5) | MIT |
typescript-eslint |
Dev (all) | TypeScript-aware ESLint rules | MIT |
eslint-plugin-react-hooks |
Dev (web) | React hooks lint rules | MIT |
prettier |
Dev (all) | Code formatting (Section 6.5) | MIT |
5.8 Explicitly rejected alternatives #
| Alternative considered | Why it is rejected for this product |
|---|---|
| PostgreSQL | A client-server database adds a second process and network hop for a single-user, small-dataset workload that an embedded SQLite file serves equally well with far less operational overhead. |
| Next.js | Server-side rendering and its routing/data conventions solve problems (SEO, large dynamic page trees) this single-screen internal tool does not have; a plain Vite SPA is simpler and sufficient. |
| Prisma | An ORM's query-building and migration-generation machinery is disproportionate to seven simple, hand-writable queries against two tables (Section 7); plain SQL migrations and prepared statements are more transparent at this scale. |
| Redis | Nothing in this product needs a cache or pub/sub layer; TanStack Query's in-memory client cache (Section 5.5) and SQLite's own page cache are sufficient. |
| A component library (e.g. MUI, Chakra) | The UI surface is small enough (Section 17) that hand-built Tailwind components give tighter control over the mobile-first, status-driven visual language in Section 15 without adopting a library's full design system and bundle weight. |
| A mobile app shell (e.g. React Native, Capacitor) | This specification calls for a responsive web app, not app-store distribution; a mobile shell would duplicate the UI in a second runtime for no requirement this product has. |
6. Repository Layout and Coding Conventions #
This section is canonical for the repository's directory structure and every coding convention used throughout the codebase. Other sections reference it by number rather than restating any of it.
6.1 Directory tree #
houseplant-tracker/
├── package.json # npm workspaces root — see 6.2
├── tsconfig.base.json # shared compiler options — see 6.3
├── eslint.config.js # flat config — see 6.5
├── .prettierrc.json # formatting rules — see 6.5
├── Dockerfile # multi-stage build — see Section 26
├── docker-compose.yml # local/prod compose file — see Section 26
├── .env.example # documents every var in Section 21, no real secrets
├── .gitignore # see 6.8
├── README.md # operator quick-start, links into this spec
├── packages/
│ └── shared/
│ ├── package.json # name: "@houseplant/shared"
│ ├── tsconfig.json # extends ../../tsconfig.base.json
│ └── src/
│ ├── index.ts # public barrel export (the ONE allowed barrel, see 6.5)
│ ├── schemas/ # Zod schemas — plant.schema.ts, watering.schema.ts, pagination.schema.ts
│ ├── types/ # inferred TS types + API DTOs — plant.types.ts, watering.types.ts, api.types.ts
│ ├── domain/ # pure watering-schedule logic — schedule.ts (Section 8),
│ │ # calendar-date.ts, errors.ts (+ colocated .test.ts)
│ └── constants.ts # MAX_ACTIVE_PLANTS, PURGE_AFTER_DAYS, DUE_SOON_THRESHOLD_DAYS, error codes
├── apps/
│ ├── api/
│ │ ├── package.json # name: "@houseplant/api"
│ │ ├── tsconfig.json # extends ../../tsconfig.base.json
│ │ ├── migrations/ # 0001_init.sql — see Section 7.10
│ │ └── src/
│ │ ├── index.ts # process bootstrap: load config, build server, listen
│ │ ├── server.ts # buildServer() -> FastifyInstance, registers all plugins/routes
│ │ ├── config.ts # env parsing with Zod — the canonical implementation of Section 21
│ │ ├── db/
│ │ │ ├── connection.ts # single better-sqlite3 connection, WAL pragmas — see 5.6
│ │ │ ├── migrate.ts # migration runner against schema_migrations
│ │ │ └── repositories/ # plants.repository.ts, waterings.repository.ts, settings.repository.ts
│ │ ├── routes/
│ │ │ ├── plants.ts # /api/v1/plants*
│ │ │ ├── waterings.ts # /api/v1/plants/:plantId/waterings*
│ │ │ ├── meta.ts # /api/v1/meta — see Section 13.5.10
│ │ │ ├── health.ts # /api/v1/health — see Section 22
│ │ │ └── backup.ts # /api/v1/export, /api/v1/import — see Section 24
│ │ ├── plugins/
│ │ │ ├── error-handler.ts # maps AppError -> canonical error envelope, see 6.6
│ │ │ ├── request-id.ts # UUIDv7 request id assignment
│ │ │ ├── rate-limit.ts # wraps @fastify/rate-limit with APP_RATE_LIMIT_* config
│ │ │ ├── security-headers.ts # Helmet-equivalent headers and the CSP — see Section 20
│ │ │ └── static-files.ts # wraps @fastify/static per Section 5.4
│ │ └── lib/
│ │ ├── access-code.ts # optional unlock-screen logic — see Section 20
│ │ ├── backup.ts # nightly VACUUM INTO snapshot job — see Section 24
│ │ ├── backup-verify.ts # snapshot integrity and row-count verification — see Section 24
│ │ ├── purge.ts # soft-delete purge timer — see Section 9
│ │ ├── export.ts # GET /api/v1/export implementation — see Section 24
│ │ ├── import.ts # POST /api/v1/import implementation — see Section 24
│ │ ├── zod-error.ts # maps a ZodError to AppError details — see 6.6
│ │ └── logger.ts # pino instance construction — see Section 22
│ └── web/
│ ├── package.json # name: "@houseplant/web"
│ ├── tsconfig.json # extends ../../tsconfig.base.json
│ ├── vite.config.ts
│ ├── index.html
│ └── src/
│ ├── main.tsx # ReactDOM root, providers (QueryClientProvider, RouterProvider)
│ ├── App.tsx # top-level layout shell
│ ├── routes/ # route components — see Section 14
│ ├── components/ # PlantList, PlantRow, PlantForm, StatusBadge, ... — see Section 16
│ ├── hooks/ # usePlants, useWaterPlant, useTheme, ... — see Section 16
│ ├── api/ # typed fetch client wrapping /api/v1, one function per endpoint
│ ├── styles/
│ │ └── tokens.css # CSS custom properties — see Section 15
│ └── lib/
│ ├── sort.ts # SORT_ORDERS and the name/recently-watered comparators — see Section 12.3
│ └── theme.ts # localStorage-backed theme override — see Section 15
└── e2e/
├── fixtures/ # seeded-database fixtures for Playwright
└── specs/ # journey specs mirroring Section 4.3 scenarios6.2 Workspace configuration #
The root package.json declares the workspaces and every cross-cutting script an operator or CI job
runs:
{
"name": "houseplant-tracker",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"dev": "npm run dev --workspace=apps/api & npm run dev --workspace=apps/web",
"build": "npm run build --workspace=packages/shared && npm run build --workspace=apps/api && npm run build --workspace=apps/web",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:api": "vitest run --project api",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "npm run typecheck --workspaces --if-present",
"migrate": "npm run migrate --workspace=apps/api",
"seed": "npm run seed --workspace=apps/api",
"size-check": "node scripts/check-bundle-size.mjs"
}
}The root package declares "type": "module" so every workspace's own build output is loaded as ESM by
default; better-sqlite3 is a CommonJS native addon and is imported in apps/api via its default
interop (import Database from 'better-sqlite3'), which Node's ESM loader supports without a
createRequire shim.
6.3 TypeScript configuration #
tsconfig.base.json at the repository root, extended by every package's own tsconfig.json:
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@shared/*": ["../../packages/shared/src/*"]
}
}
}apps/api/tsconfig.json and apps/web/tsconfig.json each extend this file, adding only the
outDir, rootDir, and (for apps/web) the jsx: "react-jsx" and DOM lib entries specific to
that app. packages/shared/tsconfig.json extends it with declaration: true so both apps consume
typed output. The @shared/* path alias is the only cross-package import path; nothing imports
packages/shared via a relative ../../ path.
6.4 Naming conventions #
| Concern | Convention | Right | Wrong |
|---|---|---|---|
| Database tables/columns | snake_case, plural table names |
plants, watering_interval_days |
Plants, wateringIntervalDays (as a column name) |
| TypeScript variables/functions | camelCase |
getDueStatus(), plantId |
get_due_status(), PlantId |
| TypeScript types/components | PascalCase |
type PlantDto, function PlantRow() |
type plantDto, function plantRow() |
| Module-level constants | SCREAMING_SNAKE_CASE |
MAX_ACTIVE_PLANTS |
maxActivePlants |
| API JSON keys | camelCase, always |
{ "wateringIntervalDays": 7 } |
{ "watering_interval_days": 7 } |
| Module files | kebab-case.ts |
schedule.ts, plants.repository.ts |
Schedule.ts, plantsRepository.ts |
| React component files | PascalCase.tsx |
PlantRow.tsx |
plant-row.tsx |
| URL paths | lowercase, kebab-case, plural nouns |
/api/v1/plants, /api/v1/plants/:plantId/waterings |
/api/v1/Plant, /api/v1/plant_waterings |
| Error codes | SCREAMING_SNAKE_CASE |
VALIDATION_FAILED |
validationFailed |
| Env vars | SCREAMING_SNAKE_CASE, prefixed APP_ (except NODE_ENV, PORT) |
APP_TIMEZONE |
appTimezone, TIMEZONE |
| CSS | Tailwind utilities inline; shared tokens as custom properties | className="text-sm", --color-overdue-text |
ad hoc inline style={{ color: '#9F1239' }} for a token that already exists |
6.5 Code style rules #
ESLint 9 flat config (eslint.config.js) at the repository root:
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.strict,
{
files: ['apps/web/**/*.{ts,tsx}'],
plugins: { 'react-hooks': reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'no-restricted-imports': 'error', // rule sets configured per-workspace, see 6.7
'import/no-default-export': 'off', // overridden per-file for React route components only
},
},
{
ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', '**/playwright-report/**'],
}
);.prettierrc.json:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}Non-negotiable rules beyond what the configs above enforce mechanically:
- No
anywithout an inline// justified: <reason>comment on the same line; the ESLint rule is set toerror, and a suppression must explain itself. - No default exports, except React route components (Section 14 requires the router to consume a default export). Every other module uses named exports.
- No barrel files deeper than
packages/shared/src/index.ts. Nothing else in the repository re-exports through anindex.tsaggregator. - Functions over classes, except for repository objects in
apps/api/src/db/repositories/, which are plain object literals of functions grouped by table, not classes — "repository objects" refers to that grouping, not to instantiable classes. - Errors are thrown only as typed
AppErrorinstances (Section 6.6); no route or domain function throws a bareErroror a string.
6.6 The AppError contract #
Every layer of the application signals a handled failure by throwing this class, defined once in
packages/shared/src/domain/errors.ts and imported by both apps:
export interface AppErrorDetail {
path: string;
message: string;
}
export interface AppErrorParams {
code: string;
httpStatus: number;
message: string;
details?: AppErrorDetail[];
cause?: unknown;
}
export class AppError extends Error {
readonly code: string;
readonly httpStatus: number;
readonly details?: AppErrorDetail[];
readonly cause?: unknown;
constructor(params: AppErrorParams) {
super(params.message);
this.name = 'AppError';
this.code = params.code;
this.httpStatus = params.httpStatus;
this.details = params.details;
this.cause = params.cause;
}
}code must be one of the canonical error codes and httpStatus must be its paired status, both
defined in Section 13. apps/api/src/plugins/error-handler.ts catches every AppError thrown from a
route or repository and maps its fields directly onto the canonical error envelope from Section 13;
any thrown value that is not an AppError instance is treated as an unexpected failure and mapped to
500 INTERNAL_ERROR as described in Section 5.3, step 15.
6.7 Module boundary rules #
| From | May import | Must not import |
|---|---|---|
packages/shared |
Nothing from apps/* |
apps/api/*, apps/web/* |
apps/api/src/routes/* |
apps/api/src/db/repositories/*, @shared/* |
apps/api/src/db/connection.ts directly, apps/web/* |
apps/api/src/db/repositories/* |
apps/api/src/db/connection.ts, @shared/* |
apps/api/src/routes/* |
apps/web/src/components/* |
apps/web/src/hooks/*, apps/web/src/api/*, @shared/* |
apps/api/* |
apps/web/src/routes/* |
apps/web/src/components/*, apps/web/src/hooks/* |
apps/api/* directly (must go through apps/web/src/api/*) |
These boundaries are enforced by ESLint's no-restricted-imports, configured per workspace. The rule
for apps/web (the pattern for apps/api mirrors it with the paths reversed):
// apps/web/eslint.config.js addition
{
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/apps/api/**', '@houseplant/api', '@houseplant/api/*'],
message: 'apps/web must never import from apps/api. Go through the HTTP API instead.',
},
{
group: ['**/db/connection*'],
message: 'Import a repository from db/repositories instead of the raw connection.',
},
],
},
],
},
}A violation of any row in the table above fails npm run lint and therefore fails CI (Section 26).
6.8 Git conventions #
- Branch naming:
<type>/<short-kebab-description>, e.g.feat/plant-list,fix/overdue-day-math,chore/eslint-config.<type>matches the Conventional Commits type list below. - Commit messages: Conventional Commits. Allowed types:
feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert. Example:feat(api): add watering idempotency check. - Commit granularity: at minimum, one commit per milestone as defined in Section 27; commits within a milestone may be finer-grained, but the milestone's exit criteria must be met by its final commit.
.gitignore:node_modules/ dist/ data/ *.db *.db-journal *.db-wal *.db-shm .env coverage/ playwright-report/ test-results/
6.9 Comment and documentation policy #
Every exported function in packages/shared carries a JSDoc block describing its parameters, return
value, and any non-obvious invariant (for example, the calendar-arithmetic guarantees in Section 8).
Code in apps/api and apps/web does not require JSDoc on every export, but any function whose
behavior is not obvious from its name and types gets one. In both cases, comments exist to explain
why a piece of code does something non-obvious — a tradeoff, an edge case, a rule from this
specification — never to restate what the code visibly already does.
7. Data Model and Database Schema #
7.1 Entity overview #
┌─────────────────────┐ ┌──────────────────────┐
│ plants │ 1 * │ waterings │
│───────────────────────│─────────│────────────────────────│
│ id (PK) │◄────────│ plant_id (FK) │
│ name │ │ id (PK) │
│ watering_interval_days │ │ watered_on │
│ notes │ │ created_at │
│ last_watered_on │ └──────────────────────┘
│ created_at │
│ updated_at │
│ deleted_at │
└─────────────────────┘
┌─────────────────────┐ ┌──────────────────────────┐
│ settings │ │ schema_migrations │
│───────────────────────│ │────────────────────────────│
│ key (PK) │ │ version (PK) │
│ value │ │ name │
│ updated_at │ │ applied_at │
└─────────────────────┘ │ checksum │
└──────────────────────────┘plants is the single record of every houseplant the owner tracks. Each row is the plant's
identity (name), its schedule input (watering_interval_days), its schedule anchor
(last_watered_on), and optional free text (notes). A plant is never physically removed by normal
use; it is soft-deleted (deleted_at) and later purged (Section 7.8).
waterings is the append-only event log of watering actions. One plant has many waterings; one
watering row belongs to exactly one plant, enforced by the plant_id foreign key. plants.last_watered_on
is a denormalised cache of the most recent waterings.watered_on for that plant — it exists so every
read of the schedule (Section 8) is a single-row lookup with no join, at the cost of the repository
layer being responsible for keeping the two in sync (Section 7.13).
settings is a single-row-per-key store for small pieces of server-generated or operational state
that are not part of the plant domain and do not belong in an environment variable (Section 21).
schema_migrations records which migration files have been applied, in order, with an integrity
checksum. It is the only table the migration runner itself manages outside the numbered migration
files (Section 7.10).
Cardinality summary: plants (1) — (0..*) waterings. settings and schema_migrations are
independent single-purpose tables with no foreign keys to or from plants or waterings.
7.2 SQLite configuration #
The following pragmas are executed on every connection open, in apps/api/src/db/connection.ts,
immediately after the better-sqlite3 Database instance is constructed and before any other
statement runs:
| Pragma | Value | Reason |
|---|---|---|
journal_mode |
WAL |
Allows concurrent readers while a write is in progress; the API's health checks and background purge/backup tasks (Section 7.8, Section 24) can read while a request writes. |
synchronous |
NORMAL |
Safe under WAL (checkpoints are still durable) and meaningfully faster than FULL; appropriate for a single-writer, non-clustered deployment. |
foreign_keys |
ON |
Enables enforcement of the waterings.plant_id → plants.id foreign key and its ON DELETE CASCADE behaviour (Section 7.4, Section 7.8). |
busy_timeout |
5000 |
Milliseconds a writer waits for a lock before failing, instead of immediately throwing SQLITE_BUSY. Covers brief overlaps between a request and the nightly purge/backup tasks. |
foreign_keys = ON is a per-connection setting, not a database-file setting. SQLite does not
persist it. If the connection bootstrap code is ever changed and this pragma is dropped, every foreign
key constraint in Section 7.3–7.4 silently stops being enforced — no error is raised, rows simply stop
being validated. The connection module must set this pragma unconditionally on every Database open,
including in test setup and the migration runner, and a test in Section 25 asserts
PRAGMA foreign_keys returns 1 on a freshly opened connection.
Because the process model is a single Node process with a single better-sqlite3 connection
(Section 5.1 — one process, one user, one writer), no connection pool is used;
these four pragmas are set exactly once at process start.
7.3 plants table #
CREATE TABLE plants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
watering_interval_days INTEGER NOT NULL,
notes TEXT NULL,
last_watered_on TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT NULL,
CHECK (watering_interval_days BETWEEN 1 AND 365),
CHECK (length(trim(name)) BETWEEN 1 AND 60),
CHECK (notes IS NULL OR length(notes) <= 2000),
CHECK (last_watered_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]')
);| Column | Type | Nullable | Default | Constraint | Description |
|---|---|---|---|---|---|
id |
TEXT |
No | none (app-supplied) | Primary key | UUIDv7, generated in the API layer, lowercase 36-character hyphenated form (Section 4). |
name |
TEXT |
No | none | CHECK length 1–60 after trim |
The plant's display name, already normalised (Section 9.2) before it reaches storage. |
watering_interval_days |
INTEGER |
No | none | CHECK 1–365 |
Days between waterings; the schedule input (Section 8). |
notes |
TEXT |
Yes | NULL |
CHECK length ≤ 2000 when present |
Optional free text; empty string is never stored, only NULL or a non-empty string (Section 9.2). |
last_watered_on |
TEXT |
No | none | CHECK YYYY-MM-DD shape |
Calendar date, local to APP_TIMEZONE, of the most recent watering. Never null (Section 8.2). |
created_at |
TEXT |
No | none (app-supplied) | — | ISO 8601 UTC instant with milliseconds, set once at insert (Section 4). |
updated_at |
TEXT |
No | none (app-supplied) | — | ISO 8601 UTC instant, set on every column change including watering actions. |
deleted_at |
TEXT |
Yes | NULL |
— | ISO 8601 UTC instant the plant was soft-deleted; NULL means active (Section 7.8). |
The four CHECK constraints are the last line of defence against a bug that bypasses the application
layer (a bad migration, a manual sqlite3 session, a future integration). They are deliberately
loose: the last_watered_on GLOB pattern verifies the four-digit/two-digit/two-digit shape but not
that the date is a real calendar date (it would accept 2026-13-40). Real calendar-date validity,
Unicode normalisation, and control-character stripping are enforced once, in the shared Zod schemas
(Section 9), which is the primary and authoritative validation layer. The database never trusts input
it has not already been validated by the API.
7.4 waterings table #
CREATE TABLE waterings (
id TEXT PRIMARY KEY,
plant_id TEXT NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
watered_on TEXT NOT NULL,
created_at TEXT NOT NULL,
CHECK (watered_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]')
);
CREATE UNIQUE INDEX uq_waterings_plant_date ON waterings(plant_id, watered_on);| Column | Type | Nullable | Default | Constraint | Description |
|---|---|---|---|---|---|
id |
TEXT |
No | none (app-supplied) | Primary key | UUIDv7 for this watering event. |
plant_id |
TEXT |
No | none | FOREIGN KEY … ON DELETE CASCADE |
The plant this watering belongs to. Cascade delete only ever fires from the purge job (Section 7.8), never from the soft-delete path. |
watered_on |
TEXT |
No | none | CHECK YYYY-MM-DD shape |
Calendar date, local to APP_TIMEZONE, this watering occurred on. |
created_at |
TEXT |
No | none (app-supplied) | — | ISO 8601 UTC instant the row was inserted; used only for audit ordering when two entries share a watered_on value across different plants, never for schedule math. |
The uq_waterings_plant_date unique index on (plant_id, watered_on) is what makes the "Watered
today" action idempotent at the storage layer, independent of any application-level check: a second
insert attempt for a plant already watered on a given calendar date raises a SQLite
UNIQUE constraint failed error, which the repository layer (Section 7.13) catches and turns into the
idempotent "already watered today" response (Section 11.2) rather than propagating as a server error.
7.5 settings table #
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);settings is a plain key/value store. value is always stored as TEXT; a key whose logical type is
a number or boolean stores its string representation, and the reading code owns the parse. The
complete, closed set of keys the application ever writes:
| Key | Value format | Written by | Purpose |
|---|---|---|---|
session_secret |
opaque string, ≥ 32 characters | API bootstrap, once, only if APP_SESSION_SECRET is unset (Section 21) |
Persists an auto-generated session-signing secret across restarts so existing unlock cookies stay valid. |
schema_version_note |
free text | Migration runner, after the last migration in a batch | A human-readable description of the current schema state, for operator diagnostics (SELECT value FROM settings WHERE key = 'schema_version_note' is faster to eyeball than the full schema_migrations table). |
last_purge_run_at |
ISO 8601 UTC instant | Soft-delete purge task (Section 7.8) | Timestamp of the purge task's last completed run, so a restart shortly after a run does not immediately re-run it. |
last_backup_run_at |
ISO 8601 UTC instant | Backup task (Section 24) | Timestamp of the last completed backup snapshot. |
install_id |
UUIDv7 | API bootstrap, once, on first ever start (when the table is empty) | A stable anonymous identifier for this deployment, used only in log lines and backup filenames — never transmitted anywhere. |
No other key may be introduced without adding a row to this table in this specification. A row for a
key not in this table is a bug and must fail a Section 25 test that enumerates SELECT key FROM settings against this exact list.
7.6 schema_migrations table #
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL,
checksum TEXT NOT NULL
);| Column | Type | Description |
|---|---|---|
version |
INTEGER |
The migration's numeric prefix (e.g. 1 for 0001_init.sql). |
name |
TEXT |
The migration's snake_case name (e.g. init). |
applied_at |
TEXT |
ISO 8601 UTC instant the migration was applied. |
checksum |
TEXT |
SHA-256 hex digest of the migration file's exact byte contents at the time it was applied. |
This table is created by the migration runner itself, not by a numbered migration file. The
runner's bootstrap step is CREATE TABLE IF NOT EXISTS schema_migrations (...) executed directly by
apps/api/src/db/migrate.ts before it reads the migrations/ directory. This avoids the
chicken-and-egg problem of the first numbered migration needing a schema_migrations row recorded in
a table that migration itself would otherwise have to create.
Checksum guard: every time the process starts, the runner recomputes the SHA-256 of every
migration file already recorded as applied and compares it against the stored checksum. If any
previously applied file's contents no longer match its recorded checksum, the process logs the exact
filename and the two checksums, then exits with code 78 (configuration error, matching Section 21's
convention) without attempting to apply any further migrations. This makes silently hand-editing an
already-applied migration file impossible to do without the mismatch being caught on the next boot.
7.7 Index inventory #
| Index | Table | Columns | Unique | Serves |
|---|---|---|---|---|
idx_plants_deleted_at |
plants |
(deleted_at) |
No | The plant list query, which always filters WHERE deleted_at IS NULL (Section 10). |
idx_plants_last_watered_on |
plants |
(last_watered_on) |
No | Any future or diagnostic query that scans plants by watering recency; not required by any Section 10–12 query today, kept for the "no archival strategy needed" storage story in Section 7.12 and to keep watering-recency lookups cheap if a future feature needs them. |
uq_waterings_plant_date |
waterings |
(plant_id, watered_on) |
Yes | Enforces one watering row per plant per calendar day; also serves the point lookup "has this plant already been watered on date X" used by the idempotent watering path (Section 11). |
idx_waterings_plant_id_watered_on_desc |
waterings |
(plant_id, watered_on DESC) |
No | The watering-history pagination query, WHERE plant_id = ? ORDER BY watered_on DESC LIMIT ? OFFSET ? (Section 11, Section 13). |
At the product's hard cap of 500 plants and a worst case of a few thousand watering rows per plant
over many years (Section 7.12), SQLite's query planner would produce acceptable plans via full table
scans alone; at this scale these indexes are close to irrelevant for raw speed. They exist for
correctness (the UNIQUE index is a data-integrity constraint, not an optimisation) and as a safety
margin if the 500-plant cap is ever revisited.
7.8 Referential integrity and delete behaviour #
Plants soft-delete.
DELETE /api/v1/plants/:plantIdsetsplants.deleted_atto the current UTC instant andupdated_atto the same value. The row is otherwise untouched —last_watered_on,name,notes, andwatering_interval_daysare preserved so the 10-second Undo affordance (Section 10) can restore the plant exactly as it was viaPOST /api/v1/plants/:plantId/restore, which setsdeleted_atback toNULL.The 30-day purge permanently removes soft-deleted plants and, via the
ON DELETE CASCADEforeign key onwaterings.plant_id, all of that plant's watering rows in the same statement. The purge task runs once at process start and then on a 24-hour interval, executing:DELETE FROM plants WHERE deleted_at IS NOT NULL AND deleted_at <= ?; -- bound parameter: (now - 30 days) as an ISO 8601 UTC instant stringThe cutoff instant is computed in application code (
subDaysin UTC, not throughAPP_TIMEZONE— purge timing is an operational concern, not a calendar-date concern, so it uses plain UTC instant arithmetic) and passed as a bound parameter; the query never uses SQLite's owndatetime('now', ...)so the cutoff is computed once and is trivially unit-testable. Becausedeleted_atvalues are ISO 8601 UTC strings with zero-padded fields, lexicographic string comparison (<=) is equivalent to chronological comparison.After the
DELETE, the task writes the number of rows affected to the log (Section 22) and updatessettings.last_purge_run_at.Waterings hard-delete individually.
DELETE /api/v1/plants/:plantId/waterings/:wateringIdremoves exactly that row with no cascade (a watering has no children). Deleting a watering recomputes the parent plant'slast_watered_ononly when the deleted row'swatered_onequals the plant's currentlast_watered_on; otherwiselast_watered_onis left untouched, because a backdating edit may already have movedlast_watered_onearlier thanMAX(watered_on)on purpose, and deleting an unrelated older row must never silently pull it forward again (the never-advance rule in Section 8.2):- If the deleted row's
watered_onis not equal to the plant's currentlast_watered_on, no recomputation happens;last_watered_onandupdated_atare unchanged. - Otherwise, the repository re-queries
MAX(watered_on)for the remaining watering rows of that plant. If a maximum exists,plants.last_watered_onis set to it andupdated_atis refreshed. - If no watering rows remain (the deleted row was the plant's only watering),
last_watered_onis set to the earlier of the calendar date ofplants.created_atconverted throughAPP_TIMEZONE(todayInTimeZone-style conversion applied to the storedcreated_atinstant, not to "now") and the plant's currentlast_watered_on— never a later date, so this fallback can only preserve or increase how overdue the plant is, and neverNULL, preserving the Section 8.2 invariant. This is the fallback the domain layer defines in Section 8.2, and it is deterministic: re-running the same deletion against the same data always produces the samelast_watered_on. All steps run inside a single SQLite transaction so a crash between them cannot leavelast_watered_onpointing at a watering row that no longer exists.
- If the deleted row's
7.9 Derived and non-stored values #
| Value | Computed from | Never stored because |
|---|---|---|
nextDueOn |
addDays(last_watered_on, watering_interval_days) (Section 8.4) |
It changes meaning every time today passes it without any write to the row occurring. Storing it would require a background job ticking every plant forward at midnight to keep the stored value truthful, which the system deliberately has no need for (Section 8.6: the server holds no timers for status). |
status |
computeStatus(nextDueOn, today) (Section 8.3) |
Same reason as nextDueOn: it is purely a function of the current date, not of any event. A stored status column would go stale the instant local midnight passes in APP_TIMEZONE, with nothing in the write path to trigger its update. |
daysUntilDue / daysOverdue |
differenceInCalendarDays(nextDueOn, today) and its negation (Section 8.4) |
Identical staleness problem, one level more granular — these are numbers that change by exactly 1 every single day even with zero application activity. |
wateringCount |
COUNT(*) FROM waterings WHERE plant_id = ? |
Would require a counter column kept in lockstep with every insert and delete of a watering row (Section 7.4, Section 7.8); at the scale in Section 7.12 a COUNT(*) against the indexed plant_id is cheap enough that the denormalisation buys nothing and only adds a place for the counter and the true count to drift apart. |
Every one of these values is computed on every read, server-side, using the pure functions in Section 8.4, and returned in the plant DTO alongside the stored fields. The client may recompute them optimistically (Section 8.4, Section 16) but never writes them back.
7.10 Migration files #
0001_init.sql, the first and — as of this specification — only migration, creates every table and
index defined in Sections 7.3–7.6:
-- 0001_init.sql
-- Creates the full initial schema: plants, waterings, settings.
-- (schema_migrations itself is created by the migration runner, not by this file — see Section 7.6.)
CREATE TABLE plants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
watering_interval_days INTEGER NOT NULL,
notes TEXT NULL,
last_watered_on TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT NULL,
CHECK (watering_interval_days BETWEEN 1 AND 365),
CHECK (length(trim(name)) BETWEEN 1 AND 60),
CHECK (notes IS NULL OR length(notes) <= 2000),
CHECK (last_watered_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]')
);
CREATE INDEX idx_plants_deleted_at ON plants(deleted_at);
CREATE INDEX idx_plants_last_watered_on ON plants(last_watered_on);
CREATE TABLE waterings (
id TEXT PRIMARY KEY,
plant_id TEXT NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
watered_on TEXT NOT NULL,
created_at TEXT NOT NULL,
CHECK (watered_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]')
);
CREATE UNIQUE INDEX uq_waterings_plant_date ON waterings(plant_id, watered_on);
CREATE INDEX idx_waterings_plant_id_watered_on_desc ON waterings(plant_id, watered_on DESC);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);Migration runner contract:
- Migration files live in
apps/api/migrations/and are namedNNNN_snake_case_name.sql, a four-digit zero-padded integer, an underscore, and a lowercase snake_case description (e.g.0001_init.sql,0002_add_plant_index.sql). - On boot, the runner: (1) ensures
schema_migrationsexists (Section 7.6); (2) lists migration files sorted by their numeric prefix ascending; (3) recomputes and verifies checksums for every file whose version is already recorded (Section 7.6); (4) for each unrecorded file, in ascending order, reads its contents, opens a singlebetter-sqlite3transaction (db.transaction(() => { db.exec(sql); db.prepare('INSERT INTO schema_migrations (...) VALUES (...)').run(...); })()), and runs the file's SQL followed by inserting its ownschema_migrationsrow, so the migration's DDL and its ledger entry commit or roll back together. - A migration file, once applied (its version exists in
schema_migrations), must never be edited. Any further schema change is a new file with the next integer. - No down-migrations exist. The rollback strategy for a bad migration is to restore the most recent backup snapshot (Section 24) and re-deploy the previous application version; forward-only migrations keep the runner and the schema simple, which matches the single-environment, single-writer nature of this product.
7.11 Seed and demo data #
An optional npm run seed --workspace=apps/api command inserts the fixture defined in Section 30.5.
Every last_watered_on in that fixture is expressed relative to "today" at seed time rather than as a
fixed date, so the resulting status spread is identical regardless of what day the seed command runs.
The seed script computes today once via todayInTimeZone(new Date(), APP_TIMEZONE) (Section 8.4),
derives each plant's last_watered_on with addDays(today, -N) per the fixture in Section 30.5
(Section 8.4), inserts each plant row and a single matching waterings row (watered_on equal to the
plant's last_watered_on, created_at equal to the current instant), and prints the generated names
and computed statuses to stdout for operator confirmation.
Seeding refuses to run against a non-empty database: it first runs SELECT COUNT(*) FROM plants, and
if the count is greater than zero, it exits with a non-zero status and the message
"Refusing to seed: N existing plant(s) found. Pass --force to seed anyway." unless invoked with
--force, in which case it proceeds without deleting existing rows (it only adds the 8 seed rows on
top).
7.12 Storage sizing #
Worst-case row size, plants (every optional field at its maximum):
| Field | Bytes (UTF-8, worst case) |
|---|---|
id |
36 |
name |
60 (Unicode can exceed 60 bytes for 60 characters, so use 240 as a conservative 4-byte-per-char worst case) |
watering_interval_days |
8 (SQLite integer storage class, ≤ 8 bytes) |
notes |
8000 (2000 characters × 4 bytes worst case) |
last_watered_on |
10 |
created_at |
24 |
updated_at |
24 |
deleted_at |
24 |
| Row total (worst case) | ≈ 8,366 bytes |
At the hard cap of 500 active plants, worst-case plants data is 500 × 8,366 ≈ 4.1 MB. In practice
almost no plant will have a 2000-character notes field, so real-world usage is far smaller.
Worst-case waterings volume, ten years of daily watering for all 500 plants at the cap:
500 plants × 3,653 days (10 years including 2–3 leap days) ≈ 1,826,500 rows. Each row is
36 (id) + 36 (plant_id) + 10 (watered_on) + 24 (created_at) ≈ 106 bytes, giving
1,826,500 × 106 ≈ 193.6 MB of row data. Adding the two waterings indexes (roughly comparable in
size to the row data for narrow indexes at this cardinality, a conservative 1.5× multiplier) brings the
total database file to on the order of 300–350 MB after ten years at the absolute maximum plant
count and maximum realistic watering frequency (once per calendar day is already the practical
ceiling, since the unique index in Section 7.4 forbids more than one watering row per plant per day).
This comfortably fits in memory and on any reasonable persistent volume for the lifetime of the product; no archival, partitioning, or data-tiering strategy is warranted, and none is specified anywhere in this document.
7.13 Repository layer contract #
Repositories are the only place raw SQL exists in the codebase. Every statement is a prepared
statement created once (module scope) and reused with bound parameters — no string-built or
concatenated SQL anywhere, for both correctness and injection safety (cross-reference Section 20).
Repositories map snake_case rows to camelCase DTOs (Section 3); no other layer touches
better-sqlite3 directly.
// apps/api/src/db/repositories/plant-repository.ts
export interface PlantRow {
id: string;
name: string;
watering_interval_days: number;
notes: string | null;
last_watered_on: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface CreatePlantInput {
id: string;
name: string;
wateringIntervalDays: number;
notes: string | null;
lastWateredOn: string;
now: string; // ISO 8601 UTC instant, used for created_at and updated_at
}
export interface UpdatePlantInput {
name?: string;
wateringIntervalDays?: number;
notes?: string | null;
now: string;
}
export interface PlantRepository {
/** Non-deleted plants, ordered by created_at ascending. Maps every row to a PlantRow. */
findAll(): PlantRow[];
/** A single plant by id, including soft-deleted ones (callers filter on deleted_at as needed). */
findById(id: string): PlantRow | null;
/** COUNT(*) of active (deleted_at IS NULL) plants, for the 500-plant cap check (Section 9.5). */
countActive(): number;
/** Inserts a new plant row. created_at and updated_at are both set to input.now. */
create(input: CreatePlantInput): PlantRow;
/** Partial update of name / wateringIntervalDays / notes. Always refreshes updated_at. Returns
* null if no active plant with that id exists. */
update(id: string, input: UpdatePlantInput): PlantRow | null;
/** Sets deleted_at = now. Returns false if the plant does not exist or is already deleted. */
softDelete(id: string, now: string): boolean;
/** Clears deleted_at. Returns the restored row, or null if the plant does not exist or was not
* deleted. */
restore(id: string, now: string): PlantRow | null;
/** Directly sets last_watered_on and updated_at, used by the watering-recompute path in
* Section 7.8 and by the "Watered today" action in Section 11. */
updateLastWateredOn(id: string, lastWateredOn: string, now: string): void;
/** Hard-deletes every plant with deleted_at <= cutoffInstant (Section 7.8). Returns the number of
* rows deleted. Cascades to waterings via the foreign key. */
purgeDeletedBefore(cutoffInstant: string): number;
}// apps/api/src/db/repositories/watering-repository.ts
export interface WateringRow {
id: string;
plant_id: string;
watered_on: string;
created_at: string;
}
export interface CreateWateringInput {
id: string;
plantId: string;
wateredOn: string;
now: string;
}
export interface WateringPage {
rows: WateringRow[];
total: number;
}
export type CreateWateringResult =
| { created: true; row: WateringRow }
| { created: false; row: WateringRow }; // row already existed for that plant + date (idempotent path)
export interface WateringRepository {
/** Paginated history for one plant, newest first, using idx_waterings_plant_id_watered_on_desc. */
findByPlantId(plantId: string, limit: number, offset: number): WateringPage;
/** The single most recent watering row for a plant, or null if it has none (should not normally
* happen given Section 8.2, but the type models the possibility explicitly). */
findLatestForPlant(plantId: string): WateringRow | null;
/** Attempts to insert; if uq_waterings_plant_date rejects the insert (SQLITE_CONSTRAINT_UNIQUE),
* catches it, re-reads the existing row for (plantId, wateredOn), and returns { created: false,
* row }. Never throws for the duplicate case. */
create(input: CreateWateringInput): CreateWateringResult;
/** Deletes a single watering row by id (and, by contract, only if it belongs to plantId — callers
* pass both so a mismatched id/plantId pair returns null rather than deleting the wrong plant's
* row). Returns the deleted row (so the caller can recompute plants.last_watered_on per Section
* 7.8) or null if no matching row existed. */
deleteById(plantId: string, wateringId: string): WateringRow | null;
/** COUNT(*) of waterings for a plant, used for the wateringCount derived value (Section 7.9). */
countForPlant(plantId: string): number;
}8. Watering Schedule Domain Logic #
8.1 Vocabulary #
| Term | Definition |
|---|---|
| Instant | A specific point in universal time, represented in this system as a JavaScript Date or an ISO 8601 UTC string with milliseconds and a Z suffix (Section 4). Instants are timezone-independent by construction. |
| Calendar date | A day on the proleptic Gregorian calendar with no time-of-day or timezone component, represented as a YYYY-MM-DD string. Two calendar dates are equal if and only if their strings are equal. |
APP_TIMEZONE |
The single IANA timezone name (Section 21) used everywhere an instant must be converted into a calendar date. There is exactly one such timezone for the whole deployment; there is no per-plant or per-user timezone. |
| Today | The calendar date obtained by converting the current instant into APP_TIMEZONE. This is the only definition of "today" anywhere in the system (Section 8.4). |
Interval (wateringIntervalDays) |
An integer, 1–365, the number of calendar days between one watering and the next being due. |
lastWateredOn |
The calendar date, local to APP_TIMEZONE, of a plant's most recent watering. Never null (Section 8.2). |
nextDueOn |
The calendar date on which a plant next becomes due, computed as addDays(lastWateredOn, wateringIntervalDays). Never stored (Section 7.9). |
daysUntilDue |
differenceInCalendarDays(nextDueOn, today). Negative when overdue, zero when due today, positive when not yet due. |
daysOverdue |
0 when a plant is not overdue; otherwise -daysUntilDue (a positive integer). |
8.2 The invariant #
lastWateredOn is never null for any plant that exists in the system. Every code path that can
create or mutate a plant row must uphold this:
| Code path | How the invariant is upheld |
|---|---|
Create (POST /api/v1/plants) |
lastWateredOn defaults to todayInTimeZone(now, APP_TIMEZONE) when the request omits it; when supplied, it is validated as a real, non-future calendar date (Section 9.5) before the insert. |
Watering-delete (DELETE .../waterings/:wateringId) |
Section 7.8's recompute step only runs when the deleted row's watered_on equals the plant's current last_watered_on; otherwise last_watered_on is left untouched. When a recompute does run: if other watering rows remain, MAX(watered_on) among them; if none remain, the earlier of the plant's own created_at calendar date and the current last_watered_on. Every branch produces a non-null value and never advances lastWateredOn. |
| Import (Section 24) | The import schema (Section 9.4, ImportPayloadSchema) requires lastWateredOn on every imported plant record; a record missing it is a validation failure for that record, not a plant created with a null date. |
Create (POST /api/v1/plants) — history side |
Section 10.4 step 7 inserts a waterings row with watered_on = last_watered_on in the same transaction, so wateringCount is never 0. |
If a row is ever found to violate the invariant (for example, a hand-edited database file, or a
future migration bug), the read path never crashes. computeScheduleView (Section 8.4) is never
called with a null lastWateredOn in the first place, because the repository layer guards it: on
read, if plants.last_watered_on is NULL or fails the calendar-date GLOB/validity check, the API
(1) logs an ERROR-level structured log line including the plant id and the raw stored value
(Section 22), (2) treats the plant's created_at instant, converted to a calendar date via
APP_TIMEZONE, as the repaired value for that response only, and (3) adds the plant's id to
meta.repairedPlantIds: string[] on the list and detail responses so the client can surface a
non-blocking "this plant's data was repaired" notice for each affected plant. The repository does
not write the repaired value back automatically — a
background repair would silently mutate data the operator might want to investigate first; the actual
row is only corrected if a subsequent legitimate mutation (an edit or a watering) touches it.
8.3 The four statuses #
| Status | Condition | Meaning | UI label pattern (Section 8.4) | Colour (Section 15) |
|---|---|---|---|---|
overdue |
daysUntilDue < 0 |
The plant should already have been watered; daysOverdue = -daysUntilDue. |
"Overdue by 1 day" / "Overdue by N days" | red |
due_today |
daysUntilDue === 0 |
The plant is due right now. | "Due today" | amber |
due_soon |
daysUntilDue === 1 |
The plant is due tomorrow. | "Due tomorrow" | blue |
upcoming |
daysUntilDue >= 2 |
The plant is not due yet. | "Due in N days" | neutral/green |
Boundary examples, holding today = 2026-08-05:
nextDueOn |
daysUntilDue |
Status | Why |
|---|---|---|---|
2026-08-01 |
−4 | overdue |
nextDueOn is 4 calendar days in the past. |
2026-08-04 |
−1 | overdue |
One calendar day in the past is still overdue, not "due yesterday" — there is no such status. |
2026-08-05 |
0 | due_today |
nextDueOn equals today exactly. |
2026-08-06 |
1 | due_soon |
Exactly one calendar day ahead. |
2026-08-07 |
2 | upcoming |
Two or more calendar days ahead; this is the first upcoming value, not due_soon. |
2026-09-04 |
30 | upcoming |
Any value >= 2 is upcoming; there is no separate "far future" status. |
These four values, spelled exactly as shown (overdue, due_today, due_soon, upcoming), are the
only legal values of the status field anywhere in the API or the database layer; no other string is
ever produced or accepted.
8.4 Function specifications #
All functions in this section live in packages/shared/src/domain/schedule.ts and
packages/shared/src/domain/calendar-date.ts, are pure (no I/O, no Date.now() calls except where a
now/today value is an explicit parameter), total (they never throw for inputs that satisfy their
documented parameter contract), and dependency-light: calendar-date arithmetic (addDays,
differenceInCalendarDays, and the calendar-date validity check reused by Section 9.4) is implemented
with plain integer arithmetic and never constructs a JavaScript Date object, so its correctness can
never depend on the host process's local timezone setting. The only function that crosses the
instant-to-calendar-date boundary, todayInTimeZone, uses @date-fns/tz and date-fns exactly once,
precisely because that conversion is the one place an IANA timezone genuinely has to be consulted.
// packages/shared/src/domain/calendar-date.ts
export type CalendarDate = string & { readonly __brand: 'CalendarDate' };
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function daysInMonth(year: number, month: number): number {
// month is 1-12
if (month === 2 && isLeapYear(year)) return 29;
return DAYS_IN_MONTH[month - 1] ?? 31;
}
/** True if year/month/day form a real proleptic-Gregorian calendar date. */
export function isValidCalendarDateParts(year: number, month: number, day: number): boolean {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return false;
if (month < 1 || month > 12) return false;
if (day < 1 || day > daysInMonth(year, month)) return false;
return true;
}
const CALENDAR_DATE_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
/** True if the string is both YYYY-MM-DD shaped and a real calendar date. */
export function isValidCalendarDate(value: string): value is CalendarDate {
const match = CALENDAR_DATE_SHAPE.exec(value);
if (!match) return false;
const y = Number(match[1]);
const m = Number(match[2]);
const d = Number(match[3]);
return isValidCalendarDateParts(y, m, d);
}
function parseCalendarDate(date: CalendarDate): { year: number; month: number; day: number } {
const parts = date.split('-');
const year = Number(parts[0]);
const month = Number(parts[1]);
const day = Number(parts[2]);
return { year, month, day };
}
function formatCalendarDate(year: number, month: number, day: number): CalendarDate {
const yy = String(year).padStart(4, '0');
const mm = String(month).padStart(2, '0');
const dd = String(day).padStart(2, '0');
return `${yy}-${mm}-${dd}` as CalendarDate;
}
// Howard Hinnant's civil_from_days / days_from_civil algorithm: an exact, branch-light conversion
// between a proleptic-Gregorian (year, month, day) and a day count relative to 1970-01-01, correct
// for every year including leap years and leap days, with no floating-point rounding risk in the
// ranges this product uses (1970-01-01 through 2400-12-31 and beyond).
function daysFromCivil(year: number, month: number, day: number): number {
const y = month <= 2 ? year - 1 : year;
const era = Math.floor((y >= 0 ? y : y - 399) / 400);
const yoe = y - era * 400; // [0, 399]
const doy = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1; // [0, 365]
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy; // [0, 146096]
return era * 146097 + doe - 719468; // days relative to 1970-01-01
}
function civilFromDays(daysSinceEpoch: number): { year: number; month: number; day: number } {
const z = daysSinceEpoch + 719468;
const era = Math.floor((z >= 0 ? z : z - 146096) / 146097);
const doe = z - era * 146097; // [0, 146096]
const yoe = Math.floor(
(doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365
); // [0, 399]
const y = yoe + era * 400;
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); // [0, 365]
const mp = Math.floor((5 * doy + 2) / 153); // [0, 11]
const day = doy - Math.floor((153 * mp + 2) / 5) + 1; // [1, 31]
const month = mp + (mp < 10 ? 3 : -9); // [1, 12]
const year = y + (month <= 2 ? 1 : 0);
return { year, month, day };
}
/** Adds (or subtracts, for negative `days`) whole calendar days to a calendar date. Pure integer
* arithmetic; never touches a JS Date object. */
export function addDays(date: CalendarDate, days: number): CalendarDate {
const { year, month, day } = parseCalendarDate(date);
const epochDay = daysFromCivil(year, month, day) + days;
const result = civilFromDays(epochDay);
return formatCalendarDate(result.year, result.month, result.day);
}
/** Number of calendar days from `b` to `a` (positive when `a` is after `b`). */
export function differenceInCalendarDays(a: CalendarDate, b: CalendarDate): number {
const pa = parseCalendarDate(a);
const pb = parseCalendarDate(b);
return daysFromCivil(pa.year, pa.month, pa.day) - daysFromCivil(pb.year, pb.month, pb.day);
}// packages/shared/src/domain/schedule.ts
import { TZDate } from '@date-fns/tz';
import { format } from 'date-fns';
import {
type CalendarDate,
addDays,
differenceInCalendarDays,
} from './calendar-date';
export type PlantStatus = 'overdue' | 'due_today' | 'due_soon' | 'upcoming';
export interface ScheduleView {
nextDueOn: CalendarDate;
daysUntilDue: number;
daysOverdue: number;
status: PlantStatus;
}
export interface SchedulePlantInput {
lastWateredOn: CalendarDate;
wateringIntervalDays: number;
}
/** Converts an instant into "today" in the given IANA timezone. The only function in this module
* that consults a timezone. */
export function todayInTimeZone(now: Date, timeZone: string): CalendarDate {
const zoned = new TZDate(now.getTime(), timeZone);
return format(zoned, 'yyyy-MM-dd') as CalendarDate;
}
/** nextDueOn = lastWateredOn + wateringIntervalDays, pure calendar arithmetic, no DST adjustment. */
export function computeNextDueOn(
lastWateredOn: CalendarDate,
wateringIntervalDays: number
): CalendarDate {
return addDays(lastWateredOn, wateringIntervalDays);
}
/** Maps a computed daysUntilDue to one of the four canonical statuses (Section 8.3). */
function statusFromDaysUntilDue(daysUntilDue: number): PlantStatus {
if (daysUntilDue < 0) return 'overdue';
if (daysUntilDue === 0) return 'due_today';
if (daysUntilDue === 1) return 'due_soon';
return 'upcoming';
}
/** Status only, given nextDueOn and today. */
export function computeStatus(nextDueOn: CalendarDate, today: CalendarDate): PlantStatus {
return statusFromDaysUntilDue(differenceInCalendarDays(nextDueOn, today));
}
/** True if a plant with this lastWateredOn has already been watered today. Used by the idempotency
* check in Section 11.2. */
export function isAlreadyWateredToday(lastWateredOn: CalendarDate, today: CalendarDate): boolean {
return lastWateredOn === today;
}
/** The full derived view for one plant on one day: everything Section 7.9 says must never be
* stored. */
export function computeScheduleView(plant: SchedulePlantInput, today: CalendarDate): ScheduleView {
const nextDueOn = computeNextDueOn(plant.lastWateredOn, plant.wateringIntervalDays);
const daysUntilDue = differenceInCalendarDays(nextDueOn, today);
const status = statusFromDaysUntilDue(daysUntilDue);
const daysOverdue = status === 'overdue' ? -daysUntilDue : 0;
return { nextDueOn, daysUntilDue, daysOverdue, status };
}
/** The exact, and only, user-facing phrasing for a plant's due state. No other phrasing is
* permitted anywhere in the UI (Section 17). */
export function formatRelativeDueLabel(daysUntilDue: number): string {
if (daysUntilDue < 0) {
const n = -daysUntilDue;
return n === 1 ? 'Overdue by 1 day' : `Overdue by ${n} days`;
}
if (daysUntilDue === 0) return 'Due today';
if (daysUntilDue === 1) return 'Due tomorrow';
return `Due in ${daysUntilDue} days`;
}
const STATUS_URGENCY_RANK: Record<PlantStatus, number> = {
overdue: 0,
due_today: 1,
due_soon: 2,
upcoming: 3,
};
/** Canonical sort comparator: most urgent first. Within `overdue`, the most-overdue plant sorts
* first. Within `upcoming`, the soonest-due plant sorts first. Ties break on name
* (case-insensitive, locale-pinned), then on `id`, so the order is a total order and never
* depends on the host process's locale. */
export function compareByUrgency(
a: { status: PlantStatus; daysUntilDue: number; name: string; id: string },
b: { status: PlantStatus; daysUntilDue: number; name: string; id: string }
): number {
const rankDiff = STATUS_URGENCY_RANK[a.status] - STATUS_URGENCY_RANK[b.status];
if (rankDiff !== 0) return rankDiff;
if (a.status === 'overdue') {
if (a.daysUntilDue !== b.daysUntilDue) return b.daysUntilDue - a.daysUntilDue; // more negative = more overdue = first
} else if (a.daysUntilDue !== b.daysUntilDue) {
return a.daysUntilDue - b.daysUntilDue;
}
const nameDiff = a.name.localeCompare(b.name, 'en', { sensitivity: 'base' });
if (nameDiff !== 0) return nameDiff;
return a.id.localeCompare(b.id);
}Worked examples, todayInTimeZone (APP_TIMEZONE = America/New_York, offset UTC−5 outside DST):
now (UTC instant) |
timeZone |
Result |
|---|---|---|
2026-08-05T14:23:11.482Z |
America/New_York |
2026-08-05 (10:23 local, EDT UTC−4) |
2026-08-06T02:00:00.000Z |
America/New_York |
2026-08-05 (22:00 previous local day, EDT UTC−4) |
2026-01-01T04:59:00.000Z |
America/New_York |
2025-12-31 (23:59 previous local day, EST UTC−5) |
2026-01-01T05:00:00.000Z |
America/New_York |
2026-01-01 (00:00 local day, EST UTC−5) |
Worked examples, addDays:
date |
days |
Result | Note |
|---|---|---|---|
2026-08-05 |
7 |
2026-08-12 |
Ordinary case. |
2026-02-27 |
2 |
2026-03-01 |
Crosses a month boundary in a non-leap year (2026 is not divisible by 4). |
2028-02-27 |
2 |
2028-02-29 |
Crosses into the leap day of 2028, a leap year. |
2027-12-30 |
5 |
2028-01-04 |
Crosses a year boundary. |
2026-08-05 |
-5 |
2026-07-31 |
Negative input subtracts days; used by the seed script (Section 7.11). |
Worked examples, differenceInCalendarDays:
a (nextDueOn) |
b (today) |
Result (daysUntilDue) |
Status |
|---|---|---|---|
2026-08-05 |
2026-08-05 |
0 |
due_today |
2026-08-01 |
2026-08-05 |
-4 |
overdue |
2026-08-06 |
2026-08-05 |
1 |
due_soon |
2029-03-01 |
2028-02-28 |
367 |
upcoming (spans the 2028 leap day, which the algorithm counts automatically) |
Worked examples, computeStatus / computeScheduleView (lastWateredOn = 2026-08-01,
wateringIntervalDays = 7, so nextDueOn = 2026-08-08):
today |
daysUntilDue |
status |
daysOverdue |
|---|---|---|---|
2026-08-06 |
2 | upcoming |
0 |
2026-08-07 |
1 | due_soon |
0 |
2026-08-08 |
0 | due_today |
0 |
2026-08-09 |
−1 | overdue |
1 |
Worked examples, formatRelativeDueLabel:
daysUntilDue |
Result |
|---|---|
-1 |
"Overdue by 1 day" |
-30 |
"Overdue by 30 days" |
0 |
"Due today" |
1 |
"Due tomorrow" |
2 |
"Due in 2 days" |
265 |
"Due in 265 days" |
Pluralisation rule: "day" is singular only for exactly 1 day overdue ("Overdue by 1 day"); every
other overdue count and every upcoming count uses "days" (there is no singular upcoming case
because upcoming never applies to daysUntilDue === 1, which is always due_soon). No other
phrasing — no "Xd overdue", no "in X days!", no localisation variants — is permitted anywhere in
the UI; every screen that shows a due label (Section 17) calls this function.
Worked example, compareByUrgency, sorting four plants (today = 2026-08-05):
| Name | Status | daysUntilDue |
Sort position |
|---|---|---|---|
| Fiddle Leaf Fig | overdue |
−5 | 1 |
| Snake Plant | overdue |
−1 | 2 |
| Pothos | due_today |
0 | 3 |
| Monstera | upcoming |
6 | 4 |
Within overdue, the comparator returns b.daysUntilDue - a.daysUntilDue, so a plant with
daysUntilDue = -5 (Fiddle Leaf Fig) sorts before one with daysUntilDue = -1 (Snake Plant), because
-1 - (-5) = 4 > 0. The final order is: Fiddle Leaf Fig (−5, most overdue), Snake Plant (−1), Pothos
(due_today), Monstera (upcoming).
8.5 Timezone and DST correctness #
Calendar arithmetic in this system is deliberately performed on YYYY-MM-DD values using the
integer day-count algorithm in Section 8.4, never on millisecond instants shifted by a fixed offset.
Millisecond arithmetic on a Date object is unsafe for this domain for two independent reasons: (1)
adding "24 hours" to an instant does not reliably land on the same wall-clock time the next day across
a Daylight Saving Time transition (the local day can be 23 or 25 hours long), and (2) any arithmetic
performed with a JS Date's local getters (getDate(), getMonth(), etc.) silently depends on the
host process's TZ environment variable rather than on APP_TIMEZONE, which is a configuration value,
not a process environment property. addDays and differenceInCalendarDays sidestep both problems by
never constructing a Date at all (Section 8.4); the only place an instant is ever converted into a
calendar date is todayInTimeZone, which explicitly takes APP_TIMEZONE as a parameter and uses
@date-fns/tz to perform that one conversion correctly.
The following six scenarios each demonstrate a different timezone hazard and confirm the system produces the correct result:
| # | Scenario | APP_TIMEZONE |
Current instant (UTC) | today |
lastWateredOn |
wateringIntervalDays |
nextDueOn |
daysUntilDue |
status |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Spring-forward day (clocks skip 02:00→03:00 local; the local day is 23 hours long) | America/New_York |
2026-03-08T06:30:00.000Z |
2026-03-08 |
2026-03-01 |
7 | 2026-03-08 |
0 | due_today |
| 2 | Fall-back day (clocks repeat 01:00–02:00 local; the local day is 25 hours long) | America/New_York |
2026-11-01T09:30:00.000Z |
2026-11-01 |
2026-10-20 |
7 | 2026-10-27 |
−5 | overdue (5 days) |
| 3 | UTC+13 zone where the local calendar date is already the next day relative to the UTC date | Pacific/Auckland |
2026-01-15T20:00:00.000Z |
2026-01-16 |
2026-01-09 |
10 | 2026-01-19 |
3 | upcoming |
| 4 | UTC−11 zone where the local calendar date lags a full day behind the UTC date | Pacific/Pago_Pago |
2026-01-16T08:00:00.000Z |
2026-01-15 |
2026-01-01 |
15 | 2026-01-16 |
1 | due_soon |
| 5 | Half-hour-offset zone (UTC+5:30, no DST) | Asia/Kolkata |
2026-06-10T19:00:00.000Z |
2026-06-11 |
2026-06-11 |
30 | 2026-07-11 |
30 | upcoming |
| 6 | Leap day | UTC |
2028-02-29T12:00:00.000Z |
2028-02-29 |
2028-02-15 |
14 | 2028-02-29 |
0 | due_today |
Row-by-row notes:
2026-03-08is the second Sunday of March 2026, whenAmerica/New_Yorksprings forward at 2 a.m. local. At the sampled UTC instant, local time is01:30 EST(still UTC−5, just before the jump).todayis unaffected by the fact that the local day will be one hour short later that day, becausetodayis a calendar date, not a duration.2026-11-01is the first Sunday of November 2026, whenAmerica/New_Yorkfalls back at 2 a.m. local (EDT UTC−4 → EST UTC−5). At the sampled instant, local time is04:30 EST, after the transition. Despite the local day being 25 hours long,nextDueOnanddaysUntilDueare computed from calendar dates alone and are exactly what plain calendar subtraction gives: 5 days overdue.Pacific/Aucklandobserves NZDT (UTC+13) in January (southern-hemisphere summer). The sampled UTC instant20:00on the 15th is09:00on the 16th locally — a full calendar day ahead of the UTC date. This is precisely whytodayInTimeZonemust convert throughAPP_TIMEZONEand never simply take the UTC date'sYYYY-MM-DDsubstring.Pacific/Pago_Pagois UTC−11 year-round (no DST observed). The sampled instant08:00on the 16th is21:00on the 15th locally — a full calendar day behind the UTC date, the mirror-image hazard of row 3.Asia/Kolkatais UTC+5:30 year-round. The half-hour offset does not participate in the calendar-date calculation at all oncetodayInTimeZonehas produced theYYYY-MM-DDstring; everything downstream is pure integer day arithmetic, so a fractional-hour offset introduces no special case anywhere in Section 8.4.- 2028 is a leap year (
2028 / 4 = 507exactly, and 2028 is not a century year), so February has 29 days.addDays(2028-02-15, 14)must land on2028-02-29, not roll over into March; the Section 8.4 algorithm handles this automatically becausedaysInMonthand the Hinnant conversion both encode the leap-year rule directly, with no special-cased "if leap year" branch anywhere inaddDaysitself.
Invalid APP_TIMEZONE values. APP_TIMEZONE is validated once, at process boot, against the
IANA Time Zone Database via Intl.supportedValuesOf('timeZone') (or, if unavailable in the runtime, by
attempting new Intl.DateTimeFormat('en-US', { timeZone: value }) and catching the RangeError it
throws for an unknown zone). An invalid value fails configuration validation and the process exits
with code 78 before opening a database connection or binding a port (Section 21). There is no
runtime fallback to UTC for an invalid value — an operator typo is a configuration error to be fixed,
not silently masked.
8.6 Day-rollover behaviour #
At local midnight in APP_TIMEZONE, today (Section 8.1) advances by one calendar day. This changes,
for every plant, exactly these derived values: daysUntilDue, daysOverdue, and status (and, by
extension, formatRelativeDueLabel's output and compareByUrgency's ordering). It changes nothing
that is stored: plants.last_watered_on is untouched, no row is written, no migration or scheduled SQL
statement runs at midnight.
The server holds no timer, cron job, or scheduled task tied to midnight for status purposes — every
status value is computed fresh on every read, from lastWateredOn and wateringIntervalDays
against whatever today is at the moment of the request (Section 8.4). This means a request made one
second after local midnight in APP_TIMEZONE already sees the new day's statuses with zero
propagation delay, and a request made one second before sees the previous day's statuses; there is no
in-between state to reconcile.
Because the server is stateless with respect to time, it is the client's responsibility to notice
when a new calendar day has begun while a tab is left open, since nothing pushes an update to it. The
client schedules a timer for the next local-midnight boundary in APP_TIMEZONE (using the timezone
returned in meta.timezone, Section 8.4) and re-fetches the plant list when it
fires, rescheduling for the following midnight afterward — this refresh trigger and its surrounding
cache policy are canonical in Section 16.
8.7 Clock-change and clock-skew handling #
- Host clock jumps backwards (e.g. an NTP correction, a container restart with a skewed hardware
clock). The server does not track "last seen time" for schedule purposes, so a backward jump has no
special-case handling: the very next request simply computes
todayfrom whatever instantnew Date()returns at that moment (Section 8.1'stodayInTimeZone(new Date(), APP_TIMEZONE)). If the jump is large enough to change the calendar date, statuses reflect the corrected date immediately and consistently on the next request; nothing needs to be un-done because nothing was written based on the skewed time (Section 7.9 — no derived value is ever persisted). - Operator changes
APP_TIMEZONEbetween restarts. This is an expected, supported operation, not an error condition. Every plant'snextDueOn/daysUntilDue/statusis recomputed from the same storedlastWateredOnandwateringIntervalDaysagainst atodaycalculated in the new timezone, which can shift every plant's apparent due-ness by up to one calendar day in either direction (identical in kind to the divergence shown in Section 8.5, rows 3–4). This is correct and intended behaviour, and it is documented for operators in Section 21 rather than treated as a bug. - Client clock disagrees with server clock. The client may recompute
nextDueOn/daysUntilDue/statusoptimistically using the shared pure functions (Section 8.4, Section 16), but the client's own device clock and timezone are never authoritative for anything persisted or returned by a subsequent request: the server's computed value, using the server process's clock and the configuredAPP_TIMEZONE, always wins the moment the client's next fetch completes. A client with a fast, slow, or wrongly-zoned system clock can only ever cause a brief, self-correcting visual discrepancy between refreshes — never a data-integrity issue, since the client never writes a computed value back to the server.
8.8 Worked end-to-end examples #
Each scenario traces one plant through a sequence of events. All dates assume APP_TIMEZONE = UTC
for simplicity; the arithmetic is identical in any timezone once today has been established
(Section 8.5).
Scenario 1 — ordinary cycle, watered on time.
| Step | Event | lastWateredOn |
wateringIntervalDays |
nextDueOn |
today |
daysUntilDue |
status |
|---|---|---|---|---|---|---|---|
| 1 | Create, lastWateredOn omitted |
2026-08-01 |
7 | 2026-08-08 |
2026-08-01 |
7 | upcoming |
| 2 | 6 days pass | 2026-08-01 |
7 | 2026-08-08 |
2026-08-07 |
1 | due_soon |
| 3 | 1 more day passes | 2026-08-01 |
7 | 2026-08-08 |
2026-08-08 |
0 | due_today |
| 4 | "Watered today" | 2026-08-08 |
7 | 2026-08-15 |
2026-08-08 |
7 | upcoming |
Scenario 2 — watered late, interval unchanged.
| Step | Event | lastWateredOn |
wateringIntervalDays |
nextDueOn |
today |
daysUntilDue |
status |
|---|---|---|---|---|---|---|---|
| 1 | Create | 2026-08-01 |
7 | 2026-08-08 |
2026-08-01 |
7 | upcoming |
| 2 | 12 days pass, no watering | 2026-08-01 |
7 | 2026-08-08 |
2026-08-13 |
−5 | overdue (5 days) |
| 3 | "Watered today" | 2026-08-13 |
7 | 2026-08-20 |
2026-08-13 |
7 | upcoming |
Scenario 3 — idempotent same-day watering.
| Step | Event | lastWateredOn |
nextDueOn |
today |
Result |
|---|---|---|---|---|---|
| 1 | "Watered today" | 2026-08-05 |
2026-08-12 (interval 7) |
2026-08-05 |
New waterings row created; meta.alreadyWateredToday = false, meta.wateringId is the new row's id. |
| 2 | "Watered today" again, same day | 2026-08-05 |
2026-08-12 |
2026-08-05 |
No new row (unique index, Section 7.4); HTTP 200, meta.alreadyWateredToday = true, meta.wateringId is the existing row's id from step 1; lastWateredOn and nextDueOn unchanged. |
Scenario 4 — shortening the interval makes a plant instantly overdue.
| Step | Event | lastWateredOn |
wateringIntervalDays |
nextDueOn |
today |
daysUntilDue |
status |
|---|---|---|---|---|---|---|---|
| 1 | Create | 2026-08-01 |
14 | 2026-08-15 |
2026-08-01 |
14 | upcoming |
| 2 | 5 days pass | 2026-08-01 |
14 | 2026-08-15 |
2026-08-06 |
9 | upcoming |
| 3 | Edit interval 14 → 3 | 2026-08-01 |
3 | 2026-08-04 |
2026-08-06 |
−2 | overdue (2 days) |
The edit in step 3 is applied exactly as specified: nextDueOn is recomputed from the unchanged
lastWateredOn, producing an immediate overdue status. This is correct and intended; nothing clamps
the result.
Scenario 5 — lengthening the interval defers the next due date.
| Step | Event | lastWateredOn |
wateringIntervalDays |
nextDueOn |
today |
daysUntilDue |
status |
|---|---|---|---|---|---|---|---|
| 1 | Create | 2026-08-01 |
7 | 2026-08-08 |
2026-08-01 |
7 | upcoming |
| 2 | 7 days pass | 2026-08-01 |
7 | 2026-08-08 |
2026-08-08 |
0 | due_today |
| 3 | Edit interval 7 → 21 | 2026-08-01 |
21 | 2026-08-22 |
2026-08-08 |
14 | upcoming |
Scenario 6 — deleting the only watering entry resets lastWateredOn to created_at's date.
| Step | Event | lastWateredOn |
today |
status |
|---|---|---|---|---|
| 1 | Create on 2026-07-01 (default lastWateredOn, one implicit watering row watered_on = 2026-07-01) |
2026-07-01 |
2026-07-01 |
upcoming (interval 7) |
| 2 | 10 days pass, plant is overdue | 2026-07-01 |
2026-07-11 |
overdue (3 days, interval 7) |
| 3 | The single watering row for 2026-07-01 is deleted via the history screen |
2026-07-01 (recomputed to the plant's created_at calendar date, which is the same 2026-07-01 here because the creation-time watering row — Section 10.4 step 7 — carried that same date) |
2026-07-11 |
overdue (3 days, unchanged) |
Scenario 7 — deleting a non-latest watering entry recomputes to the remaining latest.
| Step | Event | Watering rows (watered_on) |
lastWateredOn after |
|---|---|---|---|
| 1 | Watered on 2026-07-01, 2026-07-08, 2026-07-15 |
{2026-07-01, 2026-07-08, 2026-07-15} |
2026-07-15 |
| 2 | Delete the 2026-07-08 entry (not the latest) |
{2026-07-01, 2026-07-15} |
2026-07-15 (unchanged — the deleted row's watered_on was not the plant's current last_watered_on, so no recompute happens) |
| 3 | Delete the 2026-07-15 entry (the latest) |
{2026-07-01} |
2026-07-01 (recomputed — the deleted row's watered_on equalled the current last_watered_on, so MAX(watered_on) among the remaining rows, 2026-07-01, is used) |
Scenario 8 — watering while overdue clears the overdue state entirely, never partially.
| Step | Event | lastWateredOn |
wateringIntervalDays |
today |
daysUntilDue |
status |
|---|---|---|---|---|---|---|
| 1 | 45 days overdue | 2026-06-01 |
10 | 2026-07-26 |
−45 | overdue (45 days) |
| 2 | "Watered today" | 2026-07-26 |
10 | 2026-07-26 |
10 | upcoming |
There is no partial credit for lateness: watering resets lastWateredOn to today regardless of how
many days overdue the plant was, and daysOverdue returns to 0 in the very next read.
Scenario 9 — daily interval (wateringIntervalDays = 1) is always due_soon the instant after
watering, never upcoming.
| Step | Event | lastWateredOn |
today |
nextDueOn |
daysUntilDue |
status |
|---|---|---|---|---|---|---|
| 1 | "Watered today" | 2026-08-05 |
2026-08-05 |
2026-08-06 |
1 | due_soon |
| 2 | 1 day passes, not watered | 2026-08-05 |
2026-08-06 |
2026-08-06 |
0 | due_today |
| 3 | 1 more day passes | 2026-08-05 |
2026-08-07 |
2026-08-06 |
−1 | overdue (1 day) |
Scenario 10 — maximum interval (wateringIntervalDays = 365) spanning a leap day.
| Step | Event | lastWateredOn |
today |
nextDueOn |
daysUntilDue |
status |
|---|---|---|---|---|---|---|
| 1 | Create with lastWateredOn = 2027-06-01, interval 365 |
2027-06-01 |
2027-06-01 |
2028-05-31 (2027-06-01 + 365 days, spanning the 2028-02-29 leap day, correctly landing on May 31 rather than June 1, since 2028 contributes one extra day) |
365 | upcoming |
| 2 | 364 days pass | 2027-06-01 |
2028-05-30 |
2028-05-31 |
1 | due_soon |
8.9 Property-based invariants #
The following hold for every valid input (1 <= wateringIntervalDays <= 365, lastWateredOn and
today any real calendar dates) and are the basis for the property-based tests named in Section 25:
lastWateredOnis never null for any plant returned by the API (Section 8.2).computeStatusalways returns exactly one of the four enum values — neverundefined, never a fifth value — for anynextDueOn/todaypair, because the four conditions in Section 8.3 (< 0,=== 0,=== 1,>= 2) are exhaustive and mutually exclusive over the integers.- Watering a plant never immediately produces
overdueordue_today. BecausewateringIntervalDays >= 1always (interval0is not a valid input — the real minimum is1, Section 9.3),nextDueOn = addDays(today, wateringIntervalDays) >= addDays(today, 1), sodaysUntilDue >= 1immediately after watering: the result isdue_soonwhenwateringIntervalDays === 1andupcomingwhenwateringIntervalDays >= 2. nextDueOnis a function oflastWateredOnandwateringIntervalDaysalone. It never changes when onlytodaychanges; onlydaysUntilDue,daysOverdue, andstatusvary withtoday.- Status urgency is non-decreasing as
todayadvances, holdingnextDueOnfixed: movingtodayforward by any positive number of days can only move status alongupcoming → due_soon → due_today → overdue, never backward, and only a watering event (which changeslastWateredOnand thereforenextDueOn) can reduce urgency. addDaysanddifferenceInCalendarDaysare inverses:differenceInCalendarDays(addDays(d, n), d) === nfor every integern(positive, negative, or zero) and every valid calendar dated, including across month, year, and leap-day boundaries.daysOverdueis exactly0whenstatus !== 'overdue', and exactly-daysUntilDue(a positive integer) whenstatus === 'overdue'. There is no state in whichdaysOverdueis positive whilestatusis notoverdue, or vice versa.- The schedule functions are pure and total: two calls to
computeScheduleViewwith identicallastWateredOn,wateringIntervalDays, andtodayarguments always return bitwise-identical results, with no dependency on call order, wall-clock time at the moment of the call, or any mutable state — editingwateringIntervalDaysalone (Scenario 4/5 in Section 8.8) always recomputesnextDueOndeterministically from the storedlastWateredOn.
9. Validation Rules and Limits #
9.1 Validation philosophy #
Every validation rule in this product is expressed exactly once, as a Zod schema in
packages/shared/src/schemas/, and imported by both apps/api and apps/web (Section 5.1). There are
three layers, in order of authority:
- Client-side validation (the same Zod schemas, run in the browser before a request is sent) exists purely for fast user feedback — a form can show an inline error before a network round trip. It is never trusted as the source of truth.
- Server-side validation (the same Zod schemas, run in a Fastify
preValidationhook on every route, Section 13) is the authority. Every request is validated here regardless of what the client already checked, because the client is not trusted. - Database
CHECKconstraints (Section 7.3, Section 7.4) are the last-resort backstop against a bug that bypasses the application layer entirely. They are deliberately looser than the Zod schemas (Section 7.3) and are never relied upon to produce a user-facing error message.
No validation rule is ever encoded in only one of these layers when it could reasonably be encoded in
more than one; the field-limit rules in Section 9.3 exist in both the Zod schema and, for the fields
that have one, a database CHECK constraint.
9.2 Normalisation pipeline #
Input strings are normalised in a fixed order before the length and content checks in Section 9.3 are applied, so that, for example, a name that is only valid after trimming is correctly accepted, and a name that is too long only after Unicode normalisation is correctly rejected. The order is:
- Unicode NFC normalisation — canonical composition, so that visually identical strings (e.g. an "é" typed as a single code point versus as "e" + combining acute accent) compare and store identically.
- Control-character stripping — every C0 (
U+0000–U+001F) and C1 (U+007F–U+009F) control character is removed fromnamewith no exceptions (this means no newlines, tabs, or carriage returns are ever allowed inname). Fornotes, every C0/C1 control character is removed except line feed (U+000A) and carriage return (U+000D), which are preserved becausenotesis free text that may reasonably span multiple lines.nameadditionally strips every zero-width and bidirectional-formatting character (soft hyphen, zero-width space/joiner/non-joiner, the left-to-right/right-to-left embedding and override marks, word joiner, invisible math operators, and the byte-order mark) so a name cannot render as blank or visually reverse adjacent UI text while still passing the length check below; after this step anamemust contain at least one remaining non-whitespace character or it fails validation (Section 9.3). - Whitespace collapsing — applied to
nameonly: any run of one or more Unicode whitespace characters is collapsed to a single ASCII space. Not applied tonotes, which preserves its internal formatting (including blank lines) as the user typed it. - Trimming — leading and trailing whitespace is removed from both
nameandnotes. - Empty-to-null — applied to
notesonly: if the string is empty after the previous four steps, it becomesnullrather than an empty string.namehas no equivalent step because an emptynameis always a validation failure (Section 9.3), never a stored empty value.
// packages/shared/src/schemas/normalize.ts
const CONTROL_CHARS_ALL = /[\u0000-\u001F\u007F-\u009F]/g;
const CONTROL_CHARS_EXCEPT_NEWLINES = /[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
const INVISIBLE_FORMATTING_CHARS = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
const WHITESPACE_RUN = /\s+/g;
export function normalizeName(input: string): string {
return input
.normalize('NFC')
.replace(CONTROL_CHARS_ALL, '')
.replace(INVISIBLE_FORMATTING_CHARS, '')
.replace(WHITESPACE_RUN, ' ')
.trim();
}
export function normalizeNotes(input: string | undefined | null): string | null {
if (input === undefined || input === null) return null;
const cleaned = input
.normalize('NFC')
.replace(CONTROL_CHARS_EXCEPT_NEWLINES, '')
.trim();
return cleaned === '' ? null : cleaned;
}Both functions run as Zod .transform() steps (Section 9.4), so the value a route handler receives
has already passed through this pipeline; no route ever normalises input itself.
9.3 Field rules table #
| Field | Type | Zod fragment | Error code | User-facing message | Rejected example 1 | Rejected example 2 |
|---|---|---|---|---|---|---|
name |
Required, 1–60 Unicode code points after normalisation (not UTF-16 code units — a 60-emoji name is 60 characters even though it is 120 code units) |
z.string().transform(normalizeName).pipe(z.string().min(1)).refine((v) => [...v].length <= 60) |
VALIDATION_FAILED |
"Name must be between 1 and 60 characters." | "" (empty) |
61-code-point string |
wateringIntervalDays |
Required integer, 1–365 |
z.number().int().min(1).max(365) |
VALIDATION_FAILED |
"Watering interval must be a whole number of days between 1 and 365." | 0 |
366 |
notes |
Optional, 0–2000 characters after normalisation; a string, "", or null are all accepted, and empty becomes null |
NotesSchema (see Section 9.4) |
VALIDATION_FAILED |
"Notes must be 2000 characters or fewer." | 2001-character string | (n/a — a string, "", or null are all accepted; only length above 2000 is rejected) |
lastWateredOn |
Optional on create (defaults to today); YYYY-MM-DD; real calendar date; not in the future; not before 1970-01-01 |
CalendarDateSchema plus the today-aware refinement in Section 9.5 |
VALIDATION_FAILED |
"Date must be a real calendar date in YYYY-MM-DD form, not in the future, and not before 1970-01-01." | "2026-13-01" (invalid month) |
tomorrow's date |
| Plant count | Hard cap 500 active plants |
Stateful check in the route, not a schema (Section 9.5) | LIMIT_EXCEEDED |
"You have reached the maximum of 500 plants." | 501st plant creation attempt | (n/a — single rejection condition) |
| Watering history per plant | Unbounded storage; API page size capped at 100 |
WateringHistoryQuerySchema (Section 9.4) |
VALIDATION_FAILED |
"limit must be between 1 and 100." | limit=101 |
limit=0 |
limit (query) |
Integer, 1–100, default 20 |
z.coerce.number().int().min(1).max(100).default(20) |
VALIDATION_FAILED |
"limit must be between 1 and 100." | limit=-1 |
limit=abc |
offset (query) |
Integer, >= 0, default 0 |
z.coerce.number().int().min(0).default(0) |
VALIDATION_FAILED |
"offset must be 0 or greater." | offset=-1 |
offset=1.5 |
9.4 The Zod schemas #
// packages/shared/src/schemas/calendar-date.ts
import { z } from 'zod';
import { isValidCalendarDate, type CalendarDate } from '../domain/calendar-date';
export const CALENDAR_DATE_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
const DATE_MESSAGE =
'Date must be a real calendar date in YYYY-MM-DD form, not in the future, and not before 1970-01-01.';
export const CalendarDateSchema = z
.string()
.regex(CALENDAR_DATE_SHAPE, DATE_MESSAGE)
.refine(isValidCalendarDate, DATE_MESSAGE)
.transform((value) => value as CalendarDate);
export type CalendarDateInput = z.infer<typeof CalendarDateSchema>;// packages/shared/src/schemas/plant-id.ts
import { z } from 'zod';
const UUID_LOWERCASE_HYPHENATED =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export const PlantIdSchema = z
.string()
.regex(UUID_LOWERCASE_HYPHENATED, 'Must be a valid plant ID.');
export type PlantId = z.infer<typeof PlantIdSchema>;// packages/shared/src/schemas/plant.ts
import { z } from 'zod';
import { CalendarDateSchema } from './calendar-date';
import { normalizeName, normalizeNotes } from './normalize';
import type { CalendarDate } from '../domain/calendar-date';
const NAME_MESSAGE = 'Name must be between 1 and 60 characters.';
export const NameSchema = z
.string()
.transform(normalizeName)
.pipe(z.string().min(1, NAME_MESSAGE))
.refine((v) => [...v].length <= 60, NAME_MESSAGE); // length in Unicode code points, not UTF-16 code units
const INTERVAL_MESSAGE = 'Watering interval must be a whole number of days between 1 and 365.';
export const WateringIntervalDaysSchema = z
.number({ required_error: INTERVAL_MESSAGE, invalid_type_error: INTERVAL_MESSAGE })
.int(INTERVAL_MESSAGE)
.min(1, INTERVAL_MESSAGE)
.max(365, INTERVAL_MESSAGE);
// Distinguishes "absent" (leave unchanged on PATCH) from "explicitly null" (clear it) — see
// Section 9.5's note on the empty-body check, which used to rely on this distinction being lost.
export const NotesSchema = z
.union([z.string(), z.null()])
.optional()
.transform((v) => (v === undefined ? undefined : normalizeNotes(v)))
.pipe(z.string().max(2000, 'Notes must be 2000 characters or fewer.').nullable().optional());
// Base schema: structural rules only. The today-aware "not in the future" rule for
// lastWateredOn is added by the factory in Section 9.5, because it needs a `today` value that
// only the route handler can supply.
export const CreatePlantBaseSchema = z.object({
name: NameSchema,
wateringIntervalDays: WateringIntervalDaysSchema,
// Create has no "leave unchanged" case, so absent and explicit null both collapse to null.
notes: NotesSchema.transform((v) => v ?? null),
lastWateredOn: CalendarDateSchema.optional(),
});
export type CreatePlantInput = z.infer<typeof CreatePlantBaseSchema>;
// No `.refine()` here for "at least one field must be provided" — that check has moved out of Zod
// entirely and runs in the route before this schema is invoked (Section 9.5, Section 10.5).
export const UpdatePlantBaseSchema = z.object({
name: NameSchema.optional(),
wateringIntervalDays: WateringIntervalDaysSchema.optional(),
notes: NotesSchema,
lastWateredOn: CalendarDateSchema.optional(),
});
export type UpdatePlantInput = z.infer<typeof UpdatePlantBaseSchema>;// packages/shared/src/schemas/watering.ts
import { z } from 'zod';
const LIMIT_MESSAGE = 'limit must be between 1 and 100.';
const OFFSET_MESSAGE = 'offset must be 0 or greater.';
export const WateringHistoryQuerySchema = z.object({
limit: z.coerce
.number({ invalid_type_error: LIMIT_MESSAGE })
.int(LIMIT_MESSAGE)
.min(1, LIMIT_MESSAGE)
.max(100, LIMIT_MESSAGE)
.default(20),
offset: z.coerce
.number({ invalid_type_error: OFFSET_MESSAGE })
.int(OFFSET_MESSAGE)
.min(0, OFFSET_MESSAGE)
.default(0),
});
export type WateringHistoryQuery = z.infer<typeof WateringHistoryQuerySchema>;// packages/shared/src/schemas/import.ts
// Field-level validation gate for the import payload. The full file format, versioning, and
// import/export behaviour are canonical in Section 24; this schema only enforces per-field shape.
import { z } from 'zod';
import { CalendarDateSchema } from './calendar-date';
import { NameSchema, WateringIntervalDaysSchema, NotesSchema } from './plant';
import { PlantIdSchema } from './plant-id';
const ImportInstantSchema = z
.string()
.datetime({ precision: 3, offset: false, message: 'Must be an ISO 8601 UTC instant with milliseconds.' });
export const ImportPlantSchema = z.object({
id: PlantIdSchema,
name: NameSchema,
wateringIntervalDays: WateringIntervalDaysSchema,
notes: NotesSchema.transform((v) => v ?? null), // an imported row always has notes as string|null, never absent
lastWateredOn: CalendarDateSchema,
createdAt: ImportInstantSchema,
updatedAt: ImportInstantSchema,
deletedAt: ImportInstantSchema.nullable(),
});
export const ImportWateringSchema = z.object({
id: PlantIdSchema, // watering ids share the same UUIDv7 shape as plant ids
plantId: PlantIdSchema,
wateredOn: CalendarDateSchema,
createdAt: ImportInstantSchema,
});
// `mode` is supplied by the caller alongside the exported document (Section 24.5); it is never part
// of an export document and this schema validates the combined import *request*, not a raw export.
export const ImportPayloadSchema = z
.object({
formatVersion: z.literal(1),
exportedAt: ImportInstantSchema,
timezone: z.string().min(1),
mode: z.enum(['replace', 'merge']),
plants: z.array(ImportPlantSchema),
waterings: z.array(ImportWateringSchema),
})
.superRefine((value, ctx) => {
const plantIds = new Set<string>();
value.plants.forEach((plant, i) => {
if (plantIds.has(plant.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['plants', i, 'id'],
message: 'Duplicate id within the import file.',
});
}
plantIds.add(plant.id);
});
const wateringIds = new Set<string>();
value.waterings.forEach((watering, i) => {
if (wateringIds.has(watering.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['waterings', i, 'id'],
message: 'Duplicate id within the import file.',
});
}
wateringIds.add(watering.id);
if (!plantIds.has(watering.plantId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['waterings', i, 'plantId'],
message: 'plantId does not match any plant in the import file.',
});
}
});
});
export type ImportPayload = z.infer<typeof ImportPayloadSchema>;9.5 Cross-field and stateful rules #
lastWateredOn must not be in the future relative to today in APP_TIMEZONE. Because "today"
depends on the current instant and the configured timezone — neither of which a static schema
object can know — the not-in-the-future rule and the not-before-1970-01-01 rule are added by a
factory function that takes today as a parameter and is called once per request, immediately
after the request's today has been computed:
// packages/shared/src/schemas/plant.ts (continued)
import { differenceInCalendarDays, type CalendarDate } from '../domain/calendar-date';
export const MIN_CALENDAR_DATE = '1970-01-01' as CalendarDate;
function lastWateredOnNotInFuture(today: CalendarDate) {
return (date: CalendarDate, ctx: z.RefinementCtx) => {
if (differenceInCalendarDays(date, today) > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Date must not be in the future.',
});
}
if (differenceInCalendarDays(date, MIN_CALENDAR_DATE) < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Date must not be earlier than 1970-01-01.',
});
}
};
}
export function buildCreatePlantSchema(today: CalendarDate) {
return CreatePlantBaseSchema.extend({
lastWateredOn: CalendarDateSchema.optional().superRefine((date, ctx) => {
if (date !== undefined) lastWateredOnNotInFuture(today)(date, ctx);
}),
});
}
export function buildUpdatePlantSchema(today: CalendarDate) {
return UpdatePlantBaseSchema.extend({
lastWateredOn: CalendarDateSchema.optional().superRefine((date, ctx) => {
if (date !== undefined) lastWateredOnNotInFuture(today)(date, ctx);
}),
});
}Every route that accepts lastWateredOn calls buildCreatePlantSchema(today) or
buildUpdatePlantSchema(today) with the request's own today (Section 8.1) rather than validating
against the base schema directly, so the future-date check is always evaluated against the correct
APP_TIMEZONE-local "now."
The "at least one field must be provided" check no longer lives in this schema. Because
NotesSchema now distinguishes an absent notes key from an explicit null (Section 9.4), the
.refine() this schema used to carry for that check could never fail — value.notes was always
undefined after parsing whether or not the caller sent it. Section 10.5 states that the route rejects
a request with 400 VALIDATION_FAILED / UPDATE_EMPTY_BODY when the raw parsed JSON body, before
any schema runs, contains none of the keys name, wateringIntervalDays, notes, lastWateredOn. By
the time buildUpdatePlantSchema validates the body, the route has already established that at least
one recognised key is present.
The 500-plant cap is a stateful check, not a schema rule. No Zod schema can know how many active
plants already exist without a database read, so the create-plant route performs
plantRepository.countActive() (Section 7.13) after schema validation passes and before the insert;
if the count is already 500, the route returns 422 LIMIT_EXCEEDED with the message "You have
reached the maximum of 500 plants" (cross-reference Section 13 for the envelope) without ever calling
create. This check and the schema validation are independent: a request can pass every Zod rule and
still be rejected here, and this rejection is never expressed as a Zod issue.
9.6 Error mapping #
Every validation failure — from any schema in Section 9.4 — becomes the canonical error envelope's
details array (Section 13) via a single mapping function:
// apps/api/src/lib/zod-error.ts
import type { ZodError } from 'zod';
export interface ErrorDetail {
path: string;
message: string;
}
const MAX_DETAILS = 20;
export function zodErrorToDetails(error: ZodError): ErrorDetail[] {
return error.issues.slice(0, MAX_DETAILS).map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
}));
}pathis the dotted JSON path of the offending field (e.g."wateringIntervalDays", or"plants.2.name"for the third element of theplantsarray in an import payload). A root-level refinement (such as the "at least one field must be provided" check in Section 9.5) has an empty path, rendered as"".messageis exactly the human-readable string attached to the failing schema rule in Section 9.4 — never a raw Zod internal message like"Expected number, received string".- Order follows the order
error.issuesreports them in, which Zod produces in the order fields are declared in the schema (top to bottom), sodetailsis deterministic for a given input. - A maximum of 20 entries are returned even if more fields fail; this bound exists so a
pathologically malformed request (e.g. the import endpoint with hundreds of invalid rows) cannot
produce an unbounded response body.
detailsis omitted from the envelope entirely when it would be empty (Section 13), never sent as[].
9.7 Validation message catalogue #
This table is the catalogue of text carried in the API's error.message and
error.details[].message (Section 13) — it is the server message catalogue, not the UI copy
catalogue (Section 15.11 owns every client-rendered string). Every server-sent validation string in the
product is one of the following, keyed by an identifier so no route composes its own wording for a
condition already listed here:
| ID | Message |
|---|---|
NAME_REQUIRED |
"Name must be between 1 and 60 characters." |
NAME_TOO_LONG |
"Name must be between 1 and 60 characters." |
NAME_DUPLICATE_WARNING |
"Another plant already has this name." (non-blocking, Section 10) |
INTERVAL_REQUIRED |
"Watering interval must be a whole number of days between 1 and 365." |
INTERVAL_NOT_INTEGER |
"Watering interval must be a whole number of days between 1 and 365." |
INTERVAL_TOO_LOW |
"Watering interval must be a whole number of days between 1 and 365." |
INTERVAL_TOO_HIGH |
"Watering interval must be a whole number of days between 1 and 365." |
NOTES_TOO_LONG |
"Notes must be 2000 characters or fewer." |
DATE_INVALID_FORMAT |
"Date must be a real calendar date in YYYY-MM-DD form, not in the future, and not before 1970-01-01." |
DATE_INVALID_CALENDAR |
"Date must be a real calendar date in YYYY-MM-DD form, not in the future, and not before 1970-01-01." |
DATE_IN_FUTURE |
"Date must not be in the future." |
DATE_TOO_EARLY |
"Date must not be earlier than 1970-01-01." |
UPDATE_EMPTY_BODY |
"At least one field must be provided." |
PLANT_NOT_FOUND |
"Plant not found." |
WATERING_NOT_FOUND |
"Watering entry not found." |
PLANT_LIMIT_EXCEEDED |
"You have reached the maximum of 500 plants." |
HISTORY_LIMIT_RANGE |
"limit must be between 1 and 100." |
HISTORY_OFFSET_RANGE |
"offset must be 0 or greater." |
MALFORMED_JSON_BODY |
"Request body must be valid JSON." |
UNSUPPORTED_MEDIA_TYPE |
"Content-Type must be application/json." |
PAYLOAD_TOO_LARGE |
"Request body is too large." |
IMPORT_INVALID_VERSION |
"Unsupported import file version." |
IMPORT_INVALID_INSTANT |
"Must be an ISO 8601 UTC instant with milliseconds." |
IMPORT_DUPLICATE_ID |
"Duplicate id within the import file." |
IMPORT_UNKNOWN_PLANT_ID |
"plantId does not match any plant in the import file." |
IMPORT_FUTURE_DATE_CLAMPED |
"A future watering date in the import file was clamped to today." |
RATE_LIMITED |
"Too many requests. Please wait and try again." |
UNAUTHORIZED_ACCESS_CODE |
"Incorrect access code." |
Every route that produces a VALIDATION_FAILED error uses the message text from this table verbatim;
no route composes its own wording for a condition already listed here. There is no
IMPORT_TOO_MANY_PLANTS message: the 500-plant cap on import is the same stateful 422 LIMIT_EXCEEDED / PLANT_LIMIT_EXCEEDED check as plant creation (Section 9.5), evaluated in Section
24.5 after this schema passes, not a schema-level array-length cap. IMPORT_FUTURE_DATE_CLAMPED is
produced by the import route, not by ImportPayloadSchema — Section 24.5 applies the future-date clamp
to waterings[].wateredOn after this schema's structural validation passes, which is why
ImportWateringSchema accepts any real calendar date without a not-in-the-future check.
9.8 Rejected-input test vectors #
The following inputs are rejected. This table is the direct source for the validation test suite named in Section 25; each row is one test case.
| # | Input | Field / location | Value | Expected HTTP status | Error code | Message ID (Section 9.7) |
|---|---|---|---|---|---|---|
| 1 | Empty name | name |
"" |
400 | VALIDATION_FAILED |
NAME_REQUIRED |
| 2 | 61-character name | name |
"A".repeat(61) |
400 | VALIDATION_FAILED |
NAME_TOO_LONG |
| 3 | Whitespace-only name | name |
" " |
400 | VALIDATION_FAILED |
NAME_REQUIRED (normalises to empty) |
| 4 | Name of only control characters | name |
"\u0000\u0001" (no visible characters) |
400 | VALIDATION_FAILED |
NAME_REQUIRED (normalises to "") |
| 5 | Interval zero | wateringIntervalDays |
0 |
400 | VALIDATION_FAILED |
INTERVAL_TOO_LOW |
| 6 | Interval above maximum | wateringIntervalDays |
366 |
400 | VALIDATION_FAILED |
INTERVAL_TOO_HIGH |
| 7 | Interval not an integer | wateringIntervalDays |
7.5 |
400 | VALIDATION_FAILED |
INTERVAL_NOT_INTEGER |
| 8 | Interval as a string | wateringIntervalDays |
"7" |
400 | VALIDATION_FAILED |
INTERVAL_REQUIRED (wrong type) |
| 9 | Interval as NaN (via JSON null coerced client-side, arriving as null) |
wateringIntervalDays |
null |
400 | VALIDATION_FAILED |
INTERVAL_REQUIRED |
| 10 | Notes over the limit | notes |
2001-character string | 400 | VALIDATION_FAILED |
NOTES_TOO_LONG |
| 11 | Invalid month | lastWateredOn |
"2026-13-01" |
400 | VALIDATION_FAILED |
DATE_INVALID_CALENDAR |
| 12 | Invalid day for month (not a leap year) | lastWateredOn |
"2026-02-30" |
400 | VALIDATION_FAILED |
DATE_INVALID_CALENDAR |
| 13 | Invalid day for month (leap year edge, Feb 30 still invalid even in a leap year) | lastWateredOn |
"2028-02-30" |
400 | VALIDATION_FAILED |
DATE_INVALID_CALENDAR |
| 14 | Future date | lastWateredOn |
tomorrow's date in YYYY-MM-DD |
400 | VALIDATION_FAILED |
DATE_IN_FUTURE |
| 15 | Two-digit year | lastWateredOn |
"26-08-05" |
400 | VALIDATION_FAILED |
DATE_INVALID_FORMAT |
| 16 | Date before the minimum | lastWateredOn |
"1969-12-31" |
400 | VALIDATION_FAILED |
DATE_TOO_EARLY |
| 17 | Date with a time component | lastWateredOn |
"2026-08-05T00:00:00Z" |
400 | VALIDATION_FAILED |
DATE_INVALID_FORMAT |
| 18 | Null request body | body | null |
400 | MALFORMED_JSON |
MALFORMED_JSON_BODY |
| 19 | Array instead of object body | body | [] |
400 | MALFORMED_JSON |
MALFORMED_JSON_BODY |
| 20 | Body exceeding 64 KB on a normal endpoint | body | 70 KB JSON string | 413 | PAYLOAD_TOO_LARGE |
PAYLOAD_TOO_LARGE |
| 21 | Unknown field in body | body | { "name": "Fern", "wateringIntervalDays": 7, "colour": "green" } |
201 (accepted — see note) | n/a | n/a — unknown fields are stripped, not rejected (Section 13); colour is silently dropped and the plant is created normally |
| 22 | Wrong Content-Type on a body-bearing request |
header | Content-Type: text/plain |
415 | UNSUPPORTED_MEDIA_TYPE |
UNSUPPORTED_MEDIA_TYPE |
| 23 | Update request with an empty object body | body | {} |
400 | VALIDATION_FAILED |
UPDATE_EMPTY_BODY |
| 24 | limit above maximum |
query | ?limit=101 |
400 | VALIDATION_FAILED |
HISTORY_LIMIT_RANGE |
| 25 | limit of zero |
query | ?limit=0 |
400 | VALIDATION_FAILED |
HISTORY_LIMIT_RANGE |
| 26 | limit non-numeric |
query | ?limit=abc |
400 | VALIDATION_FAILED |
HISTORY_LIMIT_RANGE |
| 27 | Negative offset |
query | ?offset=-1 |
400 | VALIDATION_FAILED |
HISTORY_OFFSET_RANGE |
| 28 | Creating a plant when 500 already exist | body | valid plant payload, 501st create | 422 | LIMIT_EXCEEDED |
PLANT_LIMIT_EXCEEDED |
| 29 | Import payload with an unsupported version | body | { "formatVersion": 2, ... } |
400 | VALIDATION_FAILED |
IMPORT_INVALID_VERSION |
| 30 | Import payload exceeding the 500-plant cap | body | plants array of length 501, mode: "replace" on an empty database |
422 | LIMIT_EXCEEDED |
PLANT_LIMIT_EXCEEDED |
| 31 | Plant ID path parameter not shaped like a UUID | path | /api/v1/plants/not-a-uuid |
400 | VALIDATION_FAILED |
n/a (path-parameter shape failure, generic "Must be a valid plant ID." message) |
| 32 | Plant ID path parameter well-formed but non-existent | path | /api/v1/plants/01917f2c-0000-7000-8000-000000000000 |
404 | NOT_FOUND |
PLANT_NOT_FOUND |
Each row's error code and HTTP status match the canonical error taxonomy in Section 13; no test vector in this table produces a code or status not already defined there.
10. Feature: Plant Management #
10.1 Feature summary and user value #
Plant management is the foundation of the product: a user builds and maintains the list of plants they own, each carrying just enough information to drive the due/overdue calculation in Section 8. Every other feature in this specification operates on the plant records created here. Without an accurate, low-friction way to add, correct, and remove plants, the due-tracking value of the app never materialises.
Capabilities included:
- Add a new plant with a name and a watering interval, optionally with notes and an initial last-watered date.
- View the full plant list with computed due status (see Section 12 for display behaviour).
- Edit any field of an existing plant.
- Soft-delete a plant, with a short-window undo.
- Restore a soft-deleted plant within 30 days.
10.2 The Plant DTO #
The Plant DTO is the canonical wire shape returned by every endpoint that returns a plant, in full,
under the data key (or as an array element of data for the list endpoint). No endpoint returns a
partial or summarised plant object; the shapes below are always complete.
type PlantStatus = 'overdue' | 'due_today' | 'due_soon' | 'upcoming';
interface Plant {
id: string; // UUIDv7, canonical lowercase hyphenated form
name: string; // 1-60 chars, trimmed, control chars stripped
wateringIntervalDays: number; // integer, 1-365
notes: string | null; // 0-2000 chars, trimmed, empty string normalised to null
lastWateredOn: string; // YYYY-MM-DD, local calendar date in APP_TIMEZONE
nextDueOn: string; // YYYY-MM-DD, computed, never stored
daysUntilDue: number; // computed, signed integer, negative means overdue
daysOverdue: number; // computed, 0 unless status is "overdue"
status: PlantStatus; // computed, see Section 8.3
wateringCount: number; // computed, count of rows in waterings for this plant
createdAt: string; // ISO 8601 UTC instant with milliseconds, e.g. "2026-05-02T09:11:04.220Z"
updatedAt: string; // ISO 8601 UTC instant with milliseconds
}{
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 7,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-01",
"nextDueOn": "2026-08-08",
"daysUntilDue": 3,
"daysOverdue": 0,
"status": "upcoming",
"wateringCount": 12,
"createdAt": "2026-05-02T09:11:04.220Z",
"updatedAt": "2026-08-01T07:44:52.010Z"
}| Field | Stored or computed | Notes |
|---|---|---|
id |
Stored | Immutable after creation. |
name |
Stored | Editable. Not unique; see Section 10.4. |
wateringIntervalDays |
Stored | Editable. Drives nextDueOn. |
notes |
Stored | Editable. null when absent. |
lastWateredOn |
Stored | Set on create, on "Watered today" (Section 11.1), on watering deletion (Section 11.3), and directly editable (Section 10.5). |
nextDueOn |
Computed | addDays(lastWateredOn, wateringIntervalDays), per Section 8.4. |
daysUntilDue |
Computed | Per Section 8.3, using today from Section 8.4. |
daysOverdue |
Computed | max(0, -daysUntilDue). |
status |
Computed | Per Section 8.4's status derivation. |
wateringCount |
Computed | SELECT COUNT(*) FROM waterings WHERE plant_id = ?, see Section 7.9. |
createdAt |
Stored | Immutable. |
updatedAt |
Stored | Updated on any field change, including lastWateredOn changes caused by watering actions. |
Every other section that refers to "a plant object" or "the Plant DTO" refers to this shape.
10.3 List plants #
GET /api/v1/plants returns every active (non-soft-deleted) plant. There is no query string, no
filtering, and no pagination on this endpoint: Section 5 fixes a hard cap of 500 plants, which makes
the full list cheap enough to always return whole. Sorting, filtering, and search are entirely
client-side and are specified in Section 12.
Ordering guarantee from the server: ascending
created_at, thenidas a stable tiebreak for rows created in the same millisecond. The client re-sorts for display per Section 12.3; the server order exists only to make "recently added" sort and pagination-free scrolling behaviour deterministic and independent of SQLite's default row order.Exclusion: rows with a non-null
deleted_atare never included, regardless of purge state.Response:
{ "data": [ /* Plant[], see Section 10.2 */ ], "meta": { "timezone": "America/Chicago", "today": "2026-08-05", "count": 2 } }meta.timezoneechoes the configuredAPP_TIMEZONE(Section 21).meta.todayechoestodayInTimeZone(new Date(), APP_TIMEZONE)(Section 8.4) as computed by the server for this response.meta.countisdata.length.Why
todayis echoed: the client's own device clock and timezone are not trusted for status computation (Section 8.4 lets the client optimistically recompute, but the server value always wins). Echoingtodaylets the client detect that local midnight has passed even if thevisibilitychange/timer refresh in Section 16.5 has not yet fired, by comparing the previous response'smeta.todayto the new one and forcing a full status recomputation when they differ.An empty plant list returns
200with"data": []and"count": 0, never a404.
10.4 Create a plant #
POST /api/v1/plants creates a plant and its first watering record in a single transaction.
Request body:
{
"name": "Monstera",
"wateringIntervalDays": 7,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-01"
}name and wateringIntervalDays are required. notes and lastWateredOn are optional. All field
rules are defined once in Section 9 and enforced identically by the shared Zod schema on both client
and server; this section only specifies the create-time behaviour built on top of that validation.
Processing steps, in order:
- Validate the body against the shared
CreatePlantInputZod schema (Section 9). On failure, return400 VALIDATION_FAILEDwith onedetailsentry per failing field; no database work happens. - Count active plants (
deleted_at IS NULL). If the count is already500, return422 LIMIT_EXCEEDEDand stop. Creating the plant that brings the count from 499 to 500 succeeds; only the attempt that would exceed 500 is rejected. - Default
lastWateredOn: if omitted, usetodayInTimeZone(new Date(), APP_TIMEZONE)(Section 8.4). If supplied, it must pass the validation in Section 9 (not in the future, not before1970-01-01). - Check for an existing active plant whose
name, trimmed and compared case-insensitively, equals the new plant's trimmed name. A match does not block creation; it adds a warning (see below). Names are never required to be unique, by explicit product decision (Section 9). - Generate
idas a UUIDv7 (application-side, per Section 4). - Insert the
plantsrow:id,name,watering_interval_days,notes(orNULL),last_watered_on,created_at = now,updated_at = now,deleted_at = NULL. - Insert one
wateringsrow in the same transaction:id(a second, independent UUIDv7),plant_id = plants.id,watered_on = plants.last_watered_on,created_at = now. This row exists so that the watering history (Section 11.4) andwateringCountnever disagree withlastWateredOn: every plant's history contains at least one entry, and that entry is always the most recentlastWateredOnat the time it was set. Without it, a brand-new plant would showwateringCount: 0while simultaneously reporting alastWateredOn, which is an unexplained inconsistency to the user. - Commit the transaction. Both inserts happen inside one
better-sqlite3synchronous transaction (db.transaction(fn)); if step 7 were to fail (it cannot under normal operation, since the unique index in Section 7.4 cannot yet be violated by a plant's own first watering), step 6 rolls back too. - Return
201with the created Plant DTO underdata, aLocation: /api/v1/plants/{id}header, and, when step 4 found a match,meta.warnings.
Response (success, no duplicate name):
HTTP/1.1 201 Created
Location: /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80
Content-Type: application/json; charset=utf-8
{ "data": { /* Plant DTO */ } }Response (success, duplicate name):
{
"data": { /* Plant DTO */ },
"meta": {
"warnings": [
{ "code": "DUPLICATE_NAME" }
]
}
}The warning object carries only the machine-readable code, never a message: the displayed text is
owned exclusively by Section 15.11's form.name.duplicateWarning, so the server and the client can
never drift into different wording. A warning is informational only: it never changes the HTTP status,
never blocks the write, and is rendered in the UI as a dismissible inline notice, not a form error.
10.5 Update a plant #
PATCH /api/v1/plants/:plantId performs a partial update. Updatable fields: name,
wateringIntervalDays, notes, lastWateredOn. id, createdAt, and all computed fields
(nextDueOn, daysUntilDue, daysOverdue, status, wateringCount) are never accepted in the
request body; if present, they are stripped as unknown/read-only fields per Section 5's
unknown-field-stripping rule and simply ignored (not an error).
- Empty body: this check runs on the raw parsed JSON body, before schema validation, not as
part of the Zod schema. If the raw body, after JSON parsing, contains none of the keys
name,wateringIntervalDays,notes,lastWateredOn— including when it is{}or contains only unrecognised keys — the route rejects the request with400 VALIDATION_FAILEDand error codeUPDATE_EMPTY_BODYbefore the sharedUpdatePlantInputschema (Section 9) ever runs. Performing this check on the raw body, rather than on the schema's parsed output, is deliberate: it does not depend on how the schema normalises an omittednotesfield, so it stays reachable regardless of that schema's internal behaviour. - Partial semantics: any field omitted from the body is left unchanged. There is no way to
"unset"
nameorwateringIntervalDays(they are always required to have a value);notesdistinguishes "omitted" (left unchanged) from an explicit"notes": nullor"notes": ""(both of which clear it tonull), per the sharedNotesSchemain Section 9.4. - Derived values:
nextDueOn,daysUntilDue,daysOverdue, andstatusare never sent in the request and are always recomputed fresh in the response from whateverlastWateredOnandwateringIntervalDaysare in effect after the update. Section 8.8: shorteningwateringIntervalDayscan make a previously-upcoming plant instantlyoverdue; this is correct behaviour and is never clamped or blocked. updatedAt: set tonowwhenever the update changes at least one column. (The empty-body case above never reaches this point, since it is rejected before any column comparison.)- Changing
lastWateredOn: when the request includes alastWateredOndifferent from the plant's current value, the API reconciles the watering history in the same transaction as the plant update:- It upserts a
wateringsrow for the new date: if a row withwatered_onequal to the new date already exists for this plant, nothing further happens towaterings; otherwise a new row is inserted with a fresh UUIDv7idandcreated_at = now. - It never deletes any existing
wateringsrow as a side effect of this upsert, even if the newlastWateredOnis earlier than existing entries. The history is a append-mostly log; editinglastWateredOnis treated as "record one more true watering event" (or "this event was already recorded, just correct the summary field"), not as a rewrite of history. plants.last_watered_onis set to exactly the submitted value, regardless of whether it is chronologically the newest entry inwaterings.nextDueOnis always computed fromplants.last_watered_on, per Section 8.2, so this is what drives due-status.- There is no floor relating
lastWateredOntocreatedAt: an update may setlastWateredOnto any date that passes the Section 9 validation (not in the future, not before1970-01-01), including a date earlier than the plant's owncreatedAt. A user correcting a plant they added late is a normal, expected case.
- It upserts a
- Optimistic concurrency: the request may include an
If-Unmodified-Sinceheader. It is optional; omitting it means last-write-wins, with no conflict detection. When present:- Its value must be a valid HTTP-date per RFC 7231 (e.g.
Wed, 05 Aug 2026 14:23:11 GMT). An invalid value returns400 VALIDATION_FAILEDwithdetails: [{ "path": "If-Unmodified-Since", "message": "Must be a valid HTTP-date." }]. - The server compares the header, truncated to the second, against the plant's current
updatedAt, also truncated to the second (HTTP-date has no sub-second precision, so the comparison intentionally drops milliseconds). If they do not match, the update is rejected with409 CONFLICTand the plant is not modified; the response body'serror.messageis "The resource was modified by another request. Reload and try again." anddatais omitted (error responses never carry adatakey, only the envelope in Section 13.2). - If they match, the update proceeds normally.
- Its value must be a valid HTTP-date per RFC 7231 (e.g.
Request example (interval change only):
{ "wateringIntervalDays": 5 }Response:
{ "data": { /* full Plant DTO with recomputed nextDueOn/status */ } }10.6 Delete a plant #
DELETE /api/v1/plants/:plantId performs a soft delete.
- The server looks up an active plant by id (
WHERE id = ? AND deleted_at IS NULL). If none is found, return404 NOT_FOUND. This covers both "id never existed" and "id belongs to an already-deleted plant" — the two are indistinguishable to the caller by design, since a soft-deleted plant is conceptually gone. - Set
deleted_at = nowandupdated_at = now. - Return
204 No Contentwith an empty body. - The plant disappears from
GET /api/v1/plantson the very next fetch (it was excluded by the query in Section 10.3 the instantdeleted_atwas set; there is no propagation delay). - Watering history rows for the plant are left untouched at this point; they are only removed at permanent purge time (step 7 below).
- The client shows a toast with an Undo action for 10 seconds. Undo calls
POST /api/v1/plants/:plantId/restore(Section 10.7). The 10-second window is a UI affordance only, enforced client-side; the restore endpoint itself has no such time limit (Section 10.7). - A maintenance task, running once at process start and then every 24 hours, permanently purges any
plant whose
deleted_atis more than 30 days in the past. Purging a plant hard-deletes the plant row and, viaON DELETE CASCADEonwaterings.plant_id(Section 7.4), all of its watering rows. Purge is irreversible; there is no endpoint to recover a purged plant. - A second
DELETEon the sameplantId(whether immediately after the first or any time before purge) returns404 NOT_FOUND, per step 1 — deleting an already-deleted plant is not idempotent in its response, only in its effect (the plant ends up deleted either way). See Section 13.8.
10.7 Restore a plant #
POST /api/v1/plants/:plantId/restore reverses a soft delete.
- The server looks up a plant by id where
deleted_at IS NOT NULL(i.e. it must currently be soft-deleted; restoring an already-active plant is not a valid operation). If no such row exists — because the id never existed, the plant was never deleted, or the plant has already been permanently purged — return404 NOT_FOUND. - Eligibility window: restore has no independent time check of its own. It succeeds for as long
as the row still physically exists, which in practice means "any time before the 30-day purge task
in Section 10.6 step 7 runs for that row." A restore attempted at, say, day 29 succeeds. A restore
attempted after purge has already run (which, given the task's 24-hour cadence, is any time from
roughly day 30 to day 31 onward) returns
404 NOT_FOUNDbecause the row is gone. The specification does not distinguish these404cases in the response; both simply mean "nothing to restore." - Set
deleted_at = NULLandupdated_at = now. - Name collisions: nothing special happens. Names are never unique (Section 10.4), so a restored
plant sharing a name with another currently-active plant is a normal state. The restore response
includes the same
meta.warnings: [{ "code": "DUPLICATE_NAME", ... }]structure as create (Section 10.4) when such a collision exists at restore time, using the same case-insensitive, trimmed comparison. - Return
200with the full, restored Plant DTO underdata(with recomputednextDueOn,daysUntilDue,daysOverdue,statusbased on the plant's unchangedlastWateredOnandwateringIntervalDays— time has passed since deletion, so a plant that wasupcomingwhen deleted may now beoverdue; this is expected and is not corrected or hidden).
{
"data": { /* Plant DTO */ },
"meta": { "warnings": [ { "code": "DUPLICATE_NAME" } ] }
}10.8 Edge cases table #
| # | Input / scenario | Expected result | HTTP status | Section |
|---|---|---|---|---|
| 1 | Create with lastWateredOn in the past (e.g. 10 days ago) |
Plant created; lastWateredOn set exactly as given; status computed from that date and may already be overdue |
201 | 10.4, 8.4 |
| 2 | Create with lastWateredOn explicitly equal to today |
Plant created; identical to omitting the field | 201 | 10.4 |
| 3 | Create when active plant count is exactly 499 before the call | Plant created; count becomes 500 | 201 | 10.4 |
| 4 | Create when active plant count is exactly 500 before the call | Rejected, no row written | 422 LIMIT_EXCEEDED |
10.4 |
| 5 | Create with name that is only emoji, e.g. "🌵" |
Accepted; emoji are valid Unicode characters and count toward the 1-60 length limit by code point per Section 9 | 201 | 9, 10.4 |
| 6 | Create with name at exactly 60 characters after trim |
Accepted (boundary is inclusive) | 201 | 9, 10.4 |
| 7 | Create with name at 61 characters after trim |
Rejected | 400 VALIDATION_FAILED |
9 |
| 8 | Create with notes at exactly 2000 characters after trim |
Accepted | 201 | 9, 10.4 |
| 9 | Create with notes containing embedded newlines ("Loc:\nEast window") |
Accepted verbatim; newlines are preserved as plain text and rendered with CSS white-space: pre-wrap on the client, never interpreted as Markdown or HTML |
201 | 9, 10.4 |
| 10 | PATCH with an empty body {} |
Rejected before any DB write, checked on the raw body before schema validation | 400 VALIDATION_FAILED / UPDATE_EMPTY_BODY |
10.5 |
| 11 | PATCH setting notes to null on a plant that already has notes |
notes cleared to null; other fields unchanged |
200 | 10.5, 9 |
| 12 | PATCH setting lastWateredOn to a date before the plant's own createdAt |
Accepted; no floor relative to createdAt exists |
200 | 10.5 |
| 13 | DELETE a plant while a POST .../waterings request for the same plant is in flight |
Requests are processed strictly sequentially by the single-threaded, synchronous SQLite driver (Section 1); whichever request's transaction commits first wins. If delete commits first, the in-flight watering request subsequently fails its own active-plant lookup and returns 404. If watering commits first, it succeeds and the delete proceeds normally afterward. No partial or corrupted state is possible. | 204 or 404 depending on ordering | 10.6, 11.1 |
| 14 | POST .../restore at day 31 after deletion (purge already ran) |
Row no longer exists | 404 NOT_FOUND |
10.7, 10.6 |
| 15 | Two browser tabs PATCH the same plant with different fields at nearly the same time, neither sending If-Unmodified-Since |
Both succeed; last write to reach the server wins (last-write-wins), per the optional-precondition contract | 200, 200 | 10.5 |
| 16 | Two browser tabs PATCH the same plant, both sending If-Unmodified-Since from the same stale snapshot |
First request succeeds and advances updatedAt; second request's precondition no longer matches |
200, then 409 CONFLICT |
10.5 |
| 17 | Create with wateringIntervalDays: 0 |
Rejected (must be ≥ 1) | 400 VALIDATION_FAILED |
9 |
| 18 | Create with wateringIntervalDays: 3.5 |
Rejected (must be an integer) | 400 VALIDATION_FAILED |
9 |
| 19 | Create with name equal (case-insensitively, after trim) to an existing active plant's name |
Plant created; meta.warnings includes DUPLICATE_NAME; not blocking |
201 | 10.4 |
| 20 | Create with name equal to a soft-deleted plant's name |
Plant created; no warning, since the duplicate check only considers active plants | 201 | 10.4 |
| 21 | PATCH a plant that does not exist (never created, or a random UUID) |
No row matched | 404 NOT_FOUND |
10.5 |
| 22 | PATCH a soft-deleted plant |
Treated as not found, same as any other non-active id | 404 NOT_FOUND |
10.5, 10.6 |
| 23 | POST /plants with Content-Type: text/plain and a valid JSON string body |
Rejected before body parsing is trusted | 415 UNSUPPORTED_MEDIA_TYPE |
5, 13.3 |
10.9 Traceability #
| Core feature | Specified in |
|---|---|
| 1. Plant list management (add, edit, delete) | 10.2 (DTO), 10.3 (list), 10.4 (create), 10.5 (update), 10.6 (delete), 10.7 (restore), 10.8 (edge cases) |
| 2. Optional notes field | 10.2 (DTO field), 10.4 (create), 10.5 (update), Section 9 (validation limits) |
11. Feature: Watering Actions and History #
11.1 The "Watered today" action #
POST /api/v1/plants/:plantId/waterings is the single primary action of the product — the one-tap
button the user reaches for most often. It takes no meaningful request body: any body sent is ignored
(unknown fields are stripped per Section 5, and this endpoint defines no accepted fields at all). The
client never sends a date; the server alone determines "today," using todayInTimeZone(new Date(), APP_TIMEZONE)
(Section 8.4), so that watering recorded from any device is anchored to the same clock regardless of
the device's own timezone or clock drift.
Transaction, in order:
- Look up the plant by id where
deleted_at IS NULL. Not found →404 NOT_FOUND. - Attempt to insert a
wateringsrow: fresh UUIDv7id,plant_id,watered_on = today,created_at = now. - If the insert succeeds (no conflict with the unique index on
(plant_id, watered_on)from Section 7.4): updateplants.last_watered_on = todayandplants.updated_at = now. - If the insert conflicts (a watering for this plant on this date already exists): skip the update in step 3 entirely — see Section 11.2.
- Commit. Both the insert and the plant update happen in one
better-sqlite3transaction. - Return
200(not201: this is an idempotent action endpoint that updates an existing resource's state, not a resource-creation endpoint in the REST sense) with the full, freshly recomputed Plant DTO underdata, so the client needs no second request to refresh the row it just acted on.meta.wateringIdis theidof thewateringsrow this call created — or, on the idempotent path (Section 11.2), theidof the pre-existing row for(plantId, today).meta.alreadyWateredTodayis always present in this response and isfalseon this (the normal, non-idempotent) path. This is the only wire path by which the client learns a watering row's id; the Plant DTO (Section 10.2) never carries it.
{
"data": {
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 7,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-05",
"nextDueOn": "2026-08-12",
"daysUntilDue": 7,
"daysOverdue": 0,
"status": "upcoming",
"wateringCount": 13,
"createdAt": "2026-05-02T09:11:04.220Z",
"updatedAt": "2026-08-05T14:02:10.005Z"
},
"meta": { "alreadyWateredToday": false, "wateringId": "0191802a-1e4f-79aa-8b3c-4a2d7e9f1c02" }
}11.2 Idempotency #
Watering a plant twice within the same local day (per APP_TIMEZONE) is idempotent:
- The second call returns
200withmeta.alreadyWateredToday: trueandmeta.wateringIdset to theidof the pre-existing(plant_id, watered_on)row — the same value the first call returned, not a new one. - No duplicate
wateringsrow is created. plants.updated_atis not changed by the second call, because step 3 above only runs when the insert in step 2 actually happened. The response'sdata.updatedAtreflects whenever the row was last genuinely modified (which may be the first watering call, an earlier edit, or plant creation).- When
alreadyWateredTodayistrue, the client shows no Undo action for that call: undoing would delete a watering the user did not just create, since the row being referenced was created by an earlier call.
The guarantee is enforced at the database layer by the unique index (plant_id, watered_on) on
the waterings table (canonical definition in Section 7.4), not by an application-level "check then
insert" that could race. The insert in step 2 is attempted directly; a unique-constraint violation is
caught and treated as the idempotent path, never as an error surfaced to the caller.
Double-tap race on a slow connection: because better-sqlite3 is a synchronous driver running in
a single Node.js process handling one request at a time against the database, two near-simultaneous
POST .../waterings requests for the same plant are still executed as two fully sequential
transactions — there is no window in which both could observe "no row yet" and both attempt an
insert that succeeds. The first transaction to reach step 2 inserts the row and updates the plant; the
second transaction's insert then conflicts against a row that already exists, and it takes the
idempotent path in step 4. The user sees two successful 200 responses and no error, regardless of
tap timing.
11.3 Undo a watering #
The 10-second Undo affordance shown immediately after a "Watered today" tap calls
DELETE /api/v1/plants/:plantId/waterings/:wateringId, using the id of the watering row created (or
matched) by the action in Section 11.1. This is the same endpoint used for general history-entry
deletion (Section 11.5); this subsection defines its behaviour once.
- Look up the watering row by
(plant_id, id). Not found →404 NOT_FOUND. The client's Undo button treats this response as success — it dismisses the toast without an error, since the desired end state (the watering event no longer exists) already holds. This also covers the case where the watering was already deleted by an earlier Undo or a duplicate click. - Delete the row.
- Recompute
plants.last_watered_on, but only when the deleted row'swatered_onequals the plant's currentlast_watered_on. If the deleted row was not the plant's currentlast_watered_on— for example, an older row left behind after a backdating edit (Section 10.5) —last_watered_onis left untouched, since the deleted row was never the value driving the due calculation.- When a recompute is needed and other
wateringsrows remain for the plant, setlast_watered_onto the maximum (most recent)watered_onamong the remaining rows. - When a recompute is needed and none remain (this was the plant's only watering record), set
last_watered_onto the earlier of the plant'screated_at(truncated to its calendar date inAPP_TIMEZONE) and the plant's currentlast_watered_on. This floor rule (Section 8.2:lastWateredOnis never null) can therefore only preserve or increase how overdue the plant appears — it never advanceslast_watered_onto a later date, matching the rule that nothing silently un-overdues a plant (Section 12.8).
- When a recompute is needed and other
- Set
plants.updated_at = now. - Commit. Return
204 No Content.
Window enforcement: the "10 seconds" is a purely client-side UI affordance — a timer that hides
the Undo button and lets the toast disappear. The DELETE endpoint itself imposes no time limit: an
Undo request that reaches the server after the 10-second window (e.g. due to a slow network) still
succeeds and still deletes the row, per the same rules as any other history deletion in Section 11.5.
There is no server-side concept of "too late to undo."
11.4 Watering history retrieval #
GET /api/v1/plants/:plantId/waterings?limit=&offset= returns the plant's watering history, paginated.
This is the only paginated collection in the API (Section 5).
Path parameter:
plantId— must reference a plant, active or soft-deleted (history remains viewable for a plant the user is mid-Undo on deleting; see Section 11.8 for the not-found cases).Query parameters:
limit(integer, 1–100, default 20),offset(integer, ≥ 0, default 0). Both are validated by the shared Zod schema (Section 9); out-of-range or non-integer values are rejected — see Section 11.8.DTO:
{ "id": "0191802a-1e4f-79aa-8b3c-4a2d7e9f1c02", "wateredOn": "2026-08-01", "createdAt": "2026-08-01T07:44:52.010Z" }interface Watering { id: string; // UUIDv7 wateredOn: string; // YYYY-MM-DD createdAt: string; // ISO 8601 UTC instant }Ordering:
watered_ondescending, thencreated_atdescending as a tiebreak. Under normal operation the unique index in Section 7.4 guarantees at most one row per(plant_id, watered_on), so the tiebreak is a defensive, deterministic default rather than one that is regularly exercised.Response:
{ "data": [ /* Watering[] */ ], "meta": { "total": 12, "limit": 20, "offset": 0 } }meta.totalis the full count ofwateringsrows for the plant, independent oflimit/offset."Show more" behaviour: the client requests the first page with
limit=20&offset=0. Each subsequent "Show more" tap requests the next page with the samelimitandoffsetadvanced bylimit, appending results to the list already rendered. The control is hidden onceoffset + data.length >= meta.total.
11.5 Deleting a history entry #
Deleting a specific watering history entry uses the same endpoint as Undo:
DELETE /api/v1/plants/:plantId/waterings/:wateringId, specified in full in Section 11.3. The
recomputation rules (most-recent-remaining, or floor to created_at's calendar date if none remain)
apply identically regardless of which entry is deleted or how long ago it was recorded.
UI confirmation requirement: the client requires an explicit confirmation dialog ("Delete this watering record?") before calling this endpoint for any entry other than the single most recent one in the currently-loaded history list. Deleting the most recent entry within the 10-second post-water window is treated as an Undo and proceeds without a confirmation dialog (Section 11.3); deleting the most recent entry from the history list outside that window, or deleting any older entry, always requires confirmation, because it does not correspond to an action the user just took and is harder to mentally reverse.
11.6 What history is NOT #
Watering history exists solely to answer "when was this plant last watered" and to let a mistaken watering entry be corrected. It is not an analytics feature: this specification never surfaces charts, streaks, running averages, or summary statistics such as "you water this plant every 6.2 days on average." Watering history analytics of any kind beyond the basic due/overdue status is explicitly out of scope for this product (Section 3.2), and no future-work language elsewhere in this document implies otherwise.
11.7 Worked history scenarios #
Scenario 1 — Plant creation seeds the first entry.
Plant "Fern" created 2026-07-01 with wateringIntervalDays: 7, no lastWateredOn supplied (defaults
to today, 2026-07-01).
| Step | waterings rows (wateredOn) |
plants.lastWateredOn |
|---|---|---|
| After create | 2026-07-01 |
2026-07-01 |
Scenario 2 — "Watered today" tapped twice on the same day. Continuing Scenario 1, on 2026-07-08 the user taps "Watered today," then taps it again by accident.
| Step | waterings rows |
plants.lastWateredOn |
Response |
|---|---|---|---|
| First tap | 2026-07-01, 2026-07-08 |
2026-07-08 |
200, alreadyWateredToday: false |
| Second tap | 2026-07-01, 2026-07-08 (unchanged) |
2026-07-08 (unchanged) |
200, alreadyWateredToday: true |
Scenario 3 — Undo the most recent watering. Continuing Scenario 2, the user immediately taps Undo on the 2026-07-08 entry.
| Step | waterings rows |
plants.lastWateredOn |
|---|---|---|
| Before undo | 2026-07-01, 2026-07-08 |
2026-07-08 |
| After undo | 2026-07-01 |
2026-07-01 (falls back to the next most recent remaining row) |
Scenario 4 — Deleting a middle entry has no effect on lastWateredOn.
Plant "Pothos" has three waterings: 2026-06-01, 2026-06-15, 2026-07-01 (most recent).
plants.lastWateredOn = 2026-07-01. The user deletes the 2026-06-15 entry from the history list
(with confirmation, per Section 11.5).
| Step | waterings rows |
plants.lastWateredOn |
|---|---|---|
| Before | 2026-06-01, 2026-06-15, 2026-07-01 |
2026-07-01 |
After deleting 2026-06-15 |
2026-06-01, 2026-07-01 |
2026-07-01 (unchanged — the deleted row was not the maximum) |
Scenario 5 — Deleting the last remaining entry floors to createdAt.
Plant "Cactus" created 2026-01-10, with exactly one watering row (2026-01-10, from creation) and no
"Watered today" taps since. plants.lastWateredOn = 2026-01-10. The user deletes that single history
entry.
| Step | waterings rows |
plants.lastWateredOn |
|---|---|---|
| Before | 2026-01-10 |
2026-01-10 |
| After deleting the only row | (none) | 2026-01-10 (floored to createdAt's calendar date, per Section 8.2 — identical value here because it was never watered again, but the rule holds even when it differs; see Scenario 6) |
Scenario 6 — Deleting the only entry after later corrections.
Plant "Aloe" created 2026-02-01. On 2026-02-01 the user immediately edits lastWateredOn to
2026-01-20 (a backdated correction, per Section 10.5), which upserts a waterings row for
2026-01-20 (the creation-time row for 2026-02-01 already exists and is left in place, per the
upsert-never-deletes rule). The user then deletes the 2026-02-01 row, leaving only 2026-01-20.
Finally, the user deletes the 2026-01-20 row too.
| Step | waterings rows |
plants.lastWateredOn |
|---|---|---|
| After create | 2026-02-01 |
2026-02-01 |
After editing lastWateredOn to 2026-01-20 |
2026-01-20, 2026-02-01 |
2026-01-20 |
After deleting 2026-02-01 |
2026-01-20 |
2026-01-20 (unchanged — the deleted row's watered_on did not equal the current lastWateredOn, so no recompute happens, per Section 11.3 step 3) |
After deleting 2026-01-20 (now the only row) |
(none) | 2026-01-20 (floored to the earlier of createdAt's calendar date, 2026-02-01, and the current lastWateredOn, 2026-01-20 — the floor rule in Section 11.3 step 3 never advances lastWateredOn to a later date) |
11.8 Edge cases table #
| # | Input / scenario | Expected result | HTTP status |
|---|---|---|---|
| 1 | Water a plant that is 40 days overdue | Watering succeeds normally; lastWateredOn becomes today, daysOverdue resets to 0, status recomputes to upcoming (interval ≥ 2) or due_soon (interval = 1) depending on the new interval — due_today is impossible immediately after watering, since the minimum daysUntilDue after a watering is 1 |
200 |
| 2 | Watering request is in flight exactly as local midnight passes in APP_TIMEZONE |
The transaction reads todayInTimeZone(new Date(), APP_TIMEZONE) once, at the instant it executes on the server; the request is stamped with whichever calendar date that instant falls on, with no ambiguity, since the server — not the client — is the sole source of "today" (Section 8.4) |
200 |
| 3 | POST .../waterings for a soft-deleted plant |
Lookup filters deleted_at IS NULL; no match |
404 NOT_FOUND |
| 4 | DELETE /plants/:plantId/waterings/:wateringId where wateringId belongs to a different plant than :plantId |
Lookup is scoped to (plant_id, id) together; a mismatched pair matches no row |
404 NOT_FOUND |
| 5 | GET .../waterings?offset= past the end of the total count |
Returns an empty array with a valid meta block reflecting the true total |
200, "data": [] |
| 6 | GET .../waterings?limit=0 |
Rejected (limit minimum is 1) |
400 VALIDATION_FAILED |
| 7 | GET .../waterings?limit=101 |
Rejected (limit maximum is 100) |
400 VALIDATION_FAILED |
| 8 | GET .../waterings?offset=-1 |
Rejected (offset minimum is 0) |
400 VALIDATION_FAILED |
| 9 | GET .../waterings?limit=abc |
Rejected (not an integer) | 400 VALIDATION_FAILED |
| 10 | GET .../waterings for a plant id that does not exist at all |
No row matched | 404 NOT_FOUND |
| 11 | POST .../waterings twice in the same local day but from two different devices/tabs |
Idempotent per Section 11.2 regardless of originating device; both calls return 200, only the first has alreadyWateredToday: false |
200, 200 |
| 12 | DELETE a watering entry, then immediately DELETE the same wateringId again |
Second call finds no row | 404 NOT_FOUND, treated as success by the UI (Section 11.3) |
| 13 | Watering a plant whose interval was just shortened to make it already overdue before the tap | Watering succeeds and resets daysOverdue to 0 exactly as in case 1; the prior overdue state has no special handling |
200 |
12. Feature: Due and Overdue Status Display #
12.1 What the user must be able to answer in three seconds #
Opening the app, the user must be able to answer "what needs water right now" without reading every row. Three mechanisms work together to deliver this, each specified below: the summary bar (12.2) gives the answer as a single sentence before any row is read; the default urgency sort (12.3) puts everything requiring action at the top of the list, in order of severity; and the highlighting rules (12.7) make overdue and due-today rows visually distinct at a glance, without requiring the user to read status text on every row.
12.2 The summary bar #
A single line rendered above the plant list, above any sort/filter/search controls. "Needs water"
means status overdue or due_today only (Section 6.4) — due_soon and upcoming plants are never
counted here, since the bar's purpose is to answer "what do I need to do today," not "what's on the
horizon."
The exact copy for the zero/one/many cases is canonical in Section 15.11 (summary.zeroDue,
summary.oneDue, summary.manyDue); this section does not restate it.
The count is recomputed on every plant-list refresh (Section 16.5) and updates immediately after any mutation that changes a plant's status (watering, editing the interval, deleting, restoring).
12.3 Sorting #
The default list order is urgency, computed entirely client-side from the Plant DTOs returned by
GET /api/v1/plants (which are server-ordered by created_at, per Section 10.3, but always re-sorted
before display).
Comparator decision table (evaluated top to bottom; the first row that distinguishes two plants decides their order):
| Tier | Condition | Order within tier |
|---|---|---|
| 1 | status === 'overdue' |
Most days overdue first (daysOverdue descending) |
| 2 | status === 'due_today' |
(all equal — same tier, tiebreak below) |
| 3 | status === 'due_soon' |
(all equal — same tier, tiebreak below) |
| 4 | status === 'upcoming' |
Soonest nextDueOn first (ascending) |
| — | (any remaining tie) | Case-insensitive, locale-aware name comparison, ascending |
| — | (still tied) | id ascending (lexical, absolute tiebreak for full stability) |
Tier order itself is fixed: overdue < due_today < due_soon < upcoming, matching the urgency
order defined in Section 8.4.
The comparator itself is the single shared compareByUrgency function defined once in Section 8.4,
which this section does not redefine or duplicate; it implements exactly the decision table above,
including the id-ascending absolute tiebreak and a collator pinned to 'en' with
{ sensitivity: 'base' } so list order is identical across every host locale, including CI.
User-selectable sorts, in addition to the default urgency sort, exposed via a control the user can change at any time:
Sort key (hpt.sort value) |
Behaviour | Default |
|---|---|---|
'urgency' |
The comparator in Section 8.4 | Yes |
'name' |
name ascending, localeCompare with { sensitivity: 'base' }, then id tiebreak |
No |
'recently-watered' |
lastWateredOn descending (most recently watered plant first), then id tiebreak |
No |
The chosen value is persisted in localStorage under the key hpt.sort and restored on next visit. An
absent or invalid stored value falls back to 'urgency'.
12.4 Filtering #
A Show select (Section 16.2) with options "All plants" and "Needs water" filters the displayed list
to plants whose status is overdue or due_today (the identical predicate used by the summary bar
in Section 12.2). It does not affect the summary bar's count, which always reflects the full
unfiltered list.
The selected value is persisted in localStorage under the key hpt.filter, with values 'all'
(default, no filtering) and 'needs-water'. An absent or invalid stored value falls back to 'all'.
When the filter is active and matches no plants, the list area shows the copy canonical in Section
15.11 (emptyState.noDueMatches); this section does not restate it.
12.5 Search #
A text input appears above the list when the unfiltered active plant count exceeds 10 or the current search query is non-empty — this threshold is evaluated against the total number of active plants returned by the API, independent of whether the "Needs water" filter (12.4) is currently on, so the search control does not flicker in and out of existence as the filter is toggled.
Disappearing while a query is active: if the active plant count later falls to 10 or fewer while a search query is non-empty (for example, the user deletes a plant), the query is reset to the empty string, the input unmounts, the full list re-renders, and — if focus was inside the search input — focus moves to the
SummaryBar(tabindex="-1"). Resetting the query rather than leaving it applied with no visible control prevents plants from silently disappearing from the list with no way to see why.Matching: client-side, case-insensitive, diacritic-insensitive substring match against
nameonly.notesis never searched, since notes are meant as free-form private context, not an index.Normalisation function, applied identically to the query and to each candidate
namebefore substring comparison:function normalizeForSearch(value: string): string { return value .normalize('NFD') .replace(/[̀-ͯ]/g, '') // strip combining diacritical marks .toLowerCase(); }Example: a search for
"monstera"matches a plant named"Monstéra".Debounce: 150 ms after the last keystroke before the filter re-applies. The debounce is a pure UI responsiveness detail — search runs entirely client-side over already-loaded data, so it never triggers a network request.
Combination with the "Needs water" filter: the two apply with logical AND. A plant must match the search text (if any) and pass the filter (if active) to be shown.
Empty result copy: canonical in Section 15.11 (
emptyState.noResults), interpolated with{query}— the raw, non-normalised text the user typed, HTML-escaped by React's default text rendering. This section does not restate the string.
12.6 The status badge #
Every plant row carries a status badge combining an icon and a text label, with the label carrying the
day count for overdue. Label copy comes from the shared formatRelativeDueLabel function
(Section 8.4) so the wording used in the badge, in any alert text, and anywhere else a status is
described in prose is generated from one place, never hand-duplicated. The badge is the only
status label rendered in the row — no separate line duplicates or abbreviates it elsewhere in the row
markup:
| Status | Label copy | Icon (Section 15) | Accessible text |
|---|---|---|---|
overdue |
Overdue by {daysOverdue} day (singular, daysOverdue === 1) / Overdue by {daysOverdue} days (plural) |
AlertTriangle |
Identical to the visible label copy |
due_today |
Due today |
Droplet |
Identical to the visible label copy |
due_soon |
Due tomorrow |
Clock |
Identical to the visible label copy |
upcoming |
Due in {daysUntilDue} days (daysUntilDue is always ≥ 2 for this status, so this label is never singular) |
Check |
Identical to the visible label copy |
The icon is decorative (aria-hidden="true") and the label text is what a screen reader announces —
there is no separate, redundant "icon name" announcement. See Section 19 for the full accessibility
treatment.
12.7 Highlighting rules #
A plant row is visually distinguished from a non-urgent row using four coordinated signals, none of which is colour alone (satisfying WCAG 2.2 success criterion 1.4.1; the design tokens are canonical in Section 15):
- A left border accent, 4px wide, in the status colour (Section 15 defines the exact tokens; this section only defines that the accent exists and which status it maps to).
- A tinted background surface for the whole row, using the paired surface-tint token for that status.
- The status badge from Section 12.6, which combines an icon and a text label.
- For
overduespecifically, the numeric day count is always shown as part of the badge text, never omitted even when space is tight — "Overdue" alone is insufficient information.
Non-colour redundancy requirement: removing colour entirely (e.g. a grayscale rendering, or a user with a colour-vision deficiency) must not remove any information. Verifying this is testable by confirming that the icon and text label alone, with colour desaturated, still let a user distinguish all four statuses. See Section 19 for the automated and manual accessibility checks that enforce this.
12.8 Overdue never resets #
Restating the binding rule from Section 6.6: nothing in the system silently advances
lastWateredOn, and no status "expires" or resets itself with the passage of time — a plant only
leaves the overdue state when the user explicitly waters it (Section 11.1) or edits its
lastWateredOn/wateringIntervalDays (Section 10.5).
Worked example: a plant with wateringIntervalDays: 14, last watered 104 days ago and never
watered since. daysUntilDue = 14 - 104 = -90, so daysOverdue = 90 and status = 'overdue'. It
reads "Overdue by 90 days" (Section 12.6), sorts to the very top of the default urgency list (Section
12.3, tier 1, daysOverdue descending), and is counted in the summary bar (Section 12.2). Every one of
these facts remains exactly true, day after day, with no change, until the user takes an explicit
action on the plant. On day 105 it reads "Overdue by 91 days"; nothing about the mechanism changes.
12.9 Edge cases #
| # | Scenario | Expected behaviour |
|---|---|---|
| 1 | Every plant in the list is overdue |
All rows highlighted; sorted by daysOverdue descending; summary bar counts all of them |
| 2 | Exactly one plant exists in total | List renders with a single row; search (12.5) does not appear (threshold is > 10); summary bar reads based on that one plant's status (0/1 wording from Section 12.2) |
| 3 | 500 plants exist (the hard cap) | All render; client-side sort/filter/search operate over the full in-memory list; see Section 23 for the performance budget this must meet |
| 4 | A plant created today with wateringIntervalDays: 1 and no lastWateredOn override |
lastWateredOn = today, nextDueOn = tomorrow, daysUntilDue = 1 → status due_soon |
| 5 | A plant created today with wateringIntervalDays: 7 but a backdated lastWateredOn 10 days in the past |
nextDueOn = 3 days before today → daysUntilDue = -3 → status overdue, daysOverdue: 3, immediately upon creation |
13. API Design and Contract #
13.1 Conventions #
- Base path:
/api/v1. Every JSON endpoint in this specification lives under it; there is no unversioned or v2 surface. - Versioning policy: the version segment is part of the path, not a header. This product will only
ever ship a
v1; introducing av2API is explicitly a rejected non-requirement. - Casing: all JSON keys, in requests and responses, are
camelCase. The database usessnake_case(Section 7); a repository/mapper layer converts between the two, andsnake_casenever appears on the wire. - Content type: the media type must be
application/json; acharset=utf-8parameter is permitted (and is what every response sets). A request with a body whose media type is anything else is rejected with415 UNSUPPORTED_MEDIA_TYPEbefore the body is parsed. - Non-object JSON bodies: a request body that parses successfully as JSON but is not a JSON
object —
null, an array, a string, a number, or a boolean — is rejected with400 MALFORMED_JSONand message idMALFORMED_JSON_BODY. This check runs in the body parser, before the route's Zod schema ever sees the body, so it is distinguished from a body that is a JSON object but fails field-level validation (which yieldsVALIDATION_FAILEDinstead). - Trailing slashes: rejected. Fastify is configured with
ignoreTrailingSlash: false; a request to/api/v1/plants/(trailing slash) returns404 NOT_FOUND, distinct from and not silently redirected to/api/v1/plants. - Unknown fields: stripped, not rejected. Every request-body Zod schema uses
.strip()(the Zod v3 default for plain objects), so an unrecognised key in the body is silently dropped before validation of the recognised keys proceeds. This is a deliberate leniency: it lets older clients send extra fields without breaking, at the cost of never warning about typos in field names. Field name typos are instead caught by required-field validation when the correctly-named field is therefore missing. - Body size caps: 64 KB for every endpoint except
POST /api/v1/import, which allows up to 5 MB (Section 24 defines the import format; the size allowance exists to admit a full data export re-imported as a backup restore). A body exceeding the applicable cap is rejected with413 PAYLOAD_TOO_LARGEbefore parsing.
13.2 Success and error envelopes #
Every response body, success or error, is a JSON object at the top level. No endpoint ever returns a
bare array or a bare scalar — a list is always { "data": [...] }, never a top-level [...]; a
count or boolean is always nested under data or meta, never returned as a bare JSON number,
string, or boolean.
Success envelope:
{
"data": { /* or [] for list endpoints, or omitted entirely for 204 responses */ },
"meta": { /* optional; present only where a specific endpoint in Section 13.5 defines it */ }
}metais omitted entirely when an endpoint has nothing to put in it (e.g.GET /api/v1/healthreturns onlydata).- HTTP status is
200for reads and updates,201for creates,204with a fully empty body (nodata, nometa, zero bytes) for deletes.
Error envelope — the only error shape used anywhere in the API:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Human-readable, one sentence, safe to display.",
"details": [ { "path": "wateringIntervalDays", "message": "Must be between 1 and 365." } ],
"requestId": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80"
}
}error.detailsis an array and is omitted entirely (not an empty array) when there is nothing field-specific to report — e.g.NOT_FOUNDnever hasdetails.error.requestIdis always present, set from theX-Request-Idvalue used for that request (Section 13.6), and is safe to show the user for support purposes.- An error response never contains a top-level
datakey.
13.3 Error code catalogue #
| Code | HTTP status | Produced when | details populated |
|---|---|---|---|
VALIDATION_FAILED |
400 | Request body or query string fails Zod schema validation (Section 9) | Yes — one entry per failing field, { path, message } |
MALFORMED_JSON |
400 | Request body is present but is not syntactically valid JSON, or parses to a JSON value that is not an object (Section 13.1) | No |
NOT_FOUND |
404 | Path references a resource id that does not match any currently-visible row (see each endpoint's specific "not found" conditions in Section 13.5), or the path itself matches no route | No |
METHOD_NOT_ALLOWED |
405 | The path exists but the HTTP method used is not one it supports | No |
CONFLICT |
409 | An If-Unmodified-Since precondition fails (Section 10.5) |
No |
PAYLOAD_TOO_LARGE |
413 | Request body exceeds the applicable size cap (Section 13.1) | No |
UNSUPPORTED_MEDIA_TYPE |
415 | Request has a body but the media type is not application/json (Section 13.1) |
No |
LIMIT_EXCEEDED |
422 | Creating a plant would exceed the 500-plant cap (Section 10.4) | Yes — [{ "path": "plants", "message": "..." }], text per Section 9.7 |
RATE_LIMITED |
429 | Caller has exceeded APP_RATE_LIMIT_MAX requests within APP_RATE_LIMIT_WINDOW_MS (Section 21) |
No |
UNAUTHORIZED |
401 | APP_ACCESS_CODE is set (Section 20) and the request has no valid unlock session |
No |
INTERNAL_ERROR |
500 | An unexpected server-side failure; never includes stack traces or internal detail | No |
SERVICE_UNAVAILABLE |
503 | The server cannot currently serve the request — e.g. the database is unreachable, or startup migrations have not finished (Section 22 defines readiness) | No |
The exact error.message text for each code above, and the exact text of every details[].message
entry, is defined once — in Section 9.7, keyed by the code itself as its message id — and is never
restated here. This is the complete catalogue of error codes used anywhere in this specification.
13.4 HTTP status usage #
| Status | Used for |
|---|---|
| 200 | Successful GET, PATCH, and the two action-style POST endpoints (.../waterings, .../restore) that update existing state rather than create a new resource in the REST sense |
| 201 | Successful POST /api/v1/plants (creates a new plant resource) and successful POST /api/v1/session (Section 20.5) |
| 204 | Successful DELETE (plant, watering, and session) — no response body |
| 400 | VALIDATION_FAILED, MALFORMED_JSON |
| 401 | UNAUTHORIZED — only ever returned when APP_ACCESS_CODE is configured (Section 20); a default deployment with no access code never returns 401 |
| 404 | NOT_FOUND |
| 405 | METHOD_NOT_ALLOWED |
| 409 | CONFLICT |
| 413 | PAYLOAD_TOO_LARGE |
| 415 | UNSUPPORTED_MEDIA_TYPE |
| 422 | LIMIT_EXCEEDED |
| 429 | RATE_LIMITED |
| 500 | INTERNAL_ERROR |
| 503 | SERVICE_UNAVAILABLE |
Never returned by this API, under any circumstance: 301, 302 (no redirects exist anywhere in
the API surface — the SPA's own client-side routing, Section 14, is unrelated to API responses), 403
(there is no permission tier below "authenticated" to be forbidden from — Section 20's model is binary:
either no auth is configured, or the caller is unlocked or gets 401), 418.
13.5 Endpoint reference #
| Method | Path | Success |
|---|---|---|
| GET | /api/v1/plants |
200 |
| POST | /api/v1/plants |
201 |
| GET | /api/v1/plants/:plantId |
200 |
| PATCH | /api/v1/plants/:plantId |
200 |
| DELETE | /api/v1/plants/:plantId |
204 |
| POST | /api/v1/plants/:plantId/restore |
200 |
| POST | /api/v1/plants/:plantId/waterings |
200 |
| GET | /api/v1/plants/:plantId/waterings |
200 |
| DELETE | /api/v1/plants/:plantId/waterings/:wateringId |
204 |
| GET | /api/v1/meta |
200 |
| GET | /api/v1/health |
200 |
| GET | /api/v1/health/ready |
200 |
| GET | /api/v1/export |
200 |
| POST | /api/v1/import |
200 |
| POST | /api/v1/session |
201 |
| DELETE | /api/v1/session |
204 |
| GET | /api/v1/openapi.json |
200 (development only, Section 13.11) |
13.5.1 GET /api/v1/plants #
No path or query parameters. See Section 10.3 for full behaviour, ordering, and the meta shape.
Errors: none beyond the cross-cutting cases in Section 13.3 (e.g. RATE_LIMITED).
13.5.2 POST /api/v1/plants #
No path or query parameters. Request body schema: CreatePlantInput (Section 9). See Section 10.4 for
full behaviour. Errors: VALIDATION_FAILED, LIMIT_EXCEEDED, MALFORMED_JSON,
UNSUPPORTED_MEDIA_TYPE, PAYLOAD_TOO_LARGE. Not idempotent — see Section 13.8.
13.5.3 GET /api/v1/plants/:plantId #
- Path parameter:
plantId— UUIDv7 string, validated with the sharedPlantIdSchema(Section 9) so the API and the SPA's route matching can never diverge; malformed input returns400 VALIDATION_FAILEDwithdetails: [{ "path": "plantId", "message": "Must be a valid plant ID." }]. - Returns the single Plant DTO (Section 10.2) for an active plant. A soft-deleted or non-existent
id returns
404 NOT_FOUND. - Response:
{ "data": { /* Plant DTO */ } }. - Errors:
VALIDATION_FAILED(malformed id),NOT_FOUND.
13.5.4 PATCH /api/v1/plants/:plantId #
Path parameter as in 13.5.3. Request body schema: UpdatePlantInput (Section 9), all fields optional
but at least one required. See Section 10.5 for full behaviour, including the If-Unmodified-Since
contract. Errors: VALIDATION_FAILED, NOT_FOUND, CONFLICT, MALFORMED_JSON,
UNSUPPORTED_MEDIA_TYPE, PAYLOAD_TOO_LARGE.
13.5.5 DELETE /api/v1/plants/:plantId #
Path parameter as in 13.5.3. No request body. See Section 10.6. Response: 204, empty body. Errors:
VALIDATION_FAILED (malformed id), NOT_FOUND (already deleted or never existed).
13.5.6 POST /api/v1/plants/:plantId/restore #
Path parameter as in 13.5.3. No request body. See Section 10.7. Response:
{ "data": { /* Plant DTO */ }, "meta"?: { "warnings": [...] } }. Errors: VALIDATION_FAILED
(malformed id), NOT_FOUND (not currently soft-deleted, or purged).
13.5.7 POST /api/v1/plants/:plantId/waterings #
Path parameter as in 13.5.3. No request body fields are read (any body is accepted and ignored). See
Section 11.1 and 11.2. Response:
{ "data": { /* Plant DTO */ }, "meta": { "alreadyWateredToday": boolean, "wateringId": string } }.
meta.wateringId is the id of the waterings row this call created, or, on the idempotent path, the
id of the pre-existing row for (plantId, today); meta.alreadyWateredToday is always present.
Errors: VALIDATION_FAILED (malformed id), NOT_FOUND (plant not active). Idempotent — see Section
13.8.
13.5.8 GET /api/v1/plants/:plantId/waterings #
- Path parameter:
plantIdas in 13.5.3, but this endpoint does not require the plant to be currently active — history remains readable for a plant that is soft-deleted but not yet purged (for example, while its Undo toast is still visible). AplantIdthat has never existed, or that references a permanently purged plant, returns404 NOT_FOUND. - Query parameters:
limit(integer, 1–100, default 20),offset(integer, ≥ 0, default 0). - See Section 11.4 for the DTO, ordering, and
metashape. - Errors:
VALIDATION_FAILED(malformed id or out-of-rangelimit/offset),NOT_FOUND.
13.5.9 DELETE /api/v1/plants/:plantId/waterings/:wateringId #
- Path parameters:
plantId,wateringId— both UUIDv7 strings, both validated the same way asplantIdelsewhere. - See Section 11.3 and 11.5. Response:
204, empty body. - Errors:
VALIDATION_FAILED(either id malformed),NOT_FOUND(no watering row matches the(plantId, wateringId)pair — including the case wherewateringIdbelongs to a different plant).
13.5.10 GET /api/v1/meta #
No path or query parameters. Response, when the caller is unlocked (or no access code is configured):
{
"data": {
"timezone": "America/Chicago",
"today": "2026-08-05",
"plantCap": 500,
"version": "1.0.0",
"accessCodeEnabled": false,
"unlocked": true
}
}This is what the client uses to schedule its next local-midnight refresh (Section 16.5) without
needing to trust the device's own clock or timezone for that scheduling decision.
accessCodeEnabled reflects whether APP_ACCESS_CODE is configured; see Section 20 for the unlock
flow this feeds. unlocked reflects whether the caller currently holds a valid session (Section 20.5)
and is always true when accessCodeEnabled is false. version is the application's own semantic
version string, read from package.json at boot; it is present only when unlocked is true or
accessCodeEnabled is false — otherwise the field is omitted, so an unauthenticated caller cannot
fingerprint the running release when the deployment is protected. No authentication is required to
call this endpoint at all, even when APP_ACCESS_CODE is set — the client needs accessCodeEnabled
and unlocked before it can know whether to show the unlock screen.
13.5.11 GET /api/v1/health #
No parameters, no authentication required even when APP_ACCESS_CODE is set, exempt from rate
limiting (Section 13.7). Liveness probe: returns 200 whenever the process is up and able to respond
at all. Full response schema and semantics are canonical in Section 22.
13.5.12 GET /api/v1/health/ready #
No parameters, same auth/rate-limit exemptions as 13.5.11. Readiness probe: returns 200 only once
migrations have completed and the database is reachable, 503 SERVICE_UNAVAILABLE otherwise. Full
response schema and semantics are canonical in Section 22.
13.5.13 GET /api/v1/export #
No parameters. Returns the full data set (all plants including soft-deleted ones not yet purged, and all watering history) in the canonical export format. Full request/response schema, file naming, and the fields included are canonical in Section 24.
13.5.14 POST /api/v1/import #
Request body: a full export document, up to 5 MB (Section 13.1). Full request/response schema, validation, and merge-versus-replace semantics are canonical in Section 24.
13.5.15 POST /api/v1/session #
Request body: { "accessCode": string }. Only meaningful, and only routable to anything other than
404, when APP_ACCESS_CODE is configured. On success, returns 201 with
{ "data": { "unlocked": true } } and sets the session cookie. Full behaviour, cookie contract, and
lockout rules are canonical in Section 20.5. Errors: VALIDATION_FAILED (missing/malformed body),
UNAUTHORIZED (wrong code), RATE_LIMITED (lockout).
13.5.16 DELETE /api/v1/session #
No path or query parameters, no request body. Ends the caller's session, giving the user an explicit
way to lock the app again on a shared device (Section 20.5). Returns 204 whether or not a session was
present — ending an already-ended session is not an error. Only meaningful, and only routable to
anything other than 404, when APP_ACCESS_CODE is configured.
13.6 Headers #
Request headers honoured:
| Header | Used for |
|---|---|
Content-Type |
Must be application/json (optionally with ; charset=utf-8) on any request with a body; see Section 13.1 |
If-Unmodified-Since |
Optional optimistic-concurrency precondition on PATCH /api/v1/plants/:plantId; see Section 10.5 |
X-Request-Id |
If the caller supplies a canonical UUID, or a 1–64 character string matching [A-Za-z0-9._-]+ (Section 22.4), the server echoes it back on the response and includes it as error.requestId on any error for that request; anything else is ignored and the server generates a UUIDv7 for the request instead. A client-supplied id is used only for correlation and is never rendered to the user (Section 18.5) |
Response headers always sent:
| Header | Value |
|---|---|
X-Request-Id |
The request's id, per above |
Cache-Control |
no-store on every /api/v1/* response, without exception — plant and watering data is never cached by an intermediary or the browser |
Last-Modified |
An RFC 7231 HTTP-date derived from updatedAt, truncated to the second; sent on every response whose body includes a Plant DTO (Section 10.2), so a client can capture it and send it back as If-Unmodified-Since (Section 10.5) |
| Security headers | Helmet-equivalent headers and CSP, canonical in Section 20 |
13.7 Rate limiting #
Rate limiting applies per client IP address, across all /api/v1/* routes except:
GET /api/v1/health(Section 13.5.11) andGET /api/v1/health/ready(Section 13.5.12), which must remain reachable for liveness and readiness checks even under load or abuse, or with the access code enabled;GET /api/v1/meta(Section 13.5.10), which the client calls before it can know whether an access code is required at all.
The threshold and window are configured by APP_RATE_LIMIT_MAX and APP_RATE_LIMIT_WINDOW_MS
respectively — see Section 21 for their types and defaults; this section does not restate the values.
When a client exceeds the configured threshold, every further request in the remainder of the current window receives:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json; charset=utf-8
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests. Try again shortly.", "requestId": "..." } }Retry-After is an integer number of seconds until the current window resets.
13.8 Idempotency and safety table #
| Endpoint | Safe (no side effects) | Idempotent (repeat call, same effect) | Retry consequence |
|---|---|---|---|
GET /api/v1/plants |
Yes | Yes | Always safe to retry |
POST /api/v1/plants |
No | No | Retrying after a timeout with the same body creates a second, distinct plant (new id); the client must guard against double-submit (e.g. disabling the submit control while the request is in flight) rather than relying on the API |
GET /api/v1/plants/:plantId |
Yes | Yes | Always safe to retry |
PATCH /api/v1/plants/:plantId |
No | Yes, if the request body is identical across retries | Re-applying the same field values produces the same end state; use If-Unmodified-Since (Section 10.5) to detect a conflicting change that happened between the original attempt and a retry |
DELETE /api/v1/plants/:plantId |
No | Idempotent in effect, not in response code | The plant ends up deleted either way, but a retry after the first successful call returns 404 instead of repeating 204; the client should treat a 404 on a delete retry as success |
POST /api/v1/plants/:plantId/restore |
No | No | The first call transitions the plant from deleted to active; a retry finds an already-active plant (which no longer matches the deleted_at IS NOT NULL lookup) and returns 404; the client should treat a 404 on a restore retry as success only if it already observed a successful restore |
POST /api/v1/plants/:plantId/waterings |
No | Yes, by design | See Section 11.2; safe to retry any number of times per local day |
GET /api/v1/plants/:plantId/waterings |
Yes | Yes | Always safe to retry |
DELETE /api/v1/plants/:plantId/waterings/:wateringId |
No | Idempotent in effect, not in response code | Identical pattern to plant delete above; a 404 on retry means success |
GET /api/v1/meta |
Yes | Yes | Always safe to retry |
GET /api/v1/health |
Yes | Yes | Always safe to retry |
GET /api/v1/health/ready |
Yes | Yes | Always safe to retry |
GET /api/v1/export |
Yes | Yes | Always safe to retry |
POST /api/v1/import |
No | No | Section 24 defines the merge semantics that make repeated imports additive, not idempotent — see Section 24 |
POST /api/v1/session |
No | No | Repeated correct submissions each roll the session cookie's expiry forward, per Section 20.5; repeated incorrect submissions count toward the lockout in Section 20.5 |
DELETE /api/v1/session |
No | Yes | Ending an already-ended (or never-started) session is a no-op; the caller receives 204 either way |
13.9 The typed client #
The frontend's apiClient module (apps/web/src/api/client.ts) exposes exactly one function per
endpoint, each fully typed against the shared DTOs from packages/shared, and throws a typed
ApiError for any non-2xx response so calling code never has to branch on raw status codes.
import type {
Plant,
Watering,
CreatePlantInput,
UpdatePlantInput,
PlantListMeta,
WateringListMeta,
MetaResponse,
} from '@houseplant/shared';
export class ApiError extends Error {
readonly code: string;
readonly status: number;
readonly details?: Array<{ path: string; message: string }>;
readonly requestId: string;
constructor(params: {
code: string;
status: number;
message: string;
details?: Array<{ path: string; message: string }>;
requestId: string;
}) {
super(params.message);
this.name = 'ApiError';
this.code = params.code;
this.status = params.status;
this.details = params.details;
this.requestId = params.requestId;
}
}
export interface ApiClient {
listPlants(): Promise<{ data: Plant[]; meta: PlantListMeta }>;
createPlant(input: CreatePlantInput): Promise<{ data: Plant; meta?: { warnings?: Array<{ code: string; message: string }> } }>;
getPlant(plantId: string): Promise<{ data: Plant }>;
updatePlant(
plantId: string,
input: UpdatePlantInput,
options?: { ifUnmodifiedSince?: string },
): Promise<{ data: Plant }>;
deletePlant(plantId: string): Promise<void>;
restorePlant(plantId: string): Promise<{ data: Plant; meta?: { warnings?: Array<{ code: string; message: string }> } }>;
waterPlant(plantId: string): Promise<{ data: Plant; meta: { alreadyWateredToday: boolean; wateringId: string } }>;
listWaterings(
plantId: string,
params?: { limit?: number; offset?: number },
): Promise<{ data: Watering[]; meta: WateringListMeta }>;
deleteWatering(plantId: string, wateringId: string): Promise<void>;
getMeta(): Promise<{ data: MetaResponse }>;
}
export function createApiClient(baseUrl: string): ApiClient {
// Implementation performs fetch(), parses the envelope from Section 13.2, and throws
// ApiError for any response whose status is not in the 200-299 range, populating it
// from the response's error.code/message/details/requestId.
// ...
}Every method returns already-typed, already-camelCased data — there is no snake_case anywhere in
apps/web. deletePlant and deleteWatering return Promise<void> because their 204 responses
have no body. TanStack Query hooks (Section 16) wrap each of these functions; this module has no
dependency on React and is independently unit-testable with Vitest and a mocked fetch.
13.10 Full worked request/response transcript #
A realistic sequence of exchanges a single user session might produce, in order. All example headers
that are always present (X-Request-Id, Cache-Control: no-store) are shown once and then implied.
1. Create a plant.
POST /api/v1/plants HTTP/1.1
Host: example.internal
Content-Type: application/json; charset=utf-8
X-Request-Id: 6a1e9b3a-2f7c-4c9a-9d1b-2e6f0a3c7b11
{ "name": "Monstera", "wateringIntervalDays": 7, "notes": "Living room, east window" }HTTP/1.1 201 Created
Location: /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80
Content-Type: application/json; charset=utf-8
X-Request-Id: 6a1e9b3a-2f7c-4c9a-9d1b-2e6f0a3c7b11
Cache-Control: no-store
{
"data": {
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 7,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-01",
"nextDueOn": "2026-08-08",
"daysUntilDue": 7,
"daysOverdue": 0,
"status": "upcoming",
"wateringCount": 1,
"createdAt": "2026-08-01T09:00:00.000Z",
"updatedAt": "2026-08-01T09:00:00.000Z"
}
}2. List plants.
GET /api/v1/plants HTTP/1.1
Host: example.internal
X-Request-Id: 7b2f0c4b-3g8d-5d0b-0e2c-3f7g1b4d8c22HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"data": [
{ "id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80", "name": "Monstera", "wateringIntervalDays": 7, "notes": "Living room, east window", "lastWateredOn": "2026-08-01", "nextDueOn": "2026-08-08", "daysUntilDue": 7, "daysOverdue": 0, "status": "upcoming", "wateringCount": 1, "createdAt": "2026-08-01T09:00:00.000Z", "updatedAt": "2026-08-01T09:00:00.000Z" }
],
"meta": { "timezone": "America/Chicago", "today": "2026-08-01", "count": 1 }
}3. Water the plant (seven days later).
POST /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80/waterings HTTP/1.1
Host: example.internal
Content-Type: application/json; charset=utf-8
X-Request-Id: 8c3g1d5c-4h9e-6e1c-1f3d-4g8h2c5e9d33
{}HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"data": {
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 7,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-08",
"nextDueOn": "2026-08-15",
"daysUntilDue": 7,
"daysOverdue": 0,
"status": "upcoming",
"wateringCount": 2,
"createdAt": "2026-08-01T09:00:00.000Z",
"updatedAt": "2026-08-08T13:12:40.500Z"
},
"meta": { "alreadyWateredToday": false, "wateringId": "0191822d-3c5a-7e2f-9b1d-4a8f6c2e0d15" }
}4. List plants again (status reflects the new watering).
GET /api/v1/plants HTTP/1.1
Host: example.internal
X-Request-Id: 9d4h2e6d-5i0f-7f2d-2g4e-5h9i3d6f0e44HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"data": [
{ "id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80", "name": "Monstera", "wateringIntervalDays": 7, "notes": "Living room, east window", "lastWateredOn": "2026-08-08", "nextDueOn": "2026-08-15", "daysUntilDue": 7, "daysOverdue": 0, "status": "upcoming", "wateringCount": 2, "createdAt": "2026-08-01T09:00:00.000Z", "updatedAt": "2026-08-08T13:12:40.500Z" }
],
"meta": { "timezone": "America/Chicago", "today": "2026-08-08", "count": 1 }
}5. Edit the watering interval down to 3 days (the plant becomes overdue instantly).
PATCH /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80 HTTP/1.1
Host: example.internal
Content-Type: application/json; charset=utf-8
X-Request-Id: 0e5i3f7e-6j1g-8g3e-3h5f-6i0j4e7g1f55
{ "wateringIntervalDays": 3 }HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"data": {
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 3,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-08",
"nextDueOn": "2026-08-11",
"daysUntilDue": -2,
"daysOverdue": 2,
"status": "overdue",
"wateringCount": 2,
"createdAt": "2026-08-01T09:00:00.000Z",
"updatedAt": "2026-08-13T10:05:00.000Z"
}
}(This example assumes the PATCH above is sent on 2026-08-13, five days after the watering in step 3.)
6. Water again, resolving the overdue state.
POST /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80/waterings HTTP/1.1
Host: example.internal
Content-Type: application/json; charset=utf-8
X-Request-Id: 1f6j4g8f-7k2h-9h4f-4i6g-7j1k5f8h2g66
{}HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"data": {
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 3,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-13",
"nextDueOn": "2026-08-16",
"daysUntilDue": 3,
"daysOverdue": 0,
"status": "upcoming",
"wateringCount": 3,
"createdAt": "2026-08-01T09:00:00.000Z",
"updatedAt": "2026-08-13T10:06:15.000Z"
},
"meta": { "alreadyWateredToday": false, "wateringId": "01918e4f-6b7c-7a3d-8e2f-5c9b1d3a6f27" }
}7. Delete the plant.
DELETE /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80 HTTP/1.1
Host: example.internal
X-Request-Id: 2g7k5h9g-8l3i-0i5g-5j7h-8k2l6g9i3h77HTTP/1.1 204 No Content
X-Request-Id: 2g7k5h9g-8l3i-0i5g-5j7h-8k2l6g9i3h77
Cache-Control: no-store8. Restore the plant (within the Undo window).
POST /api/v1/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80/restore HTTP/1.1
Host: example.internal
Content-Type: application/json; charset=utf-8
X-Request-Id: 3h8l6i0h-9m4j-1j6h-6k8i-9l3m7h0j4i88
{}HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"data": {
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera",
"wateringIntervalDays": 3,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-13",
"nextDueOn": "2026-08-16",
"daysUntilDue": 3,
"daysOverdue": 0,
"status": "upcoming",
"wateringCount": 3,
"createdAt": "2026-08-01T09:00:00.000Z",
"updatedAt": "2026-08-13T10:07:30.000Z"
}
}13.11 OpenAPI #
@fastify/swagger generates /api/v1/openapi.json directly from the route schemas registered on each
Fastify route, in development only; the route is disabled entirely (returns 404, not merely
hidden) when NODE_ENV=production. The per-route JSON Schemas it introspects are not written by hand:
they are derived from the same shared Zod schemas used for request/response validation
(packages/shared/src/schemas), converted at build/boot time via zod-to-json-schema. This guarantees
the generated OpenAPI document can never drift from the validation actually enforced at runtime, since
both are generated from the identical source of truth.
14. Frontend Information Architecture and Routing #
14.1 Architecture summary #
The frontend is a single-page application built with React 19 and Vite 6. React Router 7 in
declarative/data router mode owns all navigation. TanStack Query v5 owns all server state: every
piece of data that originates from the API (plants, watering history, meta) lives in the Query
cache, never in useState or a global store. There is no Redux, Zustand, Jotai, or Context-based
global store in this application.
This is a deliberate architectural choice, not an omission: the app has exactly one meaningful collection of server data (plants and their waterings), one user, and one device at a time. Every piece of state that is not server data is either:
- Local component state (
useState/useReducer) — form field values, dialog open/closed, hover/focus flags. - URL state — the current route and its params (which plant, which form).
localStorage— the three durable preferences listed in §14.4.
Introducing a global client-state library would duplicate what TanStack Query's cache already provides and would add a category of bugs (cache/store desync) that this app has no reason to carry. Section 16 defines the full state-ownership table.
14.2 Route table #
All routes are declared in a single route tree in apps/web/src/routes/router.tsx, built with
createBrowserRouter. The table below is canonical; no other route exists.
| Path | Screen | Component | Data loaded | Loading strategy | Modal or page | Document title |
|---|---|---|---|---|---|---|
/ |
Plant list | PlantListRoute |
usePlants() (['plants']), useMeta() (['meta']) |
Render immediately, show SkeletonRow list while pending (§18) |
Page (always) | "Houseplant Watering Tracker" |
/plants/new |
Add plant | PlantFormRoute (mode "create") |
None (form starts empty) | No data fetch; form is interactive immediately | Full-screen page at base/sm, centred modal (480px wide) at md+, overlaying / |
"Add Plant · Houseplant Watering Tracker" |
/plants/:plantId |
Plant detail | PlantDetailRoute |
usePlant(plantId) (['plants', plantId]), useWateringHistory(plantId, { limit: 20, offset: 0 }) |
Render immediately, show SkeletonDetail while pending |
Page (always) | "{plant.name} · Houseplant Watering Tracker" once loaded, "Plant · Houseplant Watering Tracker" while pending |
/plants/:plantId/edit |
Edit plant | PlantFormRoute (mode "edit") |
usePlant(plantId) (['plants', plantId]) to prefill the form |
Show SkeletonDetail-style form skeleton while pending |
Full-screen page at base/sm, centred modal (480px wide) at md+, overlaying /plants/:plantId |
"Edit {plant.name} · Houseplant Watering Tracker" once loaded, "Edit Plant · Houseplant Watering Tracker" while pending |
/unlock |
Unlock | UnlockRoute |
None | Interactive immediately | Page (always) | "Unlock · Houseplant Watering Tracker" |
/settings |
Settings | SettingsRoute |
useMeta() (['meta']) |
Render immediately | Page (always) | "Settings · Houseplant Watering Tracker" |
* |
Not found | NotFoundRoute |
None | Interactive immediately | Page (always) | "Not Found · Houseplant Watering Tracker" |
The router renders nothing until the ['meta'] query resolves. Once it has, /unlock only renders
when meta.accessCodeEnabled is true (derived from APP_ACCESS_CODE being set, see Section 21)
and meta.unlocked is false; navigating to /unlock when !accessCodeEnabled || unlocked
redirects to /. When the optional access code is disabled — the default — /unlock is
unreachable. /unlock is a screen route, not an API endpoint: its form submits to
POST /api/v1/session (Section 20.5), which is the actual unlock endpoint. The unlock/session
mechanics are canonical in Section 20; this section only fixes the route's existence and its
screen content (§17.6).
Route params are validated before rendering: plantId must match the shape enforced by the
shared PlantIdSchema (packages/shared/src/schemas, Section 9) — imported directly rather than
restated, so the SPA's check and the API's check can never diverge. A plantId that fails this
shape check renders NotFoundRoute without issuing a network request. A well-formed plantId that
the API reports NOT_FOUND (404, Section 13) for — the plant does not exist or was purged — also
renders NotFoundRoute, via the route's errorElement (§16.9).
14.3 Navigation model #
┌────────────────────┐
│ Plant list (/) │◄────────────────────────────┐
└──────────┬──────────┘ │
tap "Add plant" │ tap a plant row │ back button /
▼ │ │ close modal /
┌─────────────────────┴──┐ ┌──────────┴──────────────┐
│ Add plant (/plants/new)│ │ Plant detail │
└───────────┬─────────────┘ │ (/plants/:plantId) │
│ submit → create succeeds └──────────┬───────────┬──┘
▼ tap "Edit" │ │ tap "Delete"
┌─────────────────────────┐ ▼ ▼ (opens ConfirmDialog
│ Plant list (/) │◄────────────────┐ ┌─────────────────────┐ in place, no route
│ + success toast │ submit → edit │ │ Edit plant │ change)
└───────────────────────────┘ succeeds └───────┤ (/plants/:plantId/ │
│ edit) │
└──────────────────────┘Rules, all normative:
- The back button always works and always does the obvious thing. Every navigation that opens
a screen (add, detail, edit, unlock) pushes a history entry; every dismissal (cancel, close,
successful submit that returns to a list) uses
navigate(-1)when the router's history state confirms the previous entry is inside the app, and falls back tonavigate("/", { replace: true })when it is not (e.g. the user arrived via a deep link and there is no in-app "back"). - Closing a modal navigates back rather than pushing a new entry. At
md+,/plants/newand/plants/:plantId/editrender as modals layered over the route the user came from. The backdrop, the "Cancel" button, and the Escape key all callnavigate(-1). They never callnavigate("/")directly, so a user editing a plant from the detail screen returns to that plant's detail screen, not to the list. - A successful create navigates to
/(not to the new plant's detail) and shows a success toast naming the plant —toast.plantAdded(§15.11): "{plantName} added". This keeps the list, the screen the user checks most often, as the landing point after every add. - A successful edit navigates back to where the user came from, defaulting to
/. The form route reads its entry point fromlocation.state.from(set when navigating to the edit route from the list row's edit affordance or from the detail screen's "Edit plant" button); iflocation.state.fromis absent (direct URL entry), it defaults to/plants/:plantId. - Deleting a plant from its detail screen navigates to
/immediately (the detail screen for a soft-deleted plant would otherwise 404) and shows the delete toast with Undo (§16.8). - Every internal link is a React Router
<Link>(ornavigate()); there are no<a href>tags for internal navigation and no full-page reloads during normal use.
14.4 URL and state persistence #
| State | Lives in | Rationale |
|---|---|---|
Current screen, plantId |
URL (route path/params) | The only state that must survive a refresh and be linkable within the single deployment. |
Sort order (SortOrder, §16.2) |
localStorage key hpt.sort, default 'urgency' |
One user, no sharing; a URL query param would add complexity with no addressability benefit (see below); the choice persists across visits. |
Filter (StatusFilter, §16.2: 'all' | 'needs-water') |
localStorage key hpt.filter, default 'all' |
Same rationale as sort. |
| Theme override | localStorage key hpt.theme |
Applies across the whole app, not per-screen; see §15.8. |
| Search text | In-memory component state only (ListControls local useState) |
Deliberately not persisted — a stale search filter silently hiding plants on next visit is a worse default than an empty search box. |
| Toast queue | In-memory (ToastRegion local state) |
Ephemeral by nature; nothing to persist. |
| Form field values (add/edit) | In-memory (PlantForm local state) |
Discarded on navigation away, per the unsaved-changes flow in §16.6. |
Sort and filter are deliberately not encoded in the URL. This is a single-user app with no
shared or bookmarked list views to preserve; putting sort/filter in the URL would only add
router complexity (extra useSearchParams synchronization, extra states to test) for a benefit
that does not exist here — nobody shares a ?sort=urgency link with themselves. Reading and
writing localStorage directly from ListControls is simpler and equally durable across
sessions.
localStorage reads are guarded: a missing or corrupt value falls back to the default (sort:
urgency, filter: all, theme: system) rather than throwing. Writes happen synchronously
on change with no debouncing (the values are tiny and infrequent).
14.5 App shell #
AppShell renders on every route and contains:
- Header — fixed to the top of the viewport, height
--header-height= 3.5rem (56px) at base/sm, 4rem (64px) atmd+. Contains, left to right: the app name ("Houseplant Watering Tracker", truncating to "Plant Watering" below 400px viewport width if it would otherwise wrap), a spacer, a "Lock" ghost button (nav.lock, §15.11) rendered only whenmeta.accessCodeEnabledistrue— activating it callsDELETE /api/v1/sessionand navigates to/unlock— theThemeToggle, and — atmd+ only — the header's "Add plant" button (AddPlantButtonrendered in header mode; at base/smthe add action is instead the floating action button described in §17.1). A gear-icon "Settings" button (§15.10) sits between the theme toggle and the add-plant button at every breakpoint and navigates to/settings. - Main region — a
<main>landmark containing the routed screen. Padding-top equal to the header height so content never sits under the fixed header; padding-bottom ofcalc(88px + env(safe-area-inset-bottom))at base/smto clear the floating action button and any device home-indicator gesture area,24pxatmd+ (no FAB to clear at that breakpoint). - Toast region —
ToastRegion, rendered as a sibling of<main>at theAppShelllevel so toasts persist across route changes; see §16.7 for placement, stacking, and the safe-area offset per breakpoint.
There is no sidebar, no footer navigation, and no tab bar at any breakpoint. With seven routes, one of which is the permanent home screen, a persistent navigation chrome would add visual weight without adding navigable destinations — the only way into every other screen is already one tap from the list.
14.6 Document titles and metadata #
Every route sets document.title via a useDocumentTitle(title: string) hook (apps/web/src/ hooks/useDocumentTitle.ts) called once per route component; exact strings are given in the route
table (§14.2).
apps/web/index.html head contents (fixed):
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F8FAFC" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0B1220" />
<meta name="description" content="Track houseplant watering schedules and see what needs water at a glance." />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Houseplant Watering Tracker</title>The #F8FAFC and #0B1220 values are the light and dark --color-surface-app tokens defined in
§15.2, kept identical here so the browser chrome (address bar on mobile) matches the app background
with no visible seam.
apps/web/public/manifest.webmanifest (fixed):
{
"name": "Houseplant Watering Tracker",
"short_name": "Plant Water",
"start_url": "/",
"display": "standalone",
"background_color": "#F8FAFC",
"theme_color": "#F8FAFC",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Adding this manifest lets the app be saved to a phone home screen and launched in standalone
display mode with its own icon. This is in scope: it is a small, standard web-platform affordance
that improves the "check it from your phone" workflow the product is built for. It is not, and
must not become, a native app — there is no native shell, no app-store packaging, no
platform-specific code, and no offline-first service worker; Section 3's exclusion of native iOS
and Android apps stands.
Favicon set (checked into apps/web/public/ as static assets — no build step generates them):
favicon.ico (32×32, multi-res), icon.svg, icon-192.png, icon-512.png,
icon-maskable-512.png (512×512 with safe-zone padding for maskable display), apple-touch-icon.png
(180×180, no transparency, background matches --color-surface-app light value).
14.7 Code splitting #
The router lazy-loads two groups:
Eager (bundled in the main chunk):
/(PlantListRoute) andAppShell— the screen the user lands on and sees most, loaded with zero extra round trip./unlock(UnlockRoute) and*(NotFoundRoute) are also eager: both are small (a single field or a single message plus a link) and keeping them eager avoids a loading flash on two screens users may hit before any other chunk has had a chance to load (an invalid link, or the very first request when the access code is enabled).Lazy, sharing one chunk:
/plants/new,/plants/:plantId, and/plants/:plantId/edit. All three render throughPlantFormRouteorPlantDetailRoute, both of which import from the samecomponents/plant-form/andcomponents/plant-detail/modules; grouping them keeps thePlantFormcode (shared by add and edit) from being duplicated across chunks. Vite/Rollup does not honour webpack-style/* webpackChunkName */magic comments, so the grouping is declared explicitly invite.config.tsinstead of in theimport()call:// apps/web/src/routes/router.tsx const plantDetailRoute = { path: "/plants/:plantId", lazy: () => import("./PlantDetailRoute"), }; const plantFormRoute = (path: string, mode: "create" | "edit") => ({ path, lazy: () => import("./PlantFormRoute").then((m) => ({ Component: () => m.default({ mode }), })), });// apps/web/vite.config.ts export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if ( id.includes("/routes/PlantDetailRoute") || id.includes("/routes/PlantFormRoute") || id.includes("/components/plant-form/") || id.includes("/components/plant-detail/") ) { return "plant-forms"; } }, }, }, }, });/settings(SettingsRoute, §17.10) is lazy-loaded on its own, not grouped intoplant-forms— it shares no components with the plant form/detail screens.
While a lazy chunk is loading, the router's <Suspense> boundary (wrapping the <Outlet /> in
AppShell) shows a full-bleed SkeletonDetail (for detail/edit) or a full-bleed SkeletonText
block (for add and settings) matching the target screen's rough shape, never a spinner-only screen
— this keeps perceived layout stable when the chunk resolves.
Prefetch on intent, hover-capable pointers only: PlantRow prefetches a plant's detail data on
onPointerEnter, gated by the media query (hover: hover) and (pointer: fine), by calling
queryClient.prefetchQuery for ['plants', plantId] using the same query function as usePlant.
On a touchscreen (pointer: coarse or hover: none) no prefetch fires on touch-start, because
pointerdown fires at the start of every scroll gesture on a touch device — flicking through a
long list would otherwise issue a burst of unwanted GET /api/v1/plants/:id requests against the
rate limiter (Section 13.7). This does not prefetch the JS chunk separately — Vite/React Router's
lazy() loader is triggered by the navigation itself and typically resolves faster than the
network round trip for the data, so the two overlap. The header/FAB "Add plant" button and the
Settings button are single elements that cannot storm on scroll, so they keep an unconditional
onPointerDown chunk prefetch (import() with no data prefetch, since there is none to fetch for
a blank form or for /settings before its own useMeta() resolves from cache).
15. Design System and Visual Language #
15.1 Design principles #
- One glance, one answer. The plant list must answer "what needs water right now" without scrolling past the fold on a 360×640 viewport for a collection of up to 8 plants, and without requiring the user to open any other screen.
- Colour is never the only signal. Every status indicator pairs colour with an icon and a text label, satisfying WCAG 2.2 SC 1.4.1 (Section 19 states the full accessibility bar).
- Nothing moves unless the user moved it. Rows never reorder or re-render mid-interaction from a background refresh; sort/filter changes and explicit actions are the only triggers for layout change (see the refresh policy cross-referenced in §16.5).
- Thumb-first. Every interactive control meets the 44×44px minimum target and the primary action on each screen sits within comfortable one-handed thumb reach on a 360px-wide viewport (bottom half of the screen where the screen supports it).
- Say less, mean it. Copy is short, literal, and unsentimental — see the tone rules in §15.11.
Each principle is testable: 1 and 4 are verified in the Playwright viewport tests (Section 25); 2 is verified in the accessibility tests (Section 19); 3 is verified by a Playwright assertion that a background refetch changing a plant's status does not reorder the DOM (Section 25) — the displayed order is recomputed only on a user-initiated event (a sort, filter, or search change, or a successful mutation), never on a background refresh (§16.5); 5 is enforced by the fixed copy table in §15.11 (no ad hoc strings are permitted outside it).
15.2 Colour tokens #
All colours are defined as CSS custom properties in apps/web/src/styles/tokens.css, scoped so
that :root holds light-mode values and [data-theme="dark"] overrides them. Section 15.8 defines
how data-theme is set. Every ratio below is against the token's stated background, computed with
the WCAG relative luminance formula, and re-verified by the automated contrast check in the
accessibility test suite (Section 25).
15.2.1 Status tokens (canonical values, reproduced from the fixed palette) #
Every ratio in this table is the value measured with the WCAG relative-luminance formula against
the token's stated background; scripts/check-contrast.mjs (Section 25) asserts each one to one
decimal place, so a future edit to a hex value that is not matched by an edit here fails the build.
| Token | Light value | Dark value | Contrast (light, text-on-tint) | Contrast (dark, text-on-tint) | Usage |
|---|---|---|---|---|---|
--color-status-overdue-text |
#9F1239 |
#FDA4AF |
7.30:1 on --color-status-overdue-surface |
8.27:1 on --color-status-overdue-surface (dark) |
Overdue label text, icon fill |
--color-status-overdue-surface |
#FFF1F2 |
#4C0519 |
— | — | Overdue row tint, badge background |
--color-status-due-today-text |
#92400E |
#FCD34D |
6.84:1 | 10.39:1 | Due-today label text, icon fill |
--color-status-due-today-surface |
#FFFBEB |
#451A03 |
— | — | Due-today row tint, badge background |
--color-status-due-soon-text |
#1E40AF |
#93C5FD |
8.01:1 | 8.15:1 | Due-soon label text, icon fill |
--color-status-due-soon-surface |
#EFF6FF |
#172554 |
— | — | Due-soon row tint, badge background |
--color-status-upcoming-text |
#166534 |
#86EFAC |
6.81:1 | 10.62:1 | Upcoming label text, icon fill |
--color-status-upcoming-surface |
#F0FDF4 |
#052E16 |
— | — | Upcoming row tint, badge background |
Icon-to-status mapping (fixed, from lucide-react, sizes in §15.10): overdue → AlertTriangle,
due_today → Droplet, due_soon → Clock, upcoming → Check.
15.2.2 Neutral surface, text and border tokens #
| Token | Light value | Dark value | Usage |
|---|---|---|---|
--color-surface-app |
#F8FAFC |
#0B1220 |
Page background |
--color-surface-raised |
#FFFFFF |
#141E30 |
Cards, rows, the header, modals |
--color-surface-sunken |
#F1F5F9 |
#0F1A2B |
Inputs, textarea, skeleton shimmer base |
--color-border-default |
#E2E8F0 |
#26344A |
Card/row borders, dividers |
--color-border-strong |
#64748B |
#94A3B8 |
Input borders, focus-adjacent borders |
--color-text-primary |
#0F172A |
#F1F5F9 |
Headings, plant names, primary body text |
--color-text-secondary |
#475569 |
#94A3B8 |
Metadata, helper text, timestamps |
--color-text-disabled |
#94A3B8 |
#5B6B85 |
Disabled control text |
--color-text-inverse |
#FFFFFF |
#0B1220 |
Text on filled primary buttons |
--color-accent |
#0E7490 |
#67E8F9 |
Primary buttons, links, focus ring base, active tab-like states |
--color-accent-hover |
#0C6178 |
#8CF0FF |
Primary button hover |
--color-danger |
#B91C1C |
#FCA5A5 |
Destructive button text/border |
--color-danger-surface |
#FEF2F2 |
#3F0D0D |
Destructive button hover surface, delete confirm accents |
--color-focus-ring |
#0E7490 |
#67E8F9 |
:focus-visible outline colour (always the accent token) |
--color-text-primary on --color-surface-app is 17.06:1 (light) and 17.09:1 (dark). --color-text- secondary on --color-surface-app is 7.1:1 (light) and 6.6:1 (dark). --color-accent on
--color-surface-raised is 5.36:1 (light); the dark-mode accent #67E8F9 is used only as text/icon
colour on dark surfaces (9.8:1) and never as a filled-button background against light text, because
its own contrast against white text would fail — dark-mode primary buttons keep a light text colour
(--color-text-inverse dark value #0B1220) against the #67E8F9 fill, which is 12.91:1.
--color-border-strong (the only visual boundary a text input has at rest, Section 15.7.2) is
4.76:1 on --color-surface-raised in light mode and 6.51:1 in dark mode, clearing WCAG 2.2 SC
1.4.11's 3:1 non-text minimum. --color-border-default is 1.23:1 on --color-surface-raised in
light mode and is deliberately exempt from the 3:1 rule: a card or row edge is a decorative
separator, not a component boundary that carries meaning on its own (every card also has a status
left-border and a text label, Section 15.6), so SC 1.4.11 does not apply to it. This exemption
covers --color-border-default only — it must never be used to justify weakening
--color-border-strong, which is a real component-boundary colour and must stay at or above the
values in this section.
15.2.3 apps/web/src/styles/tokens.css (in full) #
:root {
color-scheme: light;
/* Status */
--color-status-overdue-text: #9F1239;
--color-status-overdue-surface: #FFF1F2;
--color-status-due-today-text: #92400E;
--color-status-due-today-surface: #FFFBEB;
--color-status-due-soon-text: #1E40AF;
--color-status-due-soon-surface: #EFF6FF;
--color-status-upcoming-text: #166534;
--color-status-upcoming-surface: #F0FDF4;
/* Neutral */
--color-surface-app: #F8FAFC;
--color-surface-raised: #FFFFFF;
--color-surface-sunken: #F1F5F9;
--color-border-default: #E2E8F0;
--color-border-strong: #64748B;
--color-text-primary: #0F172A;
--color-text-secondary: #475569;
--color-text-disabled: #94A3B8;
--color-text-inverse: #FFFFFF;
--color-accent: #0E7490;
--color-accent-hover: #0C6178;
--color-danger: #B91C1C;
--color-danger-surface: #FEF2F2;
--color-focus-ring: #0E7490;
/* Spacing (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* Radii */
--radius-control: 6px;
--radius-card: 12px;
--radius-pill: 9999px;
/* Shadows */
--shadow-low: 0 1px 2px 0 rgb(15 23 42 / 0.06), 0 1px 1px 0 rgb(15 23 42 / 0.04);
--shadow-high: 0 8px 24px -4px rgb(15 23 42 / 0.18), 0 2px 8px -2px rgb(15 23 42 / 0.10);
/* Motion */
--easing-standard: cubic-bezier(0.2, 0, 0, 1);
--duration-toast: 150ms;
--duration-modal: 120ms;
--duration-status: 200ms;
}
[data-theme="dark"] {
color-scheme: dark;
--color-status-overdue-text: #FDA4AF;
--color-status-overdue-surface: #4C0519;
--color-status-due-today-text: #FCD34D;
--color-status-due-today-surface: #451A03;
--color-status-due-soon-text: #93C5FD;
--color-status-due-soon-surface: #172554;
--color-status-upcoming-text: #86EFAC;
--color-status-upcoming-surface: #052E16;
--color-surface-app: #0B1220;
--color-surface-raised: #141E30;
--color-surface-sunken: #0F1A2B;
--color-border-default: #26344A;
--color-border-strong: #94A3B8;
--color-text-primary: #F1F5F9;
--color-text-secondary: #94A3B8;
--color-text-disabled: #5B6B85;
--color-text-inverse: #0B1220;
--color-accent: #67E8F9;
--color-accent-hover: #8CF0FF;
--color-danger: #FCA5A5;
--color-danger-surface: #3F0D0D;
--color-focus-ring: #67E8F9;
--shadow-low: 0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 1px 0 rgb(0 0 0 / 0.3);
--shadow-high: 0 8px 24px -4px rgb(0 0 0 / 0.55), 0 2px 8px -2px rgb(0 0 0 / 0.4);
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-toast: 0ms;
--duration-modal: 0ms;
--duration-status: 0ms;
}
}Tailwind v4 (CSS-first config, per Section 5.1) maps these custom properties to utilities in apps/ web/src/styles/theme.css via @theme, so bg-surface-raised, text-status-overdue, border- default, rounded-card, shadow-high, etc. are available as ordinary Tailwind classes throughout
the codebase. No component hardcodes a hex value.
15.3 Typography #
Font stack (system, no web font download):
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji";Type scale (base 16px; all sizes in rem so they respect user font-size preferences):
| Step | Size | Line height | Weight | Usage |
|---|---|---|---|---|
xs |
0.75rem (12px) | 1rem (16px) | 400 | Timestamps, character counters, badge micro-labels |
sm |
0.875rem (14px) | 1.25rem (20px) | 400 | Secondary text, helper text, form labels, history rows |
base |
1rem (16px) | 1.5rem (24px) | 400 | Body text, input values, notes |
md |
1.125rem (18px) | 1.75rem (28px) | 600 | Plant name in a list row, section labels |
lg |
1.5rem (24px) | 2rem (32px) | 700 | Screen headings ("Add plant", "Watering history"), the status headline on detail |
xl |
2rem (32px) | 2.25rem (36px) | 700 | The large due/overdue day-count number on the detail screen |
Within a PlantRow, the plant name (md, weight 600) is always the largest and heaviest text in
the row — larger than the due label (sm) and the status badge (xs) — so the eye lands on
identity first and status second, consistent with design principle 1 (§15.1).
15.4 Spacing, radii, shadows, borders #
Spacing scale (4px base, Tailwind default, tokens listed in §15.2.3): 1 (4px) through 12
(48px) as shown. Component-internal padding uses space-3/space-4; gaps between stacked rows use
space-2; gaps between major page sections use space-6/space-8.
| Radius token | Value | Usage |
|---|---|---|
--radius-control |
6px | Buttons, inputs, textarea, stepper, badges (non-pill) |
--radius-card |
12px | PlantRow cards, the detail screen's status block, modals |
--radius-pill |
9999px | The floating action button, filter chips, the interval quick-pick chips |
| Shadow token | Value | Usage |
|---|---|---|
--shadow-low |
Defined in §15.2.3 | PlantRow resting elevation, SummaryBar |
--shadow-high |
Defined in §15.2.3 | Modals, ConfirmDialog, the floating action button, open toasts |
Borders use --color-border-default at 1px for resting card/row edges and --color-border-strong
at 1px for form input edges (inputs need a slightly more visible edge since they carry no shadow at
rest).
15.5 Layout and breakpoints #
Breakpoints (Tailwind defaults): base <640px, sm ≥640px, md ≥768px, lg ≥1024px. All
layout is authored mobile-first: unprefixed classes target base, and sm:/md:/lg: prefixes add
overrides.
- Base (< 640px), designed at 360px: single column, 16px (
px-4) gutters, content fills the viewport width. sm(≥ 640px): gutters grow to 24px (sm:px-6); still single column.md(≥ 768px): the plant list becomes a two-column grid (md:grid md:grid-cols-2 md:gap-4);/plants/newand/plants/:plantId/editrender as centred modals fixed at 480px wide (md:w-[480px]) rather than full-width pages.lg(≥ 1024px): the content column caps at 720px and centres in the viewport (lg:max-w-[720px] lg:mx-auto); the plant list grid stays two columns (a third column at this width would make rows too narrow to read the due label comfortably next to the name).
Canonical container classes, applied at the top of every routed screen's root element:
w-full px-4 sm:px-6 md:px-6 lg:max-w-[720px] lg:mx-autoThe plant list grid container adds, at md+ only:
md:grid md:grid-cols-2 md:gap-4Focus visibility (SC 2.4.11). A focused control must never be hidden behind the fixed header or the toast region. A global rule, applied to every focusable element, reserves clearance above and below:
/* apps/web/src/styles/theme.css */
:where(a, button, input, textarea, select, [tabindex]) {
scroll-margin-top: calc(var(--header-height) + 8px);
scroll-margin-bottom: 104px;
}--header-height is 3.5rem at base/sm and 4rem at md+, matching §14.5. The
scroll-margin-bottom value clears the toast region's tallest stacked state at base width (§16.7).
15.6 Status styling #
Canonical application of the palette from §15.2.1 to a list row. Every status renders with all four of: a 4px left border in the status text colour, a full-row surface tint, a badge (border + text colour + icon), and the badge's own text. No status is ever conveyed by colour alone.
The StatusBadge is the row's only status label — it carries formatRelativeDueLabel's output
verbatim (Section 8.4), not a shortened word, so nothing is stated twice. Its background is
--color-surface-raised (not the status surface tint the row already uses, which would leave the
badge with no boundary of its own) with a 1px border in the status text colour; that text colour
already exceeds 4.5:1 against both --color-surface-raised and the row's own status surface tint
(§15.2.1), so the border clears SC 1.4.11's 3:1 non-text minimum with margin.
| Status | Row left border | Row surface tint | Badge background | Badge border / text / icon colour | Badge text (verbatim, Section 8.4) |
|---|---|---|---|---|---|
overdue |
4px solid --color-status-overdue-text |
--color-status-overdue-surface |
--color-surface-raised |
--color-status-overdue-text, icon AlertTriangle |
"Overdue by {n} day"/"days" |
due_today |
4px solid --color-status-due-today-text |
--color-status-due-today-surface |
--color-surface-raised |
--color-status-due-today-text, icon Droplet |
"Due today" |
due_soon |
4px solid --color-status-due-soon-text |
--color-status-due-soon-surface |
--color-surface-raised |
--color-status-due-soon-text, icon Clock |
"Due tomorrow" |
upcoming |
4px solid --color-status-upcoming-text |
--color-status-upcoming-surface |
--color-surface-raised |
--color-status-upcoming-text, icon Check |
"Due in {n} days" |
Row structure (PlantRow, respecified per §17.1). At 360px wide, the row's inner content width
is only ~296px after gutters and card padding — too narrow for name, badge, button, and a
ChevronRight on one line, and a <button> cannot legally nest inside an <a>. The row is
therefore two lines inside one <li>, with the plant-name link and the water button as
independently focusable siblings, neither nested inside the other:
- Line 1: the plant name (
text-md font-semibold,line-clamp-2— wraps up to two lines rather than truncating, so a long name stays readable at any zoom level) wrapped in a React RouterLinkwhose accessible name is simply the plant's own name — its rendered text content, not a template string — since "the name" is exactly what identifies which row this is; theLinkcarriesafter:absolute after:inset-0, stretching an invisible pseudo-element over the whole<li>so the entire card is a tap target without the button being nested inside it. TheStatusBadgesits to the right of the name, right-aligned,shrink-0so it never gets squeezed by a long name. - Line 2: the
WaterButton("Watered today"), left-aligned, at least 44px tall,relative z-10so it sits above theLink's stretched pseudo-element and remains independently tappable.
There is no ChevronRight or other navigation affordance icon on the row: it cost 20px of the
tightest line in the product and conveyed nothing the link itself does not already provide via its
accessible name and the row's obvious tap affordance.
Worked markup, one row per status (the StatusBadge and PlantRow component contracts are in
§16.2; this shows the resulting DOM shape only):
<!-- overdue -->
<li class="relative rounded-card border-l-4 border-l-status-overdue-text
bg-status-overdue-surface p-4 shadow-low">
<div class="flex items-start justify-between gap-2">
<a href="/plants/01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80"
class="after:absolute after:inset-0 min-w-0 text-md font-semibold text-text-primary
line-clamp-2">
Fiddle Leaf Fig
</a>
<span class="relative z-10 shrink-0 inline-flex items-center gap-1 rounded-control
border border-status-overdue-text bg-surface-raised px-2 py-1 text-xs
font-medium text-status-overdue-text">
<svg aria-hidden="true"><!-- AlertTriangle --></svg>
Overdue by 3 days
</span>
</div>
<button class="relative z-10 mt-3 min-h-11 rounded-control bg-accent text-text-inverse px-3
text-sm font-medium">
Watered today<span class="sr-only">, Fiddle Leaf Fig</span>
</button>
</li>
<!-- due_today -->
<li class="relative rounded-card border-l-4 border-l-status-due-today-text
bg-status-due-today-surface p-4 shadow-low">
<div class="flex items-start justify-between gap-2">
<a href="/plants/01917f2c-9b4c-7d2f-a05e-3c7f6b2da91"
class="after:absolute after:inset-0 min-w-0 text-md font-semibold text-text-primary
line-clamp-2">
Snake Plant
</a>
<span class="relative z-10 shrink-0 inline-flex items-center gap-1 rounded-control
border border-status-due-today-text bg-surface-raised px-2 py-1 text-xs
font-medium text-status-due-today-text">
<svg aria-hidden="true"><!-- Droplet --></svg>
Due today
</span>
</div>
<button class="relative z-10 mt-3 min-h-11 rounded-control bg-accent text-text-inverse px-3
text-sm font-medium">
Watered today<span class="sr-only">, Snake Plant</span>
</button>
</li>
<!-- due_soon -->
<li class="relative rounded-card border-l-4 border-l-status-due-soon-text
bg-status-due-soon-surface p-4 shadow-low">
<div class="flex items-start justify-between gap-2">
<a href="/plants/01917f2c-ac5d-7e30-b16f-4d8a7c3eba02"
class="after:absolute after:inset-0 min-w-0 text-md font-semibold text-text-primary
line-clamp-2">
Pothos
</a>
<span class="relative z-10 shrink-0 inline-flex items-center gap-1 rounded-control
border border-status-due-soon-text bg-surface-raised px-2 py-1 text-xs
font-medium text-status-due-soon-text">
<svg aria-hidden="true"><!-- Clock --></svg>
Due tomorrow
</span>
</div>
<button class="relative z-10 mt-3 min-h-11 rounded-control border border-border-strong
text-text-primary px-3 text-sm font-medium">
Watered today<span class="sr-only">, Pothos</span>
</button>
</li>
<!-- upcoming -->
<li class="relative rounded-card border-l-4 border-l-status-upcoming-text
bg-status-upcoming-surface p-4 shadow-low">
<div class="flex items-start justify-between gap-2">
<a href="/plants/01917f2c-bd6e-7f41-c27a-5e9b8d4fcb13"
class="after:absolute after:inset-0 min-w-0 text-md font-semibold text-text-primary
line-clamp-2">
ZZ Plant
</a>
<span class="relative z-10 shrink-0 inline-flex items-center gap-1 rounded-control
border border-status-upcoming-text bg-surface-raised px-2 py-1 text-xs
font-medium text-status-upcoming-text">
<svg aria-hidden="true"><!-- Check --></svg>
Due in 9 days
</span>
</div>
<button class="relative z-10 mt-3 min-h-11 rounded-control border border-border-strong
text-text-primary px-3 text-sm font-medium">
Watered today<span class="sr-only">, ZZ Plant</span>
</button>
</li>Note the button variant differs by urgency: overdue and due_today rows use the filled primary
button (bg-accent) to make the action visually urgent; due_soon and upcoming rows use the
secondary (outlined) button, since watering is not yet necessary and a filled button on every row
would defeat the "one glance" hierarchy in principle 1. The button's accessible name is its visible
text ("Watered today") plus a visually-hidden , {plantName} suffix — never a bare aria-label —
so the accessible name always contains the visible label (SC 2.5.3) while still uniquely
identifying which row's button was activated.
15.7 Component visual specs #
15.7.1 Buttons #
Base classes shared by all variants: inline-flex items-center justify-center gap-2 min-h-11 rounded-control text-sm font-medium px-4 transition-colors focus-visible:outline focus-visible: outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring disabled:cursor-not- allowed disabled:opacity-50.
| Variant | Default | Hover | Active | Focus-visible | Disabled | Loading |
|---|---|---|---|---|---|---|
| Primary | bg-accent text-text-inverse |
bg-accent-hover |
bg-accent-hover scale-[0.98] |
outline per base classes, ring colour --color-focus-ring |
opacity-50 cursor-not-allowed, no hover/active change |
Text replaced by a 16px spinner (Loader2 from lucide-react, animate-spin, aria-hidden) plus visually-hidden "Loading" text; button width does not change (min-width reserved) |
| Secondary | border border-border-strong text-text-primary bg-transparent |
bg-surface-sunken |
bg-surface-sunken scale-[0.98] |
same outline | opacity-50 |
Same spinner treatment on text-text-primary |
| Ghost | text-text-primary bg-transparent |
bg-surface-sunken |
bg-surface-sunken scale-[0.98] |
same outline | opacity-50 |
Same spinner treatment |
| Destructive | border border-danger text-danger bg-transparent |
bg-danger-surface |
bg-danger-surface scale-[0.98] |
same outline, ring colour still --color-focus-ring (not danger — focus indication stays consistent app-wide) |
opacity-50 |
Same spinner treatment on text-danger |
scale-[0.98] active-state transforms are skipped entirely under prefers-reduced-motion: reduce
(§15.9) via a motion-safe:active:scale-[0.98] Tailwind variant. The pending-state spinner is a
motion effect too: under prefers-reduced-motion: reduce, a button in the Loading state renders
its pending text label instead of the spinner glyph (plantRow.waterPending — "Watering…" —
for the water button, form.submit.pending — "Saving…" — for form submits, §15.11) with no icon at
all, rather than a frozen, motionless spinner that conveys no progress.
15.7.2 Inputs and textarea #
text input and textarea share: w-full rounded-control border border-border-strong bg-surface- raised px-3 py-2 text-base text-text-primary placeholder:text-text-secondary focus-visible: outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-focus-ring disabled:bg-surface-sunken disabled:text-text-disabled. An invalid field (touched and failing
validation) adds border-danger and is paired with the inline error text described in §16.6
(never colour alone — the error text and an aria-invalid="true" are always present too).
textarea additionally sets min-h-[96px] resize-y.
15.7.3 Number stepper (IntervalField) #
A composite control: a ghost icon button (Minus, 44×44px) — numeric <input type="text" inputmode="numeric" pattern="[0-9]*"> (centred text, w-16, no native spinner) — a ghost icon
button (Plus, 44×44px), all inside a single bordered group (rounded-control border border- border-strong on the wrapping <div>, inner buttons borderless with a 1px divider between
segments using divide-x divide-border-default). Decrementing below 1 or incrementing above 365
uses aria-disabled="true" with a no-op click handler at the boundary, not the native
disabled attribute — a natively disabled button is removed from the tab order, which would throw
focus to <body> for a keyboard user pressing Minus down to the 1-day floor. The boundary press
still pushes a polite live-region announcement (form.interval.min — "Minimum is 1 day." —
or form.interval.max — "Maximum is 365 days." — §15.11) so the no-op is not silent.
15.7.4 Toggle (ThemeToggle) #
Not a checkbox toggle switch — it is a single icon button that cycles light → dark → system (see
§15.8 for the cycle order and semantics). 44×44px, ghost variant, icon swaps between Sun, Moon,
and MonitorSmartphone depending on the current effective override. Its aria-label states the
current state, not the next action — "Theme: light. Change theme." / "Theme: dark. Change theme." / "Theme: system. Change theme." (§15.11 theme.current.light / .dark / .system) —
so a screen-reader user can determine which of the three states is active, which a label naming
only the next action cannot convey (SC 4.1.2). Activating the control announces the new theme name
into the polite live region (§19).
15.7.5 Badge (StatusBadge) #
inline-flex items-center gap-1 rounded-control border px-2 py-1 text-xs font-medium plus the
per-status border/background/text pair from §15.6 (background --color-surface-raised, border and
text in the status colour). Icon size 14px (size-3.5), aria-hidden="true"; the badge's text
node (formatRelativeDueLabel's output, Section 8.4) is the accessible name and the row's only
status label (never an icon-only badge).
15.7.12 Interval field label and hint #
IntervalField's numeric input has a single visually-hidden <label> reading "Water every … days"
(the full phrase, not just form.interval.label), so its accessible name states the unit even
though the unit text sits visually to the right of the control (§17.2). An explicit hint element,
id="interval-hint", text form.interval.hint ("1 to 365 days", §15.11), is referenced by the
input's aria-describedby alongside the field's error id when present.
15.7.6 Card (PlantRow container, detail status block) #
rounded-card border border-border-default bg-surface-raised shadow-low p-4, with the left-border
override per status in §15.6 replacing the plain border on the left edge only
(border-l-4 border-l-{status-color}).
15.7.7 Toast #
flex items-start gap-3 rounded-card bg-surface-raised shadow-high border border-border-default p-4 w-full sm:w-96. Layout and stacking are defined in §16.7.
15.7.8 Modal (add/edit at md+) #
Backdrop: fixed inset-0 bg-black/40. Panel: rounded-card bg-surface-raised shadow-high p-6 w-[480px] max-h-[85vh] overflow-y-auto, centred with fixed inset-0 flex items-center justify-center on the backdrop wrapper. Enter/exit transition per §15.9.
15.7.9 Confirm dialog (ConfirmDialog) #
Same panel treatment as the modal but narrower: w-[400px] at sm+, full-width minus 32px margin
at base. Contains a title (text-lg font-bold), a body (text-base text-text-secondary), and a
two-button row (flex gap-3 justify-end at sm+, flex-col-reverse gap-2 at base so the primary
action is the bottom-most/thumb-nearest on mobile while remaining visually secondary — see
destructive-action focus rule in §17.5).
15.7.10 Skeleton #
bg-surface-sunken rounded-control relative overflow-hidden with a shimmer pseudo-element
(::after, a translucent gradient sweeping left to right, animate-[shimmer_1.6s_ease-in-out_ infinite]), disabled (replaced by a static bg-surface-sunken with no animation) under
prefers-reduced-motion: reduce. Variants: SkeletonRow (mirrors PlantRow's height and layout),
SkeletonDetail (mirrors the detail screen's status block and meta rows), SkeletonText (a single
line, configurable width, for the form skeleton).
15.7.11 Empty-state accent #
No custom illustration asset exists (photos and bespoke artwork are out of scope per Section 3 and
add a build asset for no accessibility or comprehension benefit over an icon already in the
product's icon set). Each empty state uses a lucide-react icon at 48×48px and 40% opacity —
Sprout for "no plants yet," CheckCircle2 for "all caught up" (needs-water filter, nothing
matches), SearchX for "no search results" (§15.10, §17.1) — aria-hidden="true"; the
accompanying heading/body text carries the meaning (§17.1, §15.11).
15.8 Dark mode #
Default: the app follows prefers-color-scheme with no user action required. A manual override is
available via ThemeToggle (§15.7.4), cycling Light → Dark → System on each tap (System means
"follow prefers-color-scheme" and is the default state before any override is set). The current
choice persists to localStorage key hpt.theme with value "light", "dark", or "system".
The resolved theme is applied as data-theme="light" or data-theme="dark" on <html>, matching
the tokens.css selectors in §15.2.3. This attribute must be set before first paint to avoid a
flash of the wrong theme. apps/web/index.html includes this inline script directly in <head>,
before the stylesheet link:
<script>
(function () {
try {
var stored = localStorage.getItem("hpt.theme");
var effective =
stored === "light" || stored === "dark"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-theme", effective);
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>This exact inline script (byte-for-byte, including whitespace) is what the CSP hash in Section
20.4 covers under script-src; changing this script requires regenerating that hash. When hpt. theme is "system" or unset, ThemeToggle and the app also attach a matchMedia("(prefers-color- scheme: dark)") change listener (mounted in App) that re-applies data-theme live if the OS
theme changes while the tab is open, without a reload.
15.9 Motion #
Exactly three animations exist in the product; nothing else moves.
| Animation | Duration | Easing | Trigger |
|---|---|---|---|
| Toast slide-in | 150ms (--duration-toast) |
--easing-standard |
A toast entering the ToastRegion stack |
| Modal fade + scale | 120ms (--duration-modal) |
--easing-standard |
/plants/new, /plants/:plantId/edit at md+, and ConfirmDialog opening/closing (backdrop fades opacity 0→1, panel scales 0.96→1 and fades) |
| Row status cross-fade | 200ms (--duration-status) |
--easing-standard |
A PlantRow's status changes as a direct result of a user-initiated event (e.g. the optimistic-to-server-confirmed swap immediately after tapping "Watered today," or a midnight-rollover refresh's result being adopted at the next user-initiated re-render, §16.5) — the surface tint, border colour, and badge cross-fade rather than snapping. A background refetch never triggers this on its own; per principle 3 (§15.1), nothing moves unless the user moved it. |
Under prefers-reduced-motion: reduce, all three durations collapse to 0ms (set once, globally,
in the tokens.css media query shown in §15.2.3) — the end state still changes, it simply
appears instantly with no in-between frames. No other visual property (opacity fade on hover,
skeleton shimmer per §15.7.10) is exempt from this rule; skeleton shimmer is separately disabled
under the same media query.
15.10 Iconography #
All icons come from lucide-react, imported individually (import { Droplet } from "lucide- react") — never the full library — to keep the bundle small. Default size 20px (size-5) unless
noted. Every icon below is either paired with adjacent visible text or carries an explicit aria- label when it is the sole content of a control.
| Icon | Used for | Paired label |
|---|---|---|
Droplet |
Due-today status, the "Watered today" button's leading icon | Visible text ("Due today", "Watered today") |
AlertTriangle |
Overdue status | Visible text ("Overdue") |
Clock |
Due-soon status | Visible text ("Due soon") |
Check |
Upcoming status; toast success accent; form submit confirmation | Visible text ("Upcoming", toast message) |
Plus |
Add-plant FAB and header button; stepper increment | aria-label="Add plant" on the FAB; visible text on the header button; aria-label="Increase interval" on the stepper |
Minus |
Stepper decrement | aria-label="Decrease interval" |
ChevronLeft |
Back navigation on detail/edit headers | aria-hidden="true" (paired with visible "Back" text) |
X |
Dismiss a toast; close a modal | aria-label="Dismiss" / aria-label="Close" |
Search |
ListControls search field leading icon |
aria-hidden="true" (input has its own label) |
SlidersHorizontal |
ListControls sort/filter disclosure trigger on narrow viewports |
Visible text ("Sort & filter") |
Sun / Moon / MonitorSmartphone |
ThemeToggle states |
aria-label states the current theme, §15.7.4 (e.g. aria-label="Theme: dark. Change theme.") |
Loader2 |
Loading spinner inside buttons (animate-spin) |
aria-hidden="true" plus visually-hidden "Loading" text |
AlertCircle |
Inline form field errors, error-state banners, the duplicate-name warning | Adjacent visible error text |
Trash2 |
Delete-plant and delete-history-entry actions | Visible text ("Delete plant"); aria-label="Delete watering entry for {date}" for the icon-only history row action |
Pencil |
Edit-plant action | Visible text ("Edit plant") |
Undo2 |
Undo action inside toasts | Visible text ("Undo") |
Settings |
Header settings button, navigates to /settings |
aria-label="Settings" |
LogOut |
Header "Lock" button (rendered only when the access code is enabled) | Visible text ("Lock") |
Sprout |
"No plants yet" empty state (§15.7.11) | aria-hidden="true" (heading/body text carries the meaning) |
CheckCircle2 |
"All caught up" empty state (needs-water filter matches nothing, §15.7.11) | aria-hidden="true" |
SearchX |
"No search results" empty state (§15.7.11) | aria-hidden="true" |
WifiOff |
Offline banner icon (§18.6) | aria-hidden="true" (adjacent visible banner text carries the meaning) |
15.11 Copy and tone #
Voice rules:
- Plain, calm, literal. No exclamation marks anywhere in the product.
- Second person ("your plants"), never third person about the user.
- No anthropomorphising the plants — a plant is never described as "thirsty," "happy," or "sad." State facts: it is due, or it is overdue by a number of days.
- Sentences are short. Error and empty-state copy explains what happened and, where relevant, what to do next — never just "Error."
- No marketing language anywhere in the UI ("supercharge," "effortless," "seamless" are banned words).
Canonical string table. This is the single canonical catalogue of every UI-visible string in the
product — the complete set, not a duplicate of any other list. Every string is keyed by a stable
identifier used in code (apps/web/src/lib/strings.ts exports these as a single const STRINGS
object; components never inline literal copy). {placeholder} marks interpolated values. Where a
condition is presented both by the server's error.message (Section 9.7, the API's own catalogue)
and by this table, the client always renders the string from this table for any error whose
details[].path maps to a rendered field or whose code has a row below — the server text is
rendered only when no row here covers the condition. The four due/overdue status label strings
("Overdue by {n} days," "Due today," "Due tomorrow," "Due in {n} days," and their singular/plural
handling) are canonical in Section 8.4 and are consumed here via the shared
formatRelativeDueLabel() function, not redefined.
| Key | English text |
|---|---|
app.title |
Houseplant Watering Tracker |
app.titleShort |
Plant Watering |
list.title |
Your plants |
nav.addPlant |
Add plant |
nav.back |
Back |
nav.settings |
Settings |
nav.lock |
Lock |
theme.current.light |
Theme: light. Change theme. |
theme.current.dark |
Theme: dark. Change theme. |
theme.current.system |
Theme: system. Change theme. |
summary.zeroDue |
Nothing needs water. |
summary.oneDue |
1 plant needs water. |
summary.manyDue |
{count} plants need water. |
controls.sortLabel |
Sort |
controls.sort.urgency |
Most urgent |
controls.sort.name |
Name (A–Z) |
controls.sort.recentlyWatered |
Recently watered |
controls.filterLabel |
Show |
controls.filter.all |
All plants |
controls.filter.needsWater |
Needs water |
controls.sortFilter.trigger |
Sort & filter |
controls.search.placeholder |
Search plants |
controls.search.clear |
Clear search |
controls.resultCount |
{count} plants shown. |
emptyState.noPlants.title |
No plants yet |
emptyState.noPlants.body |
Add your first plant to start tracking its watering schedule. |
emptyState.noPlants.cta |
Add your first plant |
emptyState.noResults.title |
No plants match "{query}" |
emptyState.noResults.body |
Try a different name. |
emptyState.noResults.cta |
Clear search |
emptyState.noDueMatches.title |
All caught up |
emptyState.noDueMatches.body |
Nothing on your list needs water. |
plantRow.water |
Watered today |
plantRow.waterPending |
Watering… |
plantRow.repairedNotice |
This plant's last-watered date was missing and has been estimated. |
form.add.title |
Add plant |
form.edit.title |
Edit plant |
form.name.label |
Name |
form.name.placeholder |
e.g. Fiddle Leaf Fig |
form.name.duplicateWarning |
You already have a plant named "{name}". |
form.interval.label |
Water every |
form.interval.unit |
days |
form.interval.quickPick |
{n} days |
form.interval.hint |
1 to 365 days |
form.interval.min |
Minimum is 1 day. |
form.interval.max |
Maximum is 365 days. |
form.lastWatered.label |
Last watered |
form.lastWatered.today |
Today |
form.lastWatered.yesterday |
Yesterday |
form.notes.label |
Notes (optional) |
form.notes.placeholder |
Location, care tips, anything worth remembering. |
form.notes.counter |
{count} / 2000 |
form.submit.add |
Add plant |
form.submit.edit |
Save changes |
form.submit.pending |
Saving… |
form.cancel |
Cancel |
form.unsavedChanges.title |
Discard changes? |
form.unsavedChanges.body |
You have unsaved changes. Leaving now will lose them. |
form.unsavedChanges.confirm |
Discard |
form.unsavedChanges.cancel |
Keep editing |
form.error.nameRequired |
Enter a name. |
form.error.nameTooLong |
Name must be 60 characters or fewer. |
form.error.intervalRequired |
Enter a number of days. |
form.error.intervalRange |
Must be between 1 and 365 days. |
form.error.dateFuture |
Date can't be in the future. |
form.error.dateInvalid |
Enter a valid date. |
form.error.notesTooLong |
Notes must be 2000 characters or fewer. |
form.error.fallback |
Check the highlighted fields and try again. |
detail.nextWatering |
Next watering |
detail.lastWatered |
Last watered |
detail.notesEmpty |
No notes yet. |
detail.editPlant |
Edit plant |
detail.deletePlant |
Delete plant |
detail.history.title |
Watering history |
detail.history.empty |
No watering history yet. |
detail.history.showMore |
Show more |
detail.history.loaded |
Showing {loaded} of {total} watering entries. |
detail.history.delete |
Delete watering entry for {date} |
confirm.deletePlant.title |
Delete {plantName}? |
confirm.deletePlant.body |
This removes {plantName} and its watering history from your list. You can undo it for 10 seconds. |
confirm.deletePlant.confirm |
Delete |
confirm.deletePlant.cancel |
Cancel |
confirm.deleteHistory.title |
Delete this watering entry? |
confirm.deleteHistory.body |
This can't be undone. The plant's last-watered date will be recalculated. |
confirm.deleteHistory.confirm |
Delete entry |
confirm.deleteHistory.cancel |
Cancel |
toast.plantAdded |
{plantName} added |
toast.plantUpdated |
{plantName} updated |
toast.plantDeleted |
{plantName} deleted |
toast.action.undo |
Undo |
toast.plantRestored |
{plantName} restored |
toast.watered |
{plantName} watered |
toast.alreadyWatered |
{plantName} was already watered today |
toast.wateringUndone |
Watering undone |
toast.historyEntryDeleted |
Watering entry deleted |
toast.historyEntryMissing |
That watering entry no longer exists. |
toast.plantCapReached |
You've reached the limit of 500 plants. Delete a plant before adding another. |
toast.error.generic |
Something went wrong. Try again. |
toast.error.rateLimited |
Too many requests. Please wait a moment and try again. |
toast.error.offline |
You're offline. That didn't save — try again when you're back online. |
offline.banner |
You're offline. Some actions are unavailable until you're back online. |
offline.blocked |
You're offline — this can't be saved right now. |
error.sessionExpired |
Your session expired. Unlock to save your changes. |
error.sessionExpired.action |
Unlock |
error.conflict.discardAndReload |
Discard my changes and reload |
error.malformedJson |
Something went wrong sending your changes. Please try again. |
error.payloadTooLarge |
That request was too large. |
error.notFound |
This plant no longer exists. It may have been deleted. |
error.limitExceeded |
You've reached the limit of 500 plants. Delete a plant before adding another. |
unlock.title |
Enter access code |
unlock.field.label |
Access code |
unlock.submit |
Unlock |
unlock.error.invalid |
That code didn't work. Try again. |
unlock.error.lockedOut |
Too many attempts. Try again in {minutes} minutes. |
unlock.error.lockedOutOne |
Too many attempts. Try again in 1 minute. |
settings.title |
Settings |
settings.export.heading |
Export data |
settings.export.body |
Download every plant and its watering history as a JSON file. |
settings.export.button |
Export data |
settings.import.heading |
Import data |
settings.import.body |
Choose a previously exported JSON file to restore or merge into this collection. |
settings.import.fileLabel |
File |
settings.import.modeLabel |
What should happen to your current data? |
settings.import.mode.replace |
Replace everything |
settings.import.mode.merge |
Merge with what's here |
settings.import.button |
Import data |
settings.import.confirmReplace.title |
Replace all data? |
settings.import.confirmReplace.body |
This deletes every plant and watering entry currently stored and replaces them with the contents of the imported file. This can't be undone. |
settings.import.confirmReplace.confirm |
Replace data |
settings.import.confirmReplace.cancel |
Cancel |
settings.import.success |
Import complete. {plantCount} plants imported. |
notFound.title |
Page not found |
notFound.body |
The page you're looking for doesn't exist. |
notFound.cta |
Go to plant list |
error.boundary.title |
Something went wrong |
error.boundary.body |
This part of the app hit an error. Reloading usually fixes it. |
error.boundary.reload |
Reload |
error.chunkLoad.title |
Update available |
error.chunkLoad.body |
This app was updated. Reload to get the latest version. |
error.chunkLoad.reload |
Reload now |
dialog.close |
Close |
toast.dismiss |
Dismiss |
a11y.editPlant |
Edit plant: {plantName} |
a11y.deletePlant |
Delete plant: {plantName} |
a11y.searchPlants |
Search plants |
a11y.increaseInterval |
Increase interval |
a11y.decreaseInterval |
Decrease interval |
error.reference |
Reference: {requestId} |
error.validation.fallback |
Check the highlighted fields and try again. |
error.wateringNotFound |
That watering entry no longer exists. |
error.methodNotAllowed |
This action isn't available. |
error.conflict |
This plant was changed elsewhere. Reload to see the latest version before editing. |
error.payloadTooLarge.import |
That file is too large. The import limit is 5 MB. |
error.unsupportedMediaType |
That file type isn't supported. |
error.limitExceeded.import |
Importing this file would exceed the 500-plant limit. Remove some plants from the file and try again. |
error.internal |
Something went wrong on our end. Please try again. |
error.serviceUnavailable |
The app is temporarily unavailable. Please try again in a moment. |
error.network |
Couldn't reach the server. Check your connection and try again. |
error.timeout |
That's taking longer than expected. Please try again. |
action.tryAgain |
Try again |
action.backToList |
Back to plant list |
action.viewPlantList |
View plant list |
toast.rateLimitReady |
You can try again now. |
toast.plantDeletedElsewhere |
That plant no longer exists. |
emptyState.noDueMatches.cta |
Clear filter |
No component may render a string literal for user-facing text outside this table; new copy needs are handled by adding a key here, not by inlining text at the call site. This table absorbs and supersedes any string previously duplicated elsewhere for the same condition (form field errors, empty states, toasts, and the client-rendered presentation of API error codes) — those other locations reference a key from this table instead of restating text.
16. Component Architecture and State Management #
16.1 Component tree #
App apps/web/src/App.tsx
└─ RouterProvider (React Router 7 data router) apps/web/src/routes/router.tsx
└─ AppShell apps/web/src/components/layout/AppShell.tsx
├─ ScrollRestoration (React Router 7, mounted once) —
├─ Header apps/web/src/components/layout/Header.tsx
│ ├─ ThemeToggle apps/web/src/components/layout/ThemeToggle.tsx
│ ├─ LockButton (header, conditional on accessCodeEnabled) apps/web/src/components/layout/LockButton.tsx
│ ├─ SettingsButton (header) apps/web/src/components/layout/SettingsButton.tsx
│ └─ AddPlantButton (header variant, md+ only) apps/web/src/components/layout/AddPlantButton.tsx
├─ ErrorBoundary (root) apps/web/src/components/shared/ErrorBoundary.tsx
│ └─ <Outlet /> (Suspense-wrapped, §14.7)
│ ├─ PlantListRoute apps/web/src/routes/PlantListRoute.tsx
│ │ ├─ SummaryBar apps/web/src/components/plant-list/SummaryBar.tsx
│ │ ├─ ListControls apps/web/src/components/plant-list/ListControls.tsx
│ │ ├─ PlantList apps/web/src/components/plant-list/PlantList.tsx
│ │ │ └─ PlantRow (×n) apps/web/src/components/plant-list/PlantRow.tsx
│ │ │ ├─ StatusBadge apps/web/src/components/shared/StatusBadge.tsx
│ │ │ └─ WaterButton apps/web/src/components/shared/WaterButton.tsx
│ │ ├─ EmptyState (conditional) apps/web/src/components/shared/EmptyState.tsx
│ │ ├─ SkeletonRow (×n, conditional, §15.7.10) apps/web/src/components/shared/Skeleton.tsx
│ │ └─ AddPlantButton (FAB variant, base/sm only) apps/web/src/components/layout/AddPlantButton.tsx
│ ├─ PlantDetailRoute apps/web/src/routes/PlantDetailRoute.tsx
│ │ ├─ PlantMeta apps/web/src/components/plant-detail/PlantMeta.tsx
│ │ │ └─ StatusBadge
│ │ ├─ NotesBlock apps/web/src/components/plant-detail/NotesBlock.tsx
│ │ ├─ WaterButton
│ │ ├─ WateringHistoryList apps/web/src/components/plant-detail/WateringHistoryList.tsx
│ │ │ └─ HistoryRow (×n) apps/web/src/components/plant-detail/HistoryRow.tsx
│ │ ├─ ConfirmDialog (conditional: delete plant, delete history row) apps/web/src/components/shared/ConfirmDialog.tsx
│ │ └─ SkeletonDetail (conditional) apps/web/src/components/shared/Skeleton.tsx
│ ├─ PlantFormRoute (mode: create | edit) apps/web/src/routes/PlantFormRoute.tsx
│ │ └─ PlantForm apps/web/src/components/plant-form/PlantForm.tsx
│ │ ├─ IntervalField apps/web/src/components/plant-form/IntervalField.tsx
│ │ ├─ LastWateredField apps/web/src/components/plant-form/LastWateredField.tsx
│ │ └─ ConfirmDialog (unsaved changes, edit-mode delete)
│ ├─ SettingsRoute apps/web/src/routes/SettingsRoute.tsx
│ │ └─ ImportConfirmDialog (ConfirmDialog, conditional: replace-mode import)
│ ├─ UnlockRoute apps/web/src/routes/UnlockRoute.tsx
│ └─ NotFoundRoute apps/web/src/routes/NotFoundRoute.tsx
│ (each route element also wrapped individually by RouteError via `errorElement`, §16.9)
└─ ToastRegion apps/web/src/components/toast/ToastRegion.tsx
└─ Toast (×n, max 3 visible) apps/web/src/components/toast/Toast.tsx<ScrollRestoration /> (React Router 7's built-in component) is mounted once at the AppShell
level so that navigating back to / after viewing or editing a plant restores the list's prior
scroll offset — React Router's data router does not do this automatically without it.
RouteError (apps/web/src/components/shared/RouteError.tsx) is not shown nested above because it
is not a child in the render tree — it is the errorElement React Router substitutes in place of a
route's element when that route's loader/render throws (§16.9).
16.2 Component contracts #
For each component: purpose, props, internal state, events emitted, accessibility notes. Types use
the shared DTOs from packages/shared/src/types (Plant, Watering, PlantStatus) wherever a
prop's shape matches the API DTO exactly; canonical field definitions are in Section 7.
App #
Purpose: root React tree; mounts RouterProvider, the QueryClientProvider (with the cache policy
from §16.5), the data-theme matchMedia listener (§15.8), and the root ErrorBoundary.
Props: none. Internal state: none (delegates to children). Events: none. A11y: renders no visible
UI of its own.
AppShell #
Purpose: persistent layout — header, main landmark, toast region (§14.5).
Props: { children: React.ReactNode } (the routed <Outlet />). Internal state: none. Events:
none. A11y: renders <header>, <main> landmarks; a visually-hidden "Skip to main content" link
as the first focusable element, targeting #main-content (§19).
Header #
Purpose: app identity, theme control, Settings/Lock access, md+ add action.
Props: none (reads meta.accessCodeEnabled/meta.unlocked via useMeta() to decide whether to
render LockButton). Internal state: none. Events: none. A11y: <header> landmark; app name
rendered as visually a title but not an <h1> (the routed screen owns the page's <h1>, per §19's
heading-order rule).
ThemeToggle #
Purpose: cycles light/dark/system, persists to localStorage.
Props: none. Internal state: theme: "light" | "dark" | "system", initialized from localStorage
key hpt.theme (default "system"). Events: on click, advances the cycle, writes localStorage,
and pushes the new theme's name into the polite live region (§19). A11y: single <button> with
aria-label set to the current state (§15.11 theme.current.light / .dark / .system), so a
screen-reader user can determine which theme is active, not just what the next tap will do
(SC 4.1.2, §15.7.4).
SettingsButton #
Purpose: navigates to /settings.
Props: none. Internal state: none. Events: onPointerDown (hover-capable pointers only, §14.7)
prefetches the SettingsRoute chunk; onClick navigates. A11y: icon-only <button>,
aria-label="Settings" (nav.settings).
LockButton #
Purpose: ends the current unlock session; rendered only when meta.accessCodeEnabled is true.
Props: none. Internal state: none. Events: onClick calls DELETE /api/v1/session (Section 20.5)
then navigates to /unlock. A11y: ghost <button> with visible text nav.lock ("Lock").
AddPlantButton #
Purpose: navigates to /plants/new; renders as a FAB (base/sm) or header button (md+).
Props: { variant: "fab" | "header" }. Internal state: none. Events: onPointerDown triggers the
plant-forms chunk prefetch (§14.7); onClick navigates. A11y: FAB variant is icon-only with
aria-label="Add plant"; header variant shows the Plus icon plus visible "Add plant" text (no
aria-label needed since the accessible name comes from visible text).
PlantListRoute #
Purpose: the / screen container; composes SummaryBar, ListControls, PlantList/EmptyState,
AddPlantButton.
Props: none (route element). Internal state: none — reads usePlants(), useMeta(), and reads/
writes sort & filter via the useListPreferences() hook (wrapping the localStorage reads in
§14.4). Events: none directly (delegates to children). A11y: renders the page's single <h1>
(visually hidden — list.title, "Your plants" — the visible heading role is filled by the app name
in the header, but the screen-reader-only <h1> establishes document structure, per §19).
SortOrder and StatusFilter are defined once, here, and imported wherever needed:
// apps/web/src/lib/sort.ts
export type SortOrder = "urgency" | "name" | "recently-watered";
export type StatusFilter = "all" | "needs-water";
export const DEFAULT_SORT: SortOrder = "urgency";
export const DEFAULT_FILTER: StatusFilter = "all";SummaryBar #
Purpose: the single "what needs water" headline above the list.
Props: { dueCount: number }. Internal state: none. Events: none. A11y: tabindex="-1" so it can
receive programmatic focus as a fallback landing spot (§19), plus an aria-live="polite" region so
a count change after watering a plant is announced; text per §15.11 (summary.zeroDue /
summary.oneDue / summary.manyDue).
ListControls #
Purpose: sort selector, filter selector, search input.
Props: { sort: SortOrder; filter: StatusFilter; search: string; onSortChange: (s: SortOrder) => void; onFilterChange: (f: StatusFilter) => void; onSearchChange: (q: string) => void }. Internal
state: none beyond an uncontrolled debounce timer for the search input (150ms debounce before
calling onSearchChange, so filtering doesn't thrash on every keystroke on low-end devices).
Events: onSortChange, onFilterChange, onSearchChange bubble to PlantListRoute, which
persists sort/filter to localStorage and keeps search in memory. A11y: sort and filter are native
<select> elements (not custom listboxes, not a toggle) for built-in keyboard and screen-reader
support — the filter <select>'s own <label> ("Show", controls.filterLabel) is its accessible
name and its current value is exposed natively, with no additional aria-pressed; search is a
labelled <input type="search"> with a visible clear button once non-empty, gated on the active
plant count exceeding 10 per §12.5 — when the count falls to 10 or fewer, the query resets to empty
and focus moves to SummaryBar. A fifth, separate role="status" aria-live="polite" region beneath
ListControls announces controls.resultCount ("{count} plants shown.") after any sort, filter, or
debounced search change, suppressed on initial mount.
PlantList #
Purpose: renders the filtered/sorted collection of PlantRows, or SkeletonRows while loading, or
delegates to EmptyState.
Props: { plants: Plant[]; isLoading: boolean }. Internal state: none (pure render list). Events:
none. A11y: <ul> with each PlantRow as an <li>.
PlantRow #
Purpose: one plant's summary — name, status, water action, navigation to detail. Respecified in full
in §17.1/§15.6 as a two-line card.
Props: { plant: Plant; timezone: string } (the full Plant DTO including computed status,
daysUntilDue, daysOverdue, per Section 10.2; timezone is forwarded to WaterButton). Internal
state: none (delegates mutation state to WaterButton via useWaterPlant). Events: onPointerEnter
(hover-capable pointers only, §14.7) prefetches detail data; clicking the name/badge area navigates
to /plants/:plantId. A11y: the <li> contains a Link (stretched over the card via
after:absolute after:inset-0) covering the name and badge, whose accessible name is the plant name
itself, and, as an independent sibling — never nested inside the Link — the water <button>
with relative z-10. The two are separately focusable tab stops; no interactive element is ever
nested inside another (§19). Memoization strategy is specified in §16.10.
StatusBadge #
Purpose: the icon + colour + label status indicator, reused in PlantRow and PlantMeta; the
row's only status label (§15.6).
Props: { status: PlantStatus; hideTextFromA11y?: boolean } (the four-value enum from Section 8.3;
hideTextFromA11y defaults to false). Internal state: none. Events: none. A11y: the icon is
always aria-hidden; the badge's visible text — formatRelativeDueLabel's output (Section 8.4) —
is the accessible content by default, no extra aria-label. PlantMeta (§17.3) passes
hideTextFromA11y because its adjacent xl headline already states the identical text as a
heading — without it, a screen reader would announce the same sentence twice back to back; the
badge stays visually identical either way, only its accessible-text exposure changes.
WaterButton #
Purpose: the "Watered today" one-tap action.
Props: { plant: Plant; timezone: string; size: "row" | "detail" }. Internal state: none — all
pending/success/error state comes from the useWaterPlant(plant.id, timezone) mutation object.
Events: onClick calls the mutation (§16.4, §16.8). A11y: <button>, visible label plantRow.water
at rest followed by a visually-hidden , {plantName} span (never a bare aria-label, so the
accessible name always contains the visible label — SC 2.5.3), plantRow.waterPending (with
aria-busy="true") while the mutation is in flight; disabled while pending to prevent double-taps.
EmptyState #
Purpose: shared empty-state layout for "no plants at all," "no search results," and "nothing
matches the active filter."
Props: { variant: "no-plants" | "no-results" | "no-due-matches"; onPrimaryAction?: () => void }.
Internal state: none. Events: onPrimaryAction (navigate to /plants/new, or clear the search;
absent for no-due-matches, which has no action). A11y: the icon accent (§15.7.11) is aria-hidden;
heading is an <h2>.
PlantDetailRoute #
Purpose: the /plants/:plantId screen container.
Props: none (route element; reads plantId from useParams()). Internal state: isDeleteConfirmOpen: boolean, historyEntryPendingDelete: string | null (a watering id or null). Events: opens/closes
the two ConfirmDialog instances; on confirmed plant deletion, calls useDeletePlant() then
navigates to / (§14.3 rule 5). A11y: renders the page's visible <h1> (the plant's name).
PlantMeta #
Purpose: the large status block — status headline, next-watering date, last-watered date.
Props: { plant: Plant; timezone: string } (timezone from useMeta(), needed to format dates
per §17.3). Internal state: none. Events: none. A11y: dates are wrapped in <time dateTime="...">
with the ISO value and the human-formatted text as content.
NotesBlock #
Purpose: displays the plant's notes, preserving line breaks, or the empty-notes message.
Props: { notes: string | null }. Internal state: none. Events: none. A11y: rendered as a <p>
with white-space: pre-wrap (never dangerouslySetInnerHTML — notes are always text, per Section
20.6's output-escaping requirement); when notes is null, renders detail.notesEmpty in text- text-secondary italic styling.
WateringHistoryList #
Purpose: paginated list of HistoryRows with a "Show more" control.
Props: { plantId: string }. Internal state: an offset: number driving the useWateringHistory
query key (§16.4), plus a useState<Watering[]> accumulator: each successful page fetch is appended
to this local array rather than replacing it, since each {limit, offset} combination is a
separate, independent query-cache entry (§16.4) with no built-in accumulation. Events: onShowMore
increments offset by the page limit (20), triggering the next page fetch, whose result is appended
to the accumulator. A11y: <ul> of HistoryRow <li>s; "Show more" is a standard button, not
infinite scroll (predictable focus management, testable end state); after a successful "Show more"
fetch, focus stays on the button (or moves to the detail.history.title <h2>, tabindex="-1",
once the button unmounts because every entry has loaded) and a polite live region announces
detail.history.loaded ("Showing {loaded} of {total} watering entries.").
HistoryRow #
Purpose: one watering-history entry with its date and a delete action.
Props: { watering: Watering; onRequestDelete: (wateringId: string) => void }. Internal state:
none. Events: onRequestDelete opens the confirm dialog owned by PlantDetailRoute. A11y: date
in a <time> element; delete action is an icon button with aria-label set to detail.history. delete interpolated with the entry's date.
PlantFormRoute #
Purpose: hosts PlantForm for both add and edit; handles the modal/page chrome switch at md+.
Props: { mode: "create" | "edit" } (bound per route in the router config, §14.2). Internal state:
none beyond reading plantId via useParams() when mode === "edit". Events: none directly. A11y:
in modal presentation (md+), wraps PlantForm in a dialog with role="dialog", aria-modal="true",
aria-labelledby pointing at the form's heading, and a focus trap (§19).
PlantForm #
Purpose: the shared add/edit form — all field state, validation, and submission. Props:
interface PlantFormProps {
mode: "create" | "edit";
initialValues?: {
name: string;
wateringIntervalDays: number;
lastWateredOn: string; // YYYY-MM-DD
notes: string | null;
};
onSubmitSuccess: (plant: Plant) => void;
onCancel: () => void;
onRequestDelete?: () => void; // edit mode only
}Internal state: one useState per field (name, wateringIntervalDays, lastWateredOn, notes),
plus touched: Record<FieldName, boolean> and isDirty: boolean (derived by comparing current
values to initialValues, used for the unsaved-changes guard in §16.6). Events: field onChange/
onBlur update state and re-run field-level validation; onSubmit runs full-form validation, then
calls useCreatePlant() or useUpdatePlant(). A11y: a single <form> with each field's <label>
correctly associated (htmlFor), errors linked via aria-describedby, invalid fields marked
aria-invalid="true" (§19).
IntervalField #
Purpose: the interval stepper plus quick-pick chips (§15.7.3, §15.7.12, §17.2).
Props: { value: number; onChange: (n: number) => void; error?: string }. Internal state: the raw
text of the numeric input while being typed (to allow a momentarily empty field before blur commits
a clamp/validation), reconciled to value on blur. Events: onChange fires on stepper button
clicks (each boundary press announces form.interval.min/.max, §15.7.3), chip selection, and
committed text input. A11y: the numeric input carries a single visually-hidden <label> reading the
full sentence "Water every … days" (not just form.interval.label), and aria-describedby pointing
at interval-hint (form.interval.hint, "1 to 365 days") plus the field's error id when present —
the visible "Water every"/"days" text either side of the control is presentational only, since the
accessible name already states the whole thing (§15.7.12).
LastWateredField #
Purpose: the date input plus "Today"/"Yesterday" quick-picks (§17.2).
Props: { value: string; onChange: (isoDate: string) => void; error?: string; today: string }.
today (YYYY-MM-DD in APP_TIMEZONE, sourced from useMeta().data.today) is the only source of
"today" this field ever uses — never a value derived from new Date() in the browser (Section 8).
Internal state: none (fully controlled). Events: onChange on native date input change and on
quick-pick click. A11y: native <input type="date"> with a <label>, max={today},
min="1970-01-01"; quick-pick buttons are aria-pressed toggled to reflect whether their value
currently matches the field — "Today" sets today, "Yesterday" sets addDays(today, -1) (Section
8.4).
ConfirmDialog #
Purpose: shared destructive/unsaved-changes confirmation modal. Props:
interface ConfirmDialogProps {
isOpen: boolean;
title: string;
body: string;
confirmLabel: string;
cancelLabel: string;
variant: "destructive" | "neutral";
onConfirm: () => void;
onCancel: () => void;
}Internal state: none. Events: onConfirm, onCancel. A11y: role="alertdialog", aria-modal= "true", focus trap, initial focus on the cancel button (§17.5's "primary action is never
default-focused" rule), Escape triggers onCancel.
ToastRegion #
Purpose: renders and stacks active toasts; owns the toast queue.
Props: none (reads the useToast() store, §16.7). Internal state: none of its own — delegates to
the useToast hook's module-level queue. Events: none directly. A11y: renders two side-by-side
live regions, not one — <div aria-live="polite" aria-atomic="false"> for success/informational
toasts and <div role="alert"> for error toasts (§16.4/§16.8 route every toast.error.* push to
the alert region) — so a failure is announced immediately rather than queued behind whatever a
polite region is already reading (§16.7, §19).
Toast #
Purpose: one toast notification.
Props: { id: string; message: string; actionLabel?: string; onAction?: () => void; onDismiss: () => void; durationMs: number; kind: "info" | "error" }. kind routes the toast to the polite or alert
region in ToastRegion. Internal state: a pause flag while hovered/focused, or while the toast
contains focus (stops the auto-dismiss timer; §16.7). Events: onAction, onDismiss. A11y: dismiss
button aria-label="Dismiss" (toast.dismiss) is present on every toast regardless of
duration or kind; action button (e.g. Undo) is a normal focusable button within the toast and, when
the action was produced by a keyboard-activated control, receives focus on mount (§16.7).
ErrorBoundary #
Purpose: catches render/lifecycle errors; one instance at the app root, one per route element via
errorElement (technically RouteError fills the route-level role — see §16.9 for the split).
Props: { children: React.ReactNode }. Internal state: hasError: boolean, error: Error | null
(class component, using componentDidCatch/getDerivedStateFromError — React 19 does not remove
the need for a class-based boundary). Events: "Reload" button calls window.location.reload().
A11y: renders error.boundary.title/body/reload in a centred message, focus moved to the
heading on mount (§19).
RouteError #
Purpose: the errorElement for each route; distinguishes a 404 (renders NotFoundRoute's content)
from a chunk-load failure (§16.9) from any other thrown error (renders the same message as
ErrorBoundary).
Props: none (reads the error via React Router's useRouteError()). Internal state: none. Events:
none beyond the shared reload action. A11y: same as ErrorBoundary.
Skeleton* (SkeletonRow, SkeletonDetail, SkeletonText) #
Purpose: loading placeholders (§15.7.10, §18).
Props: SkeletonRow: { count?: number } (default 4); SkeletonDetail: {}; SkeletonText: { widthClass?: string; lines?: number } — widthClass is a Tailwind width utility (e.g. "w-32"), never a raw
style string, so no style-src-attr CSP exemption (Section 20.4) is needed to render it. Internal
state: none. Events: none. A11y: aria-hidden="true" — the
loading state's accessible announcement is handled once by the containing route (aria-busy="true"
on the region), not per skeleton node.
UnlockRoute #
Purpose: the /unlock screen (§17.6).
Props: none. Internal state: code: string, isSubmitting: boolean, error: string | null,
lockedOutUntil: number | null (derived from the Retry-After header on a 429). Events: onSubmit
calls POST /api/v1/session (Section 20.5) with the code; redirects to /, or to
location.state.from if the user was bounced here from a deep link, on success. A11y: single
labelled password-type field, error announced via aria-live="assertive" region (invalid
credentials are urgent enough to interrupt, unlike the polite toast region).
SettingsRoute #
Purpose: the /settings screen (§17.10) — export and import.
Props: none (route element). Internal state: selectedFile: File | null, mode: "replace" | "merge" | null (no default, per Section 24.5), isImportConfirmOpen: boolean. Events: the export
button triggers a same-tab navigation-free download of GET /api/v1/export; selecting a file and a
mode enables the import button; submitting with mode === "replace" opens ImportConfirmDialog
before calling POST /api/v1/import, submitting with mode === "merge" calls it directly. A11y:
renders the page's visible <h1> (settings.title).
NotFoundRoute #
Purpose: the * and plant-not-found screen (§17.7).
Props: none. Internal state: none. Events: none beyond the home link. A11y: <h1> notFound.title.
16.3 State ownership rules #
| State | Lives in | May write | May read |
|---|---|---|---|
Plant list, plant detail, watering history, meta |
TanStack Query cache | Mutation hooks only (§16.4) | Any component, via the query hooks only — never a raw fetch in a component |
Route (plantId, current screen) |
URL / React Router | navigate() calls in route/event handlers |
useParams(), useLocation() |
| Sort, filter | localStorage (hpt.sort, hpt.filter) via useListPreferences() |
ListControls (through PlantListRoute's callbacks) |
PlantListRoute, ListControls |
| Theme override | localStorage (hpt.theme) |
ThemeToggle |
ThemeToggle, the inline boot script (§15.8), App's matchMedia listener |
| Search text | In-memory (ListControls) |
ListControls |
ListControls, PlantListRoute (passed down as a derived filtered list) |
| Form field values | In-memory (PlantForm) |
PlantForm, IntervalField, LastWateredField |
PlantForm and its field children only |
| Dialog open/closed flags | In-memory (owning route component) | The owning route (PlantDetailRoute, PlantFormRoute) |
ConfirmDialog (via isOpen prop) |
| Toast queue | In-memory, module-level store inside useToast |
Any mutation hook's success/error handler, via useToast().push(...) |
ToastRegion |
| Chunk-reload guard | sessionStorage (hpt.chunkReload) |
RouteError |
RouteError |
No component fetches directly. Every read of server data goes through one of the hooks defined
in §16.4; a component never calls fetch() or the raw API client (apps/web/src/api/) itself. This
keeps cache invalidation, retry policy, and error shape handling in exactly one place per resource.
16.4 Query and mutation hooks #
Canonical query-key structure (fixed):
const queryKeys = {
plants: ["plants"] as const,
plant: (plantId: string) => ["plants", plantId] as const,
waterings: (plantId: string, params: { limit: number; offset: number }) =>
["plants", plantId, "waterings", params] as const,
meta: ["meta"] as const,
};All hooks live in apps/web/src/hooks/ and wrap the typed API client in apps/web/src/api/.
| Hook | Signature | Invalidates on success | Optimistic update | Rollback on error |
|---|---|---|---|---|
usePlants |
() => UseQueryResult<Plant[]> |
n/a (query) | n/a | n/a |
usePlant |
(plantId: string) => UseQueryResult<Plant> |
n/a | n/a | n/a |
useMeta |
() => UseQueryResult<{ timezone: string; today: string; plantCap: number; version?: string; accessCodeEnabled: boolean; unlocked: boolean }> (version is present only when the caller is unlocked or the access code is unset, Section 13.5.10) |
n/a | n/a | n/a |
useWateringHistory |
(plantId: string, params: { limit: number; offset: number }) => UseQueryResult<{ items: Watering[]; total: number }> |
n/a | n/a | n/a |
useCreatePlant |
() => UseMutationResult<Plant, ApiError, CreatePlantInput> |
['plants'] |
None (create has no prior row to patch; button shows pending state only) | n/a (no optimistic patch to undo) |
useUpdatePlant |
(plantId: string) => UseMutationResult<Plant, ApiError, UpdatePlantInput> |
['plants'], ['plants', plantId] |
Patches ['plants', plantId] and the matching entry in ['plants'] immediately with the submitted values |
Restores the previous cache snapshot (captured in onMutate) on error |
useDeletePlant |
(plantId: string) => UseMutationResult<void, ApiError, void> |
['plants'] |
Removes the plant from ['plants'] immediately |
Restores the previous ['plants'] snapshot on error |
useRestorePlant |
(plantId: string) => UseMutationResult<Plant, ApiError, void> |
['plants'] |
Re-inserts the plant into ['plants'] (from the snapshot captured at delete time, held by the Undo toast's closure) |
Leaves the plant absent; surfaces an error toast |
useWaterPlant |
(plantId: string, timezone: string) => UseMutationResult<{ plant: Plant; alreadyWateredToday: boolean; wateringId: string }, ApiError, void> |
['plants'], ['plants', plantId], ['plants', plantId, 'waterings', *] |
Patches the plant's lastWateredOn to today (via todayInTimeZone(new Date(), timezone), Section 8.4) and recomputes status/daysUntilDue/daysOverdue/nextDueOn client-side using computeScheduleView from packages/shared |
Restores the previous cache snapshot on error |
useDeleteWatering |
(plantId: string, wateringId: string) => UseMutationResult<void, ApiError, void> |
['plants'], ['plants', plantId], ['plants', plantId, 'waterings', *] (invalidating ['plants', plantId] pulls the server-recomputed lastWateredOn) |
Removes the entry from the cached history page immediately | Restores the previous history-page snapshot on error |
useWaterPlant in full — the most important interaction in the product:
// apps/web/src/hooks/useWaterPlant.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { waterPlant } from "../api/plants";
import { todayInTimeZone, computeScheduleView } from "@houseplant/shared";
import { queryKeys } from "../lib/queryKeys";
import type { Plant } from "@houseplant/shared";
interface WaterPlantContext {
previousPlant: Plant | undefined;
previousList: Plant[] | undefined;
}
export function useWaterPlant(plantId: string, timezone: string) {
const queryClient = useQueryClient();
return useMutation<
{ plant: Plant; alreadyWateredToday: boolean; wateringId: string },
ApiError,
void,
WaterPlantContext
>({
mutationFn: () => waterPlant(plantId),
retry: false,
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: queryKeys.plant(plantId) });
await queryClient.cancelQueries({ queryKey: queryKeys.plants });
const previousPlant = queryClient.getQueryData<Plant>(queryKeys.plant(plantId));
const previousList = queryClient.getQueryData<Plant[]>(queryKeys.plants);
// The only source of "today" on the client: derived from the injected timezone,
// never from a bare `new Date()`/`toISOString()` (Section 8).
const today = todayInTimeZone(new Date(), timezone);
const applyOptimisticWater = (plant: Plant): Plant => ({
...plant,
lastWateredOn: today,
...computeScheduleView(
{ lastWateredOn: today, wateringIntervalDays: plant.wateringIntervalDays },
today,
),
});
if (previousPlant) {
queryClient.setQueryData<Plant>(queryKeys.plant(plantId), applyOptimisticWater(previousPlant));
}
if (previousList) {
queryClient.setQueryData<Plant[]>(
queryKeys.plants,
previousList.map((p) => (p.id === plantId ? applyOptimisticWater(p) : p)),
);
}
return { previousPlant, previousList };
},
onSuccess: ({ plant, alreadyWateredToday, wateringId }, _vars, _context, wasKeyboardActivated) => {
queryClient.setQueryData(queryKeys.plant(plantId), plant);
queryClient.setQueryData<Plant[]>(queryKeys.plants, (list) =>
list?.map((p) => (p.id === plantId ? plant : p)),
);
queryClient.invalidateQueries({ queryKey: ["plants", plantId, "waterings"] });
if (alreadyWateredToday) {
// No new row was created — an Undo action here would delete a watering the user
// did not just create, so none is offered (Section 11.2).
toast.push({
kind: "info",
message: STRINGS["toast.alreadyWatered"](plant.name),
durationMs: 5000,
});
} else {
toast.push({
kind: "info",
message: STRINGS["toast.watered"](plant.name),
actionLabel: STRINGS["toast.action.undo"],
durationMs: 10000,
wasKeyboardActivated,
onAction: () => undoWatering(plant.id, wateringId, queryClient),
});
}
},
onError: (_err, _vars, context) => {
if (context?.previousPlant) {
queryClient.setQueryData(queryKeys.plant(plantId), context.previousPlant);
}
if (context?.previousList) {
queryClient.setQueryData(queryKeys.plants, context.previousList);
}
toast.push({ kind: "error", message: STRINGS["toast.error.generic"], durationMs: 5000 });
},
});
}alreadyWateredToday (Section 11.2's idempotency rule) suppresses the Undo toast, as above.
undoWatering calls useDeleteWatering(plant.id, wateringId, queryClient) using meta.wateringId
from the mutation response — the id of the watering row just created, or, on the idempotent path,
the id of the pre-existing row for (plantId, today). The Plant DTO (Section 10.2) carries no
watering-id field; wateringId only ever exists in this response's meta.
wasKeyboardActivated is event.detail === 0, captured by WaterButton's onClick handler at the
moment of the tap (a native click event has detail === 0 when dispatched by the keyboard —
Enter/Space — and a positive integer for a pointer click) and threaded through to the toast.push
call. ToastRegion/Toast use it to decide whether to move focus to the toast's action button on
mount (§16.7).
useDeleteWatering, useDeletePlant, useRestorePlant, and useCreatePlant/useUpdatePlant all
import their shared pure functions and types the same way — from "@houseplant/shared" — never via
a deep subpath such as @houseplant/shared/domain/schedule; the package exposes a single barrel
export (Section 6.5).
16.5 Cache policy #
Global QueryClient defaults, matching the canonical settings:
// apps/web/src/lib/queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 300_000,
refetchOnWindowFocus: true,
retry: 2,
retryDelay: (attempt) => Math.min(500 * 2 ** attempt, 4000) + Math.random() * 200,
},
mutations: {
retry: false,
},
},
});The refresh policy is fixed and defined here in full: the client re-fetches the plant list on
window focus, on visibilitychange to visible, on a scheduled timer firing at the next local
midnight in APP_TIMEZONE, on a 5-minute background poll while the tab is visible, and after any
successful mutation (via query invalidation, §16.4). Window focus is covered by
refetchOnWindowFocus: true. visibilitychange and the 5-minute poll are added explicitly, since
TanStack Query does not poll by default:
// apps/web/src/hooks/usePlants.ts
export function usePlants() {
return useQuery({
queryKey: queryKeys.plants,
queryFn: fetchPlants,
refetchInterval: () => (document.visibilityState === "visible" ? 300_000 : false),
refetchOnMount: "always",
});
}document.visibilitychange triggering a refetch on becoming visible is handled by pairing
refetchOnWindowFocus: true with a manual listener that also calls queryClient.invalidateQueries ({ queryKey: queryKeys.plants }), since focus does not fire on all mobile browsers when a tab
is switched back to via the OS app switcher, while visibilitychange reliably does:
// apps/web/src/App.tsx (effect, mounted once)
useEffect(() => {
const handler = () => {
if (document.visibilityState === "visible") {
queryClient.invalidateQueries({ queryKey: queryKeys.plants });
}
};
document.addEventListener("visibilitychange", handler);
return () => document.removeEventListener("visibilitychange", handler);
}, []);The midnight timer:
// apps/web/src/hooks/useMidnightRefresh.ts
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { TZDate } from "@date-fns/tz";
import { queryKeys } from "../lib/queryKeys";
export function useMidnightRefresh(timezone: string) {
const queryClient = useQueryClient();
useEffect(() => {
if (!timezone) return;
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleNext = () => {
const now = new TZDate(new Date(), timezone);
const nextMidnight = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate() + 1,
0, 0, 0, 0,
timezone,
);
// Invariant: the scheduled delay is never below 60 seconds. A DST transition at or near
// local midnight (e.g. America/Santiago, Asia/Beirut, Cuba) or a TZDate arithmetic edge
// can otherwise compute `nextMidnight <= now`, which would fire immediately, reschedule,
// and fire immediately again — an unbounded loop hammering `invalidateQueries` until the
// rate limiter (Section 13.7) returns 429.
const rawDelay = nextMidnight.getTime() - now.getTime();
const msUntilMidnight = Math.max(60_000, rawDelay);
timeoutId = setTimeout(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.plants });
scheduleNext(); // reschedule for the following midnight
}, msUntilMidnight);
};
scheduleNext();
return () => clearTimeout(timeoutId);
}, [timezone, queryClient]);
}useMidnightRefresh is called once, from App, with timezone sourced from useMeta().data?. timezone (falling back to not scheduling anything until meta has loaded — there is a brief
window at cold start where the timer is not yet armed, acceptable since the 5-minute poll and
focus/visibility refetches cover it). setTimeout's drift on backgrounded tabs is tolerated: if the
tab was suspended and the timer fires late, invalidateQueries still runs and the visible list
reflects correct status either way (the query itself is correct at any refetch time, not just
exactly at midnight); the reschedule after firing keeps subsequent boundaries accurate. The
displayed list order itself is not recomputed by this refresh — per §16.5's ordering rule below,
a background refetch only updates each row's contents in place.
Ordering is user-initiated only. The displayed sort order is computed once when PlantList
mounts and is recomputed only on a user-initiated event: a sort, filter, or search change, or a
successful mutation. A background refetch (window focus, visibilitychange, the midnight timer, or
the 5-minute poll) updates each row's field values in place against its existing position; the
newly correct order is adopted at the next user-initiated event or route mount. This keeps design
principle 3 (§15.1) — "nothing moves unless the user moved it" — true even when a plant crosses into
overdue from a background refresh while the user is mid-scroll.
16.6 Forms #
PlantForm fields are fully controlled React state (§16.2). Validation uses the shared Zod schemas
from packages/shared/src/schemas (Section 9) — the same schema instance the API uses — imported
into the web app and run client-side with schema.safeParse().
- When validation runs: on blur (per field, using that field's own sub-schema via
.pick()or a per-field parse) and on submit (the full schema, all fields at once). - Error display: each field's error message (from the shared
form.error.*strings in §15.11, matched to the Zod issue's path) renders directly beneath that field, intext-sm text-danger, linked viaaria-describedby. - Submit is never disabled for invalid input. The submit button is disabled only while the
create/update mutation is in flight (
isPending), renderingform.submit.pending("Saving…"). Submitting an invalid form runs full-form validation, marks every fieldtouched, and displays every error — it does not silently no-op and does not block the tap. This avoids the common frustration of a submit button that is disabled for unclear reasons. Failed-submit focus follows the canonical rule in Section 19.6 (an error summary is focused when more than one field fails, the single failing field is focused directly when exactly one does) rather than being restated here. - Offline submission is a no-op, not a silent failure. While the offline banner is showing
(Section 18.6),
onSubmitreturns immediately without calling the mutation or running validation —aria-disableddoes not natively prevent a form's Enter-key submission the way thedisabledattribute does, so the early return is the actual guard. - Unsaved-changes protection: two layers, both active whenever
isDirtyis true (§16.2'sPlantFormstate):- A
beforeunloadlistener (added only while dirty) that setsevent.preventDefault()andevent.returnValue = ""to trigger the browser's native "leave site?" prompt, covering tab close/refresh/external navigation. - A React Router
useBlockercall that intercepts in-app navigation attempts (back button,<Link>clicks, programmaticnavigate()calls elsewhere) while dirty, and opens theform.unsavedChangesConfirmDialog(§15.11) instead of navigating immediately. Confirming "Discard" proceeds with the blocked navigation; "Keep editing" cancels the blocker and returns focus to the form.
- A
16.7 Toasts #
Contract: max 3 visible at once — a 4th incoming toast is queued and appears as soon as one of
the visible three is dismissed or auto-dismisses. Placement: bottom of viewport, offset above the
FAB and the device safe area (bottom: calc(88px + env(safe-area-inset-bottom)) at base/sm),
stacked with newest at the bottom of the stack; top-right, stacked with newest at the
top of the stack, at md+ (this matches where the user's attention already is: near the thumb
at the bottom on mobile after tapping a low-on-screen action, near the cursor/header at top-right
on larger screens where actions are more spread out). If the currently focused element would be
overlapped by the toast region, the region shifts further up to clear it (§15.5's scroll-margin
rule provides the equivalent clearance for keyboard navigation reaching a control near the region).
Auto-dismiss: 5 seconds for informational toasts, 10 seconds for toasts carrying an action (giving
enough time to notice and react to a reversible action). Hovering, focusing (via Tab), or containing
focus pauses a toast's dismiss timer entirely — it does not merely pause on hover, it never starts
counting down while focus is inside it; the timer resumes on mouse-leave/blur with its remaining
time, not a full reset. Every toast keeps its X dismiss button and remains manually dismissible
regardless of remaining time or kind — including a RATE_LIMITED toast, whose retry action's
enabled state is instead driven by the response's Retry-After header (Section 13.7); when that
cooldown elapses the toast's message updates in place to "You can try again now." and the change is
announced politely. Screen-reader announcement uses two regions, per §16.2's ToastRegion contract:
a polite aria-live="polite" region for success/informational toasts, and a role="alert" region
for error toasts, so a failure is not queued behind whatever the polite region is already reading.
Keyboard reachability for action-carrying toasts. Without this rule, a keyboard user must Tab through every remaining row in a long list to reach the Undo toast before its 10-second timer expires — a real failure of the manual keyboard test in Section 19.10. The rule:
- A toast carrying an action (e.g. Undo) that was produced by a keyboard-activated control
(
event.detail === 0on the originating click, §16.4) moves focus to that toast's action button the moment it mounts. - On the toast's dismissal or the action's activation, focus returns to the element that
originated the action, or to that element's replacement in the list (e.g. the same row's water
button after an Undo restores it), or to the
SummaryBar(tabindex="-1") if neither exists. - For a pointer-activated control, focus is not moved — a sighted mouse/touch user already saw the toast appear and does not need focus redirected away from what they were doing.
- The auto-dismiss timer never runs while the toast contains focus or is hovered (restated from above for emphasis: this is what makes rule 1 useful — a timer that keeps counting down while the user is focused on the Undo button would defeat the point).
useToast API:
// apps/web/src/hooks/useToast.ts
interface ToastInput {
kind: "info" | "error";
message: string;
actionLabel?: string;
onAction?: () => void;
durationMs: number;
wasKeyboardActivated?: boolean; // default false; see keyboard-reachability rule above
}
interface ToastApi {
push: (input: ToastInput) => string; // returns the generated toast id
dismiss: (id: string) => void;
}
export function useToast(): ToastApi;Internally, useToast reads/writes a module-level array store (not React Context — toasts are
fired from mutation callbacks, which are not always inside a component render, so a plain
subscribable store with a useSyncExternalStore binding in ToastRegion is simpler and avoids
provider-nesting concerns).
16.8 Optimistic updates and the Undo flow #
Step-by-step sequence for a "Watered today" tap on a PlantRow, from WaterButton:
- Tap.
WaterButton'sonClickrecordsevent.detail === 0(keyboard vs. pointer activation, §16.4) and callsuseWaterPlant(plantId, timezone).mutate(). - Local cache patch.
onMutate(§16.4) snapshots the current cache, then optimistically setslastWateredOnto today (viatodayInTimeZone(new Date(), timezone), never a barenew Date().toISOString(), Section 8) and recomputesstatus/daysUntilDue/daysOverdue/nextDueOnviacomputeScheduleViewin both the['plants']list entry and the['plants', plantId]detail entry. - Button enters pending state.
WaterButtonreadsisPendingfrom the mutation object and shows theplantRow.waterPendinglabel witharia-busy="true"; the button is disabled to prevent a duplicate tap mid-flight. The row's status badge/border cross-fades (§15.9) to the optimistic new status immediately, ahead of the network response. - Request.
POST /api/v1/plants/:plantId/waterings(Section 11.2, Section 13) is sent. - Success. The server DTO in the response replaces the optimistic patch in cache
(
onSuccess, §16.4) — the server'sstatus/nextDueOn/daysUntilDueare authoritative and may differ from the client's optimistic guess by a day if the tap happened exactly at a timezone boundary; the visible row cross-fades again if the values differ. A toast appears:toast.wateredwith anUndoaction (10s), unlessalreadyWateredTodayistrue, in which casetoast.alreadyWateredappears with no Undo action (there is nothing new to undo). - Undo (optional). Tapping
Undo— reachable by keyboard within two Tab presses of the water button, per §16.7's keyboard-reachability rule — callsuseDeleteWatering(plantId, wateringId), using thewateringIdreturned in the original mutation'smeta(§16.4), never a value read off thePlantDTO. ItsonMutateoptimistically removes the just-created history entry; the server then recomputeslast_watered_ononly if the deleted row'swatered_onequalled the plant's currentlast_watered_on(Section 11.3) — since Undo always deletes the row it just created, this is normally true, and the plant's due status reverts to what it was before the tap. On success, the cache is invalidated for['plants'],['plants', plantId], and the waterings key, and a confirming toast (toast.wateringUndone) replaces the original toast (the original toast's own timer is cancelled the moment its Undo button is pressed). If the row was already removed by another tab (a 404 on the DELETE), the client treats it as success and dismisses the toast with no error.
Reaching the 500-plant cap. When plants.length >= 500 (Section 9.3), the header/FAB
"Add plant" control renders aria-disabled="true" instead of being removed — the user should never
be let into a form only to be told at submission it was pointless, but the control stays discoverable
and announced. Activating it is a no-op that pushes an information toast, toast.plantCapReached
("You've reached the limit of 500 plants. Delete a plant before adding another.") rather than
silently doing nothing.
Failure branches, all handled inside the mutation's onError (§16.4) — every branch rolls the
optimistic patch back to the pre-mutation snapshot before showing feedback, so the UI never keeps a
wrong value stuck on screen after a failed watering attempt:
| Failure | Detection | User-facing result |
|---|---|---|
| Network error (no response, offline) | fetch rejects / TypeError, or isOffline is already true (Section 18.6 — a single failed mutation sets it, unlike queries which require two) |
Rollback; error-region toast toast.error.offline; TanStack Query's mutation retry: false means no automatic retry — the user re-taps the (now reverted) button when ready |
404 NOT_FOUND |
Response error.code === "NOT_FOUND" |
Rollback; error-region toast toast.error.generic; ['plants'] is invalidated immediately after, so a plant deleted from another context (e.g. purge) disappears from the list on the next render rather than staying visible with a dead action |
409 CONFLICT |
Response error.code === "CONFLICT" |
Rollback; error-region toast toast.error.generic; ['plants', plantId] invalidated to pull the current authoritative state |
422 LIMIT_EXCEEDED |
Response error.code === "LIMIT_EXCEEDED" |
Rollback; error-region toast toast.plantCapReached; no rollback of any created row is needed (the request never created one) |
Server 400 VALIDATION_FAILED |
Response error.code === "VALIDATION_FAILED" |
Applies the same field-level handling a client-side validation failure would (§16.6) — this path exists because the client's own copy of the schema can, in rare drift, accept something the server rejects |
429 RATE_LIMITED |
Response error.code === "RATE_LIMITED" |
Rollback; error-region toast toast.error.rateLimited, dismissible immediately, its retry affordance gated by the response's Retry-After header (§16.7) |
500 INTERNAL_ERROR / 503 SERVICE_UNAVAILABLE |
Response error.code matches |
Rollback; error-region toast toast.error.generic |
16.9 Error boundaries #
Two layers:
- Root boundary —
ErrorBoundarywraps the entire routed<Outlet />insideAppShell(§16.1). It catches errors from anything React Router's ownerrorElementmechanism does not (e.g. errors thrown fromAppShellitself, or from the toast/header chrome). It renders the full-screenerror.boundary.*message with a Reload button. - Per-route boundary — every route entry in the router config sets
errorElement: <RouteError />. React Router replaces just that route's element subtree withRouteErroron a thrown error or a rejected loader, leavingAppShell(header, toast region) intact — so, for example, a crash renderingPlantDetailRoutestill leaves the header and theme toggle usable and lets the user navigate elsewhere without a full reload.
RouteError distinguishes three cases via useRouteError():
- A React Router
ErrorResponsewithstatus === 404(thrown deliberately by a loader, or by the route-param shape/existence check in §14.2) → renders the same content asNotFoundRoute. - An error whose message matches the dynamic-
import()failure signature (Failed to fetch dynamically imported moduleor equivalent, thrown when a lazy route chunk 404s — the classic "app was redeployed while this tab was open" case) → theerror.chunkLoad.*copy, with special handling: on first occurrence, the app automatically reloads once (sessionStorageflaghpt.chunkReloadprevents a reload loop) unless a mountedPlantFormreportsisDirty(§16.6) — an automatic reload would silently discard unsaved field values, so in that case the manual "Reload now" button is shown instead, exactly as it is on the second occurrence. If the flag is already set (meaning a reload already happened and it still failed), it shows the message with a manual "Reload now" button instead of reloading again. - Any other error → the generic
error.boundary.*message with a manual Reload button.
Both boundaries move focus to their heading on mount, satisfying the general focus-management rule in Section 19 that any full-region content swap must move focus rather than leaving it on a now- removed element.
16.10 Performance practices #
React.memoonPlantRowwith an explicit comparator that re-renders only when the fields that affect its rendered output change:export const PlantRow = React.memo(PlantRowImpl, (prev, next) => { const p = prev.plant; const n = next.plant; return ( p.id === n.id && p.name === n.name && p.status === n.status && p.daysUntilDue === n.daysUntilDue && p.daysOverdue === n.daysOverdue && p.lastWateredOn === n.lastWateredOn ); });This avoids re-rendering all N rows when, e.g.,
ListControlssearch state changes and only the filtered subset passed toPlantListdiffers in membership, not in content.Stable callbacks —
PlantList(the container) creates row-level callbacks (navigate, water) withuseCallback, keyed by nothing that changes per-render, so they do not defeat thePlantRowmemo comparator above by producing a new function identity every render. Concretely,PlantRowreadsplant.iditself when callingnavigate/mutaterather than receiving a pre-bound per-row closure from the parent, so the parent never needs to create N closures per render at all.Keying by plant
id(the UUIDv7 primary key, Section 7.3) in every list render — never array index — so React's reconciliation correctly preserves each row's component instance (and thus its in-flight mutation/animation state) across re-sorts and re-filters.No list virtualization. The plant list is capped at 500 rows (Section 9.3's hard limit), and in practice this product's target usage is a personal collection in the range of 5–50 plants. Rendering up to 500 simple flex rows costs well under the frame budget on any device this app targets; virtualization would add a dependency, complicate the
PlantRowmemo/keying strategy above, and complicate the CSS Grid two-column layout atmd+ (§15.5) for a scale this product will not reach. Section 23 states the exact performance budget this assumption is measured against.
17. Screen Specifications #
17.1 Plant list (/) #
Purpose: answer "what needs water right now" in one glance, and provide every other action (add, view detail, mark watered) as a one-tap reach from here.
Layout at 360px (base):
- Header (
--header-height56px, fixed): app title (truncated if needed), Settings button, Lock button (conditional), theme toggle. SummaryBar: full-width band directly under the header,p-4, showing the due-count headline.ListControls: belowSummaryBar, a single row containing the search input (flex-1, rendered only when the active plant count exceeds 10, §12.5) and aSlidersHorizontalicon button (44×44px,aria-label="Sort & filter") that opens a bottom sheet (aConfirmDialog-styled panel anchored to the bottom of the viewport, per the same modal mechanics as §15.7.8/9 but slid from the bottom rather than centred) containing the sort<select>and filter<select>. Collapsing sort/filter into a disclosure at base width keeps the controls row from crowding the 360px viewport; both selects are still full native controls, just revealed on demand.PlantList: a single-column vertical stack ofPlantRowcards (§15.6's two-line layout),gap-2between them,p-4outer padding matching the container gutter (§15.5).AddPlantButton(FAB variant): fixed,right-4,bottom: calc(24px + env(safe-area-inset- bottom)), 56×56px circular (rounded-pill),bg-accent,shadow-high,Plusicon,aria-label="Add plant",aria-disabled="true"whenplants.length >= 500(§16.8). Sits above the toast region's z-index so a toast never covers it, and<main>'scalc(88px + env(safe-area-inset-bottom))bottom padding (§14.5) means no row's water button sits underneath it even at the bottom of a long scroll.
Layout at 1024px (lg):
- Header grows to 64px and gains the header-variant
AddPlantButton(visible text "Add plant" plusPlusicon) at the right, before the theme toggle; the FAB is not rendered atmd+ (§14.5). SummaryBarandListControlsremain full-width single rows (search — when shown — and both selects visible inline, no disclosure — there is room).PlantListbecomes a two-column CSS grid (md:grid md:grid-cols-2 md:gap-4, §15.5); rows fill left-to-right, top-to-bottom in current sort order (not two independently-scrolling columns).- Content area caps at 720px and centres (§15.5).
Row anatomy (PlantRow, respecified in full in §15.6, applies at every breakpoint): a two-line
card inside one <li>. Line 1: the plant name (md, weight 600, wraps up to two lines via
line-clamp-2 rather than truncating) as the accessible name of a stretched Link covering the
name/badge area, with the StatusBadge (icon + the full due label, e.g. "Overdue by 3 days")
right-aligned beside it. Line 2: WaterButton ("Watered today", primary variant for
overdue/due_today, secondary for due_soon/upcoming, per §15.6), left-aligned, at least 44px
tall. There is no chevron or other navigation-hint icon on the row.
Tap targets: the Link covers the whole card via a stretched pseudo-element
(after:absolute after:inset-0), so tapping anywhere on the card except the button navigates to
/plants/:plantId. WaterButton is a sibling of the Link, never nested inside it, with
relative z-10 so it remains independently tappable above the stretched pseudo-element — this is
the only structure that is both valid HTML (a <button> cannot legally nest inside an <a>) and
keyboard/screen-reader correct (§19).
What is tappable vs. what navigates: the name/badge area → navigates to detail. WaterButton →
fires the watering mutation in place, does not navigate. FAB / header "Add plant" → navigates to
/plants/new (or, at the 500-plant cap, is aria-disabled and shows an informational toast,
§16.8). Theme toggle, Settings, Lock → act in place or navigate to their own screens. Search/sort/
filter controls → filter the visible list in place, no navigation.
Water button tap behaviour: exactly the sequence in §16.8 — the row's status visually updates optimistically within the same interaction, no navigation occurs, and a toast confirms the action with Undo. The row's position in the list is not recomputed at this moment (§16.5): the sort order is recomputed on the next user-initiated event, so the row does not silently jump elsewhere in the same interaction that the user is still looking at (principle 3, §15.1).
Sort options (SortOrder, §16.2): "Most urgent" (urgency, default — overdue first ordered
by most-overdue, then due_today, then due_soon, then upcoming ordered by soonest
daysUntilDue, per compareByUrgency, Section 8.4), "Name (A–Z)" (name), "Recently watered"
(recently-watered — lastWateredOn descending, then id ascending). The choice persists in
localStorage under hpt.sort (§14.4).
Filter options (StatusFilter, §16.2): "All plants" (all, default), "Needs water"
(needs-water — status is overdue or due_today). Persists under hpt.filter (§14.4).
Search: rendered only when the active (non-deleted) plant count exceeds 10, or when the current
query is non-empty (so a search that is mid-typing never vanishes out from under the user as the
result count changes). When the count falls to 10 or fewer, the query resets to empty, the full list
re-renders, and focus moves to SummaryBar. Matches plant name, case-insensitive, substring match,
applied client-side to the already-fetched list (Section 13's client-side filtering rule — the API
always returns the full, unfiltered list).
Text wireframes for this screen are in §17.9.
17.2 Add plant (/plants/new) #
Purpose: capture a new plant's name, interval, starting last-watered date, and optional notes.
Fields, in order:
- Name (
<input type="text">) — labelform.name.label, placeholderform.name.placeholder, nomaxlengthattribute (an attribute-level cap would silently truncate a pasted over-length name with no message; the Zod schema, Section 9, is the sole authority and reportsform.error.nameTooLongon blur/submit), autofocused on mount (this is the field the user always fills first; autofocus is safe here since the form is reached via explicit user action, not on page load). Mobile keyboard: default text keyboard (no specialinputmode). If the trimmed value matches an existing plant's name exactly (case-insensitive), a non-blocking inline warning appears below the field (form.name.duplicateWarning) styled as informational (text-text- secondary, nottext-danger— Section 9.3's rule that duplicates are allowed) alongside a smallAlertCircleicon; it does not block submission. - Water every (
IntervalField, §16.2/§15.7.3/§15.7.12) — the visually-hidden accessible label reads the full "Water every … days" sentence; visibly,form.interval.labelsits to the left of the stepper control and the unit textform.interval.unit("days") to its right, with a hint (form.interval.hint, "1 to 365 days") beneath. Below that, a row of four quick-pick chips: "3 days," "7 days," "14 days," "30 days" (pill-shaped per §15.4,bg-surface-sunkenat rest,bg-accent text-text-inversewhen the current value matches that chip's number). Tapping a chip sets the value directly (equivalent to typing it). Default value: 7. Mobile keyboard for the numeric text input inside the stepper:inputmode="numeric" pattern="[0-9]*". - Last watered (
LastWateredField, §16.2) — labelform.lastWatered.label, a native<input type="date">defaulting totoday(YYYY-MM-DDinAPP_TIMEZONE, sourced fromuseMeta().data.today— never a value derived fromnew Date()in the browser, Section 8),max={today}(mirroring the Section 9 "not in the future" rule at the HTML level),minattribute1970-01-01. Below it, two quick-pick chips:form.lastWatered.today(setstoday) andform.lastWatered.yesterday(setsaddDays(today, -1), Section 8.4), same visual treatment as the interval chips. - Notes (
<textarea>) — labelform.notes.label(already indicates optional), placeholderform.notes.placeholder,min-h-[96px], resizable vertically only, nomaxlengthattribute (removed for the same reason as Name — a hard attribute cap would makeNOTES_TOO_LONGandform.error.notesTooLongunreachable through the UI and silently truncate a paste). A live character counter (form.notes.counter, e.g. "1,840 / 2000") is always present in the DOM, wired into the textarea'saria-describedby, but visually hidden below 1800 characters and visible from 1800; it announces politely once when the count first crosses 1800 and again when it exceeds- At 2000 the counter turns
text-danger; the Zod schema (Section 9) remains the sole validation authority, reportingform.error.notesTooLongon blur/submit. No specialinputmode; default keyboard.
- At 2000 the counter turns
Buttons: "Add plant" (form.submit.add, primary, full-width at base, auto-width at sm+) and
"Cancel" (form.cancel, ghost variant), stacked with Cancel above Add at base (flex-col-reverse
so Add — the primary, expected action — sits closest to the thumb at the bottom) and side-by-side
at sm+ (Cancel to the left of Add, standard reading-order convention once there is horizontal
room).
Submit behaviour: per §16.6 — always attempts submission, shows all field errors and moves
focus per the canonical failed-submit rule in Section 19.6 if invalid, disables only while the
mutation is pending (rendering form.submit.pending). On success, navigates to / and shows the
toast.plantAdded toast (§14.3 rule 3); focus moves to the newly created plant's row rather than to
the list's <h1> (overriding the general route-change focus rule for this one case, since the new
row is what the user most likely wants to confirm).
Cancel behaviour: if the form is dirty, opens the unsaved-changes ConfirmDialog (§16.6);
otherwise navigates back immediately (§14.3 rule 2).
17.3 Plant detail (/plants/:plantId) #
Purpose: the full picture of one plant — its current status, dates, notes, and watering history — plus the primary watering action and edit/delete access.
Header: a back button (ChevronLeft icon plus visible "Back" text, nav.back) navigating via
navigate(-1) (§14.3 rule 1), and the plant's name truncated to fit if very long (the full name is
always available as the page's visible <h1> below the header for screens where it was truncated).
Status block (PlantMeta): a card (§15.7.6) styled with the current status's tint and left
border, containing:
- The
StatusBadge(hideTextFromA11y, since its text is visually identical to the headline immediately below it — see §16.2). - A large headline (
xl, §15.3) stating the day count in the same phrasing as the badge's text but sized up (e.g. "Overdue by 3 days," "Due today," "Due tomorrow," "Due in 9 days" — the canonical strings from Section 8.4, produced byformatRelativeDueLabel). This headline, not the badge, is what a screen reader announces for the plant's status. - If the plant's
repairedflag (Section 10.2) istrue, atext-text-secondaryline beneath the headline:plantRow.repairedNotice("This plant's last-watered date was missing and has been estimated.") — shown only here, never in the list row, which has no room for it. - Two metadata rows beneath: "Next watering" (
detail.nextWatering) with the absolute date inD MMM YYYYformat (e.g. "8 Aug 2026") wrapped in<time dateTime="2026-08-08">; "Last watered" (detail.lastWatered) with the same absolute-date formatting, wrapped in<time>. Both rows show the absolute date only — the relative label is already the headline above, so it is not repeated redundantly beside each date.
Primary action: the WaterButton in size="detail" (larger — min-h-12, full-width at base,
auto-width centred at sm+), placed directly below the status block, always primary-styled here
regardless of current status (unlike the list row's urgency-dependent variant in §15.6 — on the
detail screen the action is always the single most prominent thing on the page since the user
already chose to focus on this one plant).
Notes block (NotesBlock): heading "Notes" (text-sm font-semibold text-text-secondary label,
not a numbered UI string since it is a structural section label reused nowhere else — treated as
part of the fixed layout, not the copy table) directly above the notes text or the detail. notesEmpty message. Line breaks in the stored notes are preserved (white-space: pre-wrap).
Watering history (WateringHistoryList): heading detail.history.title (<h2>), then a list
of HistoryRows (most recent first), each showing the absolute watered date (D MMM YYYY, <time>)
and a Trash2 icon delete button (aria-label = detail.history.delete interpolated with that
row's date). Below the list, a detail.history.showMore button appears whenever the API's meta. total (Section 13) exceeds the number of rows currently loaded; tapping it fetches the next page
(offset += 20) and appends, keeps focus on the button (or moves it to the <h2> once every entry
has loaded and the button unmounts), and announces detail.history.loaded (§16.2). If there is no
history at all, it is not a transient loading artefact — Section 8.2's never-null invariant means a
newly created plant always has exactly one watering row from the moment it is created (Section
10.4) — the only way to reach an empty history is having deleted every entry (Section 11.5); in
that case, detail.history.empty is shown instead of an empty list.
Edit and delete actions: below the history list, a full-width secondary button "Edit plant"
(detail.editPlant, Pencil icon) navigating to /plants/:plantId/edit, and a full-width
destructive-variant button "Delete plant" (detail.deletePlant, Trash2 icon) opening the delete
ConfirmDialog (§17.5).
17.4 Edit plant (/plants/:plantId/edit) #
Uses the same PlantForm component as add (§17.2), with these differences:
- Prefilled values:
name,wateringIntervalDays,lastWateredOn, andnotesare populated from the loaded plant (usePlant(plantId)) asinitialValues(§16.2). While the plant is still loading, the form renders aSkeletonText-based placeholder in place of each field rather than an empty form that would flash-then-fill. - Optimistic concurrency: when the plant loads, the form stores the
Last-Modifiedresponse header value (Section 13.6) alongside the field values. On submit it sends that stored value back as theIf-Unmodified-Sincerequest header (Section 10.5). A409 CONFLICTresponse (someone else, or another tab, saved a change first) shows the conflict banner from Section 18.8 with a "Discard my changes and reload" action. - Heading: "Edit plant" (
form.edit.title) instead of "Add plant." - Submit button: "Save changes" (
form.submit.edit) instead of "Add plant." - No autofocus on the name field (unlike add, where autofocus is desirable for a blank form; here the user is reviewing existing values, and autofocus risks unexpectedly selecting/scrolling to the name field on open).
- A "Delete plant" destructive action at the bottom, below Cancel/Save, full-width, same styling
and behaviour as the detail screen's delete action (§17.3), opening the same
ConfirmDialog(§17.5). This lets a user who opened edit specifically to delete do so without navigating back to detail first. - Submitting successfully navigates back to where the user came from (§14.3 rule 4) and shows
toast.plantUpdated.
17.5 Confirm dialogs #
Both dialogs use the shared ConfirmDialog component (§16.2, §15.7.9): variant="destructive",
focus trap active, initial focus on the Cancel button (never the destructive Confirm button — a
user who reflexively presses Enter or taps quickly must not delete something by accident), Escape
key equivalent to Cancel.
Delete plant:
| Element | Content |
|---|---|
| Title | confirm.deletePlant.title → "Delete {plantName}?" |
| Body | confirm.deletePlant.body → "This removes {plantName} and its watering history from your list. You can undo it for 10 seconds." (the watering rows are not actually erased until the 30-day purge, Section 10.6 — the copy says "from your list" rather than overstating a permanent history wipe) |
| Confirm button | confirm.deletePlant.confirm → "Delete" (destructive variant, §15.7.1) |
| Cancel button | confirm.deletePlant.cancel → "Cancel" (secondary/ghost variant, gets initial focus) |
Confirming calls useDeletePlant() (§16.4), then navigates to / and shows the toast. plantDeleted toast with an Undo action, 10-second window (Section 10.6's soft-delete/Undo
window).
Delete watering history entry:
| Element | Content |
|---|---|
| Title | confirm.deleteHistory.title → "Delete this watering entry?" |
| Body | confirm.deleteHistory.body → "This can't be undone. The plant's last-watered date will be recalculated." |
| Confirm button | confirm.deleteHistory.confirm → "Delete entry" (destructive variant) |
| Cancel button | confirm.deleteHistory.cancel → "Cancel" (gets initial focus) |
Confirming calls useDeleteWatering(plantId, wateringId) (§16.4); there is no Undo here — history
entry deletion is a genuine hard delete (Section 11.5) and the body copy says so explicitly, so no
Undo toast is offered, only the toast.historyEntryDeleted confirmation. If the entry was already
removed elsewhere (a 404 on the DELETE), the row is simply removed from the rendered list and an
information toast reads toast.historyEntryMissing ("That watering entry no longer exists.")
instead of an error.
17.6 Unlock (/unlock) #
The route path is /unlock; it is a screen, not an API endpoint. It renders only when
meta.accessCodeEnabled is true (Section 21's APP_ACCESS_CODE) and meta.unlocked is false
(Section 13.5.10); otherwise it redirects to / (§14.2).
Elements:
- Heading
unlock.title("Enter access code"),<h1>. - One field:
<input type="password">, labelunlock.field.label("Access code"), autofocused on mount,autocomplete="current-password"(lets a password manager offer to fill it). - One button:
unlock.submit("Unlock"), primary variant, full-width at base. - The form submits to
POST /api/v1/session(Section 20.5). On a 401 (wrong code), the client always renders the client-owned failure copyunlock.error.invalid("That code didn't work. Try again.") and never the server'serror.message— deliberately generic, never confirming or denying anything about the code's format or closeness, consistent with the threat model in Section- Rendered in an
aria-live="assertive"region above the field (§16.2).
- Rendered in an
- On lockout (a 429 from the 5-attempts-per-15-minutes rule, Section 20.5), the response's
Retry-Afterheader (seconds) drives the copy:Math.ceil(retryAfter / 60) === 1rendersunlock.error.lockedOutOne("Too many attempts. Try again in 1 minute."), otherwiseunlock.error.lockedOut("Too many attempts. Try again in {minutes} minutes.") with the computed minute count. This replaces the field/button area with just the message; the field and button remain in the DOM but disabled, so a screen reader user still lands on a coherent, described control rather than the whole form vanishing.
On success, redirects to / (or to the originally-requested deep link, if the user was redirected
to /unlock from a specific URL — the router preserves the intended destination in location. state.from the same way the edit-form return path does, §14.3).
17.7 Not found (*) #
Elements:
- Heading
notFound.title("Page not found"),<h1>. - Body text
notFound.body("The page you're looking for doesn't exist."). - One link,
notFound.cta("Go to plant list"), styled as a primary button, navigating to/.
Centred vertically and horizontally within <main>, same treatment used for the plant-not-found
case reached via RouteError (§16.9) — a deleted or invalid plantId renders this exact screen.
17.8 Responsive behaviour matrix #
| Screen | Base (<640px) | sm (≥640px) |
md (≥768px) |
lg (≥1024px) |
|---|---|---|---|---|
| Plant list | Single column rows, FAB add button, sort/filter in a bottom-sheet disclosure | Same as base, wider gutters (24px) | Two-column row grid, header "Add plant" button replaces FAB, sort/filter inline (no disclosure) | Same as md, content column capped at 720px and centred |
| Add plant | Full-screen page, stacked Cancel/Add buttons | Full-screen page, side-by-side buttons | Centred modal, 480px wide, over the list | Same as md |
| Plant detail | Full-screen page, single column | Same, wider gutters | Same layout, wider gutters, content still single column (a two-column detail layout was considered and rejected — the status block, notes, and history read better stacked at any width) | Content column capped at 720px and centred |
| Edit plant | Full-screen page (same as add) | Same, side-by-side buttons | Centred modal, 480px wide, over the detail screen | Same as md |
| Confirm dialogs | Full-width minus 32px margin, stacked buttons | 400px wide, side-by-side buttons | Same as sm |
Same as sm |
| Settings | Full-screen page, stacked export/import sections | Same, wider gutters | Same layout, content column capped and centred (§15.5) | Same as md |
| Unlock | Full-screen, full-width field/button | Same, field/button cap at 400px and centre | Same as sm |
Same as sm |
| Not found | Full-screen, centred | Same | Same | Same |
17.9 Text wireframes #
Plant list — 360px:
┌────────────────────────────────┐
│ Houseplant Watering Tracker ⚙ ☀│ header
├────────────────────────────────┤
│ 2 plants need water │ SummaryBar
├────────────────────────────────┤
│ [ Search plants ] [≡] │ ListControls (search shown only if >10 plants)
├────────────────────────────────┤
│┃ Fiddle Leaf Fig [!Overdue by 3 days]│
│┃ [Watered today] │ PlantRow (overdue) — 2 lines, no chevron
│ │
│┃ Snake Plant [~Due today]│
│┃ [Watered today] │ PlantRow (due_today)
│ │
│ Pothos [oDue tomorrow]│
│ [Watered today] │ PlantRow (due_soon)
│ │
│ ZZ Plant [/Due in 9 days]│
│ [Watered today] │ PlantRow (upcoming)
│ │
│ ( + )│ FAB
└────────────────────────────────┘Plant list — 1024px:
┌──────────────────────────────────────────────────────────────────────┐
│ Houseplant Watering Tracker ⚙ ☀ [ + Add plant ] │ header
├──────────────────────────────────────────────────────────────────────┤
│ 2 plants need water │ SummaryBar
├──────────────────────────────────────────────────────────────────────┤
│ [ Search plants ] Sort: [Most urgent v] Show: [All v] │ ListControls
├──────────────────────────────────────────────────────────────────────┤
│ ┃ Fiddle Leaf Fig [!Overdue by 3 days] ┃ Snake Plant [~Due today] │
│ ┃ [Watered today] ┃ [Watered today] │
│ │
│ Pothos [oDue tomorrow] ZZ Plant [/Due in 9 days] │
│ [Watered today] [Watered today] │
└──────────────────────────────────────────────────────────────────────┘Add plant — 360px:
┌────────────────────────────────┐
│ ‹ Back │
│ Add plant │
├────────────────────────────────┤
│ Name │
│ [ e.g. Fiddle Leaf Fig ] │
│ │
│ Water every │
│ ( − ) [ 7 ] ( + ) days │
│ [3 days][7 days][14][30] │
│ │
│ Last watered │
│ [ 2026-08-05 ] │
│ [ Today ] [ Yesterday ] │
│ │
│ Notes (optional) │
│ [ ]│
│ [ ]│
│ │
│ [ Cancel ] │
│ [ Add plant ] │
└────────────────────────────────┘Plant detail — 360px:
┌────────────────────────────────┐
│ ‹ Back Fiddle Leaf Fig │
├────────────────────────────────┤
│ Fiddle Leaf Fig │ h1
│ │
│┃ [!] Overdue │
│┃ Overdue by 3 days │ status block
│┃ Next watering 5 Aug 2026 │
│┃ Last watered 2 Aug 2026 │
│ │
│ [ Watered today ] │
│ │
│ Notes │
│ Bright indirect light, east │
│ window. Rotate weekly. │
│ │
│ Watering history │
│ 2 Aug 2026 [🗑]│
│ 26 Jul 2026 [🗑]│
│ 19 Jul 2026 [🗑]│
│ [ Show more ] │
│ │
│ [ ✎ Edit plant ] │
│ [ 🗑 Delete plant ] │
└────────────────────────────────┘Plant detail — 1024px:
┌──────────────────────────────────────────────────────────────────────┐
│ ‹ Back Fiddle Leaf Fig │
├──────────────────────────────────────────────────────────────────────┤
│ Fiddle Leaf Fig │
│ │
│ ┃ [!] Overdue │
│ ┃ Overdue by 3 days │
│ ┃ Next watering 5 Aug 2026 Last watered 2 Aug 2026 │
│ │
│ [ Watered today ] │
│ │
│ Notes │
│ Bright indirect light, east window. Rotate weekly. │
│ │
│ Watering history │
│ 2 Aug 2026 [🗑] │
│ 26 Jul 2026 [🗑] │
│ 19 Jul 2026 [🗑] │
│ [ Show more ] │
│ │
│ [ ✎ Edit plant ] [ 🗑 Delete plant ] │
└──────────────────────────────────────────────────────────────────────┘Settings — 360px:
┌────────────────────────────────┐
│ ‹ Back Settings │
├────────────────────────────────┤
│ Settings │ h1
│ │
│ Export data │
│ Download every plant and its │
│ watering history as a JSON file.│
│ [ Export data ] │
│ │
│ Import data │
│ Choose a previously exported │
│ JSON file to restore or merge. │
│ [ Choose file... ] │
│ What should happen to your │
│ current data? │
│ ( ) Replace everything │
│ ( ) Merge with what's here │
│ [ Import data ] │
└────────────────────────────────┘17.10 Settings (/settings) #
Purpose: the home for the two data-durability actions the product provides — export and import (Section 24) — with no other content, since the product's small feature set has nothing else to configure from the client.
Export. A heading (settings.export.heading), explanatory body text (settings.export.body),
and a single button (settings.export.button, secondary variant) that triggers a same-tab download
of GET /api/v1/export — the browser's native download handling takes over; there is no in-app
progress UI, since the payload is small (Section 24.4) and effectively instant on any connection
this product targets.
Import. A heading (settings.import.heading), explanatory body text (settings.import.body),
a native <input type="file" accept="application/json"> (label settings.import.fileLabel), and a
required radio group — mode, no option pre-selected (Section 24.5 declares mode
mandatory with no default, and defaulting the UI to either choice would silently pick a destructive
action for the user) — with two options: settings.import.mode.replace ("Replace everything") and
settings.import.mode.merge ("Merge with what's here"). The import button
(settings.import.button, primary variant) is disabled until both a file and a mode are selected.
- Submitting with
mode: "merge"callsPOST /api/v1/importdirectly. - Submitting with
mode: "replace"first opens a destructive-variantConfirmDialog(§15.7.9): titlesettings.import.confirmReplace.title, bodysettings.import.confirmReplace.body, confirmsettings.import.confirmReplace.confirm(destructive), cancelsettings.import.confirmReplace. cancel(gets initial focus, per §17.5's rule). Confirming proceeds with thePOST. - On success, an information toast shows
settings.import.success(interpolated with the imported plant count) and['plants']is invalidated so the list reflects the new data on return. - Error presentation (
PAYLOAD_TOO_LARGE,LIMIT_EXCEEDED,VALIDATION_FAILED) follows the state matrix in Section 18.5; aVALIDATION_FAILEDresponse whosedetails[].pathnames a specific array index is rendered as a form-level banner naming the problem row, since there is no per-record field to attach it to the way a plant form attaches an error to a single field.
Neither action is available anywhere else in the product — there is no export/import affordance on the plant list or detail screens — keeping the primary "check what needs water" flow free of a data-management action that is used rarely, if ever, after initial setup.
18. Interaction States: Loading, Empty, Error, Offline #
This section is canonical for how every data-driven surface behaves outside the single "data loaded
successfully" path. Six surfaces are covered throughout: plant list, plant detail, watering
history, add plant form, edit plant form, and unlock screen (the last only exists when
APP_ACCESS_CODE is set, Section 20.5). Route paths for these surfaces are canonical in Section 14;
component names are canonical in Section 16; copy for empty/loading/error strings that also appear in
the design system catalogue is cross-referenced against Section 15.11.
18.1 State taxonomy #
Every data-driven surface is, at any instant, in exactly one of six states. States are computed from
TanStack Query's status flags (isPending, isFetching, isError, data) plus a client-side
connectivity flag (Section 18.6). loading-refresh and offline may be combined with ready (see
18.1's mutual-exclusivity rule below); the other four are strictly exclusive.
| State | Definition | Derived from |
|---|---|---|
loading-initial |
No data has ever been successfully fetched for this surface in this session. The surface has nothing to show yet. | isPending === true (query has no cached data) |
loading-refresh |
Data was previously fetched successfully and a new fetch is in flight (background poll, refetch-on-focus, or post-mutation invalidation). Existing data remains on screen. | isFetching === true && isPending === false |
empty |
The fetch succeeded and returned a zero-length result set (no plants, or a filter/search that matches nothing). | isSuccess === true && data.length === 0 |
ready |
The fetch succeeded and returned one or more items, and no error is active. | isSuccess === true && data.length > 0 |
error |
The most recent fetch failed and no usable cached data exists (initial load failure) or the failure requires blocking the surface (see 18.5). | isError === true |
offline |
The client has detected it has no network connectivity (Section 18.6). This is layered on top of whichever of the above states is current; it is not itself mutually exclusive with ready. |
navigator.onLine === false or two consecutive fetch failures classified as network errors |
Mutual exclusivity rules:
loading-initial,empty,ready, anderrorare mutually exclusive — a surface is in exactly one of these four at a time, determined by the query status.loading-refreshis a modifier, not a replacement: when a background refetch is in flight, the surface stays inready(orempty) and layers a refresh indicator on top (Section 18.3). It never demotes areadysurface toloading-initial.offlineis also a modifier. A surface can bereadyandofflinesimultaneously (last-known data shown, offline banner visible, mutations disabled). A surface cannot beofflineandloading-initialwith a spinner spinning forever — if the initial load fails because the client is offline, the surface resolves toerrorwith the offline-specific message (Section 18.5), not an infiniteloading-initial.- Forms (add/edit) do not have
empty; their six-state row in the matrix below usesn/afor that column.
18.2 State matrix #
The table gives exactly what renders for each surface in each state. "Skeleton" and "spinner" are defined in Section 18.3. "Toast" and "banner" placement are canonical to Section 17's screen layouts; this table only specifies which one appears, not its pixel position.
| Surface | loading-initial |
loading-refresh |
empty |
ready |
error |
offline (layered) |
|---|---|---|---|---|---|---|
| Plant list | Five-row skeleton (18.3) | Existing rows stay, thin top progress bar, no skeleton | One of three empty states (18.4) depending on active filter/search | Rows rendered, sorted/filtered per Section 12 | Full-surface error panel (18.5) replacing the list area, retry button | Persistent offline banner above the list; rows (if any) remain visible and read-only |
| Plant detail | Skeleton: title bar + two stat blocks + notes block, all pulsing placeholders | Existing content stays, thin top progress bar | n/a (a plant either exists or the surface is error with NOT_FOUND) |
Full detail rendered | Full-surface error panel; NOT_FOUND renders a dedicated "Plant not found" panel (18.5) |
Offline banner; "Watered today" and edit actions are aria-disabled; activating either is a no-op that pushes an information toast (18.6) |
| Watering history | Skeleton: three placeholder rows inside the history panel | Existing rows stay, thin top progress bar at panel top | "No watering history yet" empty state, no CTA (history populates itself once watering starts) | History rows rendered, newest first, paginated (Section 11.4) | Inline error block inside the history panel only (does not blank the rest of plant detail), retry button | Offline banner at panel top; pagination controls disabled |
| Add plant form | n/a (form renders immediately; no server fetch precedes it) | n/a (forms do not background-refresh) | n/a | Form fields rendered; submit always enabled except while the mutation is pending (Section 16.6) | Field-level errors inline; a form-level banner for LIMIT_EXCEEDED and INTERNAL_ERROR (18.5) |
Submit is aria-disabled while offline; activating it is a no-op that pushes an information toast (18.6) |
| Edit plant form | Skeleton: form-shaped placeholder (label + input bars) while the plant record loads | n/a | n/a (a plant either exists or the surface is error) |
Form pre-filled with current values; submit always enabled except while the mutation is pending (Section 16.6) | Field-level errors inline; form-level banner for conflict/limit/server errors; NOT_FOUND redirects to the list with a toast (18.8) |
Submit is aria-disabled while offline; activating it is a no-op that pushes an information toast (18.6) |
| Unlock screen | n/a (static form, no fetch on mount) | n/a | n/a | Access-code input rendered | Inline error under the input (18.5); lockout renders a countdown message (Section 20.5) | Submit is aria-disabled while offline; activating it is a no-op that pushes an information toast (18.6) |
18.3 Loading #
Skeleton (plant list). The list skeleton renders exactly five placeholder rows regardless of how
many plants will eventually load. Each placeholder row is a <li> matching the real row's height and
padding, containing three pulsing gray blocks: a circular icon placeholder (32×32 CSS px), a two-line
text placeholder (name-width bar at 60% row width, subtitle-width bar at 40%), and a status-badge
placeholder (pill shape, 72×24 CSS px) right-aligned. The pulse animation is a 1.5 s ease-in-out
opacity tween between 0.5 and 1.0, disabled entirely when prefers-reduced-motion: reduce is set (the
placeholder then renders at a static 0.7 opacity).
Background refresh never shows a skeleton. If a surface already has data on screen
(loading-refresh), no skeleton or spinner replaces it. The only indicator is a 2px-tall indeterminate
progress bar pinned to the top of the surface's scroll container, using the due_soon blue token from
Section 15.2 at 60% opacity. It appears immediately when the refresh starts and disappears immediately
when it resolves (success or error) — no minimum display time applies to the refresh bar, because it
does not block interaction.
Immediate feedback, delayed spinner. Two signals are deliberately decoupled. The button's label swap
to its pending text (e.g. plantRow.waterPending, Section 15.11), its aria-busy="true", and its
disabled state all apply immediately on tap, with no delay — a keyboard or screen-reader user must
never wonder whether their activation registered. Only the spinner glyph itself is subject to a
delay, because a spinner that flashes for one frame on a fast connection reads as visual noise rather
than feedback:
- Start a 250 ms timer when the async action begins (the pending label,
aria-busy, and disabled state are already showing by this point). - If the action resolves before the timer fires, no spinner glyph is ever shown.
- If the timer fires first, add the spinner glyph alongside the already-visible pending label.
- Once the spinner glyph is shown, it displays for a minimum of 400 ms even if the action resolves
sooner, so it never flashes for a single frame. The button re-enables and restores its default label
at
max(actionEndTime, spinnerShownTime + 400ms).
This 250 ms-delay / 400 ms-minimum pair applies to the spinner glyph on every button-triggered async
action in the product: "Watered today", form submit, delete confirm, restore, unlock submit. Both values
are exported constants (SPINNER_DELAY_MS = 250, SPINNER_MIN_VISIBLE_MS = 400) rather than inline
literals, specifically so tests can override them to 0 and assert on the immediate-feedback signals
without racing real timers.
18.4 Empty states #
Three distinct empty states exist, each with its own copy, icon, and call to action. All three use a
centered layout: an icon (CSS/SVG only, drawn from lucide-react, 48×48 px, currentColor at 40%
opacity, no raster images and no illustration library), a heading, a supporting line, and — where
applicable — a single call-to-action button or link.
| Identifier | Trigger condition | Icon | Heading | Supporting line | Call to action |
|---|---|---|---|---|---|
emptyState.noPlants |
Zero plants exist for the account (no filter or search active) | Sprout |
"No plants yet" | "Add your first plant to start tracking its watering schedule." | Button: "Add your first plant" — opens the add plant form |
emptyState.noDueMatches |
The "Needs water" filter (Section 12.4) is active and zero plants match overdue or due_today |
CheckCircle2 |
"All caught up" | "Nothing on your list needs water." | Link (text button, not a filled button): "Clear filter" — resets the filter to "All plants" |
emptyState.noResults |
A search query is active and zero plants match | SearchX |
"No plants match "{query}"" — {query} is the trimmed search text, HTML-escaped, truncated to 40 characters with a trailing ellipsis if longer |
"Try a different name." | Link (text button): "Clear search" — empties the search field |
Precedence when both a filter and a search are active and both would independently produce zero
results: emptyState.noResults takes priority, because search is the more specific and more recent
user action; its "Clear search" action also implicitly restores filter-only results. The heading and
body text above are the canonical strings, registered under these identifiers in Section 15.11; this
table is the single place their trigger conditions, icons, and calls to action are defined. Note that
emptyState.noDueMatches's "All caught up" is deliberately distinct from the summary bar's
summary.zeroDue ("Nothing needs water.", Section 12.2) even though both can render in the same
viewport, so the two never read as duplicated text.
18.5 Error states #
The user never sees a raw error code or a stack trace. Every error is mapped to one of four
presentation styles — inline field error, form-level banner, toast, or full-page/panel
error — with exact copy. requestId (from the canonical error envelope, Section 13.2) is shown in a
small monospace line reading Reference: {requestId} wherever a banner or full-page error is used, so a
user can quote it when asking for help; it is never shown in toasts (too small) or inline field errors
(too noisy). Reference: is rendered only when the requestId is server-generated — a requestId
originating from a client-supplied X-Request-Id (Section 22.4) is used internally for log correlation
but is never displayed, since it is arbitrary caller-controlled text and rendering it inside the app's
own error UI would let a crafted link control what that UI shows.
When a VALIDATION_FAILED response's error.details[].path names a field the current form renders, the
client renders that field's form.error.* string from Section 15.11 for the inline error, never the
server-sent details[].message text; the server text is rendered only when a details[] entry's path
does not map to any rendered field (Section 15.11 and the API's Section 9 validation messages are
allowed to diverge in wording for exactly this reason — the client copy is the one a form field actually
shows). If a VALIDATION_FAILED response has no details[] at all, or every entry's path is unmapped,
a form-level banner renders error.validation.fallback — "Check the highlighted fields and try again." —
rather than presenting nothing.
On the Settings screen's import action (/settings, Section 14), PAYLOAD_TOO_LARGE, LIMIT_EXCEEDED,
and VALIDATION_FAILED all render as a form-level banner scoped to the Settings screen rather than the
generic toast/inline-field presentations in the table below, because an import failure is a whole-file
rejection with no single form field to attach an inline error to. VALIDATION_FAILED's import banner
lists every details[].message entry as a bullet; LIMIT_EXCEEDED's import banner renders
error.limit_exceeded.import — "Importing this file would exceed the 500-plant limit. Remove some
plants from the file and try again."; PAYLOAD_TOO_LARGE's import banner uses the import copy already
given in the table below. None of the three offers a retry action on the Settings screen — the operator
must fix the file and re-select it.
| Error code | HTTP | Presentation | Copy | Retry offered |
|---|---|---|---|---|
VALIDATION_FAILED |
400 | Inline field error(s), one per entry in error.details[], positioned under the offending field |
The details[].message text verbatim (these messages are already user-safe per Section 9) |
N/A — user edits the field and resubmits |
MALFORMED_JSON |
400 | Form-level banner | error.malformedJson |
Yes — "Try again" button re-submits the same payload |
NOT_FOUND (plant) |
404 | Full-page/panel error replacing plant detail or edit form | error.notFound |
No — action is "Back to plant list" |
NOT_FOUND (watering entry) |
404 | Toast | error.wateringNotFound |
No |
METHOD_NOT_ALLOWED |
405 | Full-page error (should be unreachable via normal UI; treated as a defensive fallback) | error.methodNotAllowed |
No — action is "Back to plant list" |
CONFLICT |
409 | Form-level banner | error.conflict |
Yes — a destructive-variant (Section 15.7.1) "Discard my changes and reload" button refetches and repopulates the form; it does not receive focus when the banner first appears, so a keyboard user does not activate it by an incidental Enter |
PAYLOAD_TOO_LARGE |
413 | Form-level banner (import) / toast (all others) | error.payloadTooLarge.import (import) / error.payloadTooLarge (others) |
No — user must reduce input size |
UNSUPPORTED_MEDIA_TYPE |
415 | Toast | error.unsupportedMediaType |
No |
LIMIT_EXCEEDED |
422 | Form-level banner (add form) | error.limitExceeded |
No — action is "View plant list" |
RATE_LIMITED |
429 | Toast, manually dismissible like every other toast (Section 16.7) | toast.error.rateLimited |
Yes — the retry action becomes tappable when the cooldown named by the response's Retry-After header (Section 13.7) elapses; a polite announcement, "You can try again now.", fires at that moment |
UNAUTHORIZED |
401 | On a query: full-page redirect to the unlock screen (Section 20.5), no inline copy needed. On a mutation: the form stays mounted (nothing is discarded) and a form-level banner reads error.sessionExpired — "Your session expired. Unlock to save your changes." |
— (query) / error.sessionExpired (mutation) |
N/A (query) / Yes — an "Unlock" action opens /unlock with the current route recorded so the user returns to the same, still-populated form after unlocking (mutation) |
INTERNAL_ERROR |
500 | Full-page/panel error (initial load) or form-level banner (mutation) | error.internal |
Yes |
SERVICE_UNAVAILABLE |
503 | Full-page/panel error | error.serviceUnavailable |
Yes, with a 5 s minimum gap between manual retries (button disabled during the gap) |
Network failure (no response, fetch rejects) |
— | Same presentation as the equivalent surface's error state, using the offline-aware copy from 18.6 if navigator.onLine === false, otherwise: |
error.network |
Yes |
| Client-side timeout (18.7) | — | Toast for background refreshes, form-level banner for user-initiated submits | error.timeout |
Yes |
The Copy column names the §15.11 key; §15.11 holds the literal text and this section does not
restate it.
Rules that apply across every row above:
- Never render
error.codeorerror.messagefrom a 500-class response body directly — 500-class messages are generic by contract (Section 13.2) and the table's canned copy is used regardless of what the server sent, as defense in depth. - 400-class validation and conflict messages (
VALIDATION_FAILED,CONFLICTfield-level detail) are safe to render verbatim because the server contract guarantees they are written for end users (Section 13.2). - A retry action, where offered, re-issues the exact same request (same method, path, body). It never silently changes the request.
- Toast auto-dismiss timing follows Section 16.7's two durations (5 s informational, 10 s when carrying
an action) — this section introduces no separate duration. Every toast, including
RATE_LIMITED, is manually dismissible via itsXbutton regardless of remaining time (Section 16.7); auto-dismiss never fires while a toast contains focus or is hovered.
18.6 Offline behaviour #
The product is online-only by decision: no offline data entry, no background sync, no service-worker cache of API responses. This is intentional, not a gap — a single-user tool with an always-reachable private server has no realistic offline-usage scenario, and building offline write support would add conflict-resolution complexity out of proportion to the app's scope.
Detection. Two signals combine to set the client's isOffline flag, exposed via a small
useOnlineStatus() hook:
- The browser's
navigator.onLinevalue, updated on theonlineandofflinewindow events. - Fetch-failure classification: any
fetch()rejection withTypeError(the browser's generic network-failure signature) increments a counter. For queries, two consecutive network-classified failures across any query setisOffline = true. For mutations, a single network-classified failure is sufficient to setisOffline = trueimmediately — a mutation never retries (Section 16.5), so there is no second attempt to wait for, and treating a lone failed "Watered today" tap the same as two failed background reads would leave the user staring at a silently-failed action with no explanation. Either path is true even ifnavigator.onLinestill reportstrue(captive portals and some VPN states reportnavigator.onLine: truewhile unreachable). A single successful response of any status code (including 4xx/5xx — those still prove connectivity) resets the counter to 0 and clearsisOffline.
UI while offline.
- A persistent banner renders at the top of the viewport, below any header, full width, using the
overduered token pair's surface tint (not the text color, to avoid visually competing with real overdue plants) with neutral text and anWifiOfficon: "You're offline. Changes can't be saved right now." The banner hasrole="status"aria-live="polite"(Section 19.5) and does not auto-dismiss; it disappears the instant connectivity is confirmed restored. - Every mutation-triggering control (Watered today, Save on add/edit forms, Delete, Restore) uses
aria-disabled="true"rather than the nativedisabledattribute whileisOffline === true, so the control remains keyboard-focusable (Section 19.8). Its click/Enter/Spacehandler returns early as a no-op —aria-disabled, unlike the native attribute, does not itself prevent activation or block a native form submission, so every handler behind such a control, andPlantForm'sonSubmitspecifically, must checkisOfflineand return before doing anything else. Activating anaria-disabledcontrol while offline pushes an information toast,offline.blocked— "You're offline — this needs a connection." A hover/focus tooltip is never used for this explanation, because the product's primary device (a phone) has neither hover nor a persistent focus affordance that would reveal one. - Read-only surfaces (the plant list itself, plant detail, watering history) continue to display their last-known cached data; TanStack Query's cached data is not cleared on going offline.
- The search input and the "Needs water" filter remain fully interactive offline since they operate on already-cached data client-side (Section 12).
Recovery. When isOffline transitions from true to false, every currently-mounted query
refetches immediately (in addition to the standard refetch-on-focus and 5-minute poll from
Section 16.5). The offline banner is removed as soon as that refetch either
succeeds or fails with a non-network error (a real HTTP error response is proof of connectivity even
though the request itself failed).
Explicit non-goal. Offline writes and background sync (queueing a "Watered today" tap made while offline and replaying it later) are out of scope: this app assumes a phone with normal connectivity at the moment the user is standing in front of a plant, and the failure mode of a queued write silently double-applying or conflicting with a same-day change made from another device is worse than simply asking the user to try again once they have signal.
18.7 Slow-network behaviour #
| Elapsed time since request sent | User-visible behaviour |
|---|---|
| 0–250 ms | Nothing changes (18.3 delay). |
| 250 ms–3 s | Spinner or skeleton visible per 18.3 rules; surface otherwise unchanged. |
| 3 s (still pending) | No new UI is introduced at exactly 3 s — this is not a threshold, it is the point at which the existing spinner/skeleton has been visible long enough that it reads as "working," not "stuck." No additional copy is added. |
| 15 s (client-side timeout) | The client aborts the request via AbortController and treats it identically to a network failure (18.5's network-failure row): "Couldn't reach the server. Check your connection and try again." for reads, or a form-level "That's taking longer than expected. Please try again." for writes. |
| 30 s (only reachable if the 15 s client timeout is somehow bypassed, e.g. a dev-tools network throttle test) | Not a distinct product state; the 15 s timeout has already fired by this point in every real code path. Documented here only to state explicitly that no separate 30 s behaviour exists — the 15 s limit is the single source of truth. |
The 15 s timeout is implemented once, in the shared typed fetch client (apps/web/src/api/), applied
to every request. Retry policy after a timeout or network failure follows the TanStack Query
configuration canonical to Section 16.5 (retry: 2 with exponential backoff for reads, retry: false
for mutations — mutations are never silently retried because "Watered today" and deletes must not risk
duplicate side effects; the user retries explicitly via the button described in 18.5).
18.8 Recovery paths #
Every error state has a defined way back to a working screen. None of them dead-end.
| Error state | Recovery path |
|---|---|
| Plant list full-page error | "Try again" button re-runs the list query. |
Plant detail NOT_FOUND |
"Back to plant list" link/button navigates to the list route (Section 14); the list itself refetches on mount, so a plant deleted elsewhere is correctly absent. |
| Plant detail non-404 error | "Try again" button re-runs the detail query; a secondary "Back to plant list" link is always present alongside it. |
| Watering history inline error | "Try again" button re-runs only the history query, scoped to its panel; the rest of plant detail is unaffected and needs no recovery. |
| Add form validation errors | User corrects the flagged field(s); the banner/inline errors clear individually as each field becomes valid (re-validated on blur, Section 9). |
Add form LIMIT_EXCEEDED |
"View plant list" link navigates away from the form; the form's entered values are discarded (there is nothing else to do — the cap is hard). |
Edit form CONFLICT |
The destructive-variant "Discard my changes and reload" button refetches the plant and repopulates all fields with server values, discarding local edits; a confirmation dialog is not required because the button's own label already states the consequence, but the button does not receive focus when the banner appears, so it cannot be triggered by an incidental Enter (Section 18.5). |
Edit form NOT_FOUND |
Automatic: the form immediately redirects to the plant list and shows a toast, "That plant no longer exists," rather than presenting a dead form. |
Any form mutation returning UNAUTHORIZED |
The form stays mounted with its values intact; the "Unlock" action on the error.sessionExpired banner (Section 18.5) opens /unlock and returns the user to this same, still-populated form once they unlock. |
| Unlock screen wrong code | Inline error under the input clears as soon as the user types again; the input is refocused automatically. |
| Unlock screen lockout | No action available except waiting; the countdown message (Section 20.5) tells the user exactly when the next attempt is allowed. |
| Any offline-disabled control | Recovery is automatic: the control's aria-disabled state clears the instant isOffline clears (18.6); no user action is a "fix," they simply wait for connectivity. |
RATE_LIMITED toast |
Recovery is automatic after the cooldown named by the response's Retry-After header; the retry action in the toast becomes tappable at that point (Section 18.5). |
SERVICE_UNAVAILABLE full-page error |
"Try again" button, rate-limited client-side to once per 5 s to avoid hammering a recovering server. |
18.9 Copy catalogue #
Every string introduced in this section is registered in Section 15.11's catalogue, which is the single
canonical UI copy table for the whole product; the identifiers are listed here purely for cross-checking
against this section's behaviour, and no string's literal text is restated. Strings containing
{placeholder} are interpolated exactly as described in the source subsection.
| Key (Section 15.11) | Introduced by |
|---|---|
emptyState.noPlants.title / .body / .cta |
18.4 |
emptyState.noDueMatches.title / .body / .cta |
18.4 |
emptyState.noResults.title / .body / .cta |
18.4 |
detail.history.empty |
18.2 |
error.reference |
18.5 |
error.validation.fallback |
18.5 |
error.malformedJson |
18.5 |
error.notFound |
18.5 |
error.wateringNotFound |
18.5 |
error.methodNotAllowed |
18.5 |
error.conflict |
18.5 |
error.conflict.discardAndReload |
18.5, 18.8 |
error.sessionExpired |
18.5, 18.8 |
error.payloadTooLarge.import / error.payloadTooLarge |
18.5 |
error.unsupportedMediaType |
18.5 |
error.limitExceeded |
18.5 |
error.limitExceeded.import |
18.5 |
toast.error.rateLimited |
18.5 |
error.internal |
18.5 |
error.serviceUnavailable |
18.5 |
error.network |
18.5 |
error.timeout |
18.5 |
action.tryAgain / action.backToList / action.viewPlantList |
18.5, 18.8 |
toast.plantDeletedElsewhere |
18.8 |
offline.banner |
18.6 |
offline.blocked |
18.6 |
toast.rateLimitReady |
18.5 |
19. Accessibility Requirements #
This section is canonical for the accessibility bar, keyboard map, ARIA usage, and focus rules. It applies to every screen defined in Section 17 and every component in Section 16.
19.1 The bar #
WCAG 2.2 Level AA conformance is a hard acceptance criterion. A build that fails an AA success criterion on any in-scope screen is not done, regardless of any other functionality working. The following success criteria carry elevated risk in this product specifically and receive explicit per-criterion treatment; the full AA list still applies, this is not a substitute for it.
| Success criterion | What it means here | How it is tested |
|---|---|---|
| 1.4.1 Use of Color | The four due-status states (Section 8) must never be distinguishable by color alone — each status badge always pairs its color with an icon (AlertTriangle/Droplet/Clock/Check) and a text label ("Overdue", "Due today", "Due soon", "Upcoming"). |
Manual: render each status with CSS filter: grayscale(100%) and confirm the badge is still unambiguous via icon shape and text. |
| 1.4.3 Contrast (Minimum) | All body text and status-badge text meet 4.5:1 against their surface in both light and dark themes. | Automated: @axe-core/playwright per route/theme (19.10); manual spot-check against the table in 19.7. |
| 1.4.11 Non-text Contrast | Input borders, focus indicators, icon-only buttons, and the status badge's icon meet 3:1 against adjacent surfaces. | Automated: scripts/check-contrast.mjs asserts every non-text pair listed in 19.7 at ≥3:1 on every CI run — @axe-core/playwright does not check this criterion, so it is not relied on here. |
| 2.1.1 Keyboard | Every action reachable by touch or mouse (add, edit, delete, restore, mark watered, filter, search, dismiss toast, open/close modal) has a keyboard path with no exceptions. | Manual keyboard-only pass (19.10) covering every interactive element per screen. |
| 2.4.3 Focus Order | Tab order follows visual/reading order on every screen: header controls, then search/filter, then list rows top-to-bottom, then any trailing pagination/footer controls. | Manual tab-through per screen, verified against the table in 19.3. |
| 2.4.7 Focus Visible | Every focusable element shows a visible focus indicator meeting 19.1's 1.4.11 contrast requirement; no outline: none without a replacement indicator anywhere in the codebase (enforced by an ESLint rule banning bare outline: none in Tailwind class strings, Section 6). |
Manual keyboard pass; automated grep-based lint check in CI. |
| 2.4.11 Focus Not Obscured (Minimum) | A focused element is never fully hidden behind a sticky header, sticky footer, or toast; scroll-margin is applied so focusing a list row scrolls it clear of the sticky header. | Manual check with the sticky header/toast both present while tabbing through list rows. |
| 2.5.8 Target Size (Minimum) | Every touch target (buttons, the water-icon tap area, checkboxes, the filter select) is at least 44×44 CSS px, per Section 15's touch-target rule, with a minimum 8px gap to the next target (19.8). | Automated: a Playwright test measures getBoundingClientRect() on every interactive element and asserts ≥44×44. |
| 3.3.1 Error Identification | Every validation error is presented as text adjacent to its field, not by color/icon alone, and is programmatically associated via aria-describedby (19.6). |
Manual + automated axe check; manual screen-reader pass confirms the error text is announced. |
| 4.1.3 Status Messages | Status changes that do not move focus (toast appearing, summary bar count updating, background refresh completing) are announced via aria-live regions (19.5) without disrupting the user's current focus. |
Manual screen-reader pass listening for the announcement while focus stays elsewhere. |
19.2 Semantic structure #
- Landmarks. Every screen has exactly one
<header>(the app title/back navigation only — the search input, sort select, and filter select sit inside<main>, below the header, on every breakpoint, Section 14.5/17.1) and one<main>(the screen's primary content). No screen has a<footer>; there is no footer navigation or footer-hosted control anywhere in the product (Section 14.5), including watering history's "Show more" control, which lives inside<main>. No landmark is nested inside another of the same type. - Headings. Every screen has exactly one
<h1>, visually hidden except where the screen's own content already displays the equivalent text (e.g. a visually hidden "Houseplant Watering Tracker" on the list screen, the plant's name — already visible in the page body — on plant detail). Subsections within a screen (e.g., "Watering history" within plant detail) use<h2>. No heading level is skipped. Modals (Section 17) each have their own<h2>as their accessible name source (19.5), not an<h1>, since the underlying screen's<h1>remains the document's sole level-1 heading while a modal is open. - The plant list is a
<ul>of<li>rows. Each row is a single<li>containing a<button>or link wrapping the plant's name and status (the primary tap target, Section 17), plus the "Watered today" control as a sibling<button>inside the same<li>— not nested inside the row's primary link/button, so the two remain independently focusable and independently clickable (2.5.8, 19.3). The list is never rendered as a<table>(there is no tabular multi-column relationship to convey) and never as a bare stack of<div>s (that would strip the implicit list semantics screen readers rely on to announce "list, N items").
19.3 Keyboard map #
| Context | Key | Behaviour |
|---|---|---|
| Plant list screen | Tab |
Moves focus in order: search input → sort select → filter select → each row's primary link, then that row's "Watered today" button, then the next row's primary link, and so on → (if present) any trailing "Show more" or pagination control. |
| Plant list screen | Shift+Tab |
Reverses the order above. |
| Plant list row (primary link/button focused) | Enter or Space |
Activates the row, navigating to plant detail. |
| Plant list row ("Watered today" button focused) | Enter or Space |
Marks the plant watered (Section 11) without navigating anywhere; focus remains on the button, which updates its own accessible state (19.5) and the row's status badge in place. |
| Any context | Escape |
Dismisses exactly one thing, resolved by a fixed precedence (highest first), never more than one per keypress: (1) the topmost open ConfirmDialog — closes it without confirming; while a ConfirmDialog is open over a form modal, the form modal's own Escape handling is suspended, so the same keypress can never close both; (2) otherwise, an open form modal (add/edit) — closes it without saving, returning focus per 19.4; (3) otherwise, a visible toast, but only when focus is currently inside that toast — a toast is never dismissed by Escape pressed elsewhere on the page, so a keyboard user cannot lose the Undo affordance by accident; (4) otherwise, the search input, but only when focus is currently inside it and it is non-empty — clears the input and returns focus to the list's first row if results exist, or to the search input itself if the list is now empty. If none of the four conditions holds, Escape does nothing. |
| Any open modal | Tab / Shift+Tab |
Cycles only within the modal's focusable elements (focus trap, below); focus never escapes to the underlying screen while the modal is open. |
| List, detail, or history screen | Arrow keys | No behaviour is bound to arrow keys anywhere in this product. The plant list is a simple list of independently-focusable native elements, not a composite ARIA widget (it does not use role="listbox", role="grid", or role="tree"). Implementers must not add a roving-tabindex arrow-key pattern to the list; doing so would break the plain Tab-based navigation described above and add behaviour users do not expect from a list of links. |
| Unlock screen | Enter (with focus anywhere in the form) |
Submits the access code. |
Modal focus trap. On open, focus moves to the modal's first focusable element (typically the first
form field, or the <h2> if the modal is purely confirmational, per 19.4). Tab and Shift+Tab cycle
only through elements inside the modal's DOM subtree (implemented with a focus-trap utility that
listens for focusin outside the modal boundary and redirects it back in, rather than an
inert-only approach, for broader browser support at the time of writing). Clicking the backdrop
closes the modal with the same behaviour as Escape.
19.4 Focus management #
| Event | Focus destination |
|---|---|
| Adding a plant successfully | The add form closes — as a modal at md+ or by navigating back to / as a route at base width (Section 14.2, 23.4) — and focus moves to the newly created plant's row in the list (scrolled into view), overriding the route-change rule below for this one case. If the list is sorted such that the new row is off-screen, the row still receives focus and the browser's native scroll-into-view behavior brings it on screen. |
| Deleting a plant | Focus moves to the next row in the list (the row that shifted up into the deleted row's position). If the deleted row was the last one, focus moves to the new last row. If it was the only plant, focus moves to the empty state's call-to-action button (18.4). |
| Restoring a plant (via the undo toast, Section 10.6) | Focus remains wherever it already was (typically the toast's Undo button); the restored row reappears in the list but focus is not forced onto it, since the user's attention is on the toast they just activated. |
| Opening a modal | Focus moves to the modal's first focusable element (19.3). |
| Closing a modal (Escape, backdrop click, or Cancel) | Focus returns to the element that opened the modal (the row's edit affordance, or the "Add your first plant" / header "Add plant" button). |
| Closing a modal via successful submit | Add: focus goes to the new row (see row 1). Edit: focus returns to the plant detail screen's h1, since the edit was launched from plant detail and the user's context is that screen, now showing updated values. |
An action-carrying toast (e.g. the delete/Undo toast) appears, and the action that produced it originated from a keyboard-activated control (event.detail === 0 on the originating click event) |
Focus moves to the toast's action button (e.g. "Undo") on mount, so a keyboard user reaches it in zero additional Tab presses instead of tabbing through every remaining row. The toast's auto-dismiss timer (Section 16.7) never runs while the toast contains focus or is hovered. |
| Same, but the originating control was pointer-activated | Focus is not moved; the toast still auto-dismisses on its normal schedule (Section 16.7). Every action-carrying toast keeps its own X dismiss button regardless of how it was triggered, so a mouse user who wants to dismiss it early always can. |
| Dismissing any toast — action-carrying or informational, by its action, its dismiss button, or auto-dismiss elapsing | If focus had been moved to the toast per the keyboard-activation row above, focus returns to the element that originally triggered the toast, or to that element's replacement in the list if the original element no longer exists (e.g. the row was deleted), or to the SummaryBar (tabindex="-1") if neither exists. If focus was never moved to the toast (informational toast, or an action-carrying toast from a pointer-activated control), focus is not moved by the dismissal. |
| Watering a plant while the "Needs water" filter (Section 12.4) is active, so the just-watered row leaves the visible list | If the acted-on row's "Watered today" button held focus, focus moves to the SummaryBar (tabindex="-1"); the row's button no longer exists in the filtered view, so focus is never allowed to fall to <body>. If the row remains visible (filter is "All plants"), focus stays on the button. |
| Deleting a watering history entry | Focus moves to the next remaining HistoryRow's delete button. If the deleted row was the last one, focus moves to the previous row's delete button. If it was the only entry, focus moves to the "Watering history" <h2> (tabindex="-1"). |
| Loading more watering history via "Show more" (Section 11.4) | Focus stays on the "Show more" button (it remains present unless every entry is now loaded). If every entry is now loaded and the button is removed, focus moves to the "Watering history" <h2> (tabindex="-1"). Either way, the polite live region announces detail.history.loaded — "Showing {loaded} of {total} watering entries." |
| A query error replaces a surface with a full-surface or panel error state (Section 18.2) while focus was inside the surface that was replaced | Focus moves to the error panel's heading (tabindex="-1"). |
| The search input unmounts because the active plant count falls to 10 or fewer while it held focus (Section 12.5) | Focus moves to the SummaryBar (tabindex="-1"). |
| Route change (navigating to a new screen) | Focus moves to the destination screen's <h1>, which carries tabindex="-1" so it is programmatically focusable without being in the normal tab order. This gives screen-reader users an immediate, unambiguous announcement of the new screen's identity, matching the pattern recommended for client-side-routed single-page apps. This rule is overridden by the "Adding a plant successfully" row above, where focus goes to the new row instead of the list's <h1>. |
19.5 Screen-reader experience #
Every interactive element has an accessible name that identifies both the action and its target where the target is not otherwise obvious from context. Patterns:
| Element | Accessible name pattern | Example |
|---|---|---|
| Plant row's primary link | Plant name + status, via visible text content (no extra aria-label needed since the visible text already says it) |
"Monstera, overdue by 3 days" (visible text: name + status badge text, concatenated in DOM order so it reads naturally) |
| "Watered today" button | No aria-label. The visible text "Watered today" (Section 15.11 plantRow.waterToday) is followed by a visually-hidden , {plantName} span, so the accessible name is the visible label followed by the disambiguating plant name — satisfying SC 2.5.3 (Label in Name), since a speech-input user saying "Watered today" still matches, and every row's button remains uniquely identified. |
Visible: "Watered today"; accessible name: "Watered today, Monstera" |
| Edit affordance on plant detail | Visible text "Edit plant" (Section 15.11 detail.editPlant) plus aria-label="Edit plant: {plantName}", so the accessible name contains the visible label verbatim |
aria-label="Edit plant: Monstera" |
| Delete affordance | Visible text "Delete plant" (Section 15.11 detail.deletePlant) plus aria-label="Delete plant: {plantName}", so the accessible name contains the visible label verbatim |
aria-label="Delete plant: Monstera" |
| Delete confirmation's confirm button | Visible text "Delete" is sufficient; the modal's h2 ("Delete {plantName}?") supplies the disambiguating context per 19.6's dialog labelling rule |
— |
| Search input | aria-label="Search plants" (no visible <label> is shown per the compact header design, Section 17; the aria-label substitutes for it) |
— |
| Sort select | The <select>'s bound <label> "Sort by" is the accessible name; the current value is exposed natively by the selected <option> |
— |
| Filter select ("All plants" / "Needs water") | The <select>'s bound <label> "Show" (Section 15.11 controls.filterLabel) is the accessible name; the current value is exposed natively by the selected <option>, not by aria-pressed (a native <select> has no pressed state) |
— |
| Close button on any modal | aria-label="Close" |
— |
| Restore affordance (in the undo toast) | Visible text "Undo" plus the toast's own accessible name supplies context ("Plant deleted. Undo.") | — |
| Theme toggle | aria-label="Theme: {current}. Change theme.", {current} one of "light" / "dark" / "system" — the accessible name always states the current value, never only the next action, satisfying SC 4.1.2 for a three-state control that cannot use aria-pressed |
aria-label="Theme: dark. Change theme." |
| Interval stepper's decrement/increment buttons at a boundary (1 or 365 days) | aria-disabled="true" with a no-op handler, never the native disabled attribute, so the button stays in the tab order and does not throw focus to <body>; activating it announces a polite message ("Minimum is 1 day." / "Maximum is 365 days.") via the live region below |
— |
Add a11y.waterPlant, a11y.editPlant, a11y.deletePlant, a11y.searchPlants, a11y.themeToggle as
the corresponding Section 15.11 keys for the patterns above that are not already sourced from a visible
string, so no component in this table renders a literal outside Section 15.11's catalogue.
Live regions.
| Region | role |
aria-live |
Used for |
|---|---|---|---|
| Toast container | status |
polite for informational/success toasts; a second, separate role="alert" region carries error toasts (toast.error.*) so a failure is not queued behind whatever a screen reader is currently reading and lost once the toast auto-dismisses. role="status" already implies aria-live="polite"; both are set explicitly for clarity and older assistive-technology compatibility. |
All toast messages (18.5, 18.8, delete/restore confirmations). |
| Form-level error banner | alert |
(implicit assertive via role="alert") |
Form-level error banners in 18.5, because these represent a blocking condition the user must address before continuing, warranting an immediate interruption rather than a polite queue. |
| Summary bar ("N plants need water") | status |
polite |
Updates when the underlying count changes (after a mutation or a background refresh), announced without moving focus (4.1.3). |
| Offline banner (18.6) | status |
polite |
Announced once when it appears and once when it is removed; not re-announced on every render. |
Result count, beneath ListControls |
status |
polite |
Announces controls.resultCount — "{count} plants shown." — after any sort, filter, or debounced search change (Section 12), so a screen-reader user learns the list changed without the change being silent. Suppressed on initial mount, since the count is already conveyed by the list itself loading. |
The status badge's color meaning is always carried redundantly in text (1.4.1): the badge's accessible name includes the human-readable status word ("Overdue", "Due today", "Due soon", "Upcoming") drawn directly from the same copy used visually — there is no icon-only or color-only variant anywhere in the product.
19.6 Forms and errors #
- Label association. Every input has a
<label for="...">bound to itsid(search'saria-labelin 19.5 is the only exception, justified by its compact header context). No placeholder text is ever used as a substitute for a label. - Hints. Where a field has helper text (e.g., "1–365 days" under the interval field), the hint's
element
idis included in the input'saria-describedby, appended to (not replacing) any existingaria-describedbyvalue. - Errors. When a field fails validation, an error message element is rendered with a stable
id, and thatidis added to the input'saria-describedby(alongside any hintid, space-separated), and the input receivesaria-invalid="true". When the field becomes valid again,aria-invalidis removed (not set to"false"— its absence is the correct default per the ARIA spec) and the erroridis removed fromaria-describedby. - Error summary on submit. If a submit attempt fails client-side validation on more than one field,
an error summary is inserted at the top of the form, inside a
role="alert"region, listing each failing field's label as a link that moves focus to that field when activated. The summary content is therole="alert"region, but the element that receives focus is a plain<div tabindex="-1">wrapper around it, not therole="alert"element itself — focusing an element withrole="alert"directly causes several screen readers to announce the content twice (once for the live-region interruption, once for the focus move). If exactly one field fails, no summary is shown — focus goes directly to that field (next bullet) since a one-item summary adds a redundant step. - First invalid field receives focus. On a failed submit, focus moves to the summary wrapper (if shown) or directly to the first invalid field in DOM order (if only one field failed). This summary-vs-direct choice is fixed regardless of assistive technology, since it is a document-structure decision, not a technology-detection one.
- Interval stepper at a boundary. Decrementing to 1 day or incrementing to 365 days disables the
respective button via
aria-disabled="true"with a no-op handler, never the nativedisabledattribute — a natively disabled element is removed from the tab order, which would throw focus to<body>for a keyboard user stepping down to the minimum. Activating the boundary button announces a polite message ("Minimum is 1 day." / "Maximum is 365 days.") via the summary-bar live region's pattern (Section 19.5).
19.7 Contrast verification #
Every token pair's measured contrast ratio is stated once, in Section 15.2 (status tokens in 15.2.1,
neutral surface/text/border tokens in 15.2.2, each with its light and dark value); this section does not
restate those numbers, so the two never drift out of agreement with each
other. This section fixes the thresholds and the verification method: every text pair (body text,
status-badge text) meets ≥ 4.5:1 against its surface; every non-text pair (input borders, focus
indicators, icon-only buttons, the status badge's boundary) meets ≥ 3:1 against its adjacent surface.
--color-border-default (the decorative card-edge token) is exempt from the 3:1 non-text rule — a card
edge is decoration, not a component boundary a user must perceive — so it must never be "fixed" by
weakening --color-border-strong, which is a real, load-bearing boundary (an input's border is its only
visual edge at rest, Section 15.7.2 gives inputs no shadow) and is held to the full 3:1 requirement.
--color-border-strong measures 4.76:1 (light, on --color-surface-raised) and 6.51:1 (dark), both
comfortably clear of the 3:1 bar.
The focus ring uses --color-focus-ring (Section 15.2), the same token everywhere a focus indicator is
drawn (Section 15.7.1) — never a due-status colour, so a focused element never reads as carrying
due-status meaning.
scripts/check-contrast.mjs re-derives every ratio at build time from the token source file
(apps/web/src/styles/tokens.css, Section 15.2) using the WCAG relative-luminance formula and fails the
build if any pair drops below its threshold or if the documented ratio in Section 15.2 no longer matches
the computed one. Critically, the script asserts non-text pairs as well as text pairs —
border-strong ↔ surface-raised, border-default ↔ surface-raised, and focus-ring ↔ surface-app
are each asserted at ≥ 3:1 — because @axe-core/playwright (Section 19.10) does not check SC 1.4.11 at
all, and a CI gate that ran only axe would pass an input-border regression like the one this section
exists to prevent. A future token edit that drops any pair below its threshold, text or non-text, fails
the build rather than being caught only by manual review.
19.8 Touch and pointer #
- Every interactive element (buttons, links, the filter select, the search input's clear button, checkboxes if any appear in future screens) has a minimum touch target of 44×44 CSS px, per the design-token rule (Section 15). Where a visual icon is smaller than 44px (e.g., a 20px icon), the tap target is expanded via padding, not by scaling the icon, so the icon's visual weight stays correct.
- Adjacent targets (e.g., a row's primary link and its "Watered today" button sitting side by side) maintain a minimum 8px gap between their touch-target boxes, not just their visible boundaries, so a 44px target's padding does not overlap its neighbor's.
- No affordance is hover-only. Every hover-revealed visual treatment (e.g., a subtle row background on
desktop hover) has an always-visible equivalent on touch — the "Watered today" button and edit/delete
affordances are always rendered, never revealed only on
:hover. - No gesture-only action exists anywhere in the product. Specifically, there is no swipe-to-delete: the delete affordance is always a visible, tappable button that opens the confirmation described in Section 17, because a swipe gesture has no discoverable, keyboard-equivalent, or screen-reader- equivalent path.
19.9 Motion, zoom and reflow #
- Reduced motion. Every animation (skeleton pulse, toast enter/exit, modal enter/exit, spinner
rotation) is either disabled or reduced to an instant/near-instant state change when
prefers-reduced-motion: reduceis set, implemented via a single Tailwindmotion-reduce:variant applied consistently rather than per-component overrides. A spinner under reduced motion still indicates "in progress", but via its pending text label alone (plantRow.waterPending— "Watering…", or "Saving…" for form submits, Section 15.11) with no icon rendered at all, rather than a motionless spinner glyph that would convey nothing. - Zoom and reflow. The layout supports 200% browser zoom at a 320px-wide viewport without
introducing horizontal scrolling or clipping content, per WCAG 1.4.10 (Reflow). This is achieved by
the mobile-first breakpoint system (Section 15) already targeting 360px as its narrowest design width
— 200% zoom on a 320px viewport is equivalent in available layout width to designing for a 160px
viewport. Long plant names (up to 60 characters, Section 9) wrap to at most two lines (
line-clamp-2, Section 17.1) at every breakpoint, including the list row; there is notitle-attribute truncation anywhere in the product, sincetitleis unavailable on touch and is not a conforming SC 1.4.10 mechanism. The full, untruncated name is always available on the plant detail screen. - No text in images. All icons are vector (
lucide-reactSVG components); no status, label, or instruction is ever conveyed as a raster image containing text.
19.10 Testing #
Automated. @axe-core/playwright runs against every route defined in Section 14 — including
/settings — in both light and dark theme, as part of the Playwright E2E suite (Section 25). Zero
violations of any severity is a CI merge gate — a single "moderate" violation fails the build the same as
a "critical" one; there is no severity threshold below which violations are accepted, because the
product's surface area is small enough that a real zero-violation bar is achievable and any threshold
would immediately start accumulating debt.
Manual checklist. Performed once per release candidate, recorded as a checklist in the release notes (Section 26):
- With an unrelated toast currently on screen (so the check exercises 2.4.11's obscured-focus case, Section 19.1/19.5, not just a clean layout), unplug the mouse/disable trackpad. Using only the keyboard, reach and activate every control on the plant list screen (search, sort select, filter select, each row's link, each row's water button, add-plant button), confirming the currently focused element is never fully hidden behind the sticky header or the toast.
- From the plant list, keyboard-navigate into add plant, fill and submit the form using only the keyboard, and confirm focus lands on the new row (19.4).
- Keyboard-navigate into edit plant, change the interval, submit, and confirm focus returns to plant
detail's
h1with updated values visible. - Keyboard-trigger delete, confirm the modal traps focus (
Tabcannot leave it), confirmEscapecloses it without deleting, then confirm delete viaEnterand verify focus lands per 19.4. - Water a plant using only the keyboard, then reach and activate "Undo" in the resulting toast using
only the keyboard, confirming it is reachable in at most two
Tabpresses and that the toast does not auto-dismiss while it holds focus (19.4). - On iOS Safari, enable VoiceOver and repeat steps 1–5, confirming every announcement matches the accessible names in 19.5 and that status badges are announced with their text label, not just a tone or icon.
- On Windows Firefox, enable NVDA and repeat steps 1–5, additionally confirming the offline banner (18.6) and a validation error (18.5) are both announced without requiring the user to manually navigate to them.
- Zoom the browser to 200% at a 320px-wide window and confirm no horizontal scrollbar appears and no content is clipped, on the plant list, plant detail, and both forms.
- Enable
prefers-reduced-motion: reduceat the OS level and confirm the skeleton, toast, and modal transitions all become instant or near-instant with no residual animation.
20. Security, Privacy and Threat Model #
This section is canonical for the threat model, security headers, CSP, and the optional access code.
20.1 Honest posture statement #
The default deployment has no authentication. There is no login screen, no username, no password, and no session by default. Anyone who has the app's URL — whether they were given it, found it in browser history, discovered it through a network scan, or found it indexed by a search engine — has the same full read and write access as the intended owner: they can view every plant, edit every plant, delete every plant, and read every note. This is a deliberate product decision, not an oversight, and this document does not pretend otherwise.
The mitigation this product ships with by default is one sentence: the URL is not published anywhere
public, and Section 20.3's crawler/referrer hygiene measures exist to keep it that way by accident.
Beyond that, three optional layers of hardening are available and described in this section: the
APP_ACCESS_CODE unlock screen (20.5), network-level isolation (recommended in 20.2's mitigation
column, implemented outside this application — e.g., a private network, VPN, or reverse-proxy IP
allowlist), and standard host hygiene (keeping the container and its host patched, which is an
operational concern outside this application's code). None of these are required by the application
itself; the application functions correctly with zero of them enabled.
20.2 Threat model #
| # | Threat | Likelihood | Impact | Mitigation | Residual risk |
|---|---|---|---|---|---|
| 1 | URL discovered or shared unintentionally (screenshot, browser history on a shared device, accidental paste into a chat) | Medium | High (full read/write to all plant data) | Unlisted URL by default; optional APP_ACCESS_CODE (20.5) adds a second factor beyond URL possession |
If the code is also shared or the code is off, access is full. This is the single largest residual risk in the product and is stated plainly to the operator in Section 21's operator-facing documentation. |
| 2 | Search-engine indexing of the URL | Low (unlisted URLs are not linked from anywhere crawlable) | High if it occurs | X-Robots-Tag: noindex, nofollow header on every response, a robots.txt disallowing all paths (20.3) |
Near zero if headers are respected; malicious/non-compliant crawlers are not fully preventable by these headers alone, which is why network-level isolation is recommended for anyone with a stricter requirement. |
| 3 | A shared/public device retains browser history or autofill pointing at the URL | Medium (depends entirely on operator's device hygiene, outside this app's control) | High | No control inside the application; documented as an operator responsibility in Section 21 | Fully dependent on operator behaviour; not mitigable in software. |
| 4 | On-path attacker intercepts traffic when the app is served without HTTPS | Medium (depends on deployment; many self-hosted setups sit behind a TLS-terminating reverse proxy, but the app itself does not enforce HTTPS) | High (plaintext interception of all requests, including the access code if 20.5 is enabled) | Documented recommendation to always terminate TLS in front of the app (reverse proxy or platform-provided HTTPS); the Secure cookie flag (20.5) is conditional on HTTPS being detected so it does not silently create a false sense of security over plain HTTP |
If deployed over plain HTTP, all traffic including the access code is interceptable on a shared network. This is an infrastructure decision outside the application's runtime control. |
| 5 | XSS via the notes field | Low (React escapes all text by default; no dangerouslySetInnerHTML is used anywhere) |
High if it occurred | Notes are rendered exclusively as React text nodes (never HTML/Markdown, Section 9); dangerouslySetInnerHTML is banned by an ESLint rule (Section 6); CSP script-src has no unsafe-inline (20.4) as defense in depth |
Near zero given the combination of framework-level escaping, a lint-enforced ban, and CSP. |
| 6 | SQL injection | Low (all queries use better-sqlite3 prepared statements exclusively) |
High if it occurred | No string-concatenated SQL anywhere in the codebase (Section 7); enforced by code review and by the repository-layer pattern being the only path to the database | Near zero given prepared statements are structurally the only access path. |
| 7 | CSRF (a malicious page on another origin triggers a state-changing request against this app using the victim's browser) | Low by default (no cookies exist in the default configuration, so there is no ambient credential for a forged request to ride on); Medium if APP_ACCESS_CODE is enabled |
Medium (state-changing actions are all reachable, but limited to plant CRUD — no destructive account-level actions exist) | Default config: no cookies, so classic cookie-based CSRF has no ambient credential to exploit. With APP_ACCESS_CODE: SameSite=Strict on the session cookie (20.5) prevents the cookie from being sent on cross-site requests at all, which covers CSRF without a separate token scheme |
With SameSite=Strict, residual risk is near zero in modern browsers; older browsers without SameSite support are the only gap, judged acceptable given this app's threat profile. |
| 8 | Denial of service by request flooding | Medium (any exposed HTTP endpoint can be flooded) | Medium (temporary unavailability; no data loss since SQLite writes are transactional) | Rate limiting via @fastify/rate-limit (APP_RATE_LIMIT_MAX / APP_RATE_LIMIT_WINDOW_MS, Section 21) |
A sufficiently distributed flood beyond per-IP limiting is not mitigated by this application alone; recommended mitigation for that scale is network/infrastructure-level (outside this app). |
| 9 | Disk exhaustion via large payloads | Low (body size caps enforced) | Medium (could fill the SQLite volume) | Max request body 64 KB for normal endpoints, 5 MB for import (Section 13); PAYLOAD_TOO_LARGE rejects anything over the cap before it is written |
Near zero given hard caps enforced at the HTTP layer before any disk write occurs. |
| 10 | A malicious import file (crafted JSON attempting to inject unexpected structure, oversized arrays, or excessively long strings) | Low-Medium (import is a user-invoked, infrequent action, but the file's contents are untrusted input) | Medium (could exceed limits, corrupt import state) | Import payload is validated against the same Zod schemas as manual entry (Section 9) — every plant object in the import is validated field-by-field with the same length/range rules, and the 5 MB body cap plus the 500-plant hard cap (Section 8) bound the import's total effect; the import is wrapped in a single SQLite transaction so a validation failure partway through rolls back entirely rather than partially applying | Low; the worst case is a rejected import with a clear error, not data corruption. |
| 11 | Dependency supply-chain compromise (a malicious or compromised npm package) | Low-Medium (inherent to any Node.js project) | High if it occurred | npm ci with a committed lockfile (no floating versions), npm audit failing CI on high/critical findings, weekly automated dependency update checks, a stated preference for zero new runtime dependencies beyond the Section 5 inventory (20.9) |
Cannot be reduced to zero for any software project; the mitigations reduce exposure window and catch known-vulnerable versions, not zero-day supply-chain attacks. |
| 12 | Backup file exposure (the nightly SQLite snapshot, Section 24, is readable by someone who should not have it) | Low (backups live on the same host/volume as the primary database, under the same access boundary as the app itself) | High (a backup file is a full copy of all plant data) | Backups are written to APP_BACKUP_DIR on the same private volume as the primary database — no separate, more-permissive storage location is introduced by this application; operators are told in Section 21/26 to apply the same access controls to the backup directory as to the database file |
Fully dependent on host-level file permissions and volume access controls, which are outside this application's runtime enforcement. |
| 13 | Log leakage of user content (plant names or notes appearing in logs, which may be shipped to a less-restricted log aggregator than the database itself) | Low (structurally prevented — see mitigation) | Medium-High if it occurred, since logs are often retained longer and viewed by more people than the primary database | Plant names and notes are never included in any log line (Section 22); log statements pass only structured, non-content fields (ids, counts, durations); code review checks new log call sites against this rule | Near zero given the structural exclusion; the residual risk is a future code change accidentally logging a full object without stripping content fields, mitigated by the log-schema convention in Section 22 being the single place new events are added. |
20.3 Search engine and referrer hygiene #
- Every HTTP response includes
X-Robots-Tag: noindex, nofollow— set once, globally, by the same Helmet-configuring plugin that sets the other security headers (20.4), not per-route. apps/web/public/robots.txt(served at/robots.txt) contains exactly:User-agent: * Disallow: /- Every response sets
Referrer-Policy: no-referrer, so if a user follows an outbound link from within the app (there are none by default, since the app has no external links, but the header is set regardless as defense in depth for any future content), the app's URL is never leaked in theRefererheader of that outbound request. - The app makes no external network requests of any kind from the browser: no web fonts (system
font stack, Section 15), no analytics, no CDN-hosted scripts or styles, no third-party embeds. This
is enforced structurally by the CSP in 20.4 (
connect-src 'self', no other origins permitted) and means the app's URL can never appear in a third party's server access logs, DNS logs, or analytics dashboards through normal use.
20.4 Security headers #
All headers below are set via @fastify/helmet with explicit overrides in
apps/api/src/plugins/security-headers.ts, applied globally to every response (including error
responses and the static frontend bundle).
| Header | Value |
|---|---|
Content-Security-Policy |
See full policy below. |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-Robots-Tag |
noindex, nofollow (20.3) |
Referrer-Policy |
no-referrer (20.3) |
Strict-Transport-Security |
max-age=63072000; includeSubDomains — sent only when the request arrived over HTTPS or APP_TRUST_PROXY is true and X-Forwarded-Proto: https is present, to avoid instructing browsers to force HTTPS on a deployment that has not configured it |
Cross-Origin-Opener-Policy |
same-origin |
Cross-Origin-Resource-Policy |
same-origin |
Permissions-Policy |
geolocation=(), camera=(), microphone=(), payment=(), usb=() — explicitly denies every browser feature this app has no use for |
Content-Security-Policy, written out in full:
default-src 'none';
script-src 'self' 'sha256-<theme-script-hash>';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
manifest-src 'self';
base-uri 'none';
form-action 'none';
frame-ancestors 'none';
object-src 'none';Notes on each non-obvious directive:
default-src 'none'closes every resource type not explicitly reopened below — the strictest possible baseline.script-src 'self' 'sha256-<theme-script-hash>'allows same-origin bundled scripts plus exactly one inline script: the small dark-mode bootstrap script that readslocalStorage['hpt.theme'](Section- and applies the theme class before first paint, avoiding a flash of the wrong theme. Because it
runs inline (it must, to execute before the stylesheet paints), it cannot use
'unsafe-inline'without weakening the policy for all scripts — instead its exact byte content is hashed at build time and the resultingsha256-...value is the only inline script CSP permits. The build computes this hash inapps/web/vite.config.tsvia a small plugin that reads the literal script string, computessha256over its UTF-8 bytes, base64-encodes the digest, and writes it toapps/web/dist/csp-hash.json({ "themeScriptHash": "sha256-XXXX..." }) as a build artifact. The API server reads that file once at boot (failing fast with a clear error if it is missing — the frontend build must run before the API starts in the Docker multi-stage build, Section 26) and substitutes it into the CSP header.img-srcallowsdata:for the small inline SVG icons rendered asdata:URIs in a handful of places (e.g., a favicon); no other remote or blob image source is needed since the app has no photo/image upload feature (explicitly out of scope).
- and applies the theme class before first paint, avoiding a flash of the wrong theme. Because it
runs inline (it must, to execute before the stylesheet paints), it cannot use
connect-src 'self'permitsfetch()calls only to the app's own origin — the frontend never talks to any other host.manifest-src 'self'allows the browser to fetch/manifest.webmanifest(Section 14.6). Without it,manifest-srcfalls back todefault-src 'none'and the save-to-home-screen affordance silently never works, with a CSP violation logged on every page load. Loading/produces zero CSP violations in the browser console, including the manifest request (20.10).form-action 'none'— every mutation in this app happens viafetch()from React event handlers, not native HTML form submission, so no origin ever needs to be a form action target, including the app's own.frame-ancestors 'none'prevents the app from being embedded in an iframe anywhere, closing clickjacking as an attack surface (paired withX-Frame-Options: DENYfor older browsers).
20.5 Optional access code #
APP_ACCESS_CODE is unset by default; when unset, no unlock screen exists and every route behaves as
described everywhere else in this document. The following applies only when an operator sets it.
- Endpoints.
POST /api/v1/sessionaccepts{ "accessCode": string }, validates it, and on success sets the session cookie and returns201with{ "data": { "unlocked": true } }.DELETE /api/v1/sessionclears the cookie and returns204, giving the user an explicit way to lock the app again on a shared device. - Comparison. The submitted code and the configured code are each hashed with SHA-256, and the two
digests are compared with
crypto.timingSafeEqual, never with===or a loop-based comparison, to avoid leaking timing information about how many leading characters matched. - Cookie. Name
hpt_session. Attributes:HttpOnly,SameSite=Strict,Path=/,Max-Agerepresenting a 30-day rolling expiry (reset on every authenticated request, so an actively used session never expires mid-use), andSecureset whenever the request was received over HTTPS orAPP_TRUST_PROXYistrueand the proxy reportsX-Forwarded-Proto: https(20.4's HSTS logic uses the same detection). The cookie's value is a signed token (HMAC-SHA256) keyed byAPP_SESSION_SECRET, containing an issue timestamp andsha256(APP_ACCESS_CODE)truncated to its first 16 hex characters; the server verifies the HMAC signature, recomputes the rolling expiry on every request rather than trusting a client-supplied expiry claim, and additionally rejects the token if its embedded truncated hash does not match the currently configuredAPP_ACCESS_CODE. This makes rotating a leaked access code self-enforcing: an operator who changesAPP_ACCESS_CODEbecause it leaked automatically invalidates every existing session on the next request each one makes, with no separate "sign everyone out" step required. - Secret source.
APP_SESSION_SECRETis read from the environment if set (minimum 32 characters, Section 21); if unset, the server generates a cryptographically random 32-byte secret at first boot and persists it in thesettingstable (Section 7.5) so restarts do not invalidate every existing session. The secret is never logged (20.8) and never returned by any API response. - Lockout. Five failed attempts within a rolling 15-minute window, tracked per client IP in-memory
(the same store the rate limiter uses, Section 21), locks out further attempts from that IP until 15
minutes after the fifth failed attempt. A locked-out attempt returns
429 RATE_LIMITEDwith the standard envelope and must include aRetry-Afterheader in seconds (Section 13.7's mechanism, reused here rather than duplicated); the unlock screen renders the countdown copyunlock.error.lockedOut— "Too many attempts. Try again in {minutes} minutes." — usingMath.ceil(retryAfter / 60), with a singular variantunlock.error.lockedOutOne— "Too many attempts. Try again in 1 minute." — when the computed value is exactly 1. The failure message for a simply-wrong code (not locked out) is the generic "Incorrect access code." — it never distinguishes "wrong code" from "no such code was ever configured" or any other internal state. - Gated routes. Every route under
/api/v1/*requires a valid session cookie except:POST /api/v1/session(the unlock action itself must be reachable while locked out),GET /api/v1/healthandGET /api/v1/health/ready(so container orchestration liveness and readiness checks keep working regardless of lock state, Section 22.5), andGET /api/v1/meta(the frontend needsaccessCodeEnabledbefore it can even decide whether to render the unlock screen, Section 21.5). No operational or informational endpoint in this list reveals plant data. The static frontend bundle itself (HTML/JS/CSS) is served ungated (a locked-out user must be able to load the unlock screen's own code), and the SPA's client-side router redirects to the unlock screen whenever an API call returns401 UNAUTHORIZED, preserving the originally requested route so the user lands back where they intended after unlocking.
20.6 Input handling #
- Every request body is validated against the shared Zod schemas (Section 9) before touching any business logic; validation is the authority, not a UI convenience (Section 9 restates this).
- Every SQL statement uses
better-sqlite3prepared statements with bound parameters exclusively; no string-built SQL exists anywhere in the codebase (Section 7), with a single documented exception:VACUUM INTOinlib/backup.ts(Section 24.2), whose target path SQLite does not allow to be a bound parameter. The path is always server-generated (the timestamped snapshot filename), never derived from request input, and is single-quote-escaped before interpolation, so it carries no injection surface despite not being a prepared-statement parameter. - Notes (and every other user-supplied string) are rendered exclusively as React text nodes. The
dangerouslySetInnerHTMLAPI is never used and is banned by an ESLint rule (react/no-danger, configured as an error, Section 6) so a future contributor cannot reintroduce it without a CI failure. - The export endpoint (Section 24) sets
Content-Disposition: attachment; filename="houseplant-tracker-export.json"— a fixed, hardcoded filename, never derived from user input, so there is no path-traversal or header-injection surface via a crafted filename.
20.7 Privacy #
The application stores plant names, watering intervals, optional free-text notes, and calendar/timestamp data (created/updated/watered dates). No other personal data is deliberately collected — there is no account system, no email address, no name, no location beyond whatever a user chooses to type into a notes field of their own accord.
- Nothing leaves the host. There is no telemetry, no analytics, no crash reporting service, no third-party script of any kind (20.3, 20.4). The only network traffic the running application generates is the browser talking to its own same-origin API.
- No cookies in the default configuration. With
APP_ACCESS_CODEunset, the application sets zero cookies. A cookie only exists at all when an operator opts into the optional hardening in 20.5. - Data deletion. Deleting all personal data stored by this application is a single operational
action: delete the SQLite database file at
APP_DATABASE_PATH(and its WAL/SHM sidecar files) along with any retained backups underAPP_BACKUP_DIR. There is no other system, cache, or third party holding a copy.
20.8 Logging and secrets #
- Plant names and notes are never written to any log line, under any log level, in any code path — this is enforced by convention (Section 22's log schema defines only structural fields, never free-text content fields) and checked in code review.
APP_ACCESS_CODEandAPP_SESSION_SECRETare never logged, including in the startup configuration summary (Section 21.3), which explicitly masks both.- The
cookieandauthorizationrequest headers are redacted ([REDACTED]) in the structured request log (Section 22.3) rather than omitted, so the log line's shape stays consistent for tooling while the value never appears. APP_SESSION_SECRETis never returned by any API response body, including error responses and the configuration-echo path (there is none — this application has no endpoint that echoes configuration).
20.9 Dependency and supply-chain policy #
- Installs use
npm ciexclusively in CI and Docker builds (nevernpm install), against a committedpackage-lock.json, so builds are reproducible and no floating version can silently introduce a compromised transitive dependency. npm audit --audit-level=highruns in CI (Section 26) and fails the build on any high or critical advisory with no available fix; advisories with an available fix block merge until the fix is applied.- Dependency updates are checked weekly via an automated PR-opening tool (Dependabot or equivalent),
configured for the npm ecosystem across all three workspaces (
apps/web,apps/api,packages/shared). - The project holds a stated preference for zero new runtime dependencies beyond the inventory fixed in Section 5: every dependency in that table was chosen deliberately for this app's scope, and adding another runtime dependency (not a dev dependency) requires the same scrutiny as adding a new feature — it is not a decision made lightly mid-implementation.
20.10 Security acceptance checklist #
- Every response includes the full header set from 20.4, verified by an automated test that inspects response headers on at least one route of each method (GET, POST, PATCH, DELETE).
- The CSP header contains no
unsafe-inlineand nounsafe-evalanywhere inscript-src. - The theme bootstrap script's hash in the CSP header matches the actual deployed script byte-for-byte
(verified by the build failing if
csp-hash.json, 20.4, is stale or missing). robots.txtandX-Robots-Tagare both present and correctly block all indexing.- No
dangerouslySetInnerHTMLusage exists in the frontend codebase (enforced by the ESLint rule failing CI if violated). - No string-concatenated SQL exists in the backend codebase outside the documented
VACUUM INTOexception in Section 20.6, verified by code review checklist item in Section 26 and by the repository-layer pattern being the sole database access path otherwise. npm audit --audit-level=highpasses with zero unresolved high/critical advisories at release time.- With
APP_ACCESS_CODEunset, noSet-Cookieheader appears on any response. - With
APP_ACCESS_CODEset, the unlock endpoint enforces lockout after 5 failed attempts within 15 minutes, verified by an automated test. - With
APP_ACCESS_CODEset, the session cookie carriesHttpOnly,SameSite=Strict, and (over HTTPS)Secure, verified by an automated test inspecting theSet-Cookieheader. - Both
GET /api/v1/healthandGET /api/v1/health/readyreturn 200 without a valid session even whenAPP_ACCESS_CODEis set, verified by an automated test that sets the access code and calls both endpoints without a session cookie. - Request bodies over 64 KB (or 5 MB for import) are rejected with
413 PAYLOAD_TOO_LARGEbefore being written to the database, verified by an automated test. - Plant names and notes never appear in any log output, verified by a test that creates a plant whose name and whose notes are both distinctive, searchable strings and exercises create, update, water, delete, restore, export, import, and a forced 500 response, asserting that neither string appears anywhere in captured log output across the full sequence.
APP_SESSION_SECRETandAPP_ACCESS_CODEvalues never appear in the startup log or in any API response, verified by a test that sets both and inspects all captured output.- The export endpoint's
Content-Dispositionfilename is the fixed constant regardless of any request input, verified by an automated test. npm ci(notnpm install) is the installation command used in every CI job and the Dockerfile, verified by a grep-based CI lint step.- Loading
/in a real browser produces zero Content-Security-Policy violations in the console, including the/manifest.webmanifestrequest (20.4).
21. Configuration and Environment Variables #
This section is the single, canonical source for every environment variable and its default. No other section restates a default value; every other section references a variable by name and points here.
21.1 Configuration philosophy #
Configuration follows twelve-factor principles: every tunable value is supplied through environment
variables, never through a checked-in config file, a database row (with the single narrow exception of
the auto-generated APP_SESSION_SECRET persistence described in 20.5 and 21.2, which is a fallback for
an unset variable, not a competing configuration source), or a runtime admin UI. Configuration is parsed
and validated exactly once, at process boot, by a single Zod schema (21.3). If validation fails, the
process exits immediately with code 78 (the BSD sysexits.h convention for a configuration error)
and a message naming the offending variable and why it failed — there is no partial-start, no silent
fallback to a different default than the one documented here, and no way to change configuration without
restarting the process. This makes the running process's behaviour fully determined by its environment
at the moment it started, which is essential for a single-operator, container-deployed app where
"what is this instance actually configured to do" must always be answerable by reading its environment,
not by inspecting runtime state.
21.2 The variable table #
| Variable | Type | Default | Required | Example | Purpose | Consumed by | If invalid |
|---|---|---|---|---|---|---|---|
NODE_ENV |
development | production | test |
development |
No | production |
Standard Node environment; gates dev-only conveniences (verbose stack traces in error responses) and enables production optimisations (Vite build mode). | apps/api/src/config.ts, apps/web/vite.config.ts |
Boot fails, exit 78: "NODE_ENV must be one of development, production, test." |
PORT |
integer 0–65535 (0 = OS-assigned, test only) |
8080 |
No | 8080 |
HTTP listen port for the single Node process serving both API and static frontend. | apps/api/src/server.ts |
Boot fails, exit 78: "PORT must be an integer between 0 and 65535." |
APP_HOST |
string | 0.0.0.0 |
No | 0.0.0.0 |
Bind address for the HTTP listener. | apps/api/src/server.ts |
Boot fails, exit 78: "APP_HOST must be a non-empty string." |
APP_TIMEZONE |
IANA timezone name | UTC |
No (but operators are strongly told to set it, 21.6) | America/Denver |
The single timezone all calendar-date math (Section 8) is computed in. | packages/shared/src/domain/schedule.ts, apps/api/src/config.ts |
Boot fails, exit 78: "APP_TIMEZONE must be a valid IANA timezone name (e.g. America/Denver)." |
APP_DATABASE_PATH |
path | ./data/app.db |
No | /data/app.db |
SQLite file location. | apps/api/src/db/ |
Boot fails, exit 78 if the parent directory does not exist and cannot be created, or is not writable: "APP_DATABASE_PATH's directory is not writable." The literal value :memory: is accepted and bypasses this parent-directory check entirely (test environments only, 21.6). |
APP_LOG_LEVEL |
pino level (fatal|error|warn|info|debug|trace|silent) |
info |
No | debug |
Logging verbosity (Section 22). silent suppresses all log output; it exists for test runs (21.6), not for a running deployment. |
apps/api/src/lib/logger.ts |
Boot fails, exit 78: "APP_LOG_LEVEL must be one of fatal, error, warn, info, debug, trace, silent." |
APP_ACCESS_CODE |
string, min 8 chars, or unset | unset | No | correct-horse-battery |
Optional unlock-screen gate (Section 20.5). Unset means no authentication. | apps/api/src/plugins/ (session plugin) |
Boot fails, exit 78 only if set but shorter than 8 characters: "APP_ACCESS_CODE must be at least 8 characters if set." |
APP_SESSION_SECRET |
string, min 32 chars | auto-generated at first boot and persisted to the settings table (Section 7.5) |
No | k3f9... (32+ random chars) |
Signs the unlock cookie (Section 20.5). Only meaningful when APP_ACCESS_CODE is set. |
apps/api/src/plugins/ (session plugin) |
Boot fails, exit 78 only if explicitly set but shorter than 32 characters: "APP_SESSION_SECRET must be at least 32 characters if set." |
APP_CORS_ORIGIN |
origin URL or unset | unset | No | https://plants.example.com |
Enables CORS for exactly one origin (Section 13.1); unset means same-origin only. | apps/api/src/server.ts |
Boot fails, exit 78 if set but not a syntactically valid absolute origin (scheme + host, no path, no query, no fragment): "APP_CORS_ORIGIN must be a valid origin URL (e.g. https://example.com)." |
APP_RATE_LIMIT_MAX |
integer ≥ 1 | 300 |
No | 300 |
Requests per window per IP (Section 13.7). | apps/api/src/plugins/ (rate-limit plugin) |
Boot fails, exit 78: "APP_RATE_LIMIT_MAX must be a positive integer." |
APP_RATE_LIMIT_WINDOW_MS |
integer ≥ 1000 | 60000 |
No | 60000 |
Rate limit window in milliseconds. | apps/api/src/plugins/ (rate-limit plugin) |
Boot fails, exit 78: "APP_RATE_LIMIT_WINDOW_MS must be an integer of at least 1000." |
APP_BACKUP_ENABLED |
boolean | true |
No | true |
Enables the nightly SQLite snapshot job (Section 24). | apps/api/src/lib/backup.ts |
Boot fails, exit 78: "APP_BACKUP_ENABLED must be true, false, 1, or 0." |
APP_BACKUP_DIR |
path | ./data/backups |
No | /data/backups |
Where snapshots are written. | apps/api/src/lib/backup.ts |
Boot fails, exit 78 if the directory cannot be created or is not writable, and APP_BACKUP_ENABLED is true: "APP_BACKUP_DIR is not writable." |
APP_BACKUP_RETAIN |
integer ≥ 1 | 14 |
No | 14 |
Number of snapshots kept before the oldest is pruned. | apps/api/src/lib/backup.ts |
Boot fails, exit 78: "APP_BACKUP_RETAIN must be a positive integer." |
APP_TRUST_PROXY |
boolean | false |
No | true |
Whether to honour X-Forwarded-For / X-Forwarded-Proto from an upstream reverse proxy, affecting rate-limit IP attribution (13.7) and the Secure cookie/HSTS logic (20.4, 20.5). |
apps/api/src/server.ts |
Boot fails, exit 78: "APP_TRUST_PROXY must be true, false, 1, or 0." |
APP_FAKE_NOW |
ISO 8601 UTC instant, or unset | unset | No | 2026-08-05T12:00:00.000Z |
Freezes the server's clock for deterministic tests (Section 25.3). Honoured only when NODE_ENV=test; ignored and logged as a warning otherwise. | apps/api/src/config.ts |
Boot fails, exit 78: "APP_FAKE_NOW must be an ISO 8601 UTC instant." |
Boolean variables accept exactly the literals true, false, 1, 0 (case-sensitive); any other
string is invalid per the "If invalid" column. No variable silently falls back to its default when set
to an invalid value — an invalid value is always a boot failure, never a soft fallback, per 21.1.
21.3 The config module #
apps/api/src/config.ts is the sole place environment variables are read with process.env. Every
other module imports the parsed, frozen config object from here.
// apps/api/src/config.ts
import { z } from 'zod';
const booleanFromEnv = z
.enum(['true', 'false', '1', '0'])
.transform((v) => v === 'true' || v === '1');
const IANA_TIMEZONES = new Set(Intl.supportedValuesOf('timeZone'));
function isValidTimeZone(tz: string): boolean {
if (IANA_TIMEZONES.has(tz)) return true;
// Fallback probe for runtimes where supportedValuesOf() omits a legal zone:
try {
new Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
}
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
// 0 is accepted (OS-assigned port) so parallel test runs never collide on a fixed port.
PORT: z.coerce.number().int().min(0).max(65535).default(8080),
APP_HOST: z.string().min(1).default('0.0.0.0'),
APP_TIMEZONE: z.string().refine(isValidTimeZone, {
message: 'APP_TIMEZONE must be a valid IANA timezone name (e.g. America/Denver).',
}).default('UTC'),
// The literal ':memory:' bypasses the parent-directory writability check performed at
// connection time (apps/api/src/db/) — there is no parent directory to check.
APP_DATABASE_PATH: z.string().min(1).default('./data/app.db'),
APP_LOG_LEVEL: z
.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'])
.default('info'),
APP_ACCESS_CODE: z.string().min(8).optional(),
APP_SESSION_SECRET: z.string().min(32).optional(),
APP_CORS_ORIGIN: z
.string()
.url()
.refine(
(v) => {
try {
const u = new URL(v);
return u.pathname === '/' && !u.search && !u.hash;
} catch {
return false;
}
},
{ message: 'APP_CORS_ORIGIN must be a valid origin URL (e.g. https://example.com).' },
)
.optional(),
APP_RATE_LIMIT_MAX: z.coerce.number().int().min(1).default(300),
APP_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().min(1000).default(60000),
APP_BACKUP_ENABLED: booleanFromEnv.default('true'),
APP_BACKUP_DIR: z.string().min(1).default('./data/backups'),
APP_BACKUP_RETAIN: z.coerce.number().int().min(1).default(14),
APP_TRUST_PROXY: booleanFromEnv.default('false'),
});
export type Config = Readonly<z.infer<typeof ConfigSchema>>;
function loadConfig(): Config {
const result = ConfigSchema.safeParse(process.env);
if (!result.success) {
const issue = result.error.issues[0] ?? { path: [], message: 'Unknown configuration error.' };
// eslint-disable-next-line no-console -- logger is not yet constructed at this point
console.error(
`Configuration error: ${issue.path.join('.')} — ${issue.message}`,
);
process.exit(78);
}
return Object.freeze(result.data);
}
export const config: Config = loadConfig();
export function redactedConfigForLogging(): Record<string, unknown> {
return {
...config,
APP_ACCESS_CODE: config.APP_ACCESS_CODE ? '[REDACTED]' : undefined,
APP_SESSION_SECRET: '[REDACTED]',
};
}redactedConfigForLogging() is called once at startup to emit the server.start log event (Section
22.2) with the effective configuration, secrets masked, so an operator can confirm what a running
instance is actually configured with without ever exposing APP_ACCESS_CODE or
APP_SESSION_SECRET in log output (Section 20.8).
Coercions: PORT, APP_RATE_LIMIT_MAX, APP_RATE_LIMIT_WINDOW_MS, and APP_BACKUP_RETAIN use Zod's
z.coerce.number() to accept the string form all environment variables arrive in and convert to a
number before range validation. Booleans use the explicit booleanFromEnv schema above rather than
z.coerce.boolean(), because JavaScript's native boolean coercion treats every non-empty string
(including the string "false") as true, which is exactly the footgun the explicit enum-then-transform
approach avoids.
21.4 .env.example #
# .env.example — copy to .env for local development. Every variable has a
# working default; nothing here is required to boot the app.
# --- Node runtime ---
NODE_ENV=development
PORT=8080
APP_HOST=0.0.0.0
# --- Timezone (see Section 8 for why this matters) ---
# All "due" and "overdue" calculations use this timezone to determine the
# current calendar date. Set this to the timezone the plants actually live
# in. Defaults to UTC, which is almost never what you want for a real
# deployment.
APP_TIMEZONE=UTC
# --- Storage ---
APP_DATABASE_PATH=./data/app.db
# --- Logging ---
# One of: fatal, error, warn, info, debug, trace, silent ("silent" is
# intended for test runs, not a running deployment)
APP_LOG_LEVEL=info
# --- Optional access code (off by default — see Section 20.5) ---
# Uncomment and set a value of at least 8 characters to require an unlock
# code before the app can be used. Leave unset for no authentication.
# APP_ACCESS_CODE=
# --- Session secret (only used if APP_ACCESS_CODE is set) ---
# Leave unset to have one generated and stored automatically on first boot.
# Set explicitly (32+ chars) if you need sessions to survive a database
# reset, or are running more than one instance behind a shared cookie.
# APP_SESSION_SECRET=
# --- CORS (off by default — same-origin only) ---
# APP_CORS_ORIGIN=https://plants.example.com
# --- Rate limiting ---
APP_RATE_LIMIT_MAX=300
APP_RATE_LIMIT_WINDOW_MS=60000
# --- Backups (see Section 24) ---
APP_BACKUP_ENABLED=true
APP_BACKUP_DIR=./data/backups
APP_BACKUP_RETAIN=14
# --- Reverse proxy ---
# Set to true only if this app sits behind a reverse proxy that sets
# X-Forwarded-For / X-Forwarded-Proto. Affects rate-limit IP attribution
# and cookie Secure/HSTS behaviour (Section 20.4, 20.5).
APP_TRUST_PROXY=false
# --- Test only ---
# Freezes the server's clock for deterministic tests (Section 25.3).
# Honoured only when NODE_ENV=test; ignored and logged as a warning
# otherwise.
# APP_FAKE_NOW=2026-08-05T12:00:00.000Z21.5 Frontend configuration #
The frontend build accepts Vite-standard VITE_* environment variables, but this application defines
exactly zero of them. Every variable in 21.2 is a backend concern: the frontend has no build-time
configuration of its own, because VITE_* variables are baked into the static bundle at build time and
this app's single Docker image (Section 26) is built once and can be deployed with different runtime
configuration (different APP_TIMEZONE, different APP_ACCESS_CODE state) without a rebuild.
Instead, the frontend learns every runtime fact it needs from a dedicated endpoint:
GET /api/v1/meta{
"data": {
"timezone": "America/Denver",
"today": "2026-08-05",
"plantCap": 500,
"accessCodeEnabled": false,
"version": "1.0.0"
}
}This endpoint is ungated (reachable before unlock, alongside /api/v1/session and /api/v1/health,
Section 20.5) since the frontend needs accessCodeEnabled to decide whether to render the unlock screen
at all. timezone and today let the client's optimistic status recomputation (Section 8.5) use the
exact same timezone the server uses, without duplicating APP_TIMEZONE as a separate frontend-configured
value that could drift out of sync with the backend's. This is why runtime facts are fetched rather than
baked in at build time: a single built image, deployed to two different operators with two different
APP_TIMEZONE values, serves both correctly with no rebuild, and there is exactly one source of truth
for "what timezone is this instance using" — the running server's own configuration.
21.6 Per-environment values #
| Variable | Development | Test (CI/Vitest/Playwright) | Production (recommended) |
|---|---|---|---|
NODE_ENV |
development |
test |
production |
PORT |
8080 |
0 (OS-assigned, so parallel test runs don't collide) |
8080 |
APP_TIMEZONE |
UTC (or the developer's local zone, either works for manual testing) |
UTC (fixed, so date-arithmetic assertions are deterministic) |
The operator's actual timezone, e.g. America/Denver |
APP_DATABASE_PATH |
./data/app.db (gitignored) |
:memory: — better-sqlite3 supports an in-memory database, used for unit/integration tests to avoid disk I/O and guarantee a clean slate per test run |
A path on a persistent mounted volume, e.g. /data/app.db |
APP_LOG_LEVEL |
debug |
silent (part of the canonical enum in 21.2/21.3; keeps test output clean without any test-only branch in validation) |
info |
APP_ACCESS_CODE |
unset | unset (access-code-specific tests set it per-test via a separate test harness process, not the shared dev/test default) | Operator's choice; recommended when the deployment is reachable from any network the operator does not fully control |
APP_CORS_ORIGIN |
unset | unset | unset unless the frontend is genuinely served from a different origin than the API (uncommon given the single-process model, Section 5) |
APP_BACKUP_ENABLED |
false (no need for snapshot churn on a throwaway dev database) |
false |
true |
APP_TRUST_PROXY |
false |
false |
true only if a reverse proxy is actually in front of the app; false if the container's port is exposed directly |
21.7 Changing configuration safely #
Safe to change at any time on an existing install — no data implications: APP_LOG_LEVEL,
APP_RATE_LIMIT_MAX, APP_RATE_LIMIT_WINDOW_MS, APP_BACKUP_ENABLED, APP_BACKUP_RETAIN,
APP_TRUST_PROXY, APP_CORS_ORIGIN. Restart the process to apply (21.1 — no runtime reconfiguration).
Changes user-visible behaviour — safe, but understand the effect first:
APP_TIMEZONE— changing this shifts every plant's computedstatusanddaysUntilDue/daysOverdue(Section 8) by up to one calendar day at the moment of the change, because "today" is recomputed under the new zone immediately. A plant that wasdue_todayunder the old zone might becomeupcomingoroverdueunder the new one if the two zones are on different calendar dates at that instant. This is expected and correct — the storedlastWateredOnvalues do not change, only their interpretation relative to "today" does — but operators changing this on a live install should expect exactly this one-time shift and not read it as a bug.APP_ACCESS_CODE— turning it on takes effect immediately on restart: all future requests require unlock. Turning it off removes the requirement immediately; any existing session cookie simply becomes unused (harmless, not actively invalidated).
Must not change after first boot without a documented procedure:
APP_DATABASE_PATH— changing this points the app at a different (likely empty) SQLite file, which looks to the app exactly like a fresh install with zero plants. The documented procedure for relocating the database is: stop the process, move the actual file (and its-wal/-shmsidecars) to the new path, then updateAPP_DATABASE_PATHand restart — never change the variable without also moving the file.APP_SESSION_SECRET— changing this whileAPP_ACCESS_CODEis set invalidates every existing signed session cookie (they fail signature verification against the new secret), forcing every device to unlock again. This is a safe, if inconvenient, side effect — it is never a data-loss risk — and is useful as a deliberate "sign everyone out" action. It should not be changed by accident by removing an explicit value and lettting the app regenerate one, since a regenerated secret still invalidates all sessions; the difference from setting an explicit new value is cosmetic.
22. Observability, Logging and Health #
This section is canonical for the log schema, health endpoints, and metrics.
22.1 Logging principles #
- All application logging uses
pino, emitting one structured JSON object per line tostdout. No logger writes to a file directly — the container runtime (Section 26) is responsible for capturingstdout. - Every log line is a single JSON object; multi-line output is never produced. Errors with stack traces
serialise the stack into a single-line-safe string field (
err.stack, with embedded newlines preserved inside the JSON string value, which is valid JSON and still renders as one line in the log stream) rather than being printed as raw multi-line text. - Every log line that occurs within an HTTP request's lifecycle carries that request's
requestId(22.4), enabling every line related to one request to be correlated by a single field, regardless of which subsystem emitted it (route handler, repository, background job triggered synchronously within the request). - User content never appears in logs. Plant names and notes are never included in any log field, at any log level, in any code path. This is restated here as the absolute rule that Section 22.2's schema is designed around: every event's field set below contains only structural data (ids, counts, durations, booleans) and no free-text content field exists in the schema at all.
22.2 Log schema #
Every log line carries this base field set, with event-specific fields added on top:
| Field | Type | Present on | Description |
|---|---|---|---|
level |
string | Every line | pino's numeric level, rendered as its label by the log formatter (info, warn, error, etc.). |
time |
string | Every line | ISO 8601 UTC instant with milliseconds, e.g. 2026-08-05T14:23:11.482Z (the timestamp format canonical in Section 7.3). |
pid |
number | Every line | The Node process id, useful when multiple restarts appear in the same aggregated stream. |
requestId |
string | null | Every line emitted during a request; null outside a request context (e.g. server.start) |
UUID or safe client-supplied id (22.4). |
msg |
string | Every line | A short, human-readable summary, safe to read in a terminal without parsing JSON. |
event |
string | Every line | The canonical event identifier, see the table below. |
Event catalogue (minimum required set; additional events may be added following the same
domain.action naming convention without requiring a spec change):
event |
Emitted when | Additional fields |
|---|---|---|
server.start |
Process begins booting, immediately after config is parsed | config (redacted config object, 21.3), nodeVersion |
server.ready |
HTTP listener is bound and accepting connections | port, host |
server.shutdown |
Graceful shutdown begins (22.8) | signal (SIGTERM or SIGINT) |
config.invalid |
Config validation fails at boot (21.3) | field, reason |
db.migrate.start |
The migration runner begins (Section 7) | pendingCount |
db.migrate.applied |
A single migration file is successfully applied | migrationId, filename, durationMs |
db.migrate.checksum_mismatch |
A previously-applied migration's file checksum no longer matches what is recorded in schema_migrations (Section 7) |
migrationId, expectedChecksum, actualChecksum |
http.request |
A request is received (before handler execution) | method, route (the matched route pattern, e.g. /api/v1/plants/:plantId, never the raw URL with real ids inline for this field — the pattern is what aids aggregation) |
http.response |
A response is sent | method, route, statusCode, durationMs, responseBytes |
plant.created |
A plant is successfully created | plantId |
plant.updated |
A plant is successfully updated | plantId, fieldsChanged (array of field names, never values) |
plant.deleted |
A plant is soft-deleted | plantId |
plant.restored |
A soft-deleted plant is restored via undo | plantId |
plant.purged |
A soft-deleted plant is permanently purged after the 30-day retention window (Section 24.9) | plantId, daysSinceDeleted |
purge.completed |
The maintenance purge task finishes a run, whether or not it purged anything (Section 24.9) | plantsPurged, durationMs |
watering.created |
A watering entry is recorded | plantId, wateringId |
watering.deleted |
A watering entry is deleted | plantId, wateringId |
watering.duplicate_ignored |
An idempotent "watered today" call is made against a plant already watered today (Section 11.2) | plantId |
unlock.success |
A correct access code is submitted (20.5) | ip |
unlock.failure |
An incorrect access code is submitted | ip, attemptNumber (within the current 15-minute window) |
unlock.locked_out |
An attempt is made from an IP already in lockout | ip |
backup.completed |
A nightly snapshot succeeds (Section 24) | filename, sizeBytes, durationMs |
backup.failed |
A snapshot attempt fails | reason |
import.completed |
An import finishes successfully (Section 24) | plantsImported, wateringsImported |
export.completed |
An export is generated (Section 24) | plantsExported |
ratelimit.exceeded |
A client exceeds APP_RATE_LIMIT_MAX (Section 21) |
ip, route |
error.unhandled |
An error reaches the global error handler without being a recognised AppError (Section 6.6) |
err (serialised error object: message, stack, name) |
Plant ids and watering ids are logged freely — they are opaque UUIDs (Section 7.3) carrying no personal
information. Plant names and notes are never logged, on any of the events above or any future
one; plant.updated's fieldsChanged deliberately logs only which fields changed, never their old or
new values, for exactly this reason.
22.3 Request logging #
Every completed request produces exactly one http.response line (not one line for the request and a
separate line for the response — http.request exists for the pre-handler moment only when
APP_LOG_LEVEL=debug, since at info level the single post-response line is sufficient and less noisy).
The http.response line carries: method, route (the matched Fastify route pattern, e.g.
/api/v1/plants/:plantId — never the raw URL, so that a request to /api/v1/plants/<uuid> and another
to /api/v1/plants/<different-uuid> aggregate as the same route in log analysis), statusCode,
durationMs (wall-clock time from request received to response sent), requestId, and responseBytes
(the size of the serialised response body).
No sampling is applied — every request is logged at info level. Given the product's realistic peak
load (Section 23.6: a single user, a handful of requests per minute), log volume is trivial and sampling
would only reduce debuggability for no meaningful benefit.
GET /api/v1/health is explicitly excluded from request logging (no http.response line is emitted for
it, even though it is still handled normally) because container orchestration platforms typically poll
liveness checks every few seconds, and logging each one would dominate the log stream with no diagnostic
value. GET /api/v1/health/ready is not excluded, since it is polled far less frequently and its result
can change meaningfully between polls.
22.4 Request id propagation #
- If the incoming request carries an
X-Request-Idheader whose value matches either a canonical UUID (Section 7.3's format) or a 1–64 character string of[A-Za-z0-9._-], that value is used as the request'srequestIdfor the remainder of its lifecycle. - Otherwise, a new UUIDv7 is generated (the same generation mechanism used for primary keys, Section 7.3).
- The resolved
requestIdis always echoed back in theX-Request-Idresponse header and always appears in therequestIdfield of every error envelope (Section 13.2), so a user-reportedrequestIdfrom an error message (Section 18.5) can be grepped directly against the log stream.
22.5 Health endpoints #
GET /api/v1/health — liveness. Always returns 200 if the Node process is running and able to
handle HTTP requests at all; it performs no database check, so it correctly reports "alive" even during
a database outage that would fail readiness (22.5's next endpoint), which is exactly what a container
orchestrator's restart-on-liveness-failure logic should key off — a process that is up but whose database
is unreachable should not be endlessly restarted, since restarting will not fix a database problem.
{
"data": {
"status": "ok",
"uptimeSeconds": 4213,
"version": "1.0.0"
}
}GET /api/v1/health/ready — readiness. Runs three checks: a SELECT 1 query against the SQLite
connection, a check that the highest-numbered migration file on disk matches the highest version
recorded in schema_migrations (Section 7 — catching a deployment that shipped new migration files but
has not run them), and a check that APP_DATABASE_PATH's directory is writable (a zero-byte test file is
written and immediately removed). All three must pass for 200; any failure returns 503 naming the
failing check.
Healthy response (200):
{
"data": {
"status": "ok",
"checks": {
"database": { "status": "ok" },
"migrations": { "status": "ok", "current": 1 },
"diskWritable": { "status": "ok" }
}
}
}Failing response (503), example where the database file's directory has become read-only. The failing
checks are reported as error.details[] entries, never as a sibling data key — an error response never
carries a top-level data key (Section 13.2):
{
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "The app is temporarily unavailable. Please try again in a moment.",
"requestId": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"details": [
{ "path": "diskWritable", "message": "EACCES: permission denied, open './data/.health-check-tmp'" }
]
}
}Both endpoints are excluded from rate limiting (Section 13.7) — orchestration polling must never be throttled by the same limiter protecting the plant API.
22.6 Metrics #
No external metrics system (no Prometheus exporter, no StatsD, no third-party APM) is used, because a single-user, single-instance application generates too little traffic for time-series metrics to provide signal beyond what the log stream and health endpoint already give — introducing a metrics pipeline would add operational surface area (another port, another dependency, another thing to secure) with no proportionate benefit.
Instead, a small set of in-process counters is maintained in memory (reset on process restart) and
exposed as data.counters on the liveness endpoint's response, but only when
APP_LOG_LEVEL=debug (keeping the default info-level response minimal and avoiding exposing
operational detail on an otherwise-public liveness check by default). The liveness envelope carries only
data (Section 13.2), so the counters are nested under it rather than under a separate meta key:
{
"data": {
"status": "ok",
"uptimeSeconds": 4213,
"version": "1.0.0",
"counters": {
"requestsTotal": 812,
"requestsByStatusClass": { "2xx": 790, "4xx": 20, "5xx": 2 },
"wateringsRecordedTotal": 34,
"plantsCreatedTotal": 9,
"rateLimitExceededTotal": 0,
"unlockFailuresTotal": 0
}
}
}22.7 Error handling and reporting #
A single global Fastify error handler (apps/api/src/plugins/error-handler.ts) is the only place that
translates an in-flight error into an HTTP response. Every route handler throws domain errors as
instances of AppError (Section 6.6), which carries a code (one of the canonical error codes, Section
13.3), an HTTP status, a user-safe message, and optional details. The global handler:
- If the caught error is an
AppError, maps it directly to the canonical envelope (Section 13.2) using itscode,status,message, anddetails, and logs it atwarnlevel witheventset to a value matching the error's origin where one of the catalogued events (22.2) applies (e.g. aLIMIT_EXCEEDEDon plant creation still logs under the request's normalhttp.responseline — no separate event exists for expected validation/business-rule rejections, since they are not failures of the system). - If the caught error is anything else (an unexpected exception — a bug), it is logged at
errorlevel withevent: "error.unhandled"and the full serialised error (message, stack, name), and the response sent to the client is always the genericINTERNAL_ERRORenvelope with the canned message from Section 18.5 — the real error's message and stack are never included in the HTTP response body, in any environment including development, so that behaviour is identical between local development and production and a developer never accidentally ships a debug-mode detail leak. - Every response, success or error, is passed through step 22.4's request id resolution so the envelope
always carries the correct
requestId.
Process-level handlers. process.on('uncaughtException', ...) and
process.on('unhandledRejection', ...) both log the error at error level with event: "error.unhandled" and then call process.exit(1). The process is intentionally not kept alive after
either event: a truly uncaught error means the process's in-memory state (including the single
better-sqlite3 connection) is in an unknown condition, and the container runtime is expected to restart
it (Section 26) — attempting to keep serving traffic from a process that just experienced an unhandled
exception risks serving corrupted responses rather than failing cleanly and coming back up fresh.
22.8 Graceful shutdown #
// apps/api/src/index.ts (excerpt)
async function shutdown(signal: 'SIGTERM' | 'SIGINT') {
logger.info({ event: 'server.shutdown', signal }, 'Shutting down');
const forceExitTimer = setTimeout(() => {
logger.error({ event: 'error.unhandled' }, 'Graceful shutdown timed out; forcing exit');
process.exit(1);
}, 10_000);
forceExitTimer.unref();
try {
await server.close(); // stops accepting new connections, waits for in-flight requests to finish
db.pragma('wal_checkpoint(TRUNCATE)'); // final WAL checkpoint before close
db.close();
clearTimeout(forceExitTimer);
process.exit(0);
} catch (err) {
logger.error({ event: 'error.unhandled', err }, 'Error during shutdown');
process.exit(1);
}
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));On SIGTERM or SIGINT (both are handled identically — a container orchestrator sends SIGTERM, a
developer's Ctrl+C sends SIGINT): stop accepting new connections immediately, allow up to 10 seconds
for any in-flight request to complete normally, run a final SQLite WAL checkpoint to ensure the main
database file is fully up to date on disk (rather than relying on the next process's boot to replay the
WAL), close the database connection, and exit 0. If shutdown has not completed within the 10-second
window, the process force-exits with code 1 rather than hanging indefinitely, since a container
orchestrator's own forced-kill timeout would otherwise apply first and this app's own timeout is set
comfortably under typical orchestrator defaults (commonly 30 s) to guarantee a clean exit path runs
first.
22.9 What an operator checks when something is wrong #
- App seems unreachable. Check
GET /api/v1/health— if it does not respond at all, the process is down; check container status/restart count. If it responds200, the process is alive and the problem is likely network/proxy-level, outside this app. - App responds but looks broken (errors on every action). Check
GET /api/v1/health/ready— a503names the specific failing check (database,migrations, ordiskWritable) and itsreason. - A specific action is failing for one user report. Ask for the
requestIdshown in their error message (Section 18.5) and grep the log stream for that value — every line from that request's lifecycle carries it (22.1). - Suspected disk space issue. Check
diskWritableunder/health/readyfirst; if it is failing, check the host volume backingAPP_DATABASE_PATHandAPP_BACKUP_DIRfor free space. - Suspected bad deploy (migrations out of sync). Check the
migrationsfield under/health/readyand cross-referencedb.migrate.appliedlog lines from the most recentserver.startagainst the migration files actually present in the deployed image (Section 7, Section 26). - Unexpected lockouts reported. Search for
unlock.locked_outandunlock.failureevents filtered byipto distinguish a legitimate user who mistyped their code repeatedly from a brute-force attempt from an unfamiliar IP. - Backups look stale. Search for the most recent
backup.completedorbackup.failedevent; abackup.failedline'sreasonfield names the underlying failure (Section 24).
23. Performance Budgets and Targets #
This section is canonical for every performance number in the product.
23.1 Context #
This application serves a single user against a dataset capped at 500 rows (Section 9). There is no throughput problem to solve — a single Node process with a synchronous SQLite driver comfortably handles this load with enormous headroom (23.6). The performance work that matters is entirely about perceived speed on a mid-range phone over a mediocre connection, because the product's core scenario is that a user opens this app standing in front of a plant, wanting an answer in seconds, not staring at a server dashboard. Every budget below is chosen and enforced with that single scenario in mind: fast first paint, fast interaction feedback, small transfer size, and nothing more.
23.2 Budget table #
| Metric | Target | Hard limit | How measured | Enforced where |
|---|---|---|---|---|
| Initial JS bundle (gzipped) | ≤ 150 KB | 175 KB (CI fail) | rollup-plugin-visualizer output parsed by a size-assertion script |
CI (23.3) |
| Initial CSS (gzipped) | ≤ 20 KB | 30 KB (CI fail) | Same build-size script, CSS output file | CI (23.3) |
| Total initial transfer (HTML+JS+CSS, gzipped, first paint's critical path) | ≤ 250 KB | 300 KB (CI fail) | Sum of the above plus the HTML document size | CI (23.3) |
| LCP, simulated Moto G4 over Fast 3G | ≤ 2.0 s | 2.5 s (CI fail) | Lighthouse CI, mobile config | CI (23.3) |
| INP | ≤ 200 ms | 500 ms (CI fail) | Lighthouse CI, mobile config, interaction trace on the plant list's "Watered today" tap | CI (23.3) |
| CLS | ≤ 0.05 | 0.1 (CI fail) | Lighthouse CI | CI (23.3) |
| "Watered today" tap → visible row update | ≤ 100 ms | 250 ms (manual/E2E timing assertion) | Optimistic update timing measured in a Playwright test using performance.mark/performance.measure around the click handler and the row's re-render |
Playwright E2E (Section 25) |
API p95 latency, GET /plants at 500 plants |
≤ 25 ms server-side | 60 ms (CI fail) | Benchmark script against the 500-plant fixture (23.3) | CI (23.3) |
API p99 latency, GET /plants at 500 plants |
≤ 40 ms server-side | 60 ms (CI fail) | Same benchmark script | CI (23.3) |
Cold process start to ready (server.ready log event, 22.2) |
≤ 1.5 s | 3 s (CI fail) | Timestamp delta between process spawn and the server.ready log line in a container-start test |
CI (23.3) |
| Container image size | ≤ 200 MB | 250 MB (CI fail) | docker image inspect size in the build pipeline |
CI (23.3) |
| Memory RSS at idle | ≤ 90 MB | 120 MB (flagged, not CI-blocking — see 23.3) | process.memoryUsage().rss sampled 60 s after server.ready with zero traffic, in a manual/scheduled check |
Manual/scheduled (23.3) |
23.3 How each budget is enforced #
Bundle size. apps/web/vite.config.ts includes rollup-plugin-visualizer configured to emit a
stats.json alongside the production build. scripts/check-bundle-size.mjs reads that file, sums
gzipped sizes for the JS and CSS output groups, and exits non-zero with a clear message if either
exceeds its hard limit from 23.2:
// scripts/check-bundle-size.mjs (excerpt)
import { readFileSync } from 'node:fs';
const LIMITS = { js: 175 * 1024, css: 30 * 1024, total: 300 * 1024 };
const stats = JSON.parse(readFileSync('apps/web/dist/stats.json', 'utf-8'));
const jsBytes = sumGzipBytes(stats, 'js');
const cssBytes = sumGzipBytes(stats, 'css');
const totalBytes = jsBytes + cssBytes + htmlBytes('apps/web/dist/index.html');
const failures = [];
if (jsBytes > LIMITS.js) failures.push(`JS bundle ${jsBytes}B exceeds limit ${LIMITS.js}B`);
if (cssBytes > LIMITS.css) failures.push(`CSS bundle ${cssBytes}B exceeds limit ${LIMITS.css}B`);
if (totalBytes > LIMITS.total) failures.push(`Total transfer ${totalBytes}B exceeds limit ${LIMITS.total}B`);
if (failures.length > 0) {
console.error(failures.join('\n'));
process.exit(1);
}This script runs as a dedicated CI job step after the frontend production build, before the Docker image is assembled (Section 26).
Lighthouse CI. lighthouserc.json at the repo root configures lhci autorun against the built,
locally-served frontend, using the mobile preset (simulated Moto G4, Fast 3G throttling) with
assertions matching 23.2's hard limits for LCP, INP, and CLS. A failing assertion fails the CI job.
API latency benchmark. scripts/bench-api.mjs seeds an in-memory SQLite database (Section 21.6) with
exactly 500 plants via the fixture generator used in integration tests (Section 25), then issues 1,000
sequential GET /api/v1/plants requests directly against the Fastify instance via fastify.inject()
(bypassing network overhead to isolate server-side processing time, matching the "server-side" framing
of the budget), records each duration, and computes p95/p99. The script exits non-zero if either exceeds
its hard limit.
Container image size and cold start. Both are measured as discrete CI job steps after the Docker
build (Section 26): docker image inspect --format='{{.Size}}' for size, and a container-start test that
launches the built image, tails its logs for the server.ready event, and measures wall-clock time from
docker run invocation to that log line.
Memory RSS. This is the one metric in 23.2 that is not a CI gate — process memory in a short-lived CI container is not representative of steady-state idle memory, and false failures from CI environment variance would erode trust in the gate. Instead it is checked manually before each release (recorded in the Section 26 release checklist) and, optionally, by a scheduled operational check against a real running instance; a result above the 120 MB hard limit is flagged for investigation, not an automatic release blocker.
23.4 Frontend performance practices #
- Route-level code splitting (Section 14.7): each top-level route (plant list, plant detail, add
form — a page on mobile and a modal at
md+, per Section 14.2) is a separate dynamicimport()chunk, so the initial bundle contains only what the first paint needs. React.memoon list rows. Each plant row component is wrapped inReact.memowith a custom comparator that only re-renders a row when that specific plant's data (or its computed status) changes, so a background refresh that updates one plant does not re-render all 500 rows.- No virtualisation. At a 500-row hard cap (Section 9), a plain rendered list of 500 simple
<li>rows is well within a mobile browser's comfortable rendering budget; virtualisation (e.g.react-window) would add a dependency, complexity, and accessibility friction (virtualised lists routinely break screen-reader list semantics, directly working against Section 19.2's<ul>/<li>requirement) for a problem this dataset size does not actually have. - No webfont download. The system font stack (Section 15) means zero font bytes are ever fetched
over the network — text renders in the platform's default font immediately.
font-displayconcerns and FOIT/FOUT do not apply because there is no custom font at all. - SVG icons tree-shaken from
lucide-react. Icons are imported individually (import { Droplet } from 'lucide-react'), which each resolve to a small standalone SVG React component; the bundler includes only the icons actually referenced, not the full icon library. - No runtime CSS-in-JS. Tailwind CSS (Section 15) generates a static stylesheet at build time; there is no client-side style computation or injection at runtime, keeping both bundle size and runtime cost down.
- Preload of the main chunk. The build emits a
<link rel="modulepreload">for the entry chunk inindex.html, so the browser begins fetching it as early as possible in the document parse, ahead of discovering it via the module graph. content-visibility: autoon off-screen list rows. Rows beyond the viewport (relevant mainly on very long lists near the 500-plant cap) skip layout/paint work until they scroll into view, applied via a Tailwind arbitrary-value utility on the row container.
23.5 Backend performance practices #
- Prepared statements cached at module load. Every SQL statement used by the repository layer
(Section 7) is prepared once, at module initialization, via
db.prepare(...), and the resulting statement object is reused across every call — never re-prepared per request. - A single connection.
better-sqlite3opens exactly one connection for the process's lifetime; there is no connection pool, because SQLite's single-writer model and this app's single-process deployment (Section 5) make a pool pure overhead with no concurrency benefit. - WAL mode. The database is opened with
PRAGMA journal_mode = WAL, allowing concurrent readers during a write and giving better durability characteristics than the default rollback journal for a long-running process. - Indexes. The indexes defined in Section 7.7 (on
plants.deleted_atandwaterings.plant_id, at minimum) ensure the two hottest queries — listing active plants and fetching a plant's watering history — never perform a full table scan even at the 500-plant/unbounded-history ceiling. - Synchronous driver discipline.
better-sqlite3is synchronous — every call blocks the single Node event loop thread for its duration. This is the correct choice for this workload (Section 5's stated rationale) precisely because every query against a ≤500-row table with the indexes above completes in well under a millisecond; the budget table's 25 ms p95 target has roughly 25x headroom over the expected sub-millisecond actual query time, and every route handler is written to perform exactly one or a small fixed number of prepared-statement calls, never a loop issuing N queries, so this headroom is preserved as the codebase grows.
23.6 Load and stress expectations #
The realistic peak load for this application is one user issuing a handful of requests per minute — a
page load, an occasional "Watered today" tap, a rare add/edit/delete. APP_RATE_LIMIT_MAX defaults to
300 requests per 60-second window (Section 21), which is roughly two orders of magnitude above any
plausible real usage pattern; the limiter's purpose is exclusively to blunt an unexpected flood (Section
20.2's DoS threat entry), not to manage legitimate capacity. A single instance of this application, sized
per the container budget in 23.2, handles this load with no meaningful resource pressure — there is no
horizontal scaling story, no load balancer, and none is needed; a second instance would in fact be
actively wrong for this product, since SQLite's single-writer model and the lack of any coordination
between instances would create data-consistency problems the single-process, single-instance deployment
model (Section 5) deliberately avoids.
23.7 Performance regression guard #
The CI job that runs the bundle-size, Lighthouse, and API-latency checks (23.3) records each numeric
result to performance-baseline.json, committed to the repository and updated only via a deliberate,
reviewed commit (never auto-committed by CI). On every subsequent CI run, each measured value is compared
against the corresponding baseline entry; a value that regresses by more than 5% relative to its
baseline fails the build with a message naming the specific metric, its baseline, its current value, and
the percentage drift — even if the value is still within the hard limit in 23.2. This catches slow,
incremental regressions (e.g., a bundle creeping up 2 KB per PR over months) that a fixed hard-limit gate
alone would not catch until it was already too late to easily identify the cause.
When a budget is exceeded — whether the absolute hard limit from 23.2 or the 5% regression guard — the
executor's required response is: identify the specific commit/change responsible (the CI failure message
names the metric; git bisect or reviewing the PR's diff identifies the cause), and either revert the
change, optimise it back under budget, or — only as a last resort, and only for the regression guard, not
the hard limit — deliberately update performance-baseline.json in the same commit as the change, with a
one-line justification in the commit message for why the new baseline is acceptable. A hard-limit failure
(23.2) is never resolved by raising the limit; the limits in this document are fixed.
24. Data Durability: Backup, Export and Import #
24.1 Why this matters here #
The application has exactly one durable artifact: a single SQLite file on a mounted volume. There is no replica, no managed database, and no second copy anywhere by default. Losing that file loses the entire plant collection and its watering history. The durability story is deliberately small and matches the size of the product: SQLite's write-ahead log (WAL) protects against process crashes mid-write, a nightly snapshot protects against file corruption or accidental deletion, and a one-file JSON export gives the operator a portable copy they can keep outside the deployment entirely. Sections 24.2–24.9 specify each of those three mechanisms completely; nothing about durability is left to the executor's judgment.
24.2 The nightly snapshot job #
The snapshot job runs in-process, inside the same Node process that serves the API. There is no cron
daemon, no sidecar container, and no OS-level scheduler involved. It is controlled entirely by
APP_BACKUP_ENABLED (Section 21); when that variable is false the scheduler never starts and no
snapshot files are ever written.
When enabled, the job runs on two triggers:
- Scheduled run — the first poll at or after
03:15local time inAPP_TIMEZONEon a day the job has not yet run. The poll interval is 60 seconds (CHECK_INTERVAL_MS), and the trigger condition is an inequality (localTime >= '03:15'), not an exact-minute equality — a poll delayed past the03:15boundary by process load or a paused event loop still fires, so one missed tick never skips that day's backup. - Catch-up run at process start — if the timestamp recorded in the
settingstable under keylast_backup_run_at(schema in Section 7) is missing or older than 24 hours, a snapshot runs at boot, before the scheduled run has a chance to fire. This covers containers that are frequently restarted and might otherwise never reach 03:15 while running, or that were down at 03:15 on a given day. This boot catch-up does not start until the purge job's own boot run (Section 24.9) has resolved, so the two boot-time jobs never race for the same before/after row counts (Section 24.3): a plant purged mid-vacuum is exactly the scenario Section 24.3's acceptance rule must tolerate, and staggering the two jobs keeps it rare rather than routine.
The job never runs twice for the same calendar day (in APP_TIMEZONE) even if both triggers would
otherwise fire close together; the in-memory lastRunDate guard in the implementation below prevents
that. A failed run does not set lastRunDate, so the next poll retries it: a backup that fails at
03:15 is attempted again at 03:16 and every minute after, until it succeeds or the calendar day
ends in APP_TIMEZONE.
The snapshot mechanism is VACUUM INTO '<file>'. VACUUM INTO is atomic from the caller's point of
view: SQLite writes a complete, internally consistent new database file at the target path in one
operation, or the operation fails and no file is left behind. There is no risk of a snapshot file that
is half-written, because the destination path does not exist until VACUUM INTO has finished
successfully. It is also safe to run against a live database in WAL mode: readers and writers may
continue against the live database while the vacuum executes.
File naming: app-YYYY-MM-DD-HHmmss.db, in APP_TIMEZONE, written into APP_BACKUP_DIR. Example:
app-2026-08-05-031500.db.
After a successful, verified (Section 24.3) snapshot, the job deletes the oldest files in
APP_BACKUP_DIR matching the naming pattern until at most APP_BACKUP_RETAIN remain. Non-matching
files in that directory (there should be none in normal operation) are left untouched.
A failed snapshot attempt — a thrown error from VACUUM INTO, a failed verification (Section 24.3),
or a filesystem error while pruning — is caught, logged as backup.failed with the error message and
stack, and never propagated. The job never crashes the process and never blocks or delays an in-flight
HTTP request; it runs entirely off the request path.
// apps/api/src/lib/backup.ts
import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import type Database from 'better-sqlite3';
import { TZDate } from '@date-fns/tz';
import { format } from 'date-fns';
import type { Logger } from 'pino';
import type { AppConfig } from '../config.js';
import { verifySnapshot } from './backup-verify.js';
const CHECK_INTERVAL_MS = 60_000; // poll once a minute; cheap and precise enough for a once-a-day job
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const RUN_AT_OR_AFTER = '03:15'; // lexicographic 'HH:mm' comparison: any tick from this local time onward today triggers a run
const SNAPSHOT_NAME_RE = /^app-\d{4}-\d{2}-\d{2}-\d{6}(-pre-import)?\.db$/;
export interface BackupDeps {
db: Database.Database;
config: AppConfig;
log: Logger;
now: () => Date;
}
function localToday(deps: BackupDeps): string {
return format(new TZDate(deps.now().getTime(), deps.config.APP_TIMEZONE), 'yyyy-MM-dd');
}
function localTime(deps: BackupDeps): string {
return format(new TZDate(deps.now().getTime(), deps.config.APP_TIMEZONE), 'HH:mm');
}
/**
* `afterPurgeBootRun` is the promise returned by the purge job's boot-time run (Section 24.9). The
* scheduler's own boot catch-up check waits on it before doing anything, so the purge job and a
* boot-catchup snapshot never capture before/after row counts while the other is mid-write. Wired at
* bootstrap as:
* `const purgeBootRun = runPurgeOnce(purgeDeps); const stopBackup = startBackupScheduler(backupDeps, purgeBootRun);`
*/
export function startBackupScheduler(deps: BackupDeps, afterPurgeBootRun: Promise<void>): () => void {
const { config, log } = deps;
if (!config.APP_BACKUP_ENABLED) {
log.info({ event: 'backup.disabled' });
return () => {};
}
mkdirSync(config.APP_BACKUP_DIR, { recursive: true });
let lastRunDate: string | null = null;
let running = false;
const maybeRun = (reason: 'boot-catchup' | 'scheduled') => {
const today = localToday(deps);
if (lastRunDate === today || running) return;
running = true;
void runBackup(deps, reason)
.then((succeeded) => {
lastRunDate = succeeded ? today : null; // a failure is never latched, so the next tick retries it
})
.finally(() => {
running = false;
});
};
void afterPurgeBootRun.then(() => {
const lastRunAt = readLastBackupRunAt(deps.db);
if (lastRunAt === null || deps.now().getTime() - lastRunAt.getTime() > STALE_THRESHOLD_MS) {
maybeRun('boot-catchup');
}
});
const timer = setInterval(() => {
if (localTime(deps) >= RUN_AT_OR_AFTER) {
maybeRun('scheduled');
}
}, CHECK_INTERVAL_MS);
timer.unref();
return () => clearInterval(timer);
}
async function runBackup(deps: BackupDeps, reason: string): Promise<boolean> {
const { db, config, log } = deps;
const start = Date.now();
const stamp = format(new TZDate(deps.now().getTime(), config.APP_TIMEZONE), 'yyyy-MM-dd-HHmmss');
const targetPath = join(config.APP_BACKUP_DIR, `app-${stamp}.db`);
try {
const countsBefore = readRowCounts(db);
db.exec(`VACUUM INTO '${targetPath.replace(/'/g, "''")}'`);
const countsAfter = readRowCounts(db);
verifySnapshot(targetPath, countsBefore, countsAfter); // throws on failure; the catch below deletes the bad file
writeLastBackupRunAt(db, deps.now());
prune(config.APP_BACKUP_DIR, config.APP_BACKUP_RETAIN);
log.info({
event: 'backup.completed',
reason,
file: targetPath,
durationMs: Date.now() - start,
sizeBytes: statSync(targetPath).size,
});
return true;
} catch (err) {
try {
unlinkSync(targetPath);
} catch {
/* file may not exist — VACUUM INTO itself may have failed before writing anything */
}
log.error({ event: 'backup.failed', reason, file: targetPath, err: String(err) });
return false;
}
}
function readRowCounts(db: Database.Database): { plants: number; waterings: number } {
const plants = db.prepare('SELECT COUNT(*) AS n FROM plants').get() as { n: number };
const waterings = db.prepare('SELECT COUNT(*) AS n FROM waterings').get() as { n: number };
return { plants: plants.n, waterings: waterings.n };
}
function prune(dir: string, retain: number): void {
const files = readdirSync(dir)
.filter((f) => SNAPSHOT_NAME_RE.test(f))
.sort((a, b) => timestampOf(a).localeCompare(timestampOf(b))); // sorted by the embedded timestamp, not the whole filename, so a `-pre-import` suffix never disturbs chronological order
const excess = files.length - retain;
for (let i = 0; i < excess; i += 1) {
const file = files[i];
if (file) unlinkSync(join(dir, file));
}
}
function timestampOf(filename: string): string {
const match = filename.match(/^app-(\d{4}-\d{2}-\d{2}-\d{6})/);
return match ? match[1] : filename;
}
function readLastBackupRunAt(db: Database.Database): Date | null {
const row = db.prepare("SELECT value FROM settings WHERE key = 'last_backup_run_at'").get() as
| { value: string }
| undefined;
return row ? new Date(row.value) : null;
}
function writeLastBackupRunAt(db: Database.Database, at: Date): void {
db.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('last_backup_run_at', @value, @value)
ON CONFLICT(key) DO UPDATE SET value = @value, updated_at = @value`,
).run({ value: at.toISOString() });
}24.3 Snapshot verification #
A snapshot that exists but is silently corrupt or truncated is worse than no snapshot, because it
creates false confidence. Every snapshot is verified immediately after VACUUM INTO completes and
before it is counted as a successful backup:
- Row counts are captured from the live database immediately before the vacuum starts
(
countsBefore) and again immediately after it finishes (countsAfter). Because the live database can accept writes concurrently (WAL mode), these two counts may differ if a plant was created, edited, or watered during the vacuum. - The snapshot file is opened read-only with
fileMustExist: true. PRAGMA integrity_checkis run against the snapshot. The acceptance rule requires it to return exactly one row with the valueok. Any other result (multiple rows, or a row that is notok) is a failure.- Row counts are read from the snapshot for
plantsandwaterings. - Acceptance rule: the snapshot passes verification only if
integrity_checkreturnedokand each snapshot row count falls within the inclusive range[Math.min(countsBefore, countsAfter), Math.max(countsBefore, countsAfter)]for that table. Row counts may move in either direction during the vacuum, because the live database keeps accepting writes throughout it and the purge job (Section 24.9) may also be running concurrently and deleting rows — a plant purged mid-vacuum makes the snapshot's count lower thancountsBefore, and that is an expected outcome, not a corruption signal. A count outside the min/max band in either direction means the snapshot reflects a row population that was never live at any point spanning the vacuum, which is the actual corruption signal. - On any verification failure, the snapshot file is deleted immediately (a corrupt file must never
count toward
APP_BACKUP_RETAINor be discoverable as a restore candidate) and the failure is re-logged asbackup.failedwith the specific reason (integrity_check_failedorrow_count_out_of_range, with the actual counts included).
// apps/api/src/lib/backup-verify.ts
import Database from 'better-sqlite3';
export function verifySnapshot(
path: string,
countsBefore: { plants: number; waterings: number },
countsAfter: { plants: number; waterings: number },
): void {
const snap = new Database(path, { readonly: true, fileMustExist: true });
try {
const integrity = snap.pragma('integrity_check') as Array<{ integrity_check: string }>;
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok') {
throw new Error(`integrity_check_failed: ${JSON.stringify(integrity)}`);
}
const plants = (snap.prepare('SELECT COUNT(*) AS n FROM plants').get() as { n: number }).n;
const waterings = (snap.prepare('SELECT COUNT(*) AS n FROM waterings').get() as { n: number }).n;
const inRange = (v: number, a: number, b: number) => v >= Math.min(a, b) && v <= Math.max(a, b);
if (
!inRange(plants, countsBefore.plants, countsAfter.plants) ||
!inRange(waterings, countsBefore.waterings, countsAfter.waterings)
) {
throw new Error(
`row_count_out_of_range: plants=${plants} (expected ${countsBefore.plants}-${countsAfter.plants}), ` +
`waterings=${waterings} (expected ${countsBefore.waterings}-${countsAfter.waterings})`,
);
}
} finally {
snap.close();
}
}backup-verify.ts deletes the file at the call site in backup.ts (inside the catch block that
already logs backup.failed), not inside verifySnapshot itself, so the verification function stays
a pure check with no filesystem side effect beyond opening the file it was given.
backup-verify.test.ts (Section 25, shared-adjacent unit coverage under apps/api) includes a case
that calls verifySnapshot with countsBefore higher than countsAfter for one table — the exact
shape produced by a purge running concurrently with a vacuum — and asserts it passes rather than
throws, guarding the acceptance rule above.
24.4 Export #
GET /api/v1/export returns the entire dataset as a single JSON document, including soft-deleted
plants. It is a read-only, non-destructive operation and requires no request body.
Response headers:
Content-Type: application/json; charset=utf-8
Content-Disposition: attachment; filename="houseplant-tracker-export-2026-08-05.json"The date in the filename is todayInTimeZone(new Date(), APP_TIMEZONE) (Section 8) at the time of export.
Schema:
| Field | Type | Notes |
|---|---|---|
formatVersion |
integer | Currently 1. Incremented only on a breaking change to this schema. |
exportedAt |
string | ISO 8601 UTC instant with milliseconds, per Section 7.3's timestamp format. |
timezone |
string | The APP_TIMEZONE value active at export time. |
plants |
array | Every plant row, including soft-deleted ones. Field names match the API plant DTO (camelCase). |
waterings |
array | Every watering row for every plant, including plants that are soft-deleted. |
This document contains no checksum field and no schemaVersion field; format identity and integrity
are carried entirely by formatVersion. mode ("replace" or "merge", Section 24.5) is a
request-only field supplied by the caller alongside an import; it is never present in an export
document and this schema does not declare it.
Plant object fields: id, name, wateringIntervalDays, notes (nullable), lastWateredOn,
createdAt, updatedAt, deletedAt (nullable). Watering object fields: id, plantId, wateredOn,
createdAt. nextDueOn, daysUntilDue, status, and daysOverdue are not included in the
export — they are derived values (Section 8) and are recomputed on import from lastWateredOn and
wateringIntervalDays, never stored or transported.
Complete example, two plants (one active, one soft-deleted) and four waterings:
{
"formatVersion": 1,
"exportedAt": "2026-08-05T14:23:11.482Z",
"timezone": "America/Chicago",
"plants": [
{
"id": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"name": "Monstera Deliciosa",
"wateringIntervalDays": 7,
"notes": "Living room, east window",
"lastWateredOn": "2026-08-01",
"createdAt": "2026-01-04T09:12:00.000Z",
"updatedAt": "2026-08-01T18:04:22.000Z",
"deletedAt": null
},
{
"id": "01917f2c-9b4c-7d2f-8e5a-3c7f6b2d0e91",
"name": "Fiddle Leaf Fig",
"wateringIntervalDays": 10,
"notes": null,
"lastWateredOn": "2026-06-15",
"createdAt": "2025-11-02T08:30:00.000Z",
"updatedAt": "2026-07-20T10:00:00.000Z",
"deletedAt": "2026-07-20T10:00:00.000Z"
}
],
"waterings": [
{
"id": "01917f2d-1a2b-7c3d-9e4f-5a6b7c8d9e0f",
"plantId": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"wateredOn": "2026-08-01",
"createdAt": "2026-08-01T18:04:22.000Z"
},
{
"id": "01917f2d-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
"plantId": "01917f2c-8a3b-7c1e-9f4d-2b6e5a1c9d80",
"wateredOn": "2026-07-25",
"createdAt": "2026-07-25T13:10:05.000Z"
},
{
"id": "01917f2d-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"plantId": "01917f2c-9b4c-7d2f-8e5a-3c7f6b2d0e91",
"wateredOn": "2026-06-15",
"createdAt": "2026-06-15T11:00:00.000Z"
},
{
"id": "01917f2d-4d5e-7f6a-8b7c-8d9e0f1a2b3c",
"plantId": "01917f2c-9b4c-7d2f-8e5a-3c7f6b2d0e91",
"wateredOn": "2026-06-05",
"createdAt": "2026-06-05T11:00:00.000Z"
}
]
}Size expectation at the 500-plant cap: each plant object serializes to roughly 250 bytes plus up
to 2,000 bytes of notes, so the plants array alone tops out around 1.1 MB in the worst case where
every plant uses the full notes length. Each watering object serializes to roughly 140 bytes. A plant
watered weekly for five years accumulates about 260 watering rows; 500 such plants contribute
500 × 260 × 140 bytes ≈ 18 MB to the waterings array. This comfortably exceeds the 5 MB request
body cap on the import endpoint (Section 24.5) well before the 500-plant cap itself becomes the
binding constraint. This is a deliberate, documented limitation, not an oversight: GET /api/v1/export
itself has no response size limit, but a file larger than 5 MB cannot be fed back through
POST /api/v1/import. Operators whose export exceeds 5 MB must rely on snapshot restore (Section
24.7) for full-fidelity recovery; export remains valid as an inspectable, portable record of the data
regardless of its size.
24.5 Import #
POST /api/v1/import accepts a document in the exact schema of Section 24.4 and loads it into the
live database.
Request body fields (validated by ImportPayloadSchema, Section 9.4):
| Field | Type | Required | Notes |
|---|---|---|---|
mode |
"replace" | "merge" |
Yes | No default; the client must always send it explicitly. Supplied by the caller alongside the document; never present in an export (Section 24.4). |
formatVersion |
integer | Yes | Must equal 1. |
exportedAt |
string | Yes | Informational only; not validated against the current time. |
timezone |
string | Yes | Informational only; the import uses the server's own APP_TIMEZONE for all "is this in the future" checks, never the file's timezone value. |
plants |
array | Yes | May be empty. |
waterings |
array | Yes | May be empty. |
Modes:
replace(the default the UI offers): every existing plant and watering row is deleted, then every row in the import file is inserted. IDs from the file are reused as-is.merge: for each plant and watering in the file, if a row with thatidalready exists it is left completely untouched (not updated, not overwritten); if no row with thatidexists, it is inserted.mergenever deletes anything.
Processing rules:
- The request body is capped at 5 MB (Section 5). A larger body is rejected before parsing with
413 PAYLOAD_TOO_LARGE. - The body is validated against
ImportPayloadSchema(Section 9.4). AformatVersionother than1is rejected with400 VALIDATION_FAILEDand the message"Unsupported export format version {n}. This server supports formatVersion 1."— no attempt is made to interpret an unrecognized version. - Before any write, the combined size of existing active plants (for
merge) or the incoming file (forreplace) against the 500-plant cap is checked: if applying the import would leave more than 500 active plants, the whole import is rejected with422 LIMIT_EXCEEDEDand no row is written. - An automatic snapshot is taken (reusing the exact mechanism in Section 24.2,
VACUUM INTOplus verification) immediately before the import transaction begins, namedapp-YYYY-MM-DD-HHmmss-pre-import.dbso it is visually distinguishable from a scheduled snapshot. It counts towardAPP_BACKUP_RETAINpruning like any other file matching the snapshot pattern — theSNAPSHOT_NAME_REpattern in Section 24.2 already matches the-pre-importsuffix. - Any incoming plant whose
lastWateredOnis in the future relative totodayInTimeZone(new Date(), APP_TIMEZONE)(serverAPP_TIMEZONE, never the file's owntimezonefield) is clamped totodayInTimeZone(new Date(), APP_TIMEZONE)before insertion. This clamp runs in this route handler, after schema validation, never insideImportPayloadSchemaitself — a future date sent directly toPOST /api/v1/plantsis still a hard400 VALIDATION_FAILED(Section 9); import is the one deliberate, documented exception, because the alternative is rejecting an otherwise-valid file over a single stale field. Each clamped plant adds anIMPORT_FUTURE_DATE_CLAMPEDentry to thewarningsarray in step 7's response. - The entire load — delete-then-insert for
replace, or the existence-check-then-insert loop formerge— runs inside a singlebetter-sqlite3transaction (db.transaction(fn)()). ASQLITE_CONSTRAINT*error during that transaction (an unexpected data shape that slipped past schema validation) is caught, the transaction rolls back automatically, and the response is400 VALIDATION_FAILEDwithdetails: [{ "path": "plants.{i}", "message": "This record could not be imported." }]identifying the offending array index where it can be determined, rather than a bare500that wrongly implies a server-side fault. Any other, genuinely unexpected error during the transaction still produces500 INTERNAL_ERROR. Either way, no partial data is ever written — the pre-import snapshot from step 4 remains available as a safety net regardless. - Response body on success (
200):
{
"data": {
"plantsImported": 42,
"wateringsImported": 310,
"plantsSkipped": 3,
"wateringsSkipped": 12,
"warnings": [
{ "code": "IMPORT_FUTURE_DATE_CLAMPED", "message": "Plant \"Basil\" lastWateredOn was in the future and was clamped to today.", "plantId": "01917f2c-..." }
]
}
}plantsSkipped and wateringsSkipped are always 0 in replace mode (nothing is ever skipped when
the table is wiped first) and reflect the count of rows whose id already existed in merge mode.
warnings is always present as an array, empty when there is nothing to report.
// apps/api/src/routes/import.ts (excerpt)
export async function handleImport(db: Database.Database, payload: ImportPayload, ctx: RequestContext) {
const activeCountNow = countActivePlants(db);
const incomingActive = payload.mode === 'replace'
? payload.plants.filter((p) => p.deletedAt === null).length
: payload.mode === 'merge'
? countNetNewActive(db, payload.plants)
: 0;
const projected = payload.mode === 'replace' ? incomingActive : activeCountNow + incomingActive;
if (projected > 500) {
throw new AppError({
code: 'LIMIT_EXCEEDED',
httpStatus: 422,
message: 'The maximum number of plants (500) has been reached.',
});
}
await takePreImportSnapshot(db, ctx); // Section 24.2 mechanism, '-pre-import' suffix
const clamped = clampFutureLastWateredOn(payload, ctx.now); // Section 24.5 step 5
try {
return db.transaction(() => {
return clamped.mode === 'replace'
? importReplace(db, clamped, ctx.now)
: importMerge(db, clamped, ctx.now);
})();
} catch (err) {
if (isSqliteConstraintError(err)) {
throw new AppError({
code: 'VALIDATION_FAILED',
httpStatus: 400,
message: 'This file could not be imported.',
details: [{ path: constraintErrorPath(err), message: 'This record could not be imported.' }],
});
}
throw err; // genuinely unexpected — surfaces as 500 INTERNAL_ERROR
}
}24.6 Import edge cases #
| # | Case | Handling |
|---|---|---|
| 1 | Duplicate id values within the same file (two plants share an id) |
Rejected by ImportPayloadSchema (Section 9.4) with 400 VALIDATION_FAILED before any write; the schema enforces uniqueness of id within each array. |
| 2 | A watering whose plantId does not match any plant id in the same file |
Rejected by ImportPayloadSchema (Section 9.4) with 400 VALIDATION_FAILED; referential integrity within the file is a schema-level rule, not a database foreign-key failure at insert time. |
| 3 | A plant's lastWateredOn is in the future relative to import time (in APP_TIMEZONE) |
Not rejected. Clamped to todayInTimeZone(new Date(), APP_TIMEZONE) at import time (Section 24.5, step 5), and an IMPORT_FUTURE_DATE_CLAMPED warning is added to the response for that plant. |
| 4 | A file with 600 plants imported into a database that would then exceed the 500 cap | Rejected with 422 LIMIT_EXCEEDED before any row is written (step 3 in Section 24.5). |
| 5 | Malformed JSON body | Rejected with 400 MALFORMED_JSON before schema validation runs. |
| 6 | formatVersion is 2 (or any value other than 1) |
Rejected with 400 VALIDATION_FAILED and the exact message in Section 24.5, step 2. |
| 7 | plants: [] and waterings: [] (a structurally valid, empty document) |
Accepted. In replace mode this deletes all existing data and leaves an empty database; the response reports plantsImported: 0, wateringsImported: 0. |
| 8 | A file containing only waterings, with plants: [], but the waterings reference plant ids not present in the file |
Rejected by case 2's rule — a watering's plantId must resolve within the same file's plants array, even against ids that exist in the live database in merge mode. Waterings never reference the live database's plants when validating the file. |
| 9 | A plant's notes field exceeds 2000 characters |
Rejected by ImportPayloadSchema (Section 9.4) with 400 VALIDATION_FAILED, same length rule as plant creation (Section 9). |
| 10 | A plant's wateringIntervalDays is 0 or 400 |
Rejected by ImportPayloadSchema (Section 9.4) with 400 VALIDATION_FAILED, same 1–365 rule as plant creation (Section 9). |
| 11 | A plant's deletedAt is set to a timestamp more than 30 days before exportedAt |
Accepted and imported as a soft-deleted row. It becomes eligible for the purge job (Section 24.9) on its very next run, since purge eligibility is based on deleted_at age at evaluation time, not on when the row was inserted. This is stated plainly so an operator is not surprised to see an imported plant disappear permanently within 24 hours. |
| 12 | mode is missing or is a value other than "replace" / "merge" |
Rejected with 400 VALIDATION_FAILED; there is no default mode. |
| 13 | A merge import where every incoming id already exists in the live database |
Accepted; plantsImported: 0, plantsSkipped equal to the incoming plant count, and likewise for waterings. No error — merging a file that adds nothing is a valid no-op. |
| 14 | A replace import that fails partway through the transaction with a database-level constraint error not caught by schema validation |
400 VALIDATION_FAILED with a details entry naming the offending array index (Section 24.5, step 6) — the fault is in the file, not the server. The transaction rolls back completely; the database is left exactly as it was before the import started (not empty, not partially loaded). The pre-import snapshot (Section 24.5, step 4) was already taken and remains available regardless. |
| 15 | A replace import that fails partway through the transaction with a genuinely unexpected error (not a SQLITE_CONSTRAINT* error) |
500 INTERNAL_ERROR. Same rollback and pre-import-snapshot guarantee as case 14. |
24.7 Restore runbook #
These steps restore the live database from a snapshot file in APP_BACKUP_DIR, either a scheduled
nightly snapshot, a pre-import snapshot, or a manual snapshot (Section 26.9).
# 1. Stop the running container so nothing writes to the database during the restore.
docker compose stop app
# 2. Move the current (possibly corrupt or wrong) database file aside — do not delete it yet.
mv ./data/app.db "./data/app.db.bad-$(date +%Y%m%d-%H%M%S)"
mv ./data/app.db-wal "./data/app.db-wal.bad-$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
mv ./data/app.db-shm "./data/app.db-shm.bad-$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
# 3. List available snapshots, newest first, and choose one.
ls -1t ./data/backups/app-*.db | head -5
# 4. Copy the chosen snapshot into place as the live database file.
cp ./data/backups/app-2026-08-05-031500.db ./data/app.db
# 5. The runtime container runs as uid 10001 (Section 26.2); a file copied by the operator's own
# account is owned by the host user, not that uid, so restore this ownership before starting the
# container or it exits 78 on an unwritable database path (Section 21).
sudo chown 10001:10001 ./data/app.db
sudo chmod 640 ./data/app.db
# 6. Remove any stale WAL/SHM sidecar files so SQLite starts from a clean state.
rm -f ./data/app.db-wal ./data/app.db-shm
# 7. Start the container again.
docker compose start app
# 8. Verify the process is healthy.
curl -fsS http://localhost:8080/api/v1/health/ready
# 9. Verify the data looks right — confirm the plant count is what you expect.
curl -fsS http://localhost:8080/api/v1/plants | node -e \
"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).data.length))"If step 8 or step 9 fails or shows unexpected data, do not delete the .bad-* files from step 2 —
repeat from step 3 with a different, older snapshot.
24.7b Restore from a JSON export #
A snapshot restore (Section 24.7) is the primary recovery path and preserves everything exactly. An
export (Section 24.4) is a secondary, portable fallback — useful when no snapshot survived, when
moving to a new host ahead of a snapshot copy, or when only the exported subset of history is needed.
Because an export contains no mode field (Section 24.4), the operator injects one when replaying it:
# Restore an export as a full replace of the live database. Requires the app to be reachable
# (unlike the snapshot runbook, this path does not require stopping the container first).
jq '. + {mode: "replace"}' export.json | curl -fsS -X POST http://localhost:8080/api/v1/import \
-H 'Content-Type: application/json' \
--data-binary @-This only works for files at or under the 5 MB import request cap (Section 24.5, step 1). An export that exceeds 5 MB — reachable at the 500-plant cap per Section 24.4's size estimate — cannot be replayed this way; use the snapshot restore runbook above instead, which has no such limit.
24.8 Disaster scenarios #
| Scenario | Detection signal | Recovery procedure | Expected data loss window |
|---|---|---|---|
| Corrupted database file | health/ready returns 503; API logs show SQLITE_CORRUPT; a PRAGMA integrity_check run manually against app.db returns errors. |
Restore runbook, Section 24.7, using the newest available snapshot. | Since the last successful snapshot before corruption was introduced — up to ~24 hours under the default nightly schedule, or less if a manual backup (Section 26.9) was taken more recently. |
| Deleted database file | The process starts successfully but migrations run against an empty schema, so the app reports zero plants where there were previously many — this is the dangerous case because it does not fail health checks. | Stop the container immediately to avoid writing new data into the accidentally-empty database, then follow Section 24.7. | Since the last snapshot, plus any plants added to the fresh empty database before the operator notices — minimize this by checking the plant count immediately after any deploy or restart. |
| Full disk | backup.failed log entries; write requests return 500 INTERNAL_ERROR with SQLITE_FULL in the underlying error; reads continue to succeed. |
Free disk space (delete old files outside APP_BACKUP_DIR, or manually prune snapshots beyond APP_BACKUP_RETAIN), then restart the container. |
None for previously committed data. Writes attempted during the full-disk period fail loudly with a 500 and are never silently dropped; the client can retry them once space is freed. |
| A bad import (structurally valid but wrong data) | Import response reports unexpected plantsSkipped/plantsImported counts, or the plant list looks wrong immediately after an import. |
Restore the automatic pre-import snapshot taken in Section 24.5 step 4, using the Section 24.7 runbook. | None — the pre-import snapshot is taken seconds before the import and is exact. |
Accidental "replace" import |
Plant count drops sharply right after an import call, visible in logs or in the UI. | Same as above: restore the pre-import snapshot via Section 24.7. | None if caught before the pre-import snapshot itself is pruned by APP_BACKUP_RETAIN — restore promptly. |
| Lost host (hardware failure, VPS terminated) | Host unreachable entirely; nothing to detect from within the app. | Provision a new host, install Docker, and restore from the most recent copy of APP_BACKUP_DIR that exists outside the lost host. |
Since the last time APP_BACKUP_DIR was copied off-host. This can be much larger than 24 hours if the operator has not set up an off-host copy — the app has no cloud dependency and does not do this automatically (Section 26.11); it is a manual, documented operator responsibility, listed in the operational checklist (Section 26.10). |
24.9 Retention and the purge job #
The purge job enforces the 30-day soft-delete retention policy: a plant with deleted_at set more
than 30 days ago (a fixed threshold measured against the current instant, not a calendar date) is
permanently removed, along with all of its waterings rows (foreign key ON DELETE CASCADE, per the
schema in Section 7).
It runs on the same two triggers as the backup job (Section 24.2): once at process start, and then
every 24 hours thereafter, using the same in-process timer pattern (no external scheduler). Unlike the
backup job, the purge job has no APP_BACKUP_ENABLED-style toggle — retention enforcement is always
on, because leaving soft-deleted rows around forever was never a supported mode; the 30-day window is
fixed (Section 9).
runPurgeOnce(deps) returns a Promise<void> that resolves when the boot-time run completes. The
bootstrap sequence in apps/api/src/index.ts awaits it and passes that same promise into
startBackupScheduler (Section 24.2) as afterPurgeBootRun, so the backup job's own boot catch-up
snapshot never starts while a purge is still deleting rows — the two jobs' before/after row counts
(Section 24.3) are never captured mid-write by the other job.
Each run:
- Selects every plant where
deleted_at IS NOT NULL AND deleted_at < (now - 30 days). - Deletes those plants (cascading to their waterings) inside a single transaction.
- Logs
purge.completedwith the count of plants purged and their ids, orpurge.completedwith a count of0when there was nothing to do — the job always logs, even on a no-op run, so its liveness is visible in the logs independent of whether there was anything to purge. - Writes
settings.last_purge_run_atto the current instant (Section 7.8), using the sameINSERT ... ON CONFLICT DO UPDATEpattern aswriteLastBackupRunAt(Section 24.2), including theupdated_atcolumn.
Interaction with export and import: a plant purged by this job no longer appears in
GET /api/v1/export at all — purging is a hard delete, and the export only ever reflects the current
database contents (Section 24.4). A plant imported (Section 24.5) with a deletedAt already more than
30 days old is loaded successfully and then removed by the very next purge run, exactly as described
in the import edge case table (Section 24.6, case 11) — importing an old soft-deleted row does not
reset or extend its retention window.
24.10 Data ownership statement #
The application's entire dataset lives in one SQLite file. It can be exported at any time, by any
client that can reach GET /api/v1/export, as a single documented JSON file with no proprietary
encoding and no dependency on this application to read it back. There is no lock-in: the export format
(Section 24.4) is stable, versioned, and simple enough to parse with a five-line script if the
application itself is ever retired.
25. Testing Strategy and Test Matrix #
25.1 Strategy #
The test pyramid for this product is shaped by where the actual risk lives. The watering-schedule domain (Section 8) is the only genuinely tricky logic in the application — calendar arithmetic, timezone conversion, and status boundaries are exactly the kind of code that looks correct and is subtly wrong. It receives exhaustive unit coverage: every status boundary, every named timezone case, every leap-year and year-boundary case in Section 25.5 is a real, individually named test, not a generated fuzz sweep. CRUD plumbing — creating, listing, updating, and deleting plants and waterings — is comparatively low-risk, well-trodden code; it is tested representatively through API integration tests (Section 25.7) that cover each endpoint's happy path and its realistic failure modes, not every permutation of every field. The UI is tested by behavior — what a user can see and do — never by snapshotting rendered markup, because markup snapshots break on cosmetic changes and pass on real regressions. The guiding rule for the whole suite: the schedule engine is proven correct; the CRUD plumbing is proven representative; the UI is proven behaviorally correct.
Every row in the matrices in Sections 25.5–25.9 carries a stable test ID (DOM-, VAL-, API-,
WEB-, E2E- prefixes respectively, plus A11Y- in Section 25.10); Section 28's acceptance checklist
cites evidence by these IDs.
25.2 Tooling and layout #
Three Vitest projects are declared in a workspace file at the repository root:
// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
{ extends: './packages/shared/vitest.config.ts', test: { name: 'shared' } },
{ extends: './apps/api/vitest.config.ts', test: { name: 'api' } },
{ extends: './apps/web/vitest.config.ts', test: { name: 'web' } },
]);| Layer | Location | Naming | Runner |
|---|---|---|---|
| Unit — domain and validation | packages/shared/src/**/*.test.ts, colocated with the source file it tests |
<module>.test.ts |
Vitest (shared project) |
| Unit — frontend components | apps/web/src/**/*.test.tsx, colocated |
<Component>.test.tsx |
Vitest (web project) + Testing Library |
| API integration | apps/api/test/**/*.test.ts |
<resource>.test.ts |
Vitest (api project) + fastify.inject() |
| End-to-end | e2e/specs/**/*.spec.ts, fixtures in e2e/fixtures/ |
<journey>.spec.ts |
Playwright |
Commands, defined as root package.json scripts:
{
"scripts": {
"test:unit": "vitest run --project shared --project web",
"test:api": "vitest run --project api",
"test": "npm run test:unit && npm run test:api",
"test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y"
}
}API integration tests get an isolated database per test file, created once in beforeAll, plus a
beforeEach that truncates every data table before each individual test case runs — this keeps
per-file setup cost low (one migration run, not one per case) while guaranteeing no test can observe
data a sibling test left behind, regardless of run order:
// apps/api/test/helpers/reset-database.ts
export function resetDatabase(db: Database.Database): void {
db.exec(`
DELETE FROM waterings;
DELETE FROM plants;
DELETE FROM settings WHERE key <> 'schema_version';
`);
}No test in this suite may depend on data left by another test, in the same file or a different one; every test seeds exactly the rows its own assertions need.
// apps/api/test/helpers/test-db.ts
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rmSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/db/migrate.js';
export function createTestDatabase(): { db: Database.Database; cleanup: () => void } {
const path = join(tmpdir(), `hpt-test-${randomBytes(8).toString('hex')}.db`);
const db = new Database(path);
db.pragma('journal_mode = WAL');
runMigrations(db);
return {
db,
cleanup: () => {
db.close();
for (const suffix of ['', '-wal', '-shm']) {
rmSync(`${path}${suffix}`, { force: true });
}
},
};
}buildServer takes a single { db, now, config } object — config lets each test file override any
environment-derived setting without touching real environment variables. Every API test file passes
APP_BACKUP_ENABLED: false and a tmpdir()-based APP_BACKUP_DIR, so no test file starts a real
backup scheduler or writes into the repository's working tree:
// apps/api/test/plants.test.ts (excerpt)
import { tmpdir } from 'node:os';
import { beforeAll, beforeEach, afterAll, describe, it, expect } from 'vitest';
import { createTestDatabase } from './helpers/test-db.js';
import { resetDatabase } from './helpers/reset-database.js';
import { buildServer } from '../src/server.js';
import { testConfig } from './helpers/test-config.js';
describe('plants', () => {
let ctx: ReturnType<typeof createTestDatabase>;
let app: Awaited<ReturnType<typeof buildServer>>;
beforeAll(async () => {
ctx = createTestDatabase();
app = await buildServer({
db: ctx.db,
now: () => new Date('2026-08-05T12:00:00.000Z'),
config: testConfig({ APP_BACKUP_ENABLED: false, APP_BACKUP_DIR: tmpdir() }),
});
});
beforeEach(() => resetDatabase(ctx.db));
afterAll(async () => {
await app.close();
ctx.cleanup();
});
// ...individual test cases (see Section 25.7 for the full matrix)
});testConfig(overrides) (apps/api/test/helpers/test-config.js) returns a complete, valid AppConfig
(Section 21) with test-safe defaults (APP_RATE_LIMIT_MAX: 300, APP_ACCESS_CODE unset, and so on)
merged with overrides, so every test file supplies a config object rather than depending on process
environment variables. Two scenarios cannot share a file's database or default config with the rest of
plants.test.ts and live in their own files with their own buildServer instance instead: the rate
limit case (rate-limit.test.ts), configured with APP_RATE_LIMIT_MAX: 2 so the test issues 3
requests rather than 301 to prove the limiter trips; and the pre-import-snapshot case
(import-snapshot.test.ts), which is the one API test file allowed to set
APP_BACKUP_ENABLED: true against its own tmpdir()-based APP_BACKUP_DIR, so it can assert a
snapshot file actually appears without any other test file's backup activity interfering.
25.3 Determinism #
No test in this suite may depend on the real current date or the real current instant. This is enforced structurally, not by convention alone:
- Every function in
packages/shared/src/domainthat needs "now" receives it as an explicit parameter (today: stringornow: Date). None of them reads the system clock internally. apps/api/src/server.tsaccepts an injectablenow: () => DateatbuildServer()construction time, defaulting to() => new Date()in production and overridden with a fixed function in every test file, as shown in Section 25.2. Because the clock is injected once per test file and the database is also isolated per file (Section 25.2), running the same fixednowacross every test in a file is safe and produces identical results on every run, in any order, on any real calendar date.- Domain unit tests pass fixed instants and fixed calendar-date strings directly, e.g.
computeScheduleView({ lastWateredOn: '2026-07-29', wateringIntervalDays: 7 }, '2026-08-05'). - Playwright tests install a fixed browser clock with
page.clock.install({ time: new Date('2026-08-05T12:00:00.000Z') })before navigation. Pinning the browser's clock alone is not sufficient, becausestatusis always computed server-side (Section 8.4): the E2E test server (Section 25.9) is started withAPP_TIMEZONE=America/Chicagoand withAPP_FAKE_NOW=2026-08-05T12:00:00.000Z— the same instantpage.clock.installuses — set in its environment.apps/api/src/config.tshonoursAPP_FAKE_NOWonly whenNODE_ENV=test; if it is set under any otherNODE_ENVthe process refuses to boot (exit 78), so this test-only escape hatch can never reach a real deployment. With both clocks pinned to the same instant, the E2E fixtures (Section 25.12) are seeded relative to that same value, so a status assertion never depends on which real calendar date the suite happens to run on. When a journey fast-forwards the clock (journeys 7 and 14, Section 25.9), the advance also crosses the midnight-refresh boundary and the 5-minute poll interval (Section 16.5) on the client, so those two journeys' network-call assertions tolerate one additionalGET /api/v1/plantsbeyond the count a same-instant journey would expect. - No test in this suite may rely on locale-dependent string collation.
compareByUrgency's name tiebreak (Section 8.4) pins its collator explicitly (localeCompare(name, 'en', { sensitivity: 'base' })), so sort-order assertions produce the same result under any CI runner's default locale. - This rule is enforced by lint, not just by test discipline:
eslint.config.jsadds ano-restricted-syntaxrule scoped topackages/shared/src/domain/**andapps/web/src/hooks/**that forbids a barenew Date()(zero arguments) and anyDate.now()call. A violation failsnpm run lint(Section 26.1), which fails CI (Section 26.6) before the offending code can be merged.
25.4 Coverage gates #
| Package | Statement threshold | Branch threshold | Rationale |
|---|---|---|---|
packages/shared/src/domain |
100% | 100% | Pure functions, no I/O, small surface area — 100% is achievable and treated as non-negotiable given how much correctness depends on this code (Section 25.1). |
apps/api/src |
85% | — | High but not absolute; error-handling branches for conditions that are structurally hard to trigger in a test (e.g. a filesystem write failure mid-request) are the intended gap. |
apps/web/src |
70% | — | UI code has a lower bar; visual-only branches (CSS class toggling with no behavioral difference) are not worth chasing to 100%. |
Coverage is measured with Vitest's v8 provider (vitest run --coverage), configured with the
thresholds above in each project's vitest.config.ts via the test.coverage.thresholds option. A
threshold miss makes vitest run --coverage exit non-zero, which fails the unit or api CI job
(Section 26.6) directly — there is no separate, softer coverage-reporting step; the gate is the same
command that produces the report.
The 100% branch threshold on packages/shared/src/domain is achievable only because the calendar
helpers' negative-year branches — reachable only for dates before the product's own 1970-01-01 floor
(Section 9) — are exercised directly by cases 44–45 in Section 25.5, rather than left as a gap the gate
cannot legitimately close.
Files excluded from coverage, and why:
| File / pattern | Reason for exclusion |
|---|---|
apps/api/src/index.ts |
Process bootstrap and wiring only (reads env, calls buildServer, calls listen); exercised by the fact that the API integration suite and E2E suite both depend on the server starting, not by a unit test. |
apps/web/src/main.tsx |
React root mount (createRoot(...).render(...)); no branching logic to cover. |
apps/api/migrations/*.sql |
Plain SQL, not JavaScript/TypeScript; not subject to a JS coverage tool. Migration correctness is verified by every API test running runMigrations in beforeAll (Section 25.2) succeeding. |
**/*.d.ts |
Type declarations only, no runtime code. |
25.5 Unit test matrix — schedule engine #
All cases exercise the pure functions in packages/shared/src/domain/schedule.ts described in
Sections 8.4, 8.5, 8.8, and 8.9. today below is the value todayInTimeZone(now, APP_TIMEZONE) returns, unless a case is explicitly
testing timezone derivation (cases 31–40), in which case a UTC instant and an APP_TIMEZONE are given
instead and today is the value the function under test must derive.
| ID | # | Case | Inputs | Expected nextDueOn |
Expected daysUntilDue |
Expected status |
Expected daysOverdue |
|---|---|---|---|---|---|---|---|
| DOM-01 | 1 | Due today (boundary 0) | lastWateredOn=2026-07-29, interval=7, today=2026-08-05 | 2026-08-05 | 0 | due_today |
0 |
| DOM-02 | 2 | One day overdue (boundary −1) | lastWateredOn=2026-07-28, interval=7, today=2026-08-05 | 2026-08-04 | −1 | overdue |
1 |
| DOM-03 | 3 | Due tomorrow (boundary 1) | lastWateredOn=2026-07-30, interval=7, today=2026-08-05 | 2026-08-06 | 1 | due_soon |
0 |
| DOM-04 | 4 | Upcoming, lower bound (boundary 2) | lastWateredOn=2026-07-31, interval=7, today=2026-08-05 | 2026-08-07 | 2 | upcoming |
0 |
| DOM-05 | 5 | Upcoming, three days out | lastWateredOn=2026-08-01, interval=7, today=2026-08-05 | 2026-08-08 | 3 | upcoming |
0 |
| DOM-06 | 6 | Interval 1, watered today | lastWateredOn=2026-08-05, interval=1, today=2026-08-05 | 2026-08-06 | 1 | due_soon |
0 |
| DOM-07 | 7 | Interval 1, watered yesterday | lastWateredOn=2026-08-04, interval=1, today=2026-08-05 | 2026-08-05 | 0 | due_today |
0 |
| DOM-08 | 8 | Interval 1, missed by a day | lastWateredOn=2026-08-03, interval=1, today=2026-08-05 | 2026-08-04 | −1 | overdue |
1 |
| DOM-09 | 9 | Interval 365, watered today | lastWateredOn=2026-08-05, interval=365, today=2026-08-05 | 2027-08-05 | 365 | upcoming |
0 |
| DOM-10 | 10 | Interval 365, one day before due | lastWateredOn=2025-08-05, interval=365, today=2026-08-04 | 2026-08-05 | 1 | due_soon |
0 |
| DOM-11 | 11 | 30 days overdue | lastWateredOn=2026-07-01, interval=5, today=2026-08-05 | 2026-07-06 | −30 | overdue |
30 |
| DOM-12 | 12 | 400 days overdue | lastWateredOn=2025-06-30, interval=1, today=2026-08-05 | 2025-07-01 | −400 | overdue |
400 |
| DOM-13 | 13 | Leap day, interval 1 | lastWateredOn=2028-02-29, interval=1, today=2028-03-01 | 2028-03-01 | 0 | due_today |
0 |
| DOM-14 | 14 | Non-leap Feb, no leap day in span | lastWateredOn=2027-02-27, interval=3, today=2027-03-02 | 2027-03-02 | 0 | due_today |
0 |
| DOM-15 | 15 | Leap day inside the interval span | lastWateredOn=2028-02-27, interval=3, today=2028-03-01 | 2028-03-01 | 0 | due_today |
0 |
| DOM-16 | 16 | Year boundary, exact due date | lastWateredOn=2026-12-30, interval=3, today=2027-01-02 | 2027-01-02 | 0 | due_today |
0 |
| DOM-17 | 17 | Year boundary, due tomorrow | lastWateredOn=2026-12-25, interval=7, today=2026-12-31 | 2027-01-01 | 1 | due_soon |
0 |
| DOM-18 | 18 | Interval crossing into a leap-year Feb 29 | lastWateredOn=2028-01-31, interval=29, today=2028-02-29 | 2028-02-29 | 0 | due_today |
0 |
| DOM-19 | 19 | Same interval, non-leap year (no Feb 29) | lastWateredOn=2027-01-31, interval=29, today=2027-03-01 | 2027-03-01 | 0 | due_today |
0 |
| DOM-20 | 20 | Recently watered, mid-range interval | lastWateredOn=2026-07-20, interval=20, today=2026-08-05 | 2026-08-09 | 4 | upcoming |
0 |
| DOM-21 | 21 | daysOverdue is zero for due_today |
(case 1 inputs) | — | — | due_today |
0 |
| DOM-22 | 22 | daysOverdue is zero for due_soon |
(case 3 inputs) | — | — | due_soon |
0 |
| DOM-23 | 23 | daysOverdue is zero for upcoming |
(case 4 inputs) | — | — | upcoming |
0 |
| DOM-24 | 24 | Shortening interval makes a plant instantly overdue | lastWateredOn=2026-08-01, interval changed 10→2, today=2026-08-05 | 2026-08-03 | −2 | overdue |
2 |
| DOM-25 | 25 | Lengthening interval rescues an overdue plant | lastWateredOn=2026-07-01, interval changed 5→365, today=2026-08-05 | 2027-07-01 | 330 | upcoming |
0 |
| DOM-26 | 26 | Watering resets a deeply overdue plant | lastWateredOn set to today (2026-08-05) from a prior overdue state, interval=7 | 2026-08-12 | 7 | upcoming |
0 |
| DOM-27 | 27 | isAlreadyWateredToday true |
lastWateredOn=2026-08-05, today=2026-08-05 | — | — | predicate: true |
— |
| DOM-28 | 28 | isAlreadyWateredToday false |
lastWateredOn=2026-08-04, today=2026-08-05 | — | — | predicate: false |
— |
| DOM-29 | 29 | Deleting the only watering falls back to createdAt calendar date |
createdAt=2026-01-04T09:12:00.000Z, APP_TIMEZONE=UTC |
— | — | derived lastWateredOn: 2026-01-04 |
— |
| DOM-30 | 30 | Extracting a calendar date from an instant near a timezone boundary | createdAt=2026-01-04T23:50:00.000Z, APP_TIMEZONE=Pacific/Kiritimati (UTC+14) |
— | — | derived date: 2026-01-05 | — |
| DOM-31 | 31 | addDays performs pure calendar arithmetic, no DST awareness |
addDays('2027-03-13', 1) |
2027-03-14 | — | — | — |
| DOM-32 | 32 | differenceInCalendarDays sign convention, forward |
differenceInCalendarDays('2026-08-01', '2026-08-05') |
— | −4 | — | — |
| DOM-33 | 33 | differenceInCalendarDays sign convention, backward |
differenceInCalendarDays('2026-08-05', '2026-08-01') |
— | 4 | — | — |
| DOM-34 | 34 | Timezone derivation, UTC baseline | now=2026-08-05T12:00:00.000Z, tz=UTC | — | — | today=2026-08-05 | — |
| DOM-35 | 35 | Timezone derivation, Pacific/Kiritimati rolls forward | now=2026-08-05T12:00:00.000Z, tz=Pacific/Kiritimati (UTC+14) | — | — | today=2026-08-06 | — |
| DOM-36 | 36 | Timezone derivation, Kiritimati just before the boundary | now=2026-08-05T09:59:00.000Z, tz=Pacific/Kiritimati | — | — | today=2026-08-05 | — |
| DOM-37 | 37 | Timezone derivation, Kiritimati just after the boundary | now=2026-08-05T10:00:00.000Z, tz=Pacific/Kiritimati | — | — | today=2026-08-06 | — |
| DOM-38 | 38 | Timezone derivation, Pacific/Niue stays on the previous day | now=2026-08-06T08:00:00.000Z, tz=Pacific/Niue (UTC−11) | — | — | today=2026-08-05 | — |
| DOM-39 | 39 | Timezone derivation, Niue crosses midnight | now=2026-08-06T11:00:00.000Z, tz=Pacific/Niue | — | — | today=2026-08-06 | — |
| DOM-40 | 40 | Timezone derivation, Asia/Kolkata half-hour offset, before rollover | now=2026-08-05T18:29:00.000Z, tz=Asia/Kolkata (UTC+5:30) | — | — | today=2026-08-05 | — |
| DOM-41 | 41 | Timezone derivation, Kolkata half-hour offset, after rollover | now=2026-08-05T18:30:00.000Z, tz=Asia/Kolkata | — | — | today=2026-08-06 | — |
| DOM-42 | 42 | Timezone derivation, DST spring-forward day (America/Chicago) | now=2027-03-14T06:00:00.000Z, tz=America/Chicago | — | — | today=2027-03-14 (date extraction unaffected by the missing 02:00–03:00 local hour) | — |
| DOM-43 | 43 | Timezone derivation, DST fall-back day (America/Chicago) | now=2027-11-07T06:00:00.000Z, tz=America/Chicago | — | — | today=2027-11-07 (date extraction unaffected by the repeated 01:00–02:00 local hour) | — |
| DOM-44 | 44 | Negative-epoch branch, addDays before 1970 |
addDays('1970-01-01', -1) |
1969-12-31 | — | — | — |
| DOM-45 | 45 | Negative-epoch branch, differenceInCalendarDays spanning a century before 1970 |
differenceInCalendarDays('1900-01-01', '2000-01-01') |
— | −36524 | — | — |
Cases 44–45 exist solely to reach the Hinnant conversion's negative-year branches for
noUncheckedIndexedAccess/branch-coverage purposes (Section 25.4); no date earlier than 1970-01-01
is reachable through validated input (Section 9), but the calendar functions themselves are total over
the proleptic Gregorian calendar and are tested as such.
25.6 Unit test matrix — validation #
All cases exercise the Zod schemas described in Section 9.8 (rejected-input table) and the accepted boundary values from Section 9's field limits.
| ID | # | Field | Input | Expected result |
|---|---|---|---|---|
| VAL-01 | 1 | name |
"" |
Rejected — required, minimum length 1 after trim. |
| VAL-02 | 2 | name |
" " |
Rejected — trims to empty string. |
| VAL-03 | 3 | name |
61-character string | Rejected — exceeds maximum length 60. |
| VAL-04 | 4 | name |
"Fern\u0007" (contains a control character) |
Accepted once the control character is stripped, provided the remaining length is 1–60. |
| VAL-05 | 5 | name |
field omitted | Rejected — required. |
| VAL-06 | 6 | name |
123 (number, not string) |
Rejected — wrong type. |
| VAL-07 | 7 | wateringIntervalDays |
0 |
Rejected — minimum is 1. |
| VAL-08 | 8 | wateringIntervalDays |
366 |
Rejected — maximum is 365. |
| VAL-09 | 9 | wateringIntervalDays |
3.5 |
Rejected — must be an integer. |
| VAL-10 | 10 | wateringIntervalDays |
"7" (string) |
Rejected — no coercion from string to number. |
| VAL-11 | 11 | wateringIntervalDays |
value that parses to NaN |
Rejected — fails the integer check. |
| VAL-12 | 12 | wateringIntervalDays |
field omitted | Rejected — required. |
| VAL-13 | 13 | wateringIntervalDays |
-5 |
Rejected — below minimum 1. |
| VAL-14 | 14 | notes |
2001-character string | Rejected — exceeds maximum 2000 after trim. |
| VAL-15 | 15 | notes |
"" |
Accepted — normalized to null. |
| VAL-16 | 16 | notes |
null |
Accepted — already null. |
| VAL-17 | 17 | notes |
" " |
Accepted — trims to empty, then normalized to null. |
| VAL-18 | 18 | notes |
"<script>alert(1)</script>" |
Accepted as plain text; never rejected for content, rendered escaped by React (Section 11's threat model). |
| VAL-19 | 19 | lastWateredOn |
tomorrow's date | Rejected — must not be in the future relative to APP_TIMEZONE today. |
| VAL-20 | 20 | lastWateredOn |
"1969-12-31" |
Rejected — earlier than the 1970-01-01 floor. |
| VAL-21 | 21 | lastWateredOn |
"2026-02-30" |
Rejected — not a real calendar date. |
| VAL-22 | 22 | lastWateredOn |
"08/05/2026" |
Rejected — wrong format, must be YYYY-MM-DD. |
| VAL-23 | 23 | lastWateredOn |
field omitted on create | Accepted — defaults to today in APP_TIMEZONE. |
| VAL-24 | 24 | request body | unknown extra field, e.g. "species": "Monstera" |
Accepted — unknown fields are stripped silently (Section 5). |
| VAL-25 | 25 | request body | malformed JSON | Rejected with MALFORMED_JSON, before Zod validation runs. |
| VAL-26 | 26 | request | Content-Type: text/plain with a JSON body |
Rejected with UNSUPPORTED_MEDIA_TYPE. |
| VAL-27 | 27 | request | body over 64 KB on a normal endpoint | Rejected with PAYLOAD_TOO_LARGE. |
| VAL-28 | 28 | plant count | creating a 501st active plant | Rejected with LIMIT_EXCEEDED. |
| VAL-29 | 29 | limit (pagination) |
0 |
Rejected — minimum is 1. |
| VAL-30 | 30 | limit |
101 |
Rejected — maximum is 100. |
| VAL-31 | 31 | offset |
-1 |
Rejected — minimum is 0. |
| VAL-32 | 32 | limit |
"abc" |
Rejected — wrong type. |
| VAL-33 | 33 | name |
exactly 1 character | Accepted — lower boundary. |
| VAL-34 | 34 | name |
exactly 60 characters | Accepted — upper boundary. |
| VAL-35 | 35 | wateringIntervalDays |
1 |
Accepted — lower boundary. |
| VAL-36 | 36 | wateringIntervalDays |
365 |
Accepted — upper boundary. |
| VAL-37 | 37 | notes |
omitted entirely | Accepted — stored as null. |
| VAL-38 | 38 | notes |
exactly 2000 characters | Accepted — upper boundary. |
| VAL-39 | 39 | limit |
1 |
Accepted — lower boundary. |
| VAL-40 | 40 | limit |
100 |
Accepted — upper boundary. |
| VAL-41 | 41 | offset |
0 |
Accepted — lower boundary. |
| VAL-42 | 42 | name |
60 Unicode code points made of emoji (120 UTF-16 code units) | Accepted — length is measured in Unicode code points, not UTF-16 code units. |
| VAL-43 | 43 | name |
61 Unicode code points made of emoji (122 UTF-16 code units) | Rejected — exceeds maximum length 60 code points. |
| VAL-44 | 44 | name |
a single zero-width space character | Rejected with NAME_REQUIRED — zero-width and bidi-format characters are stripped before the length/non-empty check, so the normalized value is empty. |
| VAL-45 | 45 | PATCH request body |
{"name":"X"} on a plant that already has notes |
Accepted — the existing notes value is unchanged (omitted fields are left alone, Section 10.5). |
| VAL-46 | 46 | PATCH request body |
{}, or a body containing only unknown keys, e.g. {"colour":"green"} |
Rejected with 400 VALIDATION_FAILED / UPDATE_EMPTY_BODY, checked against the raw parsed body before schema validation (Section 10.5); no column is written. |
25.7 API integration test matrix #
Every case uses fastify.inject() against a freshly migrated, isolated database (Section 25.2). Response shapes and header semantics not restated here are defined in Section 13.
| ID | # | Endpoint | Scenario | Setup | Expected result |
|---|---|---|---|---|---|
| API-01 | 1 | POST /api/v1/plants |
Happy path create | Valid body | 201, response includes computed status per Section 8. |
| API-02 | 2 | POST /api/v1/plants |
Validation failure | wateringIntervalDays: 0 |
400 VALIDATION_FAILED with a details entry for the field. |
| API-03 | 3 | POST /api/v1/plants |
Cap exceeded | 500 active plants already seeded | 422 LIMIT_EXCEEDED. |
| API-04 | 4 | GET /api/v1/plants |
Happy path list | 3 plants seeded | 200, array of length 3, each with status, nextDueOn, daysUntilDue, daysOverdue. |
| API-05 | 5 | GET /api/v1/plants |
Empty list | No plants | 200, data: []. |
| API-06 | 6 | GET /api/v1/plants/:plantId |
Not found | Random UUID | 404 NOT_FOUND. |
| API-07 | 7 | PATCH /api/v1/plants/:plantId |
Happy path rename | Valid partial body | 200, updated name in response. |
| API-08 | 8 | PATCH /api/v1/plants/:plantId |
Shortening interval causes instant overdue | Interval 30 → 2 | 200, status: "overdue", daysOverdue > 0. |
| API-09 | 9 | PATCH /api/v1/plants/:plantId |
Validation failure | name: "" |
400 VALIDATION_FAILED. |
| API-10 | 10 | PATCH /api/v1/plants/:plantId |
Not found | Already soft-deleted plant id | 404 NOT_FOUND. |
| API-11 | 11 | DELETE /api/v1/plants/:plantId |
Happy path soft delete | Existing plant | 204, plant absent from a subsequent GET /api/v1/plants. |
| API-12 | 12 | DELETE /api/v1/plants/:plantId |
Not found | Already-deleted plant id | 404 NOT_FOUND. |
| API-13 | 13 | DELETE /api/v1/plants/:plantId |
Idempotency | Delete the same plant twice | Second call returns 404 NOT_FOUND. |
| API-14 | 14 | POST /api/v1/plants/:plantId/restore |
Happy path, inside window | Deleted 5 seconds ago | 200, plant reappears in GET /api/v1/plants. |
| API-15 | 15 | POST /api/v1/plants/:plantId/restore |
Outside window (already purged) | deleted_at 31 days ago; the test invokes runPurgeOnce({ db, now }) (apps/api/src/lib/purge.ts, Section 24.9) explicitly after seeding, since the server's own boot-time purge already ran before this row was inserted |
404 NOT_FOUND — the row no longer exists after purge. |
| API-16 | 16 | POST /api/v1/plants/:plantId/restore |
Not deleted | Active plant | 404 NOT_FOUND. |
| API-17 | 17 | POST /api/v1/plants/:plantId/waterings |
Happy path, water an overdue plant | Plant is overdue | 200, lastWateredOn = today, status recomputed to upcoming (interval ≥ 2) or due_soon (interval = 1) — never due_today, since the minimum daysUntilDue after watering is 1. |
| API-18 | 18 | POST /api/v1/plants/:plantId/waterings |
Idempotent re-water | Plant already watered today | 200, meta.alreadyWateredToday: true, no second row (verified via GET .../waterings). |
| API-19 | 19 | POST /api/v1/plants/:plantId/waterings |
Not found | Soft-deleted plant id | 404 NOT_FOUND. |
| API-20 | 20 | GET /api/v1/plants/:plantId/waterings |
Default pagination | 25 waterings seeded | 200, 20 rows, meta.total: 25, meta.limit: 20, meta.offset: 0. |
| API-21 | 21 | GET /api/v1/plants/:plantId/waterings |
Pagination, lower bound | limit=1&offset=0 |
200, 1 row. |
| API-22 | 22 | GET /api/v1/plants/:plantId/waterings |
Pagination, upper bound | limit=100 |
200, up to 100 rows. |
| API-23 | 23 | GET /api/v1/plants/:plantId/waterings |
Pagination, out of range | offset=1000 |
200, data: []. |
| API-24 | 24 | GET /api/v1/plants/:plantId/waterings |
Invalid pagination input | limit=0 |
400 VALIDATION_FAILED. |
| API-25 | 25 | DELETE /api/v1/plants/:plantId/waterings/:wateringId |
Happy path | 2 waterings exist | 204, lastWateredOn recomputed from the remaining latest watering. |
| API-26 | 26 | DELETE /api/v1/plants/:plantId/waterings/:wateringId |
Deleting the only entry | 1 watering exists | 204, lastWateredOn reset to the plant's createdAt calendar date (Section 25.5, case 29). |
| API-27 | 27 | DELETE /api/v1/plants/:plantId/waterings/:wateringId |
Not found | Random watering id | 404 NOT_FOUND. |
| API-28 | 28 | Any mutating endpoint | Wrong content type | Content-Type: text/plain |
415 UNSUPPORTED_MEDIA_TYPE. |
| API-29 | 29 | Any mutating endpoint | Oversized body | 100 KB body on a normal endpoint | 413 PAYLOAD_TOO_LARGE. |
| API-30 | 30 | Any endpoint | Rate limit exceeded | Its own file (rate-limit.test.ts, Section 25.2), APP_RATE_LIMIT_MAX: 2, 3 requests issued |
3rd request returns 429 RATE_LIMITED. |
| API-31 | 31 | Any endpoint | Trailing slash | GET /api/v1/plants/ |
404 NOT_FOUND. |
| API-32 | 32 | GET /api/v1/export |
Happy path | 2 plants (1 soft-deleted), 4 waterings | 200, Content-Disposition header present, formatVersion: 1, soft-deleted plant included with deletedAt set. |
| API-33 | 33 | POST /api/v1/import |
Happy path, replace mode |
Its own file (import-snapshot.test.ts, Section 25.2), valid export document |
200, correct plantsImported/wateringsImported, pre-import snapshot file created in APP_BACKUP_DIR. |
| API-34 | 34 | POST /api/v1/import |
merge mode skips existing ids |
Document with 1 existing id + 1 new id | 200, plantsSkipped: 1, plantsImported: 1. |
| API-35 | 35 | POST /api/v1/import |
Unsupported formatVersion |
formatVersion: 2 |
400 VALIDATION_FAILED with the exact message from Section 24.5. |
| API-36 | 36 | POST /api/v1/import |
Oversized body | 6 MB body | 413 PAYLOAD_TOO_LARGE. |
| API-37 | 37 | POST /api/v1/import |
Cap exceeded | File with 600 plants | 422 LIMIT_EXCEEDED, database unchanged after the call. |
| API-38 | 38 | GET /api/v1/health |
Happy path | Server running | 200. |
| API-39 | 39 | GET /api/v1/health/ready |
Happy path | Database reachable | 200. |
| API-40 | 40 | GET /api/v1/health/ready |
Database unavailable | Database connection forced to fail | 503 SERVICE_UNAVAILABLE. |
| API-41 | 41 | PATCH /api/v1/plants/:plantId |
Optimistic concurrency, matching | If-Unmodified-Since header set to the plant's current updatedAt, formatted as an RFC 7231 HTTP-date |
200, update applied (Section 13, Section 10.5). |
| API-42 | 42 | PATCH /api/v1/plants/:plantId |
Optimistic concurrency, stale | If-Unmodified-Since header set to a stale updatedAt (also an RFC 7231 HTTP-date) |
409 CONFLICT (Section 13). |
| API-43 | 43 | PATCH /api/v1/plants/:plantId |
Optimistic concurrency, malformed header | If-Unmodified-Since: not-a-date |
400 VALIDATION_FAILED with details[0].path = "If-Unmodified-Since". |
| API-44 | 44 | POST /api/v1/plants/:plantId/restore |
Inside window, purge not yet run | deleted_at 29 days ago; no purge invoked |
200, plant restored — companion to API-15, proving the boundary is the purge run, not merely elapsed time. |
| API-45 | 45 | GET /api/v1/meta |
Happy path | Server running, APP_TIMEZONE=America/Chicago |
200, response contains timezone, today, plantCap, version, accessCodeEnabled (Section 13.5.10). |
| API-46 | 46 | POST /api/v1/plants/:plantId/waterings |
Watering across the local-midnight boundary | Two calls at 2026-08-05T23:59:59.9-05:00 and 2026-08-06T00:00:00.1-05:00 local (APP_TIMEZONE=America/Chicago) |
Both return 200 with meta.alreadyWateredToday: false; the two calls produce different watered_on values and the plant ends with two waterings rows. |
| API-47 | 47 | POST /api/v1/plants/:plantId/waterings |
Concurrent double call | Two requests issued for the same plant without awaiting the first | Both return 200; exactly one waterings row exists; the second response carries meta.alreadyWateredToday: true and the same meta.wateringId as the first. |
| API-48 | 48 | DELETE /api/v1/plants/:plantId/waterings/:wateringId |
Deleting a non-current row after a backdating edit | A watering exists whose watered_on is earlier than the plant's current last_watered_on (set by a prior backdating PATCH, Section 10.5) |
204; last_watered_on, status, and nextDueOn are unchanged, because only a deletion of the row matching the current last_watered_on triggers recomputation (Section 11.3). |
| API-49 | 49 | DELETE /api/v1/plants/:plantId/waterings/:wateringId |
Deleting the last remaining watering after a backdated last_watered_on |
The plant's last_watered_on was backdated (via PATCH) to a date earlier than its createdAt calendar date, then its only watering is deleted |
204; last_watered_on becomes the earlier of createdAt's calendar date and the pre-delete last_watered_on — never a later date, so the plant cannot become less overdue. |
| API-50 | 50 | PATCH /api/v1/plants/:plantId |
Empty or unknown-keys-only body | {}, and separately {"colour":"green"} |
Both return 400 VALIDATION_FAILED / UPDATE_EMPTY_BODY; a follow-up GET on the same plant shows no column changed. |
| API-51 | 51 | Plant cap boundary sequence | Create at the cap, exceed it, delete, retry | Create the 500th active plant, attempt a 501st, delete one active plant, retry the create | 500th: 201. 501st: 422 LIMIT_EXCEEDED, no row written. Retry after delete: 201 — soft-deleted plants never count toward the cap. |
| API-52 | 52 | Log content | A plant created with a distinctive, searchable name, then updated, watered, deleted, restored, exported, and imported, plus one forced 500 |
The distinctive string appears in no log line at any level across the whole sequence (Section 20's threat model). | |
| API-53 | 53 | GET /api/v1/health/ready |
Access code enabled | APP_ACCESS_CODE set, no session cookie sent |
200 — both health endpoints stay ungated regardless of the access code (Section 20.5). |
| API-54 | 54 | Export/import round trip | GET /api/v1/export, then POST /api/v1/import with mode: "replace" on the unmodified response body plus the injected mode |
200; plantsImported equals the exported plant count; a subsequent export is byte-equivalent to the first apart from exportedAt. |
|
| API-55 | 55 | POST /api/v1/import |
Duplicate id within plants, and separately a waterings[].plantId absent from the file's own plants |
Both rejected with 400 VALIDATION_FAILED and a details entry naming the offending array index. |
25.8 Frontend component test matrix #
All cases use React Testing Library, querying by accessible role and text rather than by test id or CSS selector, and asserting on user-visible behavior.
| ID | # | Component | Case | Assertion |
|---|---|---|---|---|
| WEB-01 | 1 | PlantRow |
Renders name and due label | The plant name and the row's due label are visible. |
| WEB-02 | 2 | PlantRow |
overdue styling |
Red-toned surface, AlertTriangle icon, and the text "Overdue" are all present (Section 12/15's rule that color is never the only signal). |
| WEB-03 | 3 | PlantRow |
due_today styling |
Amber-toned surface, Droplet icon, "Due today" text. |
| WEB-04 | 4 | PlantRow |
due_soon styling |
Blue-toned surface, Clock icon, "Due tomorrow" text. |
| WEB-05 | 5 | PlantRow |
upcoming styling |
Neutral/green surface, Check icon, days-remaining text. |
| WEB-06 | 6 | PlantRow |
Overdue day count | Text reads "Overdue by 3 days" for daysOverdue: 3 (Section 8.4's exact phrasing). |
| WEB-07 | 7 | WaterButton |
Pending state | Clicking disables the button and shows a pending indicator immediately, before the request resolves. |
| WEB-08 | 8 | WaterButton |
Success | On success, an Undo toast appears. |
| WEB-09 | 9 | Toast |
Undo restores prior state | Clicking Undo re-invalidates the plant list and the row returns to its pre-water status. |
| WEB-10 | 10 | Toast |
Auto-dismiss | Using fake timers, the toast disappears after its window elapses without user action. |
| WEB-11 | 11 | PlantForm |
Empty name validation | Submitting with an empty name shows an inline field error and does not call the create mutation. |
| WEB-12 | 12 | PlantForm |
Interval out of range | Submitting wateringIntervalDays: 400 shows an inline field error. |
| WEB-13 | 13 | PlantForm |
Successful create | Submitting a valid form calls the create mutation and closes the form. |
| WEB-14 | 14 | PlantForm |
Edit pre-fills values | Opening the form for an existing plant shows its current name, interval, and notes. |
| WEB-15 | 15 | ConfirmDialog |
Confirm triggers soft delete | Confirming calls the delete mutation and shows an Undo toast. |
| WEB-16 | 16 | ListControls |
Search hidden below threshold | With 10 or fewer plants, the search input does not render. |
| WEB-17 | 17 | ListControls |
Search visible above threshold | With 11 or more plants, the search input renders. |
| WEB-18 | 18 | ListControls |
Filters case-insensitively | Typing part of a name, in any case, narrows the visible rows to matches. |
| WEB-19 | 19 | PlantList |
Filter empty state | A search with zero matches renders a "No plants match" message, distinct from the zero-plants empty state. |
| WEB-20 | 20 | PlantList |
Zero-plants empty state | With no plants at all, a distinct "add your first plant" empty state renders. |
| WEB-21 | 21 | ThemeToggle |
Persists selection | Toggling to dark mode writes "dark" to localStorage under hpt.theme. |
| WEB-22 | 22 | ThemeToggle |
Respects system preference on first load | With no stored preference and prefers-color-scheme: dark, the app renders in dark mode without a stored value being written. |
| WEB-23 | 23 | App shell | Focus moves on route change | Navigating to a new route moves focus to that route's page heading. |
| WEB-24 | 24 | PlantRow |
Notes render as plain text | A notes value containing <b> renders the literal characters, never as markup. |
| WEB-25 | 25 | PlantRow |
Distinguishable under grayscale | With CSS filter: grayscale(100%) applied, all four statuses remain uniquely identifiable by icon data-testid and label text alone, not by hue (Section 12.7/19.1). |
25.9 E2E test matrix #
Each journey below corresponds to a scenario in Section 4.3. Every journey runs at two viewports —
360×740 (mobile-first baseline, Section 12) and 1280×800 (desktop) — and in two browser engines,
Chromium and WebKit, for 2 × 2 = 4 runs per journey. Each journey's final assertion step includes an
@axe-core/playwright scan of the page in its final state with zero violations, tagged @a11y so it
can also be run in isolation (Section 25.10).
| ID | # | Journey | Steps | Key assertions |
|---|---|---|---|---|
| E2E-01 | 1 | First-time empty state | Load the app with zero plants. | The empty state renders with an "add your first plant" call to action; axe scan passes. |
| E2E-02 | 2 | Add a plant with all fields | Open the create form; fill name, interval, notes; submit. | The new plant appears in the list with the correct interval and notes; its initial status matches Section 8's rules for a plant just watered. |
| E2E-03 | 3 | Mark an overdue plant as watered | Seed an overdue plant; tap "Watered today". | Status flips from overdue to the status matching its interval; an Undo toast appears. |
| E2E-04 | 4 | Re-water an already-watered-today plant | Water a plant, then tap "Watered today" again. | No visible duplicate entry in history; the UI shows the same watered-today state both times. |
| E2E-05 | 5 | Edit interval recomputes status | Seed an upcoming plant; shorten its interval enough to make it overdue. | Status updates to overdue after saving, without a page reload. |
| E2E-06 | 6 | Delete and undo | Delete a plant; tap Undo within the toast window. | The plant reappears in the list in its prior position. |
| E2E-07 | 7 | Delete and let undo expire | Delete a plant; wait past the toast window (using page.clock). |
The plant remains deleted; reloading the page still shows it absent; the client tolerates one extra GET /api/v1/plants from the midnight/poll timers the clock advance also crosses (Section 25.3). |
| E2E-08 | 8 | Search and filter | Seed 12 plants; type a partial name into search. | Only matching plants remain visible; the search input is present (Section 25.8, case WEB-17). |
| E2E-09 | 9 | View watering history with pagination | Seed a plant with 25 waterings; open its history. | The first page shows 20 entries; a "Show more" control appends the remaining 5 (Section 11.4). |
| E2E-10 | 10 | Delete a watering entry | Open a plant's history; delete a non-latest entry. | The entry disappears from the list; the plant's due status is unaffected since the latest entry is unchanged. |
| E2E-11 | 11 | Toggle dark mode and reload | Toggle the theme; reload the page. | Dark mode persists across the reload. |
| E2E-12 | 12 | Export data | On /settings, click "Export data". |
A downloaded file matching the houseplant-tracker-export-*.json filename pattern is present and contains the expected formatVersion. |
| E2E-13 | 13 | Import in replace mode | Seed existing plants; on /settings, choose a previously exported file with different plants, select "Replace", confirm the destructive dialog. |
The plant list now matches the imported file exactly, not the pre-import state. |
| E2E-14 | 14 | Access code gate | Start the app with APP_ACCESS_CODE set; attempt 5 wrong codes, then the correct one. |
The 6th attempt (correct code) is locked out per the lockout rule (Section 20.5); after the lockout window elapses (via page.clock), the correct code succeeds and the plant list loads; the client tolerates one extra GET /api/v1/plants from the timers the clock advance crosses (Section 25.3). |
| E2E-15 | 15 | Undo reachable by keyboard | Water a plant using only the keyboard (Enter/Space on the row's water button, never a pointer click). | Focus lands on the Undo toast's action button on mount; Undo is reachable and activatable in at most two Tab presses from the water button, and the toast's auto-dismiss timer does not run while it holds focus (Section 16.7, Section 19.4). |
| E2E-16 | 16 | Concurrent-edit conflict banner | Load a plant's edit form in one context; update the same plant via the API so its updatedAt changes; submit the stale form. |
A 409 conflict banner renders with a "Discard my changes and reload" action; activating it repopulates the form with server values (Section 13.6, Section 17.4). |
25.10 Accessibility test procedure #
Every route, in both light and dark theme, is scanned with @axe-core/playwright as part of the E2E
run (Section 25.9); each scan asserting zero violations is a hard gate — a single violation fails the
axe CI job (Section 26.6). This is automated and exhaustive for what a static and interaction-driven
scan can catch. The manual checklist that covers what automated scanning cannot (keyboard-only
traversal, screen reader announcement wording, focus order) is defined once, in Section 19.10, and is
not restated here.
One named scan per route, tagged @a11y so the full set can also run in isolation from the rest of
the E2E suite:
| ID | Route |
|---|---|
| A11Y-01 | / |
| A11Y-02 | /plants/new |
| A11Y-03 | /plants/:plantId |
| A11Y-04 | /plants/:plantId/edit |
| A11Y-05 | /settings |
| A11Y-06 | /unlock |
25.11 Performance test procedure #
Three automated checks run in CI (Section 26.6), each against the production build:
- Lighthouse CI (
lhci autorun), run against the builtapps/web/distserved locally, against the performance budget defined in Section 23.2. - Bundle-size assertion (
npm run size-checkinapps/web), comparing the built JS and CSS bundle sizes against the ceilings in Section 23.2 and failing the build if either is exceeded. - API benchmark, run against a database fixture seeded to the 500-plant cap (the maximum
supported size, Section 9), measuring
GET /api/v1/plantsresponse time against the target in Section 23.2.
25.12 Test data fixtures #
// packages/shared/src/testing/fixtures.ts
import type { PlantRow, WateringRow, Plant, Watering } from '../types/index.js';
import { computeScheduleView } from '../domain/schedule.js';
// `index` makes every id a pure function of the caller's position, never of how many fixtures earlier
// tests in the same worker created, so the `id`-ascending sort tiebreak (Section 8.4) produces the
// same order on every run regardless of execution order (Section 25.3).
function idAt(prefix: string, index: number): string {
return `${prefix}-${String(index).padStart(4, '0')}-7000-8000-000000000000`;
}
/** Database row shape (Section 7.13), for `seedDatabase` below — carries `deletedAt`, no computed fields. */
export function makePlantRow(index: number, overrides: Partial<PlantRow> = {}, today = '2026-08-05'): PlantRow {
return {
id: idAt('01917f2c', index),
name: 'Test Plant',
wateringIntervalDays: 7,
notes: null,
lastWateredOn: today,
createdAt: `${today}T09:00:00.000Z`,
updatedAt: `${today}T09:00:00.000Z`,
deletedAt: null,
...overrides,
};
}
/** Database row shape (Section 7.13), for `seedDatabase` below — carries `plantId`, unlike the wire DTO. */
export function makeWateringRow(
plantId: string,
index: number,
overrides: Partial<WateringRow> = {},
today = '2026-08-05',
): WateringRow {
return {
id: idAt('01917f2d', index),
plantId,
wateredOn: today,
createdAt: `${today}T09:00:00.000Z`,
...overrides,
};
}
/**
* Full wire DTO (Section 10.2), for component fixtures that render a `Plant` directly rather than
* going through `seedDatabase` and a real request. The computed fields (`nextDueOn`, `daysUntilDue`,
* `daysOverdue`, `status`) come from `computeScheduleView` (Section 8.4) against the same injected
* `today` used for `lastWateredOn`, never from a hand-typed literal, so a fixture can never assert a
* status its own inputs do not produce. `wateringCount` defaults to `1`, matching the always-present
* creation-time watering row (Section 8.8, Section 10.4 step 7).
*/
export function makePlant(index: number, overrides: Partial<Plant> = {}, today = '2026-08-05'): Plant {
const row = makePlantRow(index, overrides, today);
const view = computeScheduleView(
{ lastWateredOn: row.lastWateredOn, wateringIntervalDays: row.wateringIntervalDays },
today,
);
return {
id: row.id,
name: row.name,
wateringIntervalDays: row.wateringIntervalDays,
notes: row.notes,
lastWateredOn: row.lastWateredOn,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
wateringCount: 1,
...view,
...overrides,
};
}
/** Wire DTO (Section 11.4) — no `plantId`; a watering is always scoped by its parent plant's URL. */
export function makeWatering(index: number, overrides: Partial<Watering> = {}, today = '2026-08-05'): Watering {
return {
id: idAt('01917f2d', index),
wateredOn: today,
createdAt: `${today}T09:00:00.000Z`,
...overrides,
};
}// apps/api/test/helpers/seed-database.ts
import type Database from 'better-sqlite3';
import type { PlantRow, WateringRow } from '@houseplant/shared';
export function seedDatabase(db: Database.Database, plants: PlantRow[], waterings: WateringRow[]): void {
const insertPlant = db.prepare(
`INSERT INTO plants (id, name, watering_interval_days, notes, last_watered_on, created_at, updated_at, deleted_at)
VALUES (@id, @name, @wateringIntervalDays, @notes, @lastWateredOn, @createdAt, @updatedAt, @deletedAt)`,
);
const insertWatering = db.prepare(
`INSERT INTO waterings (id, plant_id, watered_on, created_at)
VALUES (@id, @plantId, @wateredOn, @createdAt)`,
);
db.transaction(() => {
for (const plant of plants) insertPlant.run(plant);
for (const watering of waterings) insertWatering.run(watering);
})();
}Every suite — unit, API, and E2E — uses the single seed fixture defined in Section 30.5, resolved
through makePlantRow/makeWateringRow (for seedDatabase) or makePlant/makeWatering (for
component fixtures) above, rather than a second fixture table maintained here. The E2E layout journeys
(Section 25.9, journey E2E-08) run against that fixture's longest name at the 60-character boundary
(Section 9.3) so name truncation and wrapping are exercised at the 360px viewport.
25.13 Manual smoke test #
A human runs this 15-step checklist against a freshly deployed build before declaring it done, using a real browser (not just automated tests):
- Load the app with no plants; confirm the empty state renders.
- Add a plant with a name, an interval, and notes; confirm it appears in the list.
- Add a second plant with no notes; confirm notes are optional and nothing breaks.
- Confirm the new plants show the status expected for "just watered today."
- Edit a plant's name; confirm the change is reflected immediately.
- Shorten a plant's interval until it becomes overdue; confirm the status and styling update.
- Tap "Watered today" on the overdue plant; confirm it returns to an upcoming/due status.
- Tap "Watered today" a second time immediately; confirm nothing breaks and no duplicate history entry appears.
- Open the plant's watering history; confirm the entries are listed newest-first.
- Delete a plant; confirm the Undo toast appears and clicking it restores the plant.
- Delete a plant and let the Undo toast expire; confirm it stays deleted after a page reload.
- Toggle dark mode; confirm the whole UI (not just some components) switches theme.
- Resize the browser to a narrow mobile width; confirm the layout remains usable and touch targets look appropriately sized.
- Export the data; open the downloaded file and confirm it is valid JSON with the expected plants.
- Import that same file back in replace mode; confirm the app ends up in the same state.
26. Build, Deployment and Operations #
26.1 Build pipeline #
The local build runs as an ordered sequence; each step must pass before the next runs, matching the CI job ordering in Section 26.6:
npm ci— installs all three workspaces from the committed lockfile.- Build
packages/shared:tsc -p packages/shared/tsconfig.json, emitting compiled JS and.d.tsfiles topackages/shared/dist, because bothapps/apiandapps/webimport it as a built package, not as raw TypeScript source. This runs before typecheck, lint, and test below, all of which import frompackages/sharedand would otherwise resolve against adistdirectory that does not exist yet. npm run typecheck—tsc --buildacross the workspace project references, no emit.npm run lint— ESLint 9 flat config across all workspaces.npm test— unit and API integration tests (Section 25.2).- Build
apps/web:vite build, emitting the static site toapps/web/dist. - Build
apps/api: bundled with esbuild (chosen over plaintscbecause the API needs a single fast bundling step that can markbetter-sqlite3external while still bundlingpackages/shared, and esbuild's Node target output requires no extra runtime transpilation step):
npx esbuild apps/api/src/index.ts apps/api/src/lib/migrate-cli.ts apps/api/src/lib/backup-cli.ts \
--bundle --platform=node --target=node22 --format=esm \
--outdir=apps/api/dist --outbase=apps/api/src \
--external:better-sqlite3 --external:pinoResulting dist/ layout:
apps/api/dist/
├── index.js # bootstrap entrypoint (buildServer + listen)
└── lib/
├── migrate-cli.js # `npm run migrate` entrypoint
└── backup-cli.js # manual backup entrypoint (Section 26.9)
apps/web/dist/
├── index.html
└── assets/
├── index-<hash>.js
└── index-<hash>.css
packages/shared/dist/
├── index.js
├── index.d.ts
└── ...apps/api/migrations/*.sql are not built; they are copied as-is (Section 26.2) and read directly by
the migration runner at startup.
26.2 Dockerfile #
# syntax=docker/dockerfile:1
########################
# Stage 1: deps
########################
FROM node:22-alpine AS deps
WORKDIR /app
# better-sqlite3 compiles a native addon at install time. python3, make, and g++
# are required here and are never copied into the runtime stage below.
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json ./
COPY packages/shared/package.json packages/shared/package.json
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
RUN npm ci
########################
# Stage 2: build
########################
FROM deps AS build
WORKDIR /app
COPY . .
RUN npm run typecheck && npm run lint
RUN npm run build --workspace=packages/shared \
&& npm run build --workspace=apps/web \
&& npm run build --workspace=apps/api
# Drop devDependencies from node_modules before it is copied into the runtime image.
RUN npm prune --omit=dev
########################
# Stage 3: runtime
########################
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# dumb-init handles PID 1 duties (signal forwarding, zombie reaping) so that
# SIGTERM from `docker stop` reaches the Node process directly for a clean shutdown.
RUN apk add --no-cache dumb-init \
&& addgroup -g 10001 appgroup \
&& adduser -D -u 10001 -G appgroup appuser \
&& mkdir -p /data/backups \
&& chown -R appuser:appgroup /data
COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/package.json ./package.json
COPY --from=build --chown=appuser:appgroup /app/packages/shared/dist ./packages/shared/dist
COPY --from=build --chown=appuser:appgroup /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=build --chown=appuser:appgroup /app/apps/api/dist ./apps/api/dist
COPY --from=build --chown=appuser:appgroup /app/apps/api/package.json ./apps/api/package.json
COPY --from=build --chown=appuser:appgroup /app/apps/api/migrations ./apps/api/migrations
COPY --from=build --chown=appuser:appgroup /app/apps/web/dist ./apps/web/dist
USER appuser
EXPOSE 8080
VOLUME ["/data"]
ENV APP_DATABASE_PATH=/data/app.db
ENV APP_BACKUP_DIR=/data/backups
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/v1/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "apps/api/dist/index.js"]The runtime stage never installs python3, make, or g++; only the compiled better-sqlite3 native
addon (already built in the deps stage and carried forward inside node_modules) is present.
26.3 docker-compose.yml #
.env is the only place any of these values are edited. The service declares no environment literals
of its own for anything an operator might reasonably change — it loads .env wholesale via env_file
and substitutes only the handful of values Compose itself needs at parse time (the ${VAR:-default}
entries below), so editing .env and running docker compose up -d always takes effect, with no
separate value silently overriding it.
services:
app:
build:
context: .
dockerfile: Dockerfile
image: houseplant-tracker:latest
restart: unless-stopped
user: "10001:10001"
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./data:/data
env_file:
- .env
environment:
# Compose needs these two at parse time (for the healthcheck URL and to guarantee they are always
# set even if absent from .env); every other variable comes from .env via env_file above.
PORT: "${PORT:-8080}"
APP_TIMEZONE: "${APP_TIMEZONE:-UTC}"
APP_ACCESS_CODE: "${APP_ACCESS_CODE:-}"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/v1/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128Mports binds only to 127.0.0.1, not 0.0.0.0: the default posture (Section 20.1) is an unlisted
URL behind a reverse proxy (Section 26.5), and a socket bound to every interface is reachable directly
on the host's public IP, bypassing the proxy, its TLS, and any IP allowlist entirely. Delete the
ports mapping altogether when the reverse proxy runs in a sibling container on a shared Docker
network instead of on the host.
The user: "10001:10001" line is redundant with the image's own USER appuser (Section 26.2, also
uid 10001) — it is declared here anyway so the uid is visible in the compose file itself, where an
operator debugging a permissions problem is more likely to look first.
26.4 Running without Docker #
export NODE_ENV=production
export PORT=8080
export APP_HOST=0.0.0.0
export APP_TIMEZONE=America/Chicago
export APP_DATABASE_PATH=/var/lib/houseplant-tracker/app.db
export APP_BACKUP_DIR=/var/lib/houseplant-tracker/backups
export APP_LOG_LEVEL=info
npm ci
npm run build --workspace=packages/shared
npm run build --workspace=apps/web
npm run build --workspace=apps/api
npm prune --omit=dev
npm run migrate --workspace=apps/api
node apps/api/dist/index.jsnpm ci installs the full dependency tree, including devDependencies such as typescript, vite,
esbuild and @vitejs/plugin-react, all of which the three build steps above need; npm ci --omit=dev
would fail partway through the first build. npm prune --omit=dev runs only after every build step has
produced its output, so the pruned node_modules that remains for the running process is
production-only.
Sample systemd unit, /etc/systemd/system/houseplant-tracker.service:
[Unit]
Description=Houseplant Watering Tracker
After=network.target
[Service]
Type=simple
User=hpt
Group=hpt
WorkingDirectory=/opt/houseplant-tracker
EnvironmentFile=/etc/houseplant-tracker/houseplant-tracker.env
ExecStartPre=/usr/bin/node /opt/houseplant-tracker/apps/api/dist/lib/migrate-cli.js
ExecStart=/usr/bin/node /opt/houseplant-tracker/apps/api/dist/index.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/houseplant-tracker
PrivateTmp=true
[Install]
WantedBy=multi-user.targethouseplant-tracker.env holds the same environment variables as Section 26.3's environment block,
one KEY=value pair per line, owned by root:hpt with mode 640 so the secret-bearing variables
(APP_ACCESS_CODE, APP_SESSION_SECRET) are not world-readable.
26.5 Reverse proxy #
nginx:
server {
listen 80;
server_name plants.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name plants.example.com;
ssl_certificate /etc/letsencrypt/live/plants.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/plants.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 1m;
# Recommended hardening: restrict access to a known set of source IPs.
# See Section 20.1. Uncomment and adjust to enable.
# allow 203.0.113.0/24;
# allow 198.51.100.42;
# deny all;
# The import endpoint accepts up to 5 MB (Section 5); raise the proxy
# body-size limit for that path only, leaving the site-wide default at 1 MB.
location /api/v1/import {
client_max_body_size 5m;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}Caddyfile equivalent (Caddy provisions and renews the TLS certificate and performs the HTTP→HTTPS redirect automatically, so neither is written explicitly):
plants.example.com {
# Recommended hardening: restrict access to a known set of source IPs.
# See Section 20.1. Uncomment and adjust to enable.
# @blocked not remote_ip 203.0.113.0/24 198.51.100.42
# respond @blocked 403
request_body {
max_size 1MB
}
handle /api/v1/import* {
request_body {
max_size 5MB
}
reverse_proxy 127.0.0.1:8080
}
handle {
reverse_proxy 127.0.0.1:8080
}
}26.6 CI pipeline #
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
typecheck:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=packages/shared
- run: npm run typecheck
lint:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=packages/shared
- run: npm run lint
format:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run format:check
unit:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=packages/shared
- run: npm run test:unit -- --coverage
- uses: actions/upload-artifact@v4
with: { name: coverage-unit, path: coverage/ }
api:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=packages/shared
- run: npm run test:api -- --coverage
- uses: actions/upload-artifact@v4
with: { name: coverage-api, path: coverage/ }
build:
needs: [typecheck, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=packages/shared
- run: npm run build --workspace=apps/web
- run: npm run build --workspace=apps/api
- uses: actions/upload-artifact@v4
with:
name: build-output
path: |
apps/web/dist
apps/api/dist
e2e:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium webkit
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with: { name: playwright-report, path: playwright-report/ }
axe:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:a11y
lighthouse:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=apps/web
- run: npx lhci autorun
size-check:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build --workspace=apps/web
- run: npm run size-check
audit:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
docker:
needs: [e2e, axe, lighthouse, size-check, audit]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: houseplant-tracker:${{ github.sha }}The Node version matrix is a single value, 22, matching the fixed runtime decision (Section 1) — no
other Node version is tested. typecheck, lint, format, unit, and api all depend only on
install and run in parallel with each other. build waits on typecheck and lint. e2e, axe,
lighthouse, and size-check all depend only on build and run in parallel with each other. docker
is the final gate, depending on every quality job (e2e, axe, lighthouse, size-check, audit)
succeeding.
Coverage artifacts (coverage-unit, coverage-api) and the Playwright HTML report are uploaded so
they can be inspected after a run without re-running the suite locally.
Branch protection: the main branch requires a pull request before merging, requires the branch
to be up to date with main before merge, and requires the following status checks to pass:
typecheck, lint, format, unit, api, build, e2e, axe, lighthouse, size-check,
audit, docker.
26.7 Release and versioning #
The application follows semantic versioning (MAJOR.MINOR.PATCH), starting at 1.0.0 for the first
production deployment. The root package.json version field is the single source of truth.
The version is injected into the frontend build via Vite's define:
// apps/web/vite.config.ts (excerpt)
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('../../package.json', 'utf-8'));
export default {
define: { __APP_VERSION__: JSON.stringify(pkg.version) },
};The API reads its own version from package.json at boot (via apps/api/package.json, kept in sync
with the root version by the release process below) and surfaces it in the GET /api/v1/health
response payload; see Section 13 for the exact response shape and Section 22 for the health endpoint's
place in the observability story.
Tag format: vMAJOR.MINOR.PATCH, e.g. v1.2.0, created on the commit that bumps package.json.
Changelog: CHANGELOG.md at the repository root, following the Keep a Changelog format — an
## [Unreleased] section accumulates entries during development, and is renamed to
## [MAJOR.MINOR.PATCH] - YYYY-MM-DD at release time, with a fresh empty ## [Unreleased] section
added above it.
26.8 Deployment procedure #
First deployment:
- Provision a host with Docker and Docker Compose installed.
- Create the data directory and give it to the uid the container runs as (Section 26.2, Section
26.3):
mkdir -p ./data/backups && sudo chown -R 10001:10001 ./data. Skipping this step is the single most common first-deploy failure: the runtime process runs as uid 10001, a directory created by the operator's own account is owned by a different uid,APP_DATABASE_PATH's parent directory is then unwritable, and the process exits 78 before it ever binds a port (Section 21). - Copy
.env.exampleto.envand set at leastAPP_TIMEZONE(Section 21) to the operator's real timezone. docker compose up -d --build.- Wait for the container to report healthy:
docker compose psshowshealthyin theSTATUScolumn. - Verify:
curl -fsS http://localhost:8080/api/v1/health/ready. - Verify
.envactually took effect:curl -fsS http://localhost:8080/api/v1/metaand confirmtimezonematches the value just set in step 3. - Put the deployment behind a reverse proxy (Section 26.5) if it will be reachable beyond localhost or a private network.
- Do not link the URL publicly (Section 20.1, Section 26.10).
Upgrade:
- Take a manual backup first (Section 26.9), in addition to relying on the automatic nightly one.
- Pull or build the new image:
docker compose pull(registry-based) ordocker compose build --no-cache app(source-based). docker compose up -d.- Migrations run automatically as part of API process startup, before the HTTP listener starts
accepting connections (the migration runner and its
schema_migrationstable are defined in Section 7). Confirm success by checking the logs for amigrate.completedentry:docker compose logs app | grep migrate.completed. - Verify
curl -fsS http://localhost:8080/api/v1/health/readyand thatcurl -fsS http://localhost:8080/api/v1/healthreports the new version (Section 26.7). - Smoke-test: load the app in a browser and confirm the plant list renders (Section 25.13 covers the full manual smoke test; this is an abbreviated version for a routine upgrade).
Expected downtime: a few seconds while the single container restarts. This is acceptable for a single-user application with no concurrent traffic to preserve during the restart — stated here explicitly as the deliberate tradeoff, not an oversight.
Rollback:
docker compose down.- If a migration ran as part of the failed upgrade, restore the pre-upgrade snapshot (the manual backup from step 1 of the upgrade procedure, or the most recent nightly snapshot) following the restore runbook, Section 24.7.
- Redeploy the previous image tag: set the prior version in
.envor the compose file'simage:line, thendocker compose up -d. - Verify health and plant count exactly as in Section 24.7's steps 7–8.
26.9 Day-2 runbook #
- Checking health:
curl -fsS http://localhost:8080/api/v1/health/ready curl -fsS http://localhost:8080/api/v1/health - Reading logs (structured JSON via pino; pipe through
pino-prettyfor a human-readable view):docker compose logs -f --tail=200 app | npx pino-pretty - Taking a manual backup (reuses the exact backup-and-verify logic from Section 24.2–24.3 via a
dedicated CLI entrypoint built alongside the API, Section 26.1):
docker compose exec app node apps/api/dist/lib/backup-cli.js - Restoring: follow the full runbook in Section 24.7.
- Changing the timezone:Changing
# edit APP_TIMEZONE in .env, then: docker compose up -dAPP_TIMEZONEdoes not rewrite any calendar date already stored — those retain their original meaning. Only future "today" calculations (Section 8) use the new zone. State this to the operator as a caveat, not a silent behavior. Verify the edit actually took effect (it does, becausedocker-compose.ymlloads.envviaenv_filerather than hardcoding values, Section 26.3):curl -fsS http://localhost:8080/api/v1/metaand confirmtimezonematches what was just set. - Enabling the access code:
# set APP_ACCESS_CODE (min 8 characters) in .env, then: docker compose up -dAPP_SESSION_SECRETauto-generates and persists to thesettingstable on first boot after enabling, if not already set (Section 21). ChangingAPP_ACCESS_CODEitself — including rotating a leaked code — automatically invalidates every existing session (the signed session token embeds a hash of the currently configured code, Section 20.5); no manual secret rotation is required for that purpose. - Rotating the session secret:This invalidates every existing unlock cookie without changing the access code itself; users must re-enter it. Use this when the secret alone may have leaked (for example, from a database backup handled insecurely) — not as the response to a leaked access code, which item 6 above already handles automatically.
docker compose exec app node -e "require('better-sqlite3')('/data/app.db').prepare(\"DELETE FROM settings WHERE key = 'session_secret'\").run()" docker compose restart app - Clearing rate-limit lockouts: both the request rate limiter and the access-code attempt counter
are held in memory (a deliberate decision — a single-process app has no need to persist ephemeral
counters across restarts), so:
docker compose restart app - Resizing the volume: for a bind mount (
./data:/data), extend the underlying host filesystem or partition directly; for a cloud-provider block volume, follow the provider's resize procedure, then:SQLite has no fixed pre-allocation, so no app-level action is required beyond ensuring the mount point has room.docker compose up -d - Upgrading Node: bump the
node:22-alpinetag in the Dockerfile to the new version, then:followed by the full upgrade procedure in Section 26.8, including the pre-upgrade backup.docker compose build --no-cache app
26.10 Operational checklist #
Before calling a deployment done, confirm every item:
APP_TIMEZONEis set to the operator's actual timezone, not left at theUTCdefault (Section 21).- The deployment URL is not linked from anywhere public — no public DNS record advertised, no link in a public repository README, no search-engine-indexed page.
- A decision has been made on
APP_ACCESS_CODE; if enabled, its value is at least 8 characters and is stored in a credentials manager, not in a chat message or ticket. - The data volume survives container recreation:
docker compose down && docker compose up -d, then confirm the plant count is unchanged. APP_BACKUP_ENABLED=trueand./data/backups(or the mounted equivalent) is writable.- A manual backup has been taken once (Section 26.9, item 3) and its
backup.completedlog entry confirmed. - If a reverse proxy is used, it forwards
X-Forwarded-Proto, andAPP_TRUST_PROXYis set to match (Section 21). - HTTPS is terminated at the reverse proxy if the deployment is reachable over any network beyond localhost.
- The reverse proxy allows a 5 MB body specifically on the import path, even though the general limit is 1 MB (Section 26.5).
- The manual smoke test (Section 25.13) has been run once against the live deployment, not just against a local build.
docker compose psreports the container ashealthy.APP_LOG_LEVELisinfoin production, not left at a more verbosedebuglevel.- The resource limits in Section 26.3 are appropriate for the host's actual available CPU and memory.
- The deployed image tag or git commit is recorded somewhere the operator will find it again later (Section 26.7).
APP_CORS_ORIGINis left unset unless a second, specific origin genuinely needs API access.- The restore runbook (Section 24.7) is saved somewhere reachable independent of the deployment itself, so it is available even if the deployment is down.
- A recurring personal reminder exists to copy
APP_BACKUP_DIRto off-host storage — the "lost host" disaster scenario (Section 24.8) is only survivable if this has been done, and the application does not do it automatically. - The
./datadirectory (and its contents) is owned by uid 10001, matching the container's runtime user (Section 26.2, Section 26.8 step 2) —ls -ln ./datashows owner10001. - From a machine other than the host itself,
curl http://<host>:8080/fails to connect — the published port is bound to127.0.0.1only (Section 26.3), so the reverse proxy is the sole path in.
26.11 Cost and hosting note #
This application runs comfortably on the smallest available VPS tier or a spare machine on a home network: one Node process, one SQLite file, no managed database service, and no cloud dependency of any kind. The resource footprint is small — well under 256 MB of resident memory and a negligible CPU share at idle, serving a single user's occasional requests. No specific hosting provider is recommended here; any host capable of running a Docker container and exposing one port is sufficient.
27. Milestones and Execution Plan #
27.1 How to use this plan #
Milestones are strictly ordered. Each milestone ends with a working, committed, testable state of the repository: the build compiles, the test suite for everything built so far is green, and the exit criteria listed for that milestone are all individually verifiable by a command, a rendered screen, or a passing test suite. No milestone may begin before the previous milestone's exit criteria all pass. Within a milestone, implementation steps are ordered but may be interleaved with test-writing as described in Section 29.3; what matters is that the milestone does not close until every exit criterion in its checklist is checked off with evidence.
27.2 Milestone table #
| # | Milestone | Ends with |
|---|---|---|
| M0 | Repository scaffold | Workspaces, TypeScript, lint, format, and a CI skeleton green on an empty test |
| M1 | Shared domain package | Schedule engine and Zod schemas complete, 100% covered, no I/O |
| M2 | Database and migrations | Schema applied by the runner, repositories implemented, integration-tested |
| M3 | API surface | Every endpoint in Section 13.5 live, validated, error-enveloped, tested |
| M4 | Frontend shell and plant list | App shell, routing, list with statuses, real data from M3 |
| M5 | Mutations and forms | Add, edit, delete, restore, water, undo, toasts, optimistic updates |
| M6 | Detail screen and history | Plant detail, watering history with pagination, entry deletion |
| M7 | States, polish and accessibility | Loading/empty/error/offline states, dark mode, axe clean, keyboard complete |
| M8 | Durability | Export, import, nightly snapshot, purge job |
| M9 | Hardening and observability | Security headers, CSP, rate limiting, optional access code, logging, health |
| M10 | Packaging and delivery | Dockerfile, compose, reverse proxy config, full CI, performance budgets met |
27.3 Per-milestone detail #
27.3.1 M0 — Repository scaffold #
Goal. Stand up the npm workspaces monorepo from Section 6.1's directory tree with a compiling, lintable, formattable, testable skeleton in every workspace, and a CI pipeline that runs those checks.
Files created.
- Root:
package.json(workspaces["packages/*", "apps/*"]),tsconfig.base.json(strict: true,target: ES2023,module: ESNext,moduleResolution: bundler— the exact contents are in Section 6.3),eslint.config.js(ESLint 9 flat config),.prettierrc.json,.gitignore,.env.example,README.md,.github/workflows/ci.yml. packages/shared/package.json,packages/shared/tsconfig.json,packages/shared/src/index.ts(stub, exports aSCHEMA_VERSIONconstant).apps/api/package.json,apps/api/tsconfig.json,apps/api/src/index.ts(bootstrap, callsbuildServer()and listens),apps/api/src/server.ts(stubbuildServer()returning a Fastify instance with oneGET /api/v1/healthroute returning{ "data": { "status": "ok" } }),apps/api/src/config.ts(stub: readsPORT,APP_HOSTonly; the baseConfigSchemais completed in M2 and extended with the access-code variables in M9).apps/web/package.json,apps/web/tsconfig.json,apps/web/vite.config.ts,apps/web/index.html,apps/web/src/main.tsx,apps/web/src/App.tsx(stub renders the text "Houseplant Watering Tracker"),apps/web/src/styles/tokens.css(stub, empty custom-property block).e2e/playwright.config.ts,e2e/specs/smoke.spec.ts.
Dependencies. None (first milestone).
Implementation steps.
- Initialize the git repository and the root
package.jsonwith the three workspaces. - Add
tsconfig.base.json; each workspacetsconfig.jsonextends it. - Configure ESLint 9 flat config with the TypeScript, React, and Vitest plugins, and Prettier 3 as a formatting-only tool (no conflicting stylistic ESLint rules).
- Scaffold
packages/sharedwith a single exported constant and a barrelindex.ts. - Scaffold
apps/apiwith a minimal Fastify 5 server exposing only the health route. - Scaffold
apps/webwith Vite 6 + React 19 + Tailwind CSS v4 (CSS-first config inapps/web/src/styles/tokens.cssvia@import "tailwindcss"), rendering a static heading. - Scaffold
e2e/with a Playwright config pointed at the dev server and one smoke spec that loads/and asserts the heading text is visible. - Add root npm scripts:
typecheck,lint,lint:fix,format,format:check,test,build,dev, each fanning out to all workspaces (npm workspaces--workspacesflag or per-package scripts invoked vianpm run <script> --workspaces --if-present). - Add
.github/workflows/ci.ymlrunning on push and pull request: checkout, setup Node 22,npm ci,npm run typecheck,npm run lint,npm run format:check,npm test,npm run build. - Write the initial
README.mdwith project name, one-paragraph description, and placeholder run instructions to be filled in as later milestones land. - Commit.
Tests written. One trivial Vitest test per workspace that has source to test
(packages/shared/src/index.test.ts asserting SCHEMA_VERSION is a string;
apps/api/src/server.test.ts asserting the health route returns 200 via fastify.inject()); one
Playwright smoke spec (e2e/specs/smoke.spec.ts) asserting the heading renders.
Exit criteria.
-
npm installcompletes with zero errors from a clean clone. -
npm run typecheckpasses with zero errors across all three workspaces. -
npm run lintpasses with zero errors and zero warnings. -
npm run format:checkpasses with zero files needing reformatting. -
npm testpasses; every workspace's placeholder test is green. -
npm run buildproducesapps/web/dist/andapps/api/dist/. -
npx playwright test e2e/specs/smoke.spec.tspasses againstnpm run dev. - The GitHub Actions workflow file is present and every job listed in step 9 above runs on push.
-
git logshows at least one commit following the convention in Section 6.8.
27.3.2 M1 — Shared domain package #
Goal. Implement the watering-schedule pure functions and the Zod validation schemas that both
apps/api and apps/web import, with zero I/O and 100% test coverage.
Files created.
packages/shared/src/domain/schedule.ts—todayInTimeZone(now: Date, timeZone: string): string,addDays(date: string, days: number): string,computeNextDueOn(lastWateredOn: string, intervalDays: number): string,differenceInCalendarDays(a: string, b: string): number,computeStatus(nextDueOn: string, today: string): PlantStatus,computeScheduleView(plant: { lastWateredOn: string; wateringIntervalDays: number }, today: string): { nextDueOn: string; daysUntilDue: number; daysOverdue: number; status: PlantStatus },formatRelativeDueLabel(daysUntilDue: number): string,compareByUrgency(a, b): number— all per Section 8.4.packages/shared/src/domain/schedule.test.ts.packages/shared/src/schemas/plant.schema.ts,watering.schema.ts,pagination.schema.ts,config.schema.ts— Zod schemas per Section 9's limits table, each with a matching.test.ts.packages/shared/src/types/plant.ts,types/watering.ts— types inferred withz.infer.packages/shared/src/constants.ts—MAX_ACTIVE_PLANTS = 500,PURGE_AFTER_DAYS = 30,DUE_SOON_THRESHOLD_DAYS = 1,PLANT_NAME_MAX_LENGTH = 60,NOTES_MAX_LENGTH = 2000,WATERING_INTERVAL_MIN = 1,WATERING_INTERVAL_MAX = 365,HISTORY_PAGE_LIMIT_MAX = 100,HISTORY_PAGE_LIMIT_DEFAULT = 20, and theERROR_CODESenum from Section 13.3.packages/shared/src/index.ts— barrel export of the above.
Dependencies. M0 (workspace and tooling exist). No dependency on M2 or M3.
Implementation steps.
- Implement
todayInTimeZone(now: Date, timeZone: string)per Section 8.4. It takesnowas an explicit parameter and never reads the system clock; no function inpackages/shared/src/domainmay callnew Date()orDate.now()(Section 25.3). - Implement
addDaysas pure calendar-day arithmetic onYYYY-MM-DDstrings (no timezone conversion), per Section 8.2. - Implement
computeStatus(nextDueOn, today)per the status table in Section 8.4:overduewhendaysUntilDue < 0,due_todaywhen= 0,due_soonwhen= 1,upcomingwhen>= 2. ImplementcomputeNextDueOn,differenceInCalendarDays, andcomputeScheduleView(which composes the three above into the object form) per the same section. - Define the four Zod schemas per Section 9's field table, with
.strip()behavior for unknown keys and every boundary rule (length, range, trim, control-character stripping, empty-string-to-null normalization fornotes). - Export inferred TypeScript types alongside each schema.
- Configure Vitest coverage thresholds for
packages/sharedat 100% for statements, branches, functions, and lines inpackages/shared/vitest.config.ts. - Add an ESLint override for
packages/shared/**disallowing imports offs,net,http,https,child_process, andbetter-sqlite3(ano-restricted-importsrule), enforcing the "no I/O" constraint mechanically. - Write unit tests: every one of the 12 worked examples in Section 30.4 as
it.eachcases; every status boundary (daysUntilDueof -1, 0, 1, 2); a leap-year case; a year-boundary case; every validation schema's valid/invalid boundary values from Section 9.
Tests written. schedule.test.ts (schedule engine, all boundaries and worked examples),
plant.schema.test.ts, watering.schema.test.ts, pagination.schema.test.ts,
config.schema.test.ts.
Exit criteria.
-
npm run test --workspace=packages/shared -- --coveragereports 100% statements, branches, functions, and lines. -
npm run lint --workspace=packages/sharedreports zerono-restricted-importsviolations. - All 12 worked examples in Section 30.4 pass as test cases with the exact
nextDueOn,daysUntilDue,daysOverdue, andstatusvalues shown in that table. -
npm run typecheck --workspace=packages/sharedpasses. -
npm run build --workspace=packages/sharedproduces adist/consumable by the other two workspaces (verified byapps/apiandapps/websuccessfully importing from@houseplant/sharedin a throwaway import statement that then typechecks).
27.3.3 M2 — Database and migrations #
Goal. Apply the schema in Sections 7.3–7.6 through the migration runner and provide repositories
that convert snake_case rows to camelCase DTOs, per Section 6.4.
Files created.
apps/api/migrations/0001_init.sql— creates three tables (plants,waterings,settings) plus the four indexes defined in Sections 7.3–7.6, including the unique index onwaterings(plant_id, watered_on)that makes watering idempotent and the index onplants(deleted_at).schema_migrationsis created by the migration runner itself, not by this file (Section 7.6).apps/api/src/db/connection.ts— opensbetter-sqlite3atAPP_DATABASE_PATH, creates the parent directory if missing, setsPRAGMA foreign_keys = ONandPRAGMA journal_mode = WALon every new connection.apps/api/src/db/migrate.ts— readsapps/api/migrations/*.sqlin filename order, tracks applied versions inschema_migrations, runs each unapplied migration inside a transaction.apps/api/src/db/repositories/plants.repository.ts,waterings.repository.ts,settings.repository.ts— prepared-statement-only CRUD with row-to-DTO mapping.- Finalized
apps/api/src/config.ts— the baseConfigSchemaparsing every environment variable in Section 21 exceptAPP_ACCESS_CODE,APP_SESSION_SECRET,APP_CORS_ORIGIN, andAPP_TRUST_PROXY, which M9 adds once the access-code feature exists. - Matching
.test.tsfiles for the migration runner and each repository.
Dependencies. M0 (workspace tooling). Independent of M1's completion but conventionally runs after it since M1 is on the critical path for M3.
Implementation steps.
- Write
0001_init.sqlwith the three tables and the four indexes above;schema_migrationsis created by the runner (Section 7.6), not by this file. - Implement
connection.ts, verifying the pragmas take effect viaPRAGMA foreign_keysandPRAGMA journal_moderead-back queries in a test. - Implement
migrate.ts: apply unapplied migrations in a transaction each, record the filename and an ISO 8601 UTCapplied_attimestamp inschema_migrations, and make re-running the runner a no-op when nothing is unapplied. - Implement
plants.repository.tswithcreate,findById,findAll,countActive,update,updateLastWateredOn,softDelete,restore,purgeDeletedBefore(cutoffIsoInstant), per Section 7.13's repository method names. - Implement
waterings.repository.tswithcreate,findByPlantId(plantId, limit, offset),countForPlant(plantId),deleteById,findLatestForPlant(plantId), per Section 7.13's repository method names. - Implement
settings.repository.tswithget(key)/set(key, value), used later for the auto-generatedAPP_SESSION_SECRET. - Finalize
config.ts: parseNODE_ENV,PORT,APP_HOST,APP_TIMEZONE,APP_DATABASE_PATH,APP_LOG_LEVEL,APP_RATE_LIMIT_MAX,APP_RATE_LIMIT_WINDOW_MS,APP_BACKUP_ENABLED,APP_BACKUP_DIR, andAPP_BACKUP_RETAINwith Zod at boot, per Section 21; on any invalid value,process.exit(78)with a message naming the invalid variable. M9 extends this schema with the access-code variables once that feature exists. - Write integration tests against a temporary SQLite file per test (created and deleted per test run) exercising every repository method and the constraint behaviors below.
Tests written. migrate.test.ts (idempotent re-run, transactional apply); repository tests for
each of the three repositories, including: inserting two waterings for the same plant_id and
watered_on throws a unique-constraint error; deleting a plant's waterings via a hard delete of the
plant (purge path) removes all matching rows through the foreign key.
Exit criteria.
- Running the migration runner against a fresh file creates the three tables in
0001_init.sqland the runner createsschema_migrations; a table-listing query against the resulting file returns exactlyplants,waterings,settings,schema_migrations— the last created by the runner. -
npm run test --workspace=apps/api -- dbis green. - A test asserts inserting two waterings with the same
plant_idandwatered_onraises a constraint violation. - A test asserts every new connection reports
foreign_keys = 1andjournal_mode = wal. - Every repository method returns camelCase-keyed objects, verified by a test inspecting the returned object's keys.
- Starting the process with an invalid
APP_DATABASE_PATHorAPP_TIMEZONEvalue exits with code 78 and a message naming the invalid variable. - Running the migration runner twice in succession applies zero migrations the second time,
verified by asserting the row count in
schema_migrationsis unchanged.
27.3.4 M3 — API surface #
Goal. Implement every endpoint in Section 13.5 against the M2 repositories and M1 schemas, with the canonical error envelope, validation, and idempotency behaviors from Sections 6, 8, and 9.
Files created.
apps/api/src/routes/plants.ts—GET /api/v1/plants,POST /api/v1/plants,GET /api/v1/plants/:plantId,PATCH /api/v1/plants/:plantId,DELETE /api/v1/plants/:plantId,POST /api/v1/plants/:plantId/restore,POST /api/v1/plants/:plantId/waterings.apps/api/src/routes/waterings.ts—GET /api/v1/plants/:plantId/waterings,DELETE /api/v1/plants/:plantId/waterings/:wateringId.apps/api/src/routes/health.ts—GET /api/v1/health.apps/api/src/routes/meta.ts—GET /api/v1/meta.apps/api/src/plugins/error-handler.ts— maps thrown errors and Zod failures to the canonical envelope in Section 13.3 with the codes in Section 13.3.apps/api/src/plugins/request-id.ts— generates a UUIDv7 per request, attaches it to logs and to every error response.apps/api/src/plugins/rate-limit.ts— wrapsAPP_RATE_LIMIT_MAX/APP_RATE_LIMIT_WINDOW_MS(values finalized in M9; wired here with defaults), excludingGET /api/v1/health,GET /api/v1/health/ready, andGET /api/v1/metaper Section 13.7.- Matching
.test.tsfiles per route module usingfastify.inject().
Dependencies. M1 (schemas, domain functions), M2 (repositories).
Implementation steps.
- Wire
server.tsto register the error handler, request-id, and rate-limit plugins before routes, andignoreTrailingSlash: falseper Section 5. - Implement
GET /api/v1/plants: fetch all active plants, computenextDueOn/daysUntilDue/daysOverdue/statusserver-side viacomputeScheduleView, return{ data: [...], meta: { timezone: APP_TIMEZONE } }. - Implement
POST /api/v1/plants: validate with the plant-create schema, defaultlastWateredOntotodayInTimeZone(new Date(), APP_TIMEZONE)when omitted, reject with 422LIMIT_EXCEEDEDwhen the active-plant count is already 500, generate a UUIDv7 id application-side, insert, also insert a matchingwateringsrow datedlastWateredOnso history is never empty for a plant that has a last-watered date, return 201. - Implement
GET /api/v1/plants/:plantIdandPATCH /api/v1/plants/:plantId(partial update; editingwateringIntervalDaysnever clamps and immediately changes the computed status on the next read, per Section 8.8). - Implement
DELETE /api/v1/plants/:plantId(setsdeleted_at, 204) andPOST /api/v1/plants/:plantId/restore(clearsdeleted_at, 200; 404NOT_FOUNDwhen the plant is already active). - Implement
POST /api/v1/plants/:plantId/waterings: setlastWateredOn = todayInTimeZone(new Date(), APP_TIMEZONE), attempt to insert awateringsrow; on a unique-constraint conflict (already watered today) catch it and return 200 withmeta: { alreadyWateredToday: true, wateringId }, wherewateringIdis the id of the existing row for(plantId, today), instead of propagating a 409, and do not create a second row. On the normal path return 200 withmeta: { alreadyWateredToday: false, wateringId }, wherewateringIdis the id of the row just inserted. - Implement
GET /api/v1/plants/:plantId/wateringswithlimit/offsetvalidated by the pagination schema, returningmeta: { total, limit, offset }. - Implement
DELETE /api/v1/plants/:plantId/waterings/:wateringId: delete the row and recompute the parent plant'slast_watered_onexactly as specified in Section 11.3. - Implement
GET /api/v1/metareturning the payload in Section 13.5.10 (timezone,today,plantCap,version,accessCodeEnabled), ungated and exempt from rate limiting per Sections 20.5 and 13.7. - Implement
GET /api/v1/healthreturning the shape defined in Section 22. - Enforce content-type (
415on mismatch), body size caps (413), and malformed JSON (400 MALFORMED_JSON) globally via the error handler plugin. - Write
fastify.inject()tests for every endpoint's success path and every documented error case.
Tests written. One test file per route module covering: every 2xx path; 400 (validation, malformed
JSON); 404 (unknown id, and restore of a non-deleted plant); 405 (wrong method on a known path); 413;
415; 422 (LIMIT_EXCEEDED at 500 plants); unknown-field stripping; the watering idempotency case; the
pagination meta shape; GET /api/v1/meta's response shape.
Exit criteria.
- Every endpoint listed above responds with its documented status code and envelope shape,
verified by named
fastify.inject()tests, catalogued asAPI-*in the API integration test matrix in Section 25.7. -
GET /api/v1/metareturnstimezone,today,plantCap,version, andaccessCodeEnabled. -
npm run test --workspace=apps/apiis green and meets the coverage gate defined in Section 25.4. - A test posts an unknown field in a create-plant body and asserts the stored/returned plant does not contain it and no error is raised.
- A test calls the water endpoint twice for the same plant on the same day and asserts: first call
returns 200 with
meta.alreadyWateredToday: falseand ameta.wateringId, second call returns 200 withmeta.alreadyWateredToday: trueand the samemeta.wateringId, and the watering-history count for that plant is 1. - A test creates 500 active plants then attempts a 501st and asserts a 422
LIMIT_EXCEEDEDresponse with no plant created. - A test sends a request with
Content-Type: text/plainand a body and asserts 415. - A test requests a path with a trailing slash and asserts 404.
27.3.5 M4 — Frontend shell and plant list #
Goal. Render the plant list against the live M3 API inside the routed app shell, with statuses displayed per Section 15's tokens.
Files created.
apps/web/src/api/client.ts— typed fetch wrapper that parses the success/error envelopes from Section 13.2–13.3.apps/web/src/main.tsx— mountsQueryClientProvider(settings from Section 16.5's freshness policy) and the React Router 7 data router.apps/web/src/App.tsx,apps/web/src/routes/PlantListRoute.tsx— root layout and the list route from Section 14.apps/web/src/components/plant-list/PlantRow.tsx,StatusBadge.tsx.apps/web/src/hooks/usePlants.ts— TanStack Query hook forGET /api/v1/plants.e2e/fixtures/seed.ts,e2e/specs/plant-list.spec.ts.
Dependencies. M3 (live API contract). May start earlier against a mocked client once the M3 contract in Section 13.5 is frozen; see Section 27.4.
Implementation steps.
- Implement the typed fetch client: throws a typed error object carrying
code,message,details,requestIdon any non-2xx response. - Configure the
QueryClientwithstaleTime: 60_000,gcTime: 300_000,refetchOnWindowFocus: true,retry: 2(exponential backoff, 500 ms base, jitter) for queries,retry: falsefor mutations, per Section 5. - Implement the React Router 7 data router with the routes in Section 14.2 (list route at minimum for this milestone; detail route added in M6).
- Implement
usePlants()wrappingGET /api/v1/plants. - Implement
PlantListRouterenderingPlantRowper plant, each showing name, interval, andStatusBadge(colour token + icon + text label per Section 15.6, never colour alone). - Implement the midnight-rollover timer, focus/visibility refetch listeners, and 5-minute poll from Section 16.5.
- Build mobile-first: base layout at 360px width,
sm/md/lgbreakpoints per Section 12. - Write component tests for
PlantListRoutein loading, populated, empty, and error states using a mocked query client. - Write a Playwright e2e spec that seeds the API (via
e2e/fixtures/seed.ts, matching Section 30.5) and asserts the list renders each plant with the correct status badge.
Tests written. PlantListRoute.test.tsx, PlantRow.test.tsx, StatusBadge.test.tsx,
e2e/specs/plant-list.spec.ts.
Exit criteria.
-
npm run devstarts both apps and navigating to/renders the plant list. -
e2e/specs/plant-list.spec.tspasses against the seed fixture in Section 30.5, asserting each of the four statuses (overdue,due_today,due_soon,upcoming) renders with its correct colour token, icon, and label. - Component tests for
PlantListRoutepass for loading, populated, empty, and error states. - Resizing the viewport to 360px wide shows no horizontal scrollbar and all touch targets remain at least 44×44 CSS px.
27.3.6 M5 — Mutations and forms #
Goal. Implement every write action reachable from the UI: add, edit, delete (with undo), restore, and watered-today, with client-side validation mirroring Section 9 and optimistic status updates.
Files created.
apps/web/src/components/PlantForm.tsx,WaterButton.tsx.apps/web/src/components/toast/Toast.tsx,ToastRegion.tsx.apps/web/src/hooks/useCreatePlant.ts,useUpdatePlant.ts,useDeletePlant.ts,useRestorePlant.ts,useWaterPlant.ts,useDeleteWatering.ts.e2e/specs/mutations.spec.ts.
Dependencies. M4 (list screen, query client, api client).
Implementation steps.
- Implement
PlantFormusing the same Zod schemas frompackages/shared(via a resolver) for both create and edit. On invalid input, show inline field errors on submit attempt; the submit button is never disabled based on validity (Section 29.6, pitfall 9). - Implement
useCreatePlant/useUpdatePlantmutations that invalidate the['plants']query key on success (Section 16.5). - Implement
useDeletePlant: on success, remove the plant from the visible list immediately (cache update) and show aToastwith an Undo action and a 10-second window; clicking Undo callsuseRestorePlant; letting the toast expire leaves the plant deleted (still recoverable server-side for 30 days per Section 10, with no further UI affordance). - Implement
useWaterPlant(plantId, timezone): compute the optimistic date astodayInTimeZone(new Date(), timezone)— never a bare UTC timestamp — optimistically set the plant's status to reflect "watered today" in the cache viacomputeScheduleViewbefore the server responds, then reconcile with the server response on settle; the server value always wins per Section 8.7. - Ensure the water action is safe against rapid double-taps: disable only the button itself (not the
whole form) for the duration of the in-flight request, and rely on the server-side idempotency from
Section 13.5 as the source of truth regardless.
5a. Implement
useDeleteWatering(plantId, wateringId)per Section 16.4 and wire the water toast's Undo action to it usingmeta.wateringIdfrom the water response (Section 16.8). Whenmeta.alreadyWateredTodayistruethe toast carries no Undo action, per Section 11.2. - Write Playwright specs covering create, edit (including an interval shortening that makes a plant instantly overdue), delete+undo, delete+expire, and watered-today.
Tests written. PlantForm.test.tsx (validation behavior, submit button never disabled), mutation
hook tests with a mocked API client, e2e/specs/mutations.spec.ts.
Exit criteria.
- E2E: creating a plant via the form makes it appear in the list with the correct initial status.
- E2E: editing a plant's interval down recomputes its status immediately on next read.
- E2E: deleting a plant removes it from the list; clicking Undo within the toast's 10-second window restores it unchanged.
- E2E: letting the undo toast expire leaves the plant absent from the list after a reload.
- E2E: tapping "Watered today" twice in quick succession results in exactly one watering row (verified via the API directly in the test).
- Component test: submitting the form with an empty name shows an inline error and the submit button remains enabled (not disabled) both before and after the failed attempt.
- E2E: watering a plant with the keyboard shows a toast whose Undo action removes the watering row
and restores the previous
lastWateredOn(E2E-15).
27.3.7 M6 — Detail screen and history #
Goal. Implement the plant detail route with paginated watering history and history-entry deletion.
Files created.
apps/web/src/routes/PlantDetailRoute.tsx,apps/web/src/components/WateringHistoryList.tsx.apps/web/src/hooks/usePlant.ts,useWateringHistory.ts.e2e/specs/history.spec.ts.
Dependencies. M5 (mutation patterns and query invalidation established).
Implementation steps.
- Add the detail route from Section 14.2, showing plant name, interval, notes, current status, and the "Watered today" action.
- Implement
useWateringHistory(plantId, page)againstGET /api/v1/plants/:plantId/waterings?limit=20&offset=..., rendering entries in descendingwatered_onorder with pagination controls driven bymeta.total. - Wire the per-entry delete control in
WateringHistoryListtouseDeleteWatering(built in M5, step 5a), including the confirmation dialog required for entries older than the most recent one (Section 11.5). The hook already invalidates both the history query and the['plants']list query key, since the parent plant'slastWateredOn/status may have changed (Sections 7.8, 11.3). - Write an e2e test for the specific rule in Section 11.3: deleting the only watering entry for a
plant resets
lastWateredOnto the plant'screated_atcalendar date, never to null. - Write an e2e test for pagination: seed a plant with more than 20 history entries, navigate to page
2, assert
offset=20was used and the entries shown are the next 20 oldest.
Tests written. PlantDetailRoute.test.tsx, WateringHistoryList.test.tsx,
e2e/specs/history.spec.ts.
Exit criteria.
- E2E: navigating to a plant's detail route shows its history entries in descending date order.
- E2E: paginating to page 2 of a 25-entry history shows entries 21–25 and the correct
meta.total. - E2E: deleting the only remaining watering entry for a plant recomputes its status from the plant's creation date, matching Section 11.3.
- API test (added in this milestone if not already covered in M3) asserts
DELETE /api/v1/plants/:plantId/waterings/:wateringIdrecomputeslast_watered_onas specified in Section 11.3.
27.3.8 M7 — States, polish and accessibility #
Goal. Implement every non-happy-path state from Section 18, dark mode, and bring the app to zero axe violations with full keyboard operability.
Files created.
apps/web/src/components/EmptyState.tsx,ErrorState.tsx,apps/web/src/components/shared/Skeleton.tsx,OfflineBanner.tsx,ThemeToggle.tsx.apps/web/src/lib/theme.ts.e2e/specs/accessibility.spec.ts,e2e/specs/offline.spec.ts.
Dependencies. M6 (all screens exist to be polished).
Implementation steps.
- Implement each state in the matrix from Section 18 (loading, empty, error, offline) with the exact copy specified there, for every screen that needs it.
- Implement dark mode: default to
prefers-color-scheme, manual override stored inlocalStorageunderhpt.theme, toggle exposed viaThemeToggle. - Implement focus management, skip-to-content link, and ARIA landmarks per Section 19.
- Ensure all motion respects
prefers-reduced-motion: reduce(Section 12). - Run
@axe-core/playwrightacross every screen and every state in the Section 18 matrix; fix violations until zero remain. - Write a keyboard-only Playwright spec covering: add a plant, water a plant, delete a plant and undo — all without a mouse.
- Implement the
OfflineBannerdriven by thenavigator.onLinestate and failed-fetch detection; verify it disappears and triggers a refetch when connectivity returns.
Tests written. e2e/specs/accessibility.spec.ts (axe across every screen/state), keyboard-only
e2e spec, e2e/specs/offline.spec.ts, ThemeToggle.test.tsx.
Exit criteria.
-
e2e/specs/accessibility.spec.tsreports zero axe violations, using theA11Y-*procedure in Section 25.10, across every route that exists at the end of M7 (/,/plants/new,/plants/:plantId,/plants/:plantId/edit,*) and every state listed in Section 19's checklist./unlockis added to this gate in M9 (Section 20.5), and/settingsin M8. - The keyboard-only e2e spec passes: add-plant, water-plant, and delete+undo are each completed
using only
Tab,Shift+Tab,Enter,Space, andEscape. - E2E: toggling dark mode persists across a page reload, verified by reading
localStorage['hpt.theme']. - E2E: simulating offline (
context.setOffline(true)) shows the offline banner; restoring connectivity removes it and triggers a list refetch.
27.3.9 M8 — Durability #
Goal. Implement export, import, the nightly backup snapshot, and the 30-day purge job.
Files created.
apps/api/src/routes/backup.ts—GET /api/v1/export,POST /api/v1/import.apps/api/src/lib/export.ts,import.ts,backup.ts,backup-verify.ts,purge.ts.apps/web/src/routes/SettingsRoute.tsx(export/import UI per Section 17.10).e2e/specs/durability.spec.ts.
Dependencies. M2 (repositories), M3 (route conventions and error envelope established).
Implementation steps.
- Implement
lib/export.ts: build the document defined in Section 24.4 exactly —formatVersion,exportedAt,timezone,plants,waterings, covering all active and soft-deleted-but-not-yet- purged plants. There is nochecksumand noschemaVersionfield. - Implement
GET /api/v1/exportreturning that document withContent-Type: application/jsonand theContent-Dispositionfilename specified in Section 24.4. - Implement
lib/import.tssupporting bothreplaceandmergeper Section 24.5, including the mandatory pre-import snapshot in that section's step 4.modeis a required field on the import request only — it is never present in an export document. Reject aformatVersionother than1with 400VALIDATION_FAILEDand leave the database unchanged. - Implement
POST /api/v1/import, capped at the 5 MB body limit from Section 5. - Implement
lib/backup.tsexactly as specified in Section 24.2, including the verification in Section 24.3:VACUUM INTOa timestamped snapshot toAPP_BACKUP_DIRwhenAPP_BACKUP_ENABLEDis true, on the 03:15-daily-in-APP_TIMEZONEschedule with a boot catch-up, pruning snapshots beyondAPP_BACKUP_RETAIN. - Implement
lib/purge.ts: on the start-then-every-24-hours cadence in Section 24.9 — staggered so the purge completes before the boot-catchup snapshot starts — hard-delete every plant whosedeleted_atis more than 30 days in the past, which cascades to itswateringsrows via the foreign key. - Build the
SettingsRoutescreen per Section 17.10, with an "Export data" download action and an "Import data" file input plus the requiredreplace/mergeradio group with no default, showing the error envelope'smessageon failure. - Write a round-trip test: export the seed fixture, import it into a fresh database, assert identical plant and watering data.
- Write a purge test that inserts a plant with
deleted_atbackdated 31 days, runs the purge function directly, and asserts the plant and its waterings are gone. - Write a backup test that runs the backup function directly and asserts a new file appears in
APP_BACKUP_DIR, passes the verification in Section 24.3, and that old files beyond the retention count are removed.
Tests written. export.test.ts, import.test.ts (including the formatVersion-mismatch case and
a merge-mode case), backup.test.ts (including the row-count verification range in Section 24.3),
purge.test.ts, e2e/specs/durability.spec.ts.
Exit criteria.
-
GET /api/v1/exporton the seed fixture returns a document matching the schema in Section 24.4:formatVersion,exportedAt,timezone,plants,waterings— nochecksum, noschemaVersion. - Export-then-import into a fresh database yields identical plant and watering counts and field values (round-trip test green).
- Importing a document whose
formatVersionis not1returns 400VALIDATION_FAILEDand leaves the database unchanged. - A test that backdates a plant's
deleted_atby 31 days and runs the purge function asserts the plant and all its waterings no longer exist in the database. - A test that runs the backup function asserts a new snapshot file exists in
APP_BACKUP_DIR, passes the verification range in Section 24.3, and that the file count never exceedsAPP_BACKUP_RETAIN. -
/settingsis added to the axe accessibility gate (Section 25.10).
27.3.10 M9 — Hardening and observability #
Goal. Add the always-on and optional security posture from Section 20, structured logging and health checks from Section 22.
Files created.
apps/api/src/plugins/security-headers.ts(headers + CSP),apps/api/src/lib/access-code.ts(optional access code).apps/api/src/lib/logger.ts(pino instance and redaction config).- Finalized
apps/api/src/config.ts(adds theAPP_ACCESS_CODE,APP_SESSION_SECRET,APP_CORS_ORIGIN, andAPP_TRUST_PROXYentries to theConfigSchemaestablished in M2). - Finalized
apps/api/src/routes/health.ts(addsGET /api/v1/health/ready). e2e/specs/security.spec.ts.
Dependencies. M3 (routes exist to be wrapped), M2 (settings repository for the persisted session
secret and the base ConfigSchema).
Implementation steps.
- Add the
APP_ACCESS_CODE,APP_SESSION_SECRET,APP_CORS_ORIGIN, andAPP_TRUST_PROXYentries to the existingConfigSchemafrom M2, per Section 21. - Implement
plugins/security-headers.ts: security headers and a strict Content-Security-Policy with nounsafe-inlinefor scripts andmanifest-src 'self', applied to every response. - Implement
plugins/rate-limit.tsfully, usingAPP_RATE_LIMIT_MAX/APP_RATE_LIMIT_WINDOW_MS, excludingPOST /api/v1/session,GET /api/v1/health,GET /api/v1/health/ready, andGET /api/v1/metaper Section 13.7. - Implement
lib/access-code.ts: whenAPP_ACCESS_CODEis unset, it is a no-op and no cookie is ever issued. When set, gate every route exceptPOST /api/v1/session,GET /api/v1/health,GET /api/v1/health/ready, andGET /api/v1/meta(Section 20.5) behind a signed, httpOnly,SameSite=Strict(andSecurewhen behind HTTPS) cookie with 30-day rolling expiry, comparing the submitted code in constant time, and lock out an IP for 15 minutes after 5 failed attempts. PersistAPP_SESSION_SECRETvia thesettingsrepository if not supplied by the operator, generating one at first boot. - Implement
lib/logger.ts: pino structured JSON logs includingrequestId, with a redaction rule that ensures thenotesfield value is never included in any log line. - Finalize
routes/health.tsper Section 22.5:GET /api/v1/healthreturns 200 withstatus,uptimeSeconds, andversionand performs no database access, so it correctly reports "alive" even during a database outage.GET /api/v1/health/readyruns the checks in Section 22.5 and returns 503 naming the failing check; it is ungated and rate-limit exempt regardless ofAPP_ACCESS_CODE(Section 20.5). - Write tests for every item above.
Tests written. config.test.ts (invalid env exits 78), security.test.ts (headers/CSP present),
auth.test.ts (no-cookie default, correct-code cookie issuance, lockout after 5 failures),
logger.test.ts (notes redaction), health.test.ts (liveness never checks the database; readiness
returns 503 when the database is unreachable), e2e/specs/security.spec.ts.
Exit criteria.
- A response-header test asserts the CSP header is present with no
unsafe-inlineinscript-srcandmanifest-src 'self'present, alongside the other headers from Section 20. - With
APP_ACCESS_CODEunset, a test asserts zeroSet-Cookieheaders across every endpoint. - With
APP_ACCESS_CODEset, a request without the code to a protected route returns 401UNAUTHORIZED; the correct code sets a cookie withhttpOnly,SameSite=Strict, and (behind HTTPS)Secure; a 6th failed attempt within 15 minutes from one IP returns 429RATE_LIMITED;GET /api/v1/healthandGET /api/v1/health/readyboth still return 200 without a session. - A test sends a request whose
notesfield contains a unique marker string and asserts the marker never appears in captured log output. -
GET /api/v1/healthreturns 200 withstatus,uptimeSeconds, andversionand performs no database access, even whenAPP_DATABASE_PATHpoints at an unreadable path.GET /api/v1/health/readyreturns 200 when the database is reachable and 503 naming the failing check when it is not. - Starting the process with an invalid
APP_TIMEZONEvalue exits with code 78 and a message namingAPP_TIMEZONE. -
/unlockis added to the axe accessibility gate (Section 25.10).
27.3.11 M10 — Packaging and delivery #
Goal. Produce a single deployable Docker image serving both the API and the built frontend, a compose file for local/production run, a finalized CI pipeline, and confirmation that the performance budgets in Section 23 are met.
Files created.
Dockerfile(multi-stage,node:22-alpine),docker-compose.yml.apps/api/src/plugins/static-files.ts— servesapps/web/distfor any non-/api/v1GET request.- Finalized
.github/workflows/ci.yml(addsdocker buildand the e2e suite). README.mdfinalized with full run/build/deploy instructions.
Dependencies. All prior milestones; this is the final milestone.
Implementation steps.
- Implement
plugins/static-files.ts: serve the built frontend for any request that does not match/api/v1/*, so one Node process serves both, per Section 5. - Write the multi-stage
Dockerfile: a builder stage installing all workspaces and runningnpm run build; a final stage copying only productionnode_modulesand the builtdistdirectories, running as a non-root user, exposing port8080, withCMDstartingapps/api/dist/index.js. - Write
docker-compose.ymlmounting a named volume at the container path backingAPP_DATABASE_PATHandAPP_BACKUP_DIR, passing through the environment variables from Section 21, and publishing port8080. - Extend the CI workflow to build the Docker image on every push and run the full Playwright e2e suite against a container instance.
- Measure every metric in Section 23 by the method that section specifies and confirm each meets its budget; if any does not, optimize before closing the milestone (do not lower the budget without recording that as a decision under Section 27.7).
- Finalize
README.md: prerequisites,npm install,npm run dev,npm test,docker compose up, and the environment variables an operator must set (APP_TIMEZONEat minimum).
Tests written. A container-level smoke test (can be a shell script invoked from CI or a Playwright spec pointed at the container's published port) asserting the health endpoint, the list screen, and data persistence across a restart.
Exit criteria.
-
docker build -t houseplant-tracker .succeeds. -
docker compose up -dstarts the container andcurl -f http://localhost:8080/api/v1/healthreturns 200 within 30 seconds of container start. - Creating a plant, running
docker compose downthendocker compose up -dagain, and reading the plant list shows the same plant still present (volume-backed persistence). - The finalized CI workflow is green on a clean clone, including the Docker build job and the full e2e suite.
- Every performance budget in Section 23 is met, verified by the method that section specifies for each metric.
-
docker execinto the running container and runningwhoamireturns a non-root user.
27.4 Dependency graph #
M0 ──▶ M1 ──▶ M2 ──▶ M3 ──┬──▶ M4 ──▶ M5 ──▶ M6 ──▶ M7 ──▶ M8 ──▶ M9 ──▶ M10
│
└── M4 may begin earlier, against a client mocked
from the Section 13.5 contract, once that
contract is frozen — it does not need M3's
implementation to be finished, only its shape.M1 depends only on M0's workspace and tooling files (root tsconfig.base.json, eslint.config.js,
package.json); it does not depend on M0's CI workflow or Playwright smoke spec being finished, so a
second contributor could start M1 as soon as those specific files exist. From M2 onward, every
milestone is strictly serial: M2 needs M1's schemas for its DTO types, M3 needs M2's repositories, M5
needs M4's query client and list screen, M6 needs M5's established mutation and invalidation pattern,
M7 needs every screen from M4–M6 to exist before it can polish them, M8 needs M2's repositories and
M3's route conventions, M9 wraps M3's routes and needs M8's settings repository for the persisted
session secret, and M10 packages the output of every previous milestone. No other parallelism is safe
without risking rework: for example, starting M5 before M4's query invalidation pattern is settled
would mean redoing M5's mutation hooks.
27.5 Definition of done per milestone #
Every milestone, in addition to its own exit criteria in Section 27.3, must satisfy this shared checklist before it is considered closed:
npm run typecheckpasses across all workspaces touched by the milestone.npm run lintandnpm run format:checkpass with zero errors and zero warnings.- All tests written in the milestone, and all tests from every prior milestone, pass (no regressions).
- Coverage gates defined in Section 25 are met for every workspace touched.
- No
TODO,FIXME, orto be decidedcomment remains in any file touched by the milestone. - The commit (or commits) for the milestone follow the convention in Section 6.8 — for example
feat(api): add plant watering endpoint,fix(web): correct overdue colour token,test(shared): add schedule boundary cases. README.mdis updated if the milestone changed how the project is run, built, or configured.- Every checkbox in the milestone's exit-criteria list in Section 27.3 is checked with stated evidence (a command's output, a screenshot description, or a test name), per the self-verification protocol in Section 29.5.
27.6 Estimated effort #
| Milestone | Relative effort (points) |
|---|---|
| M0 | 3 |
| M1 | 8 |
| M2 | 5 |
| M3 | 8 |
| M4 | 5 |
| M5 | 8 |
| M6 | 5 |
| M7 | 5 |
| M8 | 5 |
| M9 | 5 |
| M10 | 3 |
| Total | 60 |
This is a small product, and the plan is deliberately front-loaded on the domain logic (M1) and the API contract (M3): both have the highest point values because the date-and-status arithmetic and the endpoint behaviors they establish gate the correctness of every screen and feature built afterward. No calendar dates or hour estimates are given; points are relative sizing only.
27.7 What to do when blocked #
An autonomous executor that reaches a point where the specification appears silent or two requirements appear to conflict follows this rule, in order:
- Never invent product scope. If a choice would add a feature, a screen, an endpoint, or a field not implied by Sections 2 and 3, it is out of bounds regardless of how natural it seems.
- Choose the simplest option consistent with Sections 2 and 3 (the project overview and the scope boundaries) — the option that adds the least new surface area while still satisfying every stated requirement.
- Record the choice as a new row in the decision log, Section 30.3, with the decision, the alternative(s) considered, the rationale, and the section it affects.
- Continue. Do not stop the milestone to ask a question; there is no one to ask. If the ambiguity turns out to block an exit criterion from being objectively verifiable, resolve it by picking the interpretation that makes the criterion checkable, and note that in the same decision-log row.
28. Acceptance Criteria Master Checklist #
28.1 How to verify #
Every criterion in this section is checked by one of three kinds of evidence: a named automated test
(a test ID from the matrices in Sections 25.5–25.10, using the prefixes DOM- for packages/shared
schedule-engine unit tests, VAL- for validation unit tests, API- for apps/api integration tests,
WEB- for frontend component tests, E2E- for Playwright specs, and A11Y- for accessibility specs;
every row in those matrices carries a stable test ID per Section 25.1), a named manual step with its
expected result stated, or a command whose expected output is stated. A criterion with no attached
evidence is not considered met, regardless of whether the underlying behavior appears to work.
28.2 Feature traceability matrix #
| Core feature (as named in Section 2) | Specified in | Built in | Proved by |
|---|---|---|---|
| 1. Plant list management (add, edit, delete) | Sections 9, 10, 13, 16, 17 | M5 | API-* (Section 25.7), WEB-* (Section 25.8), E2E-* (Section 25.9) |
| 2. Optional notes field | Sections 9, 10, 13 | M5 | VAL-* (Section 25.6), API-* (Section 25.7), WEB-* (Section 25.8) |
| 3. Due and overdue tracking | Sections 8, 12 | M1, M4 | DOM-* (Section 25.5), API-* (Section 25.7), E2E-* (Section 25.9) |
| 4. Mark as watered ("Watered today") | Sections 8, 11, 13 | M5 | API-* (Section 25.7), WEB-* (Section 25.8), E2E-* (Section 25.9) |
| 5. Visual due-status highlighting | Sections 12, 15, 19 | M4, M7 | WEB-* (Section 25.8), A11Y-* (Section 25.10), E2E-* (Section 25.9) |
| 6. Watering history storage | Sections 7, 11 | M2, M6 | API-* (Section 25.7), E2E-* (Section 25.9) |
| 7. No-login single-owner access | Sections 20, 21 | M9 | API-* (Section 25.7), E2E-* (Section 25.9) |
Every row above has at least one section reference, one milestone, and at least two test-matrix references. A feature row with no evidence is a defect in this document and must be corrected before Section 28.6's release gate can pass.
28.3 Functional acceptance criteria #
Plant management
- Given the plant list is empty, when the user opens the app, then an empty state is shown inviting them to add their first plant.
- Given a valid name and interval, when the user submits the add-plant form, then a new plant appears in the list with a status computed from today.
- Given a name of 61 characters after trimming, when the user submits the add-plant form, then a validation error is shown and no plant is created.
- Given an existing plant, when the user edits its name, then the updated name is reflected in the list and detail screen without a full page reload.
- Given an existing plant with
wateringIntervalDays = 7, when the user changes it to 3, then the plant's status recomputes immediately from its unchangedlastWateredOn. - Given an existing plant, when the user deletes it, then it disappears from the list immediately and an undo toast appears for 10 seconds.
- Given a just-deleted plant, when the user clicks Undo within 10 seconds, then the plant reappears in the list unchanged.
- Given a just-deleted plant whose undo toast has expired, when the user reloads the plant list, then the plant does not appear, though it remains recoverable server-side until the 30-day purge (Section 10).
Watering
- Given a plant not yet watered today, when the user taps "Watered today", then
lastWateredOnbecomes today and the status becomesupcomingordue_soonper its interval. - Given a plant already watered today, when the user taps "Watered today" again, then the response
is 200 with
meta.alreadyWateredToday: trueand no second history row is created. - Given a plant overdue by 5 days, when the user taps "Watered today", then
daysOverdueresets to 0 and the overdue highlighting is removed. - Given a plant's watering history with more than one entry, when the user deletes the most recent
entry, then
lastWateredOnbecomes thewatered_ondate of the new most recent remaining entry. - Given a plant's watering history with exactly one entry, when the user deletes that entry, then
lastWateredOnresets to the plant'screated_atcalendar date, never to null. - Given a plant with more than 20 watering entries, when the user opens its history, then the first page shows the 20 most recent entries in descending date order.
- Given a plant's history page 1, when the user requests page 2, then
offset=20is used andmeta.totalreflects the true total count. - Given the "Watered today" action is in flight, when the user taps it a second time before the first response returns, then only one watering row is ultimately recorded.
Status display
- Given a plant with
daysUntilDue < 0, when the list renders, then it shows theoverduecolour token, theAlertTriangleicon, and a text label stating the number of days overdue. - Given a plant with
daysUntilDue = 0, when the list renders, then it shows thedue_todaytoken, theDropleticon, and a "Due today" label. - Given a plant with
daysUntilDue = 1, when the list renders, then it shows thedue_soontoken, theClockicon, and a "Due tomorrow" label. - Given a plant with
daysUntilDue >= 2, when the list renders, then it shows theupcomingtoken, theCheckicon, and a label stating the number of days remaining. - Given the local date rolls past midnight in
APP_TIMEZONEwhile the tab is open, when the scheduled timer fires, then the list refetches and statuses update without user interaction. - Given the browser tab regains focus after being hidden, when the focus event fires, then the plant list refetches.
- Given the app is idle and visible, when 5 minutes elapse, then a background poll refetches the plant list.
- Given a user who cannot distinguish colour, when they view the list using only the icon and text label, then they can still determine each plant's status.
List controls
- Given multiple plants with different statuses, when the user opens the app, then a summary count of due-or-overdue plants is visible.
- Given the user sorts or filters the list client-side, when they change the criterion, then no additional network request is made.
- Given 500 active plants exist, when the user loads the list, then all 500 render without pagination controls.
- Given 500 active plants already exist, when the user attempts to create a 501st, then a 422
LIMIT_EXCEEDEDerror is shown and no plant is created. - Given a plant name that duplicates an existing plant's name, when the user submits it, then the plant is created and a non-blocking warning is shown, not a blocking error.
- Given the plant list, when a plant is created, edited, deleted, restored, or watered, then the
['plants']query cache is invalidated and the list reflects the change within one refetch cycle.
Forms
- Given the add-plant form with an empty name, when the user submits, then an inline "Name is required" error appears next to the field and the submit button remains enabled.
- Given the add-plant form with
wateringIntervalDays = 0, when the user submits, then an inline error states the valid range (1–365) and no request is sent. - Given the add-plant form with
wateringIntervalDays = 366, when the user submits, then an inline error states the valid range and no request is sent. - Given the add-plant form with a non-integer
wateringIntervalDays(for example 3.5), when the user submits, then an inline error is shown. - Given notes of exactly 2000 characters, when the user submits, then the plant is created successfully with all 2000 characters preserved.
- Given notes of 2001 characters, when the user submits, then an inline error is shown and no request is sent.
- Given a
lastWateredOndate in the future, when the user submits the add-plant form with that date, then a validation error is shown. - Given notes containing characters that look like HTML tags, when the plant is viewed, then the tags render as literal text, never as markup.
Navigation
- Given the app loads at
/withAPP_ACCESS_CODEunset, when no plant is selected, then the plant list screen renders directly, with no login screen. - Given a plant in the list, when the user taps it, then the app navigates to its detail route and shows its full history.
- Given the detail screen, when the user navigates back, then the app returns to the plant list with its prior scroll position preserved.
- Given the app is opened on a 360px-wide viewport, when the list renders, then all touch targets are at least 44×44 CSS px and no horizontal scrolling occurs.
- Given the user has set a manual dark-mode override, when they reload the app, then the override
persists via the
localStoragekeyhpt.theme. - Given the user has not set a manual override and their OS is in dark mode, when the app loads, then it renders in dark mode automatically.
- Given a network request fails, when the error state renders, then a retry action is available and using it re-issues the same request.
28.4 Non-functional acceptance criteria #
Accessibility (Section 19)
- Every screen and state in Section 19's checklist passes
@axe-core/playwrightwith zero violations (A11Y-*in Section 25.10, one per route:/,/plants/new,/plants/:plantId,/plants/:plantId/edit,/settings,/unlock). - Every interactive element is reachable and operable via keyboard alone, per the keyboard map in Section 19.3.
- Focus is trapped correctly within any open modal and returns to the triggering element on close.
- No status is conveyed by colour alone; every status indicator pairs colour with an icon and a text label, verified by a DOM query.
- Every icon used as a status indicator has an accessible name.
Performance (Section 23)
- Each performance budget named in Section 23 is measured by the method that section specifies and meets its stated target.
- The plant list with 500 plants renders within the budget in Section 23 without virtualisation.
- The production frontend bundle size meets the budget in Section 23.
- Time-to-first-byte for
GET /api/v1/plantswith 500 plants meets the budget in Section 23. - The Docker image size meets the budget in Section 23.
Security (Section 20)
- Every response includes the security headers listed in Section 20, verified by an integration test asserting header presence and CSP directive values.
- With
APP_ACCESS_CODEunset, no cookie is ever issued by the API, verified across every endpoint. - With
APP_ACCESS_CODEset, an incorrect code is rejected using the constant-time comparison specified in Section 20 and returns 401UNAUTHORIZED. - Five consecutive failed access-code attempts from one IP within 15 minutes return 429
RATE_LIMITED. - Every SQL statement in the codebase uses parameter binding; a static grep for string-concatenated SQL finds zero matches.
- Notes content is never written to log output, verified by the log-redaction test.
Observability (Section 22)
GET /api/v1/healthreturns the shape defined in Section 22 and 200 regardless of database reachability (Section 22.5).- Every log line is valid JSON matching the schema in Section 22.
- Every request is logged with a
requestIdthat matches therequestIdin the error envelope when an error occurs. GET /api/v1/health/readyreturns 200 while the database is reachable and a non-200 status naming the failing check when it is not.
Durability (Section 24)
- A full export/import round trip preserves every plant and watering field exactly.
- The nightly backup job produces a restorable SQLite file.
- Backups beyond
APP_BACKUP_RETAINare pruned. - A plant soft-deleted more than 30 days ago is purged, along with its waterings.
Configuration (Section 21)
- Starting the API with an invalid environment variable exits with code 78 and a message naming the invalid variable.
- Every environment variable in Section 21 that declares a default resolves to that default when
unset; the three optional variables (
APP_ACCESS_CODE,APP_SESSION_SECRET,APP_CORS_ORIGIN) resolve to unset. Verified by starting the process with a minimal environment and inspecting the resolved config.
28.5 Out-of-scope guard criteria #
| Excluded feature | Verification |
|---|---|
| No login form exists in the default configuration | An E2E journey (Section 25.9): with APP_ACCESS_CODE unset, loading / renders the plant list directly. |
| No email, push, or SMS notification code path exists | A dependency and source-grep audit finds zero references to email, push, or SMS libraries, endpoints, or scheduled sends. |
| No photo/image upload exists | The file manifest (Section 30.6) contains no multipart-upload route and no image-storage directory; a source grep for multipart finds no route registration. |
| No plant species database or care-guide content exists | The schema in Section 7 contains no species table, and a source grep finds no species-related route or static content file. |
| No charting library is in the dependency tree | npm ls across all three workspaces contains no charting package (for example no recharts, chart.js, d3-shape). |
| No analytics or third-party network request occurs | A Playwright test capturing all network requests during a full user journey asserts every request targets a same-origin /api/v1/* path. |
| No native iOS or Android app exists | The file manifest (Section 30.6) contains no iOS or Android project files. |
| No multi-user account system exists | The schema in Section 7 contains no users table, and no route or session concept exists beyond the single optional access code in Section 20. |
28.6 Release gate #
An executor declares the build complete only after every item below is checked with evidence:
- All milestone exit criteria in Section 27.3 (M0 through M10) pass.
- All 45 functional acceptance criteria in Section 28.3 pass.
- All 26 non-functional acceptance criteria in Section 28.4 pass.
- All out-of-scope guard criteria in Section 28.5 pass.
npm run typecheck,npm run lint,npm run format:check, andnpm testall pass from the repository root.docker buildsucceeds anddocker compose upserves a working app whose health check returns- A source-wide search finds zero
TODO,FIXME, orto be decidedcomments. - The decision log (Section 30.3) contains an entry for every choice made under Section 27.7 during the build.
README.mdaccurately documents how to run, build, test, and deploy the finished application.- All items above are checked: the build is ALL GREEN and ready for handover.
29. Executor Instructions #
29.1 Read order #
Read this document in the following order before writing any code, and keep the listed sections open while building each milestone:
- Sections 1 through 6 (product overview, scope, personas, technology stack, repository layout) — read in full before creating any file.
- Sections 7 through 9 (data model, domain logic, validation) — keep open while building M1 and M2.
- Section 13 (API contract) — keep open while building M3, and reference while building M8 and M9.
- Sections 14 through 17 (frontend architecture, design system, component architecture, screens) — keep open while building M4 through M7.
- Sections 18 through 20 (state matrix, accessibility, security) — keep open while building M7 through M9.
- Section 21 (environment variables) — keep open from M0's config stub onward; it is finalized in M9.
- Sections 22 through 26 (observability, performance, durability, testing, build/deploy) — keep open while building M8 through M10.
- Sections 27 and 28 (this milestone plan and the acceptance checklist) — keep open at all times as the working checklist.
- Section 29 (this section) — read once fully before starting M0, and re-read Section 29.3 at the start of every milestone.
- Section 30 (appendices) — use the glossary and decision log as ongoing reference throughout; use the worked date examples and sample data directly in tests.
29.2 Operating rules #
- Build in milestone order (Section 27.2). Do not start a milestone until the previous milestone's exit criteria in Section 27.3 all pass.
- Never leave a
TODO,FIXME, or ananytype in committed code without a comment justifying why it is unavoidable at that point and which later milestone removes it. - Never add a runtime dependency that is not in the technology stack table in Section 5 or the dependency inventory in Section 5.7. If a milestone seems to need one, re-read the relevant section for an existing tool that already covers the need before adding anything new.
- When two sections appear to disagree, the canonical section named for that concern in Section 30.2 wins.
- When the document is genuinely silent on a decision, choose the simplest option consistent with Sections 2 and 3 and record it in the decision log, Section 30.3, per Section 27.7.
- Never add product scope beyond the seven core features in Section 2 and the boundaries in Section 3, regardless of how small the addition seems.
- Write the test named for a piece of behavior in Section 25 before or together with the code that implements it — never after the milestone containing it has already been marked closed.
- Commit at the end of each milestone (or more often, at logical sub-steps within a milestone) using the convention in Section 6.8.
29.3 Per-milestone working loop #
Repeat this cycle for every milestone in Section 27.3:
- Read the milestone's goal, files, dependencies, and implementation steps in full.
- List the exact files to be created or changed for this milestone (already enumerated in Section 27.3; confirm nothing has changed since Section 29.1's read-through).
- Write the tests named for this milestone in Section 25, or write them alongside the implementation step they cover, per Section 29.2 rule 7.
- Implement the milestone's steps in the order given.
- Run
npm run typecheck,npm run lint, andnpm testand fix everything until all three are clean. - Walk the milestone's exit-criteria checklist in Section 27.3 item by item, verify each explicitly, and record the evidence (a command's output, a test name, a described screen state).
- Commit with a conventional message describing what the milestone added.
- Move to the next milestone.
29.4 Commands reference #
| Command | What it does | Success looks like |
|---|---|---|
npm install |
Installs all workspace dependencies. | Exits 0, node_modules populated in each workspace. |
npm run typecheck |
Runs tsc --noEmit in every workspace. |
Exits 0, no type errors printed. |
npm run lint |
Runs ESLint across every workspace. | Exits 0, zero errors and zero warnings. |
npm run lint:fix |
Runs ESLint with auto-fix. | Exits 0; re-running lint afterward is clean. |
npm run format |
Runs Prettier and writes fixes. | Exits 0; all files reformatted in place. |
npm run format:check |
Runs Prettier in check mode. | Exits 0, zero files needing formatting. |
npm test |
Runs the Vitest suite in every workspace. | Exits 0, every test file reports passed. |
npm run test:coverage |
Runs Vitest with coverage collection. | Exits 0; coverage report meets the gates in Section 25. |
npm run build |
Builds packages/shared, apps/api, apps/web. |
Exits 0; dist/ directories produced in each. |
npm run dev |
Starts the API and the Vite dev server together. | Both processes stay up; http://localhost:8080 (or the Vite port) serves the app. |
npm run db:migrate --workspace=apps/api |
Applies unapplied SQL migrations. | Exits 0; schema_migrations reflects the new version(s). |
npx playwright test |
Runs the full e2e suite. | Exits 0, every spec passes. |
npx playwright test --ui |
Opens the Playwright UI runner for debugging. | Interactive session opens; used for local debugging only, never in CI. |
docker build -t houseplant-tracker . |
Builds the production image. | Exits 0; docker images lists the tag. |
docker compose up -d |
Starts the container with a persistent volume. | Container reports healthy; curl to the health endpoint returns 200. |
docker compose down |
Stops and removes the container, volume retained. | Exits 0; a subsequent up restores prior data. |
29.5 Self-verification protocol #
After the final milestone (M10) closes, run the release gate in Section 28.6 as a literal checklist: for each of its ten items, state pass or fail with the specific evidence obtained (the command run and its output, the test name and its result, or the file inspected and what it showed). Do not mark an item as passing without having actually run the command or test that proves it in this session. If any item fails, it is a defect: fix it, re-run every item affected by the fix (not only the one that failed), and only then produce the final report. The handover artifact in Section 29.7 is not produced until every item in Section 28.6 reads pass.
29.6 Common pitfalls in this specific build #
| # | Pitfall | Symptom | Correct approach |
|---|---|---|---|
| 1 | Computing "today" from a UTC timestamp instead of APP_TIMEZONE |
Statuses flip a day early or late for users away from UTC | Always derive "today" via the single function todayInTimeZone described in Section 8.4, never via new Date().toISOString().slice(0, 10) or equivalent |
| 2 | Using millisecond arithmetic for day differences | Off-by-one status near DST transitions | Use calendar-day arithmetic on YYYY-MM-DD strings as specified in Section 8.2–8.3, never Date.getTime() subtraction |
| 3 | Forgetting PRAGMA foreign_keys = ON per connection |
Cascading deletes silently fail to remove waterings on purge | Set the pragma in connection.ts on every new connection, per Section 7.2, and cover it with the test in M2 |
| 4 | Storing the computed status | Status goes stale until the next write; contradicts Section 8.5 | Never persist status, nextDueOn, daysUntilDue, or daysOverdue; compute them on every read |
| 5 | Letting the client send the watering date | A client with a wrong clock corrupts history | The watering endpoint always uses server-computed todayInTimeZone(new Date(), APP_TIMEZONE), per Section 13.5; the client never supplies a date to this endpoint |
| 6 | Forgetting the unique index that makes watering idempotent | Double-tapping "Watered today" creates two history rows | The waterings(plant_id, watered_on) unique index from Section 7.4 must exist before M3's watering endpoint is tested |
| 7 | Rendering notes as HTML | A note containing <b> renders as bold instead of literal text; an XSS vector |
Render notes only as React text nodes, never via dangerouslySetInnerHTML or a Markdown renderer, per Section 20 |
| 8 | Forgetting to set lastWateredOn when creating a plant |
Violates the "never null" invariant in Section 8.2 and breaks every downstream calculation | Default lastWateredOn to todayInTimeZone(new Date(), APP_TIMEZONE) on create when the user does not supply an earlier date |
| 9 | Disabling the submit button on invalid input instead of showing errors | Users cannot tell why they cannot submit; fails Section 28.3 criterion 31 | Keep the submit button enabled; show inline field errors on submit attempt, per Section 16 |
| 10 | Using colour alone for status | Fails WCAG 1.4.1 and Section 28.4 criterion 4 | Always pair the status colour token with the icon and text label from Section 15.6 |
29.7 Handover artifact #
At the end of the build, the executor produces exactly three things:
- The repository, at the state after M10's final commit, with every file in the manifest in Section 30.6 present and every command in Section 29.4 runnable from a clean clone.
- A filled release-gate checklist — the ten items from Section 28.6, each marked pass with its evidence, produced by the self-verification protocol in Section 29.5.
- A short build report listing every decision recorded in the decision log (Section 30.3) during the build under Section 27.7 — the ones not already present at the start of the build — so a reviewer can see exactly where the executor exercised judgment.
30. Appendices #
30.1 Glossary #
| Term | Definition |
|---|---|
| Calendar date | A date with no time component, stored as YYYY-MM-DD text, meaningful only in the context of a specific timezone (Section 4). |
| Instant | A specific point in time, stored as an ISO 8601 UTC timestamp with milliseconds (Section 4). |
APP_TIMEZONE |
The single IANA timezone name that all calendar-date computations in the system use (Section 21). |
| Today | The calendar date obtained by formatting the current instant in APP_TIMEZONE via todayInTimeZone (Section 8.4); the only source of "today" anywhere in the system. |
| Interval | wateringIntervalDays, the number of days between waterings for a plant, 1–365 (Section 9). |
| Due | Informal term for a plant whose status is due_today or later; see the status enum in Section 8.4. |
| Overdue | Status when daysUntilDue < 0; the plant should already have been watered. |
| Due soon | Status when daysUntilDue = 1; the plant is due tomorrow. |
| Upcoming | Status when daysUntilDue >= 2; the plant is not due yet. |
| Soft delete | Marking a row deleted_at without removing it, so it can be restored (Section 10). |
| Purge | The permanent hard-delete of a soft-deleted plant and its waterings, 30 days after deleted_at (Section 10). |
| WAL | Write-Ahead Logging, the SQLite journal mode enabled on every connection (Section 7.2). |
| Optimistic update | Updating the UI to reflect a mutation's expected result before the server response arrives, then reconciling with the server's actual response (Section 16). |
| Idempotent | An operation that produces the same end state no matter how many times it is repeated with the same input; the watering endpoint is idempotent per day (Section 11.2). |
| Envelope | The fixed JSON wrapper shape ({ data, meta } or { error }) every API response uses (Section 13.2–13.3). |
| DTO | Data Transfer Object; the camelCase-keyed shape returned by the API, mapped from snake_case database rows (Section 6.4). |
| Repository | A module that encapsulates all SQL access for one table, returning DTOs (Section 7). |
| Migration | A numbered, plain SQL file that changes the database schema, applied once and tracked in schema_migrations (Section 7). |
| Request id | A UUIDv7 generated per HTTP request, included in every error envelope and every log line for correlation (Section 13.6, Section 22). |
| Landmark | An ARIA landmark region (for example main, nav) used for screen-reader navigation (Section 19). |
| Focus trap | Keeping keyboard focus within an open modal until it closes, then returning focus to the trigger (Section 19). |
| Skeleton | A loading-state placeholder that mimics the shape of the content about to load (Section 18). |
| Budget | A named, measurable performance or size target with a stated method of measurement (Section 23). |
| Exit criterion | A specific, checkable assertion that must hold true before a milestone is considered complete (Section 27.3). |
| Seed fixture | The fixed set of sample plants and waterings used across tests and local development, defined in Section 30.5. |
| Prepared statement | A parameterized SQL statement compiled once and executed with bound values, the only form of SQL execution permitted in this codebase (Section 20). |
30.2 Canonical section index #
This table names, for every cross-cutting concern, the single section that is authoritative for it. When any two sections in this document appear to disagree, the section named here for that concern is the tie-breaker referenced in Section 29.2.
| Concern | Canonical section |
|---|---|
| Technology stack | Section 5 |
| Repository layout and naming conventions | Section 6 |
| Database schema (DDL, indexes, constraints) | Section 7 |
| Date and calendar-day logic | Section 8 |
| Status enum and its computation | Section 8 |
| Validation rules and field limits | Section 9 |
| Plant DTO wire shape | Section 10.2 |
| Plant CRUD and delete/restore behaviour | Section 10 |
| Watering actions, idempotency and history | Section 11 |
| Sort, filter, search and the summary bar | Section 12 |
| Error envelope and error codes | Section 13 |
| Endpoint list and HTTP semantics | Section 13 |
Server-sent error messages (error.message, details[].message) |
Section 9.7 |
| Frontend routes | Section 14 |
| Design tokens (colour, type, spacing) | Section 15 |
| UI copy (every user-visible string) | Section 15.11 |
| Component tree and props contracts | Section 16 |
| TanStack Query keys and cache policy | Section 16 |
| Client refresh and cache policy | Section 16.5 |
| Screen layouts and per-screen behaviour | Section 17 |
| Loading/empty/error/offline state matrix | Section 18 |
| Client error presentation (which style, which surface) | Section 18.5 |
| Accessibility bar and keyboard map | Section 19 |
| Threat model and security headers | Section 20 |
| Environment variables and their defaults | Section 21 |
| Log schema and health endpoints | Section 22 |
| Performance budgets | Section 23 |
| Backup, export, and import format | Section 24 |
| Test matrix and coverage gates | Section 25 |
| Build, Docker image, and CI pipeline | Section 26 |
| Milestone plan and exit criteria | Section 27 |
| Acceptance criteria and release gate | Section 28 |
| Seed fixture and sample data | Section 30.5 |
| File manifest | Section 30.6 |
30.3 Decision log #
| # | Decision | Alternatives considered | Rationale | Section |
|---|---|---|---|---|
| 1 | SQLite via better-sqlite3 over a hosted database |
PostgreSQL, MySQL, a managed cloud database | Single user, single device at a time, tiny dataset; a file-backed database removes an entire class of operational overhead | 5 |
| 2 | A server-rendered-free SPA | Server-side rendering, static site generation | No SEO or multi-page-load requirement for a personal utility app; a SPA keeps the stack smaller | 5, 14 |
| 3 | Server-side status computation | Client computes and stores status | Status must never go stale and must be correct even if the client's clock is wrong | 8 |
| 4 | lastWateredOn is never null |
Allow null until first watering | Every plant needs a reference point for due-date math from the moment it is created | 8 |
| 5 | Creating a plant also creates its first watering row | Only set last_watered_on on the plant, no history row |
Keeps watering history complete and consistent with the "always has a reference point" rule | 7, 8 |
| 6 | Soft delete for plants, hard delete for waterings | Hard delete both, soft delete both | Plants need an undo window; individual watering entries are minor edits that do not need one | 10.6, 11.3 |
| 7 | 30-day purge of soft-deleted plants | Keep soft-deleted plants forever, purge immediately | Bounds storage growth while giving a generous recovery window beyond the 10-second UI undo | 7.8, 10.6 |
| 8 | Idempotent watering via a unique index on (plant_id, watered_on) |
Idempotency enforced only in application code | A database constraint is the last line of defense against double-writes from retries or races | 7, 8 |
| 9 | 500-plant hard cap | No cap, or a much larger cap | A single houseplant owner's collection is bounded; the cap keeps the uncapped, unpaginated list cheap to render | 9.3, 10.4 |
| 10 | No pagination on the plant list | Paginate like the watering history | The 500-plant cap makes the full list small enough to fetch and render at once | 13.1 |
| 11 | No offline writes | Queue mutations while offline and sync later | Adds significant complexity for a low-value scenario in a single-user, single-device app | 18 |
| 12 | No list virtualisation | Virtualise the plant list | 500 rows of simple markup render acceptably without virtualisation's added complexity | 16, 23 |
| 13 | No authentication by default, with an optional access code | Mandatory login, no auth option at all | The product requirement is no login screen, with access controlled at the deployment level (Section 4.2); the optional access code adds a hardening path without changing that default | 20 |
| 14 | UUIDv7 generated application-side | Auto-increment integers, UUIDv4, database-generated ids | Sortable by creation time, globally unique, generated without a round trip to the database | 7.3 |
| 15 | Calendar dates stored as YYYY-MM-DD text |
Store as Unix timestamps or DATE columns |
SQLite has no native date type; storing as text keeps calendar dates unambiguous and free of timezone conversion | 7.3, 8.1 |
| 16 | Timezone as a single app-level setting (APP_TIMEZONE) |
Per-plant or per-request timezone | Single-user app; one timezone setting is sufficient and avoids per-record complexity | 21 |
| 17 | No notifications of any kind | Email, push, or SMS reminders | Explicitly out of scope (Section 3.2); in-app highlighting is the only surfaced signal | 3.2, 12 |
| 18 | No photo uploads | Support a photo per plant | Explicitly out of scope; keeps storage and the UI minimal | 3.2 |
| 19 | TanStack Query as the only client state manager | Redux, Zustand, plain Context | The app's state is almost entirely server state; a dedicated data-fetching library is sufficient without a separate global store | 5, 16 |
| 20 | Tailwind CSS with no component library | A component library such as MUI or Chakra | Small, fully custom design system does not need a general-purpose component library's surface area | 5, 15 |
| 21 | A single container serves both the API and the built static frontend | Separate containers/services for API and frontend | One user, minimal infrastructure; one process and one image is the simplest deployable unit | 5, 26 |
The executor appends further rows to this table for every decision made under Section 27.7 during the build, following the same five columns.
30.4 Worked date examples #
All twelve examples below use the schedule function from Section 8: nextDueOn = addDays(lastWateredOn, wateringIntervalDays), daysUntilDue = differenceInCalendarDays(nextDueOn, today), and the status
table in Section 8.4. today in each row is the value todayInTimeZone(now, APP_TIMEZONE) would return on the stated
date in the stated timezone. These are pure calendar-day computations; the timezone only affects what
today resolves to, never the day arithmetic itself.
| # | Timezone | lastWateredOn |
Interval (days) | today |
nextDueOn |
daysUntilDue |
Status | Note |
|---|---|---|---|---|---|---|---|---|
| 1 | UTC | 2026-01-01 | 7 | 2026-01-08 | 2026-01-08 | 0 | due_today |
Simple same-timezone case. |
| 2 | America/New_York | 2026-03-01 | 14 | 2026-03-08 | 2026-03-15 | 7 | upcoming |
2026-03-08 is a US DST spring-forward date; calendar-day math is unaffected. |
| 3 | Europe/London | 2026-03-28 | 1 | 2026-03-29 | 2026-03-29 | 0 | due_today |
2026-03-29 is a UK DST spring-forward date; minimum interval. |
| 4 | Asia/Tokyo | 2026-02-01 | 30 | 2026-03-03 | 2026-03-03 | 0 | due_today |
Japan has no DST; crosses a 28-day February (2026 is not a leap year). |
| 5 | Pacific/Auckland | 2026-04-01 | 10 | 2026-04-11 | 2026-04-11 | 0 | due_today |
Southern-hemisphere DST fallback occurs nearby; calendar-day math is unaffected. |
| 6 | UTC | 2024-02-27 | 5 | 2024-03-05 | 2024-03-03 | -2 | overdue (daysOverdue: 2) |
Crosses February 29 in leap year 2024. |
| 7 | UTC | 2025-08-05 | 365 | 2026-08-05 | 2026-08-05 | 0 | due_today |
Maximum interval value; exactly one non-leap year later. |
| 8 | UTC | 2025-12-31 | 1 | 2026-01-01 | 2026-01-01 | 0 | due_today |
Year boundary with the minimum interval. |
| 9 | America/Los_Angeles | 2026-01-01 | 3 | 2026-01-01 | 2026-01-04 | 3 | upcoming |
Plant watered the same day it is created. |
| 10 | UTC | 2025-06-01 | 45 | 2026-08-05 | 2025-07-16 | -385 | overdue (daysOverdue: 385) |
A long-neglected plant; daysOverdue has no upper bound. |
| 11 | UTC | 2026-08-04 | 2 | 2026-08-05 | 2026-08-06 | 1 | due_soon |
Minimum interval above 1. |
| 12 | Europe/London | 2025-10-25 | 21 | 2025-11-15 | 2025-11-15 | 0 | due_today |
Crosses the UK DST fall-back date (2025-10-26); calendar-day math is unaffected. |
30.5 Sample data #
The seed fixture used across development and the tests referenced in Section 27.3 and Section 25
consists of 8 plants. Dates are expressed with the relative tokens TODAY and TODAY-N /
TODAY+N, meaning N calendar days before or after whatever date the seed script is run on, computed
in APP_TIMEZONE at seed time via the same todayInTimeZone function from Section 8.4. A seed script
resolves every token to a concrete YYYY-MM-DD value immediately before inserting each row, so the
fixture produces the same relative statuses no matter when it is loaded.
{
"plants": [
{
"id": "01917f2c-0001-7000-8000-000000000001",
"name": "Monstera Deliciosa",
"wateringIntervalDays": 7,
"notes": "Bright indirect light, east window.",
"lastWateredOn": "TODAY-10"
},
{
"id": "01917f2c-0002-7000-8000-000000000002",
"name": "Snake Plant",
"wateringIntervalDays": 21,
"notes": null,
"lastWateredOn": "TODAY-25"
},
{
"id": "01917f2c-0003-7000-8000-000000000003",
"name": "Pothos",
"wateringIntervalDays": 7,
"notes": "Trailing, kitchen shelf.",
"lastWateredOn": "TODAY-7"
},
{
"id": "01917f2c-0004-7000-8000-000000000004",
"name": "Fiddle Leaf Fig",
"wateringIntervalDays": 5,
"notes": "Sensitive to drafts.",
"lastWateredOn": "TODAY-4"
},
{
"id": "01917f2c-0005-7000-8000-000000000005",
"name": "ZZ Plant",
"wateringIntervalDays": 30,
"notes": null,
"lastWateredOn": "TODAY-2"
},
{
"id": "01917f2c-0006-7000-8000-000000000006",
"name": "Peace Lily",
"wateringIntervalDays": 4,
"notes": "Droops visibly when thirsty, a reliable indicator.",
"lastWateredOn": "TODAY-1"
},
{
"id": "01917f2c-0007-7000-8000-000000000007",
"name": "Aloe Vera",
"wateringIntervalDays": 180,
"notes": "Winter dormancy, water sparingly.",
"lastWateredOn": "TODAY-90"
},
{
"id": "01917f2c-0008-7000-8000-000000000008",
"name": "Basil",
"wateringIntervalDays": 2,
"notes": "Kitchen windowsill herb pot.",
"lastWateredOn": "TODAY-5"
}
]
}Resulting statuses at seed time (per the schedule engine in Section 8): Monstera Deliciosa —
overdue, daysOverdue: 3. Snake Plant — overdue, daysOverdue: 4. Pothos — due_today. Fiddle
Leaf Fig — due_soon. ZZ Plant — upcoming, daysUntilDue: 28. Peace Lily — upcoming,
daysUntilDue: 3. Aloe Vera — upcoming, daysUntilDue: 90. Basil — overdue, daysOverdue: 3.
The seed script also inserts one waterings row per plant dated at its lastWateredOn value, per the
rule in Section 27.3.4 step 3 that plant creation always creates a matching first watering row.
30.6 File manifest #
This table is generated from Section 6.1's directory tree, Section 16.1's component tree, and every
file path named in a code comment or file list anywhere in this document, with the milestone column
filled from Section 27.3. It covers the principal files; a small number of purely mechanical files
(for example per-workspace .gitignore entries) are not enumerated separately.
| Directory | File | Purpose | Created in |
|---|---|---|---|
/ |
package.json |
Root npm workspaces manifest | M0 |
/ |
tsconfig.base.json |
Shared TypeScript compiler options | M0 |
/ |
eslint.config.js |
ESLint 9 flat config | M0 |
/ |
.prettierrc.json |
Prettier configuration | M0 |
/ |
.gitignore |
Ignored paths | M0 |
/ |
.env.example |
Documented environment variable template | M0, updated M9 |
/ |
README.md |
Setup, run, build, deploy instructions | M0, finalized M10 |
/ |
CHANGELOG.md |
Notable changes per release | M0, updated every milestone |
/ |
vitest.workspace.ts |
Vitest workspace project list | M0 |
/ |
Dockerfile |
Multi-stage production image build | M10 |
/ |
docker-compose.yml |
Local/production run with a persistent volume | M10 |
.github/workflows/ |
ci.yml |
CI pipeline: typecheck, lint, format, test, build, docker, e2e | M0, finalized M10 |
scripts/ |
check-contrast.mjs |
Asserts token contrast ratios per Section 19.7 | M7 |
scripts/ |
check-bundle-size.mjs |
Asserts frontend bundle size budgets | M10 |
scripts/ |
bench-api.mjs |
API response-time benchmark | M10 |
/ |
lighthouserc.json |
Lighthouse CI budget configuration | M10 |
/ |
performance-baseline.json |
Recorded performance-budget baseline | M10 |
packages/shared/ |
package.json, tsconfig.json, vitest.config.ts |
Package manifest, TS config, and test config | M0 |
packages/shared/src/ |
index.ts |
Barrel export | M0, finalized M1 |
packages/shared/src/ |
constants.ts |
Limits, enums, error codes | M1 |
packages/shared/src/schemas/ |
plant.schema.ts, watering.schema.ts, pagination.schema.ts, config.schema.ts, normalize.ts, calendar-date.ts, plant-id.ts |
Zod validation schemas and shared normalizers, single source of truth | M1 |
packages/shared/src/schemas/ |
import.ts |
Import-document Zod schema | M8 |
packages/shared/src/types/ |
plant.ts, watering.ts |
Inferred TypeScript types and DTOs | M1 |
packages/shared/src/domain/ |
schedule.ts, calendar-date.ts, errors.ts |
Pure watering-schedule functions, calendar helpers, AppError |
M1 |
packages/shared/src/testing/ |
fixtures.ts |
makePlant/makeWatering factories (Section 25.12) |
M1 |
apps/api/ |
package.json, tsconfig.json |
Package manifest and TS config | M0 |
apps/api/migrations/ |
0001_init.sql |
Initial schema: plants, waterings, settings (schema_migrations is created by the runner) |
M2 |
apps/api/src/ |
index.ts |
Process bootstrap | M0 |
apps/api/src/ |
server.ts |
buildServer() factory |
M0, finalized M3 |
apps/api/src/ |
config.ts |
Environment parsing and validation | M0, base schema in M2, extended M9 |
apps/api/src/db/ |
connection.ts |
SQLite connection and pragmas | M2 |
apps/api/src/db/ |
migrate.ts |
Migration runner | M2 |
apps/api/src/db/repositories/ |
plants.repository.ts, waterings.repository.ts, settings.repository.ts |
Prepared-statement data access, row-to-DTO mapping | M2 |
apps/api/src/routes/ |
plants.ts |
Plant CRUD, restore, and watering-creation endpoints | M3 |
apps/api/src/routes/ |
waterings.ts |
Watering history and entry deletion endpoints | M3 |
apps/api/src/routes/ |
meta.ts |
GET /api/v1/meta |
M3 |
apps/api/src/routes/ |
health.ts |
Liveness and readiness endpoints | M3, finalized M9 |
apps/api/src/routes/ |
backup.ts |
Export and import endpoints | M8 |
apps/api/src/plugins/ |
error-handler.ts |
Canonical error envelope mapping | M3 |
apps/api/src/plugins/ |
request-id.ts |
Per-request UUIDv7 id | M3 |
apps/api/src/plugins/ |
rate-limit.ts |
Rate limiting | M3, finalized M9 |
apps/api/src/plugins/ |
security-headers.ts |
Security headers and CSP | M9 |
apps/api/src/plugins/ |
static-files.ts |
Serves the built frontend | M10 |
apps/api/src/lib/ |
access-code.ts |
Optional access-code gate | M9 |
apps/api/src/lib/ |
logger.ts |
Pino instance and redaction | M9 |
apps/api/src/lib/ |
zod-error.ts |
Maps Zod issues to the canonical error envelope | M3 |
apps/api/src/lib/ |
export.ts, import.ts |
Export/import document build and validation | M8 |
apps/api/src/lib/ |
backup.ts |
Nightly VACUUM INTO snapshot job (Section 24.2) |
M8 |
apps/api/src/lib/ |
backup-verify.ts |
Snapshot integrity and row-count verification (Section 24.3) | M8 |
apps/api/src/lib/ |
backup-cli.ts |
Manual backup/restore CLI (Section 24.7) | M8 |
apps/api/src/lib/ |
migrate-cli.ts |
Standalone migration-runner CLI | M2 |
apps/api/src/lib/ |
purge.ts |
30-day soft-delete purge job | M8 |
apps/api/test/helpers/ |
test-db.ts |
Temporary per-test SQLite file helper | M2 |
apps/api/test/helpers/ |
seed-database.ts |
Seeds the fixture in Section 30.5 into a test database | M2 |
apps/web/ |
package.json, tsconfig.json, vite.config.ts, index.html |
App manifest and build config | M0 |
apps/web/public/ |
robots.txt, manifest.webmanifest, favicon.ico, icon.svg, icon-192.png, icon-512.png, icon-maskable-512.png, apple-touch-icon.png |
Static PWA and favicon assets, checked in (no build step) | M0 |
apps/web/src/ |
main.tsx |
React root, providers, router mount | M0, finalized M4 |
apps/web/src/ |
App.tsx |
Root layout | M0, finalized M4 |
apps/web/src/routes/ |
router.tsx |
React Router 7 data router definition | M4 |
apps/web/src/routes/ |
PlantListRoute.tsx, NotFoundRoute.tsx |
Plant list screen and catch-all route | M4 |
apps/web/src/routes/ |
PlantFormRoute.tsx |
Add/edit plant screen | M5 |
apps/web/src/routes/ |
PlantDetailRoute.tsx |
Plant detail and history screen | M6 |
apps/web/src/routes/ |
SettingsRoute.tsx |
Export/import screen | M8 |
apps/web/src/routes/ |
UnlockRoute.tsx |
Access-code entry screen | M9 |
apps/web/src/components/layout/ |
AppShell.tsx, Header.tsx |
App chrome and navigation | M4 |
apps/web/src/components/layout/ |
AddPlantButton.tsx |
FAB / header add-plant control | M5 |
apps/web/src/components/plant-list/ |
PlantRow.tsx, SummaryBar.tsx, ListControls.tsx |
Row rendering, due-count summary, sort/filter/search | M4 |
apps/web/src/components/ |
StatusBadge.tsx |
Status colour, icon, and label rendering | M4 |
apps/web/src/components/ |
PlantForm.tsx, WaterButton.tsx |
Mutation UI | M5 |
apps/web/src/components/plant-form/ |
IntervalField.tsx, LastWateredField.tsx |
Form field subcomponents | M5 |
apps/web/src/components/shared/ |
ConfirmDialog.tsx |
Destructive-action confirmation | M5 |
apps/web/src/components/toast/ |
Toast.tsx, ToastRegion.tsx |
Toast UI, including the Undo action | M5 |
apps/web/src/components/ |
WateringHistoryList.tsx |
Paginated history list | M6 |
apps/web/src/components/plant-detail/ |
PlantMeta.tsx, NotesBlock.tsx, HistoryRow.tsx |
Detail-screen subcomponents | M6 |
apps/web/src/components/ |
EmptyState.tsx, ErrorState.tsx, OfflineBanner.tsx, ThemeToggle.tsx |
Non-happy-path states and theme toggle | M7 |
apps/web/src/components/shared/ |
Skeleton.tsx, ErrorBoundary.tsx, RouteError.tsx |
Loading placeholder and error-boundary components | M7 |
apps/web/src/hooks/ |
usePlants.ts, useMeta.ts, useMidnightRefresh.ts, useDocumentTitle.ts, useListPreferences.ts |
Plant list query, runtime facts, refresh timer, document title, sort/filter persistence | M4 |
apps/web/src/hooks/ |
useCreatePlant.ts, useUpdatePlant.ts, useDeletePlant.ts, useRestorePlant.ts, useWaterPlant.ts, useDeleteWatering.ts, useToast.ts |
Plant mutations and toast dispatch | M5 |
apps/web/src/hooks/ |
usePlant.ts, useWateringHistory.ts |
Detail and history queries/mutations | M6 |
apps/web/src/hooks/ |
useOnlineStatus.ts |
Offline/online detection | M7 |
apps/web/src/api/ |
client.ts |
Typed fetch client | M4 |
apps/web/src/lib/ |
queryClient.ts, queryKeys.ts, sort.ts, strings.ts |
Query client config, query key factory, sort comparators, UI copy accessors | M4 |
apps/web/src/styles/ |
tokens.css |
Design tokens, dark-mode custom properties | M0, finalized M7 |
apps/web/src/styles/ |
theme.css |
Theme-transition and reduced-motion rules | M7 |
apps/web/src/lib/ |
theme.ts |
Dark-mode resolution and persistence | M7 |
e2e/ |
playwright.config.ts |
Playwright configuration | M0 |
e2e/fixtures/ |
seed.ts |
Seed-fixture loader (Section 30.5) | M4 |
e2e/specs/ |
smoke.spec.ts |
M0 smoke test | M0 |
e2e/specs/ |
plant-list.spec.ts |
List rendering and statuses | M4 |
e2e/specs/ |
mutations.spec.ts |
Create/edit/delete/undo/water | M5 |
e2e/specs/ |
history.spec.ts |
Detail screen and pagination | M6 |
e2e/specs/ |
accessibility.spec.ts, offline.spec.ts |
Axe checks and offline behavior | M7 |
e2e/specs/ |
durability.spec.ts |
Export/import round trip | M8 |
e2e/specs/ |
security.spec.ts |
Headers, CSP, access-code flow | M9 |
This table covers the principal files created by every milestone; an executor can use it as a completeness check at the end of the build, alongside Section 6.1's directory tree and Section 16.1's component tree.
30.7 Example export document #
The document below is a complete, valid export of the seed fixture from Section 30.5, matching the
schema in Section 24.4, generated on a run where TODAY resolved to 2026-08-05 and APP_TIMEZONE
was UTC. The document is exactly formatVersion, exportedAt, timezone, plants, waterings —
there is no checksum field and no schemaVersion field. mode is never present in an export
document; it is supplied by the caller only on the import request (Section 24.5).
{
"formatVersion": 1,
"exportedAt": "2026-08-05T09:00:00.000Z",
"timezone": "UTC",
"plants": [
{
"id": "01917f2c-0001-7000-8000-000000000001",
"name": "Monstera Deliciosa",
"wateringIntervalDays": 7,
"notes": "Bright indirect light, east window.",
"lastWateredOn": "2026-07-26",
"createdAt": "2026-07-26T09:00:00.000Z",
"updatedAt": "2026-07-26T09:00:00.000Z",
"deletedAt": null
},
{
"id": "01917f2c-0002-7000-8000-000000000002",
"name": "Snake Plant",
"wateringIntervalDays": 21,
"notes": null,
"lastWateredOn": "2026-07-11",
"createdAt": "2026-07-11T09:00:00.000Z",
"updatedAt": "2026-07-11T09:00:00.000Z",
"deletedAt": null
},
{
"id": "01917f2c-0003-7000-8000-000000000003",
"name": "Pothos",
"wateringIntervalDays": 7,
"notes": "Trailing, kitchen shelf.",
"lastWateredOn": "2026-07-29",
"createdAt": "2026-07-29T09:00:00.000Z",
"updatedAt": "2026-07-29T09:00:00.000Z",
"deletedAt": null
}
],
"waterings": [
{
"id": "01917f2c-1001-7000-8000-000000000001",
"plantId": "01917f2c-0001-7000-8000-000000000001",
"wateredOn": "2026-07-26",
"createdAt": "2026-07-26T09:00:00.000Z"
},
{
"id": "01917f2c-1002-7000-8000-000000000002",
"plantId": "01917f2c-0002-7000-8000-000000000002",
"wateredOn": "2026-07-11",
"createdAt": "2026-07-11T09:00:00.000Z"
},
{
"id": "01917f2c-1003-7000-8000-000000000003",
"plantId": "01917f2c-0003-7000-8000-000000000003",
"wateredOn": "2026-07-29",
"createdAt": "2026-07-29T09:00:00.000Z"
}
]
}The full 8-plant, 8-watering document follows the same shape; the three plants shown above illustrate the structure. An executor's test fixture includes all 8 plants and their corresponding watering rows from Section 30.5.
30.8 Document conventions #
This specification uses numbered ## sections as the top-level structure, with ### and ####
subsections using dotted numbering (for example 27.3.4). Every cross-reference is by section number
(for example "see Section 8.4"); no content is duplicated across sections except where explicitly
noted as a repeated worked example. Code, SQL, and JSON blocks are literal and ready to use as written,
not illustrative pseudocode. Tables are normative: a rule stated in a table carries the same weight as
a rule stated in prose. Within any requirement, "must" denotes a mandatory requirement and "may"
denotes a permitted option that an executor can choose to implement or skip without it being a defect.
Generated at GeneratePRD.com — one idea in, one buildable spec out.
Licensed under CC BY 4.0. Use it for anything — just credit GeneratePRD.com.