Skip to content
GenerateSpec

Houseplant Watering Tracker

11,615 lines · 105,303 words

CC BY 4.0 Open online
consumer-appPublic

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 #

  1. Before You Start — Customization Decisions
  2. Project Overview and Vision
  3. Scope Boundaries
  4. Users, Personas and Core Scenarios
  5. Technology Stack and Architecture
  6. Repository Layout and Coding Conventions
  7. Data Model and Database Schema
  8. Watering Schedule Domain Logic
  9. Validation Rules and Limits
  10. Feature: Plant Management
  11. Feature: Watering Actions and History
  12. Feature: Due and Overdue Status Display
  13. API Design and Contract
  14. Frontend Information Architecture and Routing
  15. Design System and Visual Language
  16. Component Architecture and State Management
  17. Screen Specifications
  18. Interaction States: Loading, Empty, Error, Offline
  19. Accessibility Requirements
  20. Security, Privacy and Threat Model
  21. Configuration and Environment Variables
  22. Observability, Logging and Health
  23. Performance Budgets and Targets
  24. Data Durability: Backup, Export and Import
  25. Testing Strategy and Test Matrix
  26. Build, Deployment and Operations
  27. Milestones and Execution Plan
  28. Acceptance Criteria Master Checklist
  29. Executor Instructions
  30. 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 (overduedue_todaydue_soonupcoming), 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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).
  6. The full test suite (Section 25) passes in CI on every commit to the main branch before a build is considered releasable.
  7. 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.
  8. 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:

  1. 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).
  2. Connection reaches Fastify on APP_HOST:PORT.
  3. Request-id assignment: Fastify's built-in genReqId assigns a UUIDv7 request id, attached to the logger context and echoed as requestId in any error response (Section 13).
  4. 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.
  5. Content-Type check: for requests with a body, the media type must be application/json; a charset=utf-8 parameter is permitted and any other media type yields → 415 UNSUPPORTED_MEDIA_TYPE.
  6. 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.
  7. JSON parse: malformed JSON → 400 MALFORMED_JSON.
  8. Zod validation against the shared schema for that endpoint (Section 9); unknown fields are stripped, not rejected; validation failures → 400 VALIDATION_FAILED with a populated details array.
  9. Repository call: the route handler calls a repository function in apps/api/src/db/, never touching the raw connection directly (Section 6.7).
  10. Synchronous SQLite statement execution: better-sqlite3 executes 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.
  11. DTO mapping: the repository or a mapper layer converts snake_case rows to camelCase DTOs (Section 3, Section 6.4); raw rows never reach the route handler's response.
  12. Domain computation: for plant reads, the shared schedule functions (Section 8) compute status, nextDueOn, daysUntilDue, and daysOverdue from the stored dates.
  13. Response envelope construction: success responses use the canonical envelope (Section 13); the handler sets the correct status code (200/201/204).
  14. Logging: pino logs one structured line per request with requestId, method, path, status code, and duration in milliseconds (Section 22); request and response bodies containing notes are never logged (Section 20).
  15. 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 as 500 INTERNAL_ERROR with 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 GET request whose path does not match a file in dist is rewritten to index.html (SPA fallback), so client-side routes (Section 14) resolve correctly on a hard refresh or direct link.
  • Non-GET requests to non-/api paths return 405 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.html is served with Cache-Control: no-cache so 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_timeout is 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 of FULL, consistent with the "not sensitive data" classification in Section 20.
  • The API holds one shared better-sqlite3 connection for the process lifetime; it is not pooled, because better-sqlite3 is synchronous and a pool would add complexity without benefit for a single connection.
  • Because better-sqlite3 executes 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 updatedAt timestamp; 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 scenarios

6.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:

  1. No any without an inline // justified: <reason> comment on the same line; the ESLint rule is set to error, and a suppression must explain itself.
  2. No default exports, except React route components (Section 14 requires the router to consume a default export). Every other module uses named exports.
  3. No barrel files deeper than packages/shared/src/index.ts. Nothing else in the repository re-exports through an index.ts aggregator.
  4. 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.
  5. Errors are thrown only as typed AppError instances (Section 6.6); no route or domain function throws a bare Error or 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/:plantId sets plants.deleted_at to the current UTC instant and updated_at to the same value. The row is otherwise untouched — last_watered_on, name, notes, and watering_interval_days are preserved so the 10-second Undo affordance (Section 10) can restore the plant exactly as it was via POST /api/v1/plants/:plantId/restore, which sets deleted_at back to NULL.

  • The 30-day purge permanently removes soft-deleted plants and, via the ON DELETE CASCADE foreign key on waterings.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 string

    The cutoff instant is computed in application code (subDays in UTC, not through APP_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 own datetime('now', ...) so the cutoff is computed once and is trivially unit-testable. Because deleted_at values 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 updates settings.last_purge_run_at.

  • Waterings hard-delete individually. DELETE /api/v1/plants/:plantId/waterings/:wateringId removes exactly that row with no cascade (a watering has no children). Deleting a watering recomputes the parent plant's last_watered_on only when the deleted row's watered_on equals the plant's current last_watered_on; otherwise last_watered_on is left untouched, because a backdating edit may already have moved last_watered_on earlier than MAX(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):

    1. If the deleted row's watered_on is not equal to the plant's current last_watered_on, no recomputation happens; last_watered_on and updated_at are unchanged.
    2. Otherwise, the repository re-queries MAX(watered_on) for the remaining watering rows of that plant. If a maximum exists, plants.last_watered_on is set to it and updated_at is refreshed.
    3. If no watering rows remain (the deleted row was the plant's only watering), last_watered_on is set to the earlier of the calendar date of plants.created_at converted through APP_TIMEZONE (todayInTimeZone-style conversion applied to the stored created_at instant, not to "now") and the plant's current last_watered_on — never a later date, so this fallback can only preserve or increase how overdue the plant is, and never NULL, 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 same last_watered_on. All steps run inside a single SQLite transaction so a crash between them cannot leave last_watered_on pointing at a watering row that no longer exists.

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 named NNNN_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_migrations exists (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 single better-sqlite3 transaction (db.transaction(() => { db.exec(sql); db.prepare('INSERT INTO schema_migrations (...) VALUES (...)').run(...); })()), and runs the file's SQL followed by inserting its own schema_migrations row, 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:

  1. 2026-03-08 is the second Sunday of March 2026, when America/New_York springs forward at 2 a.m. local. At the sampled UTC instant, local time is 01:30 EST (still UTC−5, just before the jump). today is unaffected by the fact that the local day will be one hour short later that day, because today is a calendar date, not a duration.
  2. 2026-11-01 is the first Sunday of November 2026, when America/New_York falls back at 2 a.m. local (EDT UTC−4 → EST UTC−5). At the sampled instant, local time is 04:30 EST, after the transition. Despite the local day being 25 hours long, nextDueOn and daysUntilDue are computed from calendar dates alone and are exactly what plain calendar subtraction gives: 5 days overdue.
  3. Pacific/Auckland observes NZDT (UTC+13) in January (southern-hemisphere summer). The sampled UTC instant 20:00 on the 15th is 09:00 on the 16th locally — a full calendar day ahead of the UTC date. This is precisely why todayInTimeZone must convert through APP_TIMEZONE and never simply take the UTC date's YYYY-MM-DD substring.
  4. Pacific/Pago_Pago is UTC−11 year-round (no DST observed). The sampled instant 08:00 on the 16th is 21:00 on the 15th locally — a full calendar day behind the UTC date, the mirror-image hazard of row 3.
  5. Asia/Kolkata is UTC+5:30 year-round. The half-hour offset does not participate in the calendar-date calculation at all once todayInTimeZone has produced the YYYY-MM-DD string; everything downstream is pure integer day arithmetic, so a fractional-hour offset introduces no special case anywhere in Section 8.4.
  6. 2028 is a leap year (2028 / 4 = 507 exactly, and 2028 is not a century year), so February has 29 days. addDays(2028-02-15, 14) must land on 2028-02-29, not roll over into March; the Section 8.4 algorithm handles this automatically because daysInMonth and the Hinnant conversion both encode the leap-year rule directly, with no special-cased "if leap year" branch anywhere in addDays itself.

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 today from whatever instant new Date() returns at that moment (Section 8.1's todayInTimeZone(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_TIMEZONE between restarts. This is an expected, supported operation, not an error condition. Every plant's nextDueOn/daysUntilDue/status is recomputed from the same stored lastWateredOn and wateringIntervalDays against a today calculated 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/ status optimistically 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 configured APP_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:

  1. lastWateredOn is never null for any plant returned by the API (Section 8.2).
  2. computeStatus always returns exactly one of the four enum values — never undefined, never a fifth value — for any nextDueOn/today pair, because the four conditions in Section 8.3 (< 0, === 0, === 1, >= 2) are exhaustive and mutually exclusive over the integers.
  3. Watering a plant never immediately produces overdue or due_today. Because wateringIntervalDays >= 1 always (interval 0 is not a valid input — the real minimum is 1, Section 9.3), nextDueOn = addDays(today, wateringIntervalDays) >= addDays(today, 1), so daysUntilDue >= 1 immediately after watering: the result is due_soon when wateringIntervalDays === 1 and upcoming when wateringIntervalDays >= 2.
  4. nextDueOn is a function of lastWateredOn and wateringIntervalDays alone. It never changes when only today changes; only daysUntilDue, daysOverdue, and status vary with today.
  5. Status urgency is non-decreasing as today advances, holding nextDueOn fixed: moving today forward by any positive number of days can only move status along upcoming → due_soon → due_today → overdue, never backward, and only a watering event (which changes lastWateredOn and therefore nextDueOn) can reduce urgency.
  6. addDays and differenceInCalendarDays are inverses: differenceInCalendarDays(addDays(d, n), d) === n for every integer n (positive, negative, or zero) and every valid calendar date d, including across month, year, and leap-day boundaries.
  7. daysOverdue is exactly 0 when status !== 'overdue', and exactly -daysUntilDue (a positive integer) when status === 'overdue'. There is no state in which daysOverdue is positive while status is not overdue, or vice versa.
  8. The schedule functions are pure and total: two calls to computeScheduleView with identical lastWateredOn, wateringIntervalDays, and today arguments always return bitwise-identical results, with no dependency on call order, wall-clock time at the moment of the call, or any mutable state — editing wateringIntervalDays alone (Scenario 4/5 in Section 8.8) always recomputes nextDueOn deterministically from the stored lastWateredOn.

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:

  1. 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.
  2. Server-side validation (the same Zod schemas, run in a Fastify preValidation hook 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.
  3. Database CHECK constraints (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:

  1. 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.
  2. Control-character stripping — every C0 (U+0000U+001F) and C1 (U+007FU+009F) control character is removed from name with no exceptions (this means no newlines, tabs, or carriage returns are ever allowed in name). For notes, every C0/C1 control character is removed except line feed (U+000A) and carriage return (U+000D), which are preserved because notes is free text that may reasonably span multiple lines. name additionally 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 a name must contain at least one remaining non-whitespace character or it fails validation (Section 9.3).
  3. Whitespace collapsing — applied to name only: any run of one or more Unicode whitespace characters is collapsed to a single ASCII space. Not applied to notes, which preserves its internal formatting (including blank lines) as the user typed it.
  4. Trimming — leading and trailing whitespace is removed from both name and notes.
  5. Empty-to-null — applied to notes only: if the string is empty after the previous four steps, it becomes null rather than an empty string. name has no equivalent step because an empty name is 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, 160 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, 1365 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, 02000 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, 1100, 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,
  }));
}
  • path is the dotted JSON path of the offending field (e.g. "wateringIntervalDays", or "plants.2.name" for the third element of the plants array 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 "".
  • message is 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.issues reports them in, which Zod produces in the order fields are declared in the schema (top to bottom), so details is 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. details is 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, then id as 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_at are never included, regardless of purge state.

  • Response:

    {
      "data": [ /* Plant[], see Section 10.2 */ ],
      "meta": {
        "timezone": "America/Chicago",
        "today": "2026-08-05",
        "count": 2
      }
    }

    meta.timezone echoes the configured APP_TIMEZONE (Section 21). meta.today echoes todayInTimeZone(new Date(), APP_TIMEZONE) (Section 8.4) as computed by the server for this response. meta.count is data.length.

  • Why today is 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). Echoing today lets the client detect that local midnight has passed even if the visibilitychange/timer refresh in Section 16.5 has not yet fired, by comparing the previous response's meta.today to the new one and forcing a full status recomputation when they differ.

  • An empty plant list returns 200 with "data": [] and "count": 0, never a 404.

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:

  1. Validate the body against the shared CreatePlantInput Zod schema (Section 9). On failure, return 400 VALIDATION_FAILED with one details entry per failing field; no database work happens.
  2. Count active plants (deleted_at IS NULL). If the count is already 500, return 422 LIMIT_EXCEEDED and stop. Creating the plant that brings the count from 499 to 500 succeeds; only the attempt that would exceed 500 is rejected.
  3. Default lastWateredOn: if omitted, use todayInTimeZone(new Date(), APP_TIMEZONE) (Section 8.4). If supplied, it must pass the validation in Section 9 (not in the future, not before 1970-01-01).
  4. 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).
  5. Generate id as a UUIDv7 (application-side, per Section 4).
  6. Insert the plants row: id, name, watering_interval_days, notes (or NULL), last_watered_on, created_at = now, updated_at = now, deleted_at = NULL.
  7. Insert one waterings row 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) and wateringCount never disagree with lastWateredOn: every plant's history contains at least one entry, and that entry is always the most recent lastWateredOn at the time it was set. Without it, a brand-new plant would show wateringCount: 0 while simultaneously reporting a lastWateredOn, which is an unexplained inconsistency to the user.
  8. Commit the transaction. Both inserts happen inside one better-sqlite3 synchronous 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.
  9. Return 201 with the created Plant DTO under data, a Location: /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 with 400 VALIDATION_FAILED and error code UPDATE_EMPTY_BODY before the shared UpdatePlantInput schema (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 omitted notes field, 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" name or wateringIntervalDays (they are always required to have a value); notes distinguishes "omitted" (left unchanged) from an explicit "notes": null or "notes": "" (both of which clear it to null), per the shared NotesSchema in Section 9.4.
  • Derived values: nextDueOn, daysUntilDue, daysOverdue, and status are never sent in the request and are always recomputed fresh in the response from whatever lastWateredOn and wateringIntervalDays are in effect after the update. Section 8.8: shortening wateringIntervalDays can make a previously-upcoming plant instantly overdue; this is correct behaviour and is never clamped or blocked.
  • updatedAt: set to now whenever 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 a lastWateredOn different from the plant's current value, the API reconciles the watering history in the same transaction as the plant update:
    • It upserts a waterings row for the new date: if a row with watered_on equal to the new date already exists for this plant, nothing further happens to waterings; otherwise a new row is inserted with a fresh UUIDv7 id and created_at = now.
    • It never deletes any existing waterings row as a side effect of this upsert, even if the new lastWateredOn is earlier than existing entries. The history is a append-mostly log; editing lastWateredOn is 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_on is set to exactly the submitted value, regardless of whether it is chronologically the newest entry in waterings. nextDueOn is always computed from plants.last_watered_on, per Section 8.2, so this is what drives due-status.
    • There is no floor relating lastWateredOn to createdAt: an update may set lastWateredOn to any date that passes the Section 9 validation (not in the future, not before 1970-01-01), including a date earlier than the plant's own createdAt. A user correcting a plant they added late is a normal, expected case.
  • Optimistic concurrency: the request may include an If-Unmodified-Since header. 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 returns 400 VALIDATION_FAILED with details: [{ "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 with 409 CONFLICT and the plant is not modified; the response body's error.message is "The resource was modified by another request. Reload and try again." and data is omitted (error responses never carry a data key, only the envelope in Section 13.2).
    • If they match, the update proceeds normally.

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.

  1. The server looks up an active plant by id (WHERE id = ? AND deleted_at IS NULL). If none is found, return 404 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.
  2. Set deleted_at = now and updated_at = now.
  3. Return 204 No Content with an empty body.
  4. The plant disappears from GET /api/v1/plants on the very next fetch (it was excluded by the query in Section 10.3 the instant deleted_at was set; there is no propagation delay).
  5. Watering history rows for the plant are left untouched at this point; they are only removed at permanent purge time (step 7 below).
  6. 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).
  7. A maintenance task, running once at process start and then every 24 hours, permanently purges any plant whose deleted_at is more than 30 days in the past. Purging a plant hard-deletes the plant row and, via ON DELETE CASCADE on waterings.plant_id (Section 7.4), all of its watering rows. Purge is irreversible; there is no endpoint to recover a purged plant.
  8. A second DELETE on the same plantId (whether immediately after the first or any time before purge) returns 404 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.

  1. 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 — return 404 NOT_FOUND.
  2. 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_FOUND because the row is gone. The specification does not distinguish these 404 cases in the response; both simply mean "nothing to restore."
  3. Set deleted_at = NULL and updated_at = now.
  4. 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.
  5. Return 200 with the full, restored Plant DTO under data (with recomputed nextDueOn, daysUntilDue, daysOverdue, status based on the plant's unchanged lastWateredOn and wateringIntervalDays — time has passed since deletion, so a plant that was upcoming when deleted may now be overdue; 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:

  1. Look up the plant by id where deleted_at IS NULL. Not found → 404 NOT_FOUND.
  2. Attempt to insert a waterings row: fresh UUIDv7 id, plant_id, watered_on = today, created_at = now.
  3. If the insert succeeds (no conflict with the unique index on (plant_id, watered_on) from Section 7.4): update plants.last_watered_on = today and plants.updated_at = now.
  4. 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.
  5. Commit. Both the insert and the plant update happen in one better-sqlite3 transaction.
  6. Return 200 (not 201: 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 under data, so the client needs no second request to refresh the row it just acted on. meta.wateringId is the id of the waterings row this call created — or, on the idempotent path (Section 11.2), the id of the pre-existing row for (plantId, today). meta.alreadyWateredToday is always present in this response and is false on 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 200 with meta.alreadyWateredToday: true and meta.wateringId set to the id of the pre-existing (plant_id, watered_on) row — the same value the first call returned, not a new one.
  • No duplicate waterings row is created.
  • plants.updated_at is not changed by the second call, because step 3 above only runs when the insert in step 2 actually happened. The response's data.updatedAt reflects whenever the row was last genuinely modified (which may be the first watering call, an earlier edit, or plant creation).
  • When alreadyWateredToday is true, 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.

  1. 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.
  2. Delete the row.
  3. Recompute plants.last_watered_on, but only when the deleted row's watered_on equals the plant's current last_watered_on. If the deleted row was not the plant's current last_watered_on — for example, an older row left behind after a backdating edit (Section 10.5) — last_watered_on is left untouched, since the deleted row was never the value driving the due calculation.
    • When a recompute is needed and other waterings rows remain for the plant, set last_watered_on to the maximum (most recent) watered_on among the remaining rows.
    • When a recompute is needed and none remain (this was the plant's only watering record), set last_watered_on to the earlier of the plant's created_at (truncated to its calendar date in APP_TIMEZONE) and the plant's current last_watered_on. This floor rule (Section 8.2: lastWateredOn is never null) can therefore only preserve or increase how overdue the plant appears — it never advances last_watered_on to a later date, matching the rule that nothing silently un-overdues a plant (Section 12.8).
  4. Set plants.updated_at = now.
  5. 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_on descending, then created_at descending 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.total is the full count of waterings rows for the plant, independent of limit/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 same limit and offset advanced by limit, appending results to the list already rendered. The control is hidden once offset + 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.

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 name only. notes is 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 name before 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):

  1. 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).
  2. A tinted background surface for the whole row, using the paired surface-tint token for that status.
  3. The status badge from Section 12.6, which combines an icon and a text label.
  4. For overdue specifically, 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 a v2 API is explicitly a rejected non-requirement.
  • Casing: all JSON keys, in requests and responses, are camelCase. The database uses snake_case (Section 7); a repository/mapper layer converts between the two, and snake_case never appears on the wire.
  • Content type: the media type must be application/json; a charset=utf-8 parameter is permitted (and is what every response sets). A request with a body whose media type is anything else is rejected with 415 UNSUPPORTED_MEDIA_TYPE before the body is parsed.
  • Non-object JSON bodies: a request body that parses successfully as JSON but is not a JSON objectnull, an array, a string, a number, or a boolean — is rejected with 400 MALFORMED_JSON and message id MALFORMED_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 yields VALIDATION_FAILED instead).
  • Trailing slashes: rejected. Fastify is configured with ignoreTrailingSlash: false; a request to /api/v1/plants/ (trailing slash) returns 404 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 with 413 PAYLOAD_TOO_LARGE before 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 */ }
}
  • meta is omitted entirely when an endpoint has nothing to put in it (e.g. GET /api/v1/health returns only data).
  • HTTP status is 200 for reads and updates, 201 for creates, 204 with a fully empty body (no data, no meta, 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.details is an array and is omitted entirely (not an empty array) when there is nothing field-specific to report — e.g. NOT_FOUND never has details.
  • error.requestId is always present, set from the X-Request-Id value 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 data key.

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 shared PlantIdSchema (Section 9) so the API and the SPA's route matching can never diverge; malformed input returns 400 VALIDATION_FAILED with details: [{ "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: plantId as 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). A plantId that has never existed, or that references a permanently purged plant, returns 404 NOT_FOUND.
  • Query parameters: limit (integer, 1–100, default 20), offset (integer, ≥ 0, default 0).
  • See Section 11.4 for the DTO, ordering, and meta shape.
  • Errors: VALIDATION_FAILED (malformed id or out-of-range limit/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 as plantId elsewhere.
  • 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 where wateringId belongs 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) and GET /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-3f7g1b4d8c22
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-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-5h9i3d6f0e44
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": { "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-8k2l6g9i3h77
HTTP/1.1 204 No Content
X-Request-Id: 2g7k5h9g-8l3i-0i5g-5j7h-8k2l6g9i3h77
Cache-Control: no-store

8. 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:

  1. 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 to navigate("/", { replace: true }) when it is not (e.g. the user arrived via a deep link and there is no in-app "back").
  2. Closing a modal navigates back rather than pushing a new entry. At md+, /plants/new and /plants/:plantId/edit render as modals layered over the route the user came from. The backdrop, the "Cancel" button, and the Escape key all call navigate(-1). They never call navigate("/") directly, so a user editing a plant from the detail screen returns to that plant's detail screen, not to the list.
  3. A successful create navigates to / (not to the new plant's detail) and shows a success toast naming the planttoast.plantAdded (§15.11): "{plantName} added". This keeps the list, the screen the user checks most often, as the landing point after every add.
  4. A successful edit navigates back to where the user came from, defaulting to /. The form route reads its entry point from location.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); if location.state.from is absent (direct URL entry), it defaults to /plants/:plantId.
  5. 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).
  6. Every internal link is a React Router <Link> (or navigate()); 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) at md+. 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 when meta.accessCodeEnabled is true — activating it calls DELETE /api/v1/session and navigates to /unlock — the ThemeToggle, and — at md+ only — the header's "Add plant" button (AddPlantButton rendered in header mode; at base/sm the 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 of calc(88px + env(safe-area-inset-bottom)) at base/sm to clear the floating action button and any device home-indicator gesture area, 24px at md+ (no FAB to clear at that breakpoint).
  • Toast regionToastRegion, rendered as a sibling of <main> at the AppShell level 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:

  1. Eager (bundled in the main chunk): / (PlantListRoute) and AppShell — 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).

  2. Lazy, sharing one chunk: /plants/new, /plants/:plantId, and /plants/:plantId/edit. All three render through PlantFormRoute or PlantDetailRoute, both of which import from the same components/plant-form/ and components/plant-detail/ modules; grouping them keeps the PlantForm code (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 in vite.config.ts instead of in the import() 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 into plant-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 #

  1. 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.
  2. 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).
  3. 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).
  4. 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).
  5. 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): overdueAlertTriangle, due_todayDroplet, due_soonClock, upcomingCheck.

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/new and /plants/:plantId/edit render 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-auto

The plant list grid container adds, at md+ only:

md:grid md:grid-cols-2 md:gap-4

Focus 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 Router Link whose 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; the Link carries after: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. The StatusBadge sits to the right of the name, right-aligned, shrink-0 so it never gets squeezed by a long name.
  • Line 2: the WaterButton ("Watered today"), left-aligned, at least 44px tall, relative z-10 so it sits above the Link'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:

  1. Plain, calm, literal. No exclamation marks anywhere in the product.
  2. Second person ("your plants"), never third person about the user.
  3. 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.
  4. Sentences are short. Error and empty-state copy explains what happened and, where relevant, what to do next — never just "Error."
  5. 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, in text-sm text-danger, linked via aria-describedby.
  • Submit is never disabled for invalid input. The submit button is disabled only while the create/update mutation is in flight (isPending), rendering form.submit.pending ("Saving…"). Submitting an invalid form runs full-form validation, marks every field touched, 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), onSubmit returns immediately without calling the mutation or running validation — aria-disabled does not natively prevent a form's Enter-key submission the way the disabled attribute does, so the early return is the actual guard.
  • Unsaved-changes protection: two layers, both active whenever isDirty is true (§16.2's PlantForm state):
    1. A beforeunload listener (added only while dirty) that sets event.preventDefault() and event.returnValue = "" to trigger the browser's native "leave site?" prompt, covering tab close/refresh/external navigation.
    2. A React Router useBlocker call that intercepts in-app navigation attempts (back button, <Link> clicks, programmatic navigate() calls elsewhere) while dirty, and opens the form.unsavedChanges ConfirmDialog (§15.11) instead of navigating immediately. Confirming "Discard" proceeds with the blocked navigation; "Keep editing" cancels the blocker and returns focus to the form.

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:

  1. A toast carrying an action (e.g. Undo) that was produced by a keyboard-activated control (event.detail === 0 on the originating click, §16.4) moves focus to that toast's action button the moment it mounts.
  2. 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.
  3. 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.
  4. 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:

  1. Tap. WaterButton's onClick records event.detail === 0 (keyboard vs. pointer activation, §16.4) and calls useWaterPlant(plantId, timezone).mutate().
  2. Local cache patch. onMutate (§16.4) snapshots the current cache, then optimistically sets lastWateredOn to today (via todayInTimeZone(new Date(), timezone), never a bare new Date().toISOString(), Section 8) and recomputes status/daysUntilDue/daysOverdue/ nextDueOn via computeScheduleView in both the ['plants'] list entry and the ['plants', plantId] detail entry.
  3. Button enters pending state. WaterButton reads isPending from the mutation object and shows the plantRow.waterPending label with aria-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.
  4. Request. POST /api/v1/plants/:plantId/waterings (Section 11.2, Section 13) is sent.
  5. Success. The server DTO in the response replaces the optimistic patch in cache (onSuccess, §16.4) — the server's status/nextDueOn/daysUntilDue are 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.watered with an Undo action (10s), unless alreadyWateredToday is true, in which case toast.alreadyWatered appears with no Undo action (there is nothing new to undo).
  6. Undo (optional). Tapping Undo — reachable by keyboard within two Tab presses of the water button, per §16.7's keyboard-reachability rule — calls useDeleteWatering(plantId, wateringId), using the wateringId returned in the original mutation's meta (§16.4), never a value read off the Plant DTO. Its onMutate optimistically removes the just-created history entry; the server then recomputes last_watered_on only if the deleted row's watered_on equalled the plant's current last_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:

  1. Root boundaryErrorBoundary wraps the entire routed <Outlet /> inside AppShell (§16.1). It catches errors from anything React Router's own errorElement mechanism does not (e.g. errors thrown from AppShell itself, or from the toast/header chrome). It renders the full-screen error.boundary.* message with a Reload button.
  2. Per-route boundary — every route entry in the router config sets errorElement: <RouteError />. React Router replaces just that route's element subtree with RouteError on a thrown error or a rejected loader, leaving AppShell (header, toast region) intact — so, for example, a crash rendering PlantDetailRoute still 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 ErrorResponse with status === 404 (thrown deliberately by a loader, or by the route-param shape/existence check in §14.2) → renders the same content as NotFoundRoute.
  • An error whose message matches the dynamic-import() failure signature (Failed to fetch dynamically imported module or equivalent, thrown when a lazy route chunk 404s — the classic "app was redeployed while this tab was open" case) → the error.chunkLoad.* copy, with special handling: on first occurrence, the app automatically reloads once (sessionStorage flag hpt.chunkReload prevents a reload loop) unless a mounted PlantForm reports isDirty (§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.memo on PlantRow with 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., ListControls search state changes and only the filtered subset passed to PlantList differs in membership, not in content.

  • Stable callbacksPlantList (the container) creates row-level callbacks (navigate, water) with useCallback, keyed by nothing that changes per-render, so they do not defeat the PlantRow memo comparator above by producing a new function identity every render. Concretely, PlantRow reads plant.id itself when calling navigate/mutate rather 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 PlantRow memo/keying strategy above, and complicate the CSS Grid two-column layout at md+ (§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-height 56px, 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: below SummaryBar, a single row containing the search input (flex-1, rendered only when the active plant count exceeds 10, §12.5) and a SlidersHorizontal icon button (44×44px, aria-label="Sort & filter") that opens a bottom sheet (a ConfirmDialog-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 of PlantRow cards (§15.6's two-line layout), gap-2 between them, p-4 outer 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, Plus icon, aria-label="Add plant", aria-disabled="true" when plants.length >= 500 (§16.8). Sits above the toast region's z-index so a toast never covers it, and <main>'s calc(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" plus Plus icon) at the right, before the theme toggle; the FAB is not rendered at md+ (§14.5).
  • SummaryBar and ListControls remain full-width single rows (search — when shown — and both selects visible inline, no disclosure — there is room).
  • PlantList becomes 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-wateredlastWateredOn 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-waterstatus 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:

  1. Name (<input type="text">) — label form.name.label, placeholder form.name.placeholder, no maxlength attribute (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 reports form.error.nameTooLong on 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 special inputmode). 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, not text-danger — Section 9.3's rule that duplicates are allowed) alongside a small AlertCircle icon; it does not block submission.
  2. 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.label sits to the left of the stepper control and the unit text form.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-sunken at rest, bg-accent text-text-inverse when 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]*".
  3. Last watered (LastWateredField, §16.2) — label form.lastWatered.label, a native <input type="date"> defaulting to today (YYYY-MM-DD in APP_TIMEZONE, sourced from useMeta().data.today — never a value derived from new Date() in the browser, Section 8), max={today} (mirroring the Section 9 "not in the future" rule at the HTML level), min attribute 1970-01-01. Below it, two quick-pick chips: form.lastWatered.today (sets today) and form.lastWatered.yesterday (sets addDays(today, -1), Section 8.4), same visual treatment as the interval chips.
  4. Notes (<textarea>) — label form.notes.label (already indicates optional), placeholder form.notes.placeholder, min-h-[96px], resizable vertically only, no maxlength attribute (removed for the same reason as Name — a hard attribute cap would make NOTES_TOO_LONG and form.error.notesTooLong unreachable 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's aria-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
    1. At 2000 the counter turns text-danger; the Zod schema (Section 9) remains the sole validation authority, reporting form.error.notesTooLong on blur/submit. No special inputmode; default keyboard.

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 by formatRelativeDueLabel). This headline, not the badge, is what a screen reader announces for the plant's status.
  • If the plant's repaired flag (Section 10.2) is true, a text-text-secondary line 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 in D MMM YYYY format (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, and notes are populated from the loaded plant (usePlant(plantId)) as initialValues (§16.2). While the plant is still loading, the form renders a SkeletonText-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-Modified response header value (Section 13.6) alongside the field values. On submit it sends that stored value back as the If-Unmodified-Since request header (Section 10.5). A 409 CONFLICT response (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">, label unlock.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 copy unlock.error.invalid ("That code didn't work. Try again.") and never the server's error.message — deliberately generic, never confirming or denying anything about the code's format or closeness, consistent with the threat model in Section
    1. Rendered in an aria-live="assertive" region above the field (§16.2).
  • On lockout (a 429 from the 5-attempts-per-15-minutes rule, Section 20.5), the response's Retry-After header (seconds) drives the copy: Math.ceil(retryAfter / 60) === 1 renders unlock.error.lockedOutOne ("Too many attempts. Try again in 1 minute."), otherwise unlock.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" calls POST /api/v1/import directly.
  • Submitting with mode: "replace" first opens a destructive-variant ConfirmDialog (§15.7.9): title settings.import.confirmReplace.title, body settings.import.confirmReplace.body, confirm settings.import.confirmReplace.confirm (destructive), cancel settings.import.confirmReplace. cancel (gets initial focus, per §17.5's rule). Confirming proceeds with the POST.
  • 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; a VALIDATION_FAILED response whose details[].path names 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:

  1. loading-initial, empty, ready, and error are mutually exclusive — a surface is in exactly one of these four at a time, determined by the query status.
  2. loading-refresh is a modifier, not a replacement: when a background refetch is in flight, the surface stays in ready (or empty) and layers a refresh indicator on top (Section 18.3). It never demotes a ready surface to loading-initial.
  3. offline is also a modifier. A surface can be ready and offline simultaneously (last-known data shown, offline banner visible, mutations disabled). A surface cannot be offline and loading-initial with a spinner spinning forever — if the initial load fails because the client is offline, the surface resolves to error with the offline-specific message (Section 18.5), not an infinite loading-initial.
  4. Forms (add/edit) do not have empty; their six-state row in the matrix below uses n/a for 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:

  1. Start a 250 ms timer when the async action begins (the pending label, aria-busy, and disabled state are already showing by this point).
  2. If the action resolves before the timer fires, no spinner glyph is ever shown.
  3. If the timer fires first, add the spinner glyph alongside the already-visible pending label.
  4. 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:

  1. Never render error.code or error.message from 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.
  2. 400-class validation and conflict messages (VALIDATION_FAILED, CONFLICT field-level detail) are safe to render verbatim because the server contract guarantees they are written for end users (Section 13.2).
  3. A retry action, where offered, re-issues the exact same request (same method, path, body). It never silently changes the request.
  4. 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 its X button 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:

  1. The browser's navigator.onLine value, updated on the online and offline window events.
  2. Fetch-failure classification: any fetch() rejection with TypeError (the browser's generic network-failure signature) increments a counter. For queries, two consecutive network-classified failures across any query set isOffline = true. For mutations, a single network-classified failure is sufficient to set isOffline = true immediately — 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 if navigator.onLine still reports true (captive portals and some VPN states report navigator.onLine: true while unreachable). A single successful response of any status code (including 4xx/5xx — those still prove connectivity) resets the counter to 0 and clears isOffline.

UI while offline.

  • A persistent banner renders at the top of the viewport, below any header, full width, using the overdue red token pair's surface tint (not the text color, to avoid visually competing with real overdue plants) with neutral text and an WifiOff icon: "You're offline. Changes can't be saved right now." The banner has role="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 native disabled attribute while isOffline === true, so the control remains keyboard-focusable (Section 19.8). Its click/Enter/Space handler 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, and PlantForm's onSubmit specifically, must check isOffline and return before doing anything else. Activating an aria-disabled control 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 its id (search's aria-label in 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 id is included in the input's aria-describedby, appended to (not replacing) any existing aria-describedby value.
  • Errors. When a field fails validation, an error message element is rendered with a stable id, and that id is added to the input's aria-describedby (alongside any hint id, space-separated), and the input receives aria-invalid="true". When the field becomes valid again, aria-invalid is removed (not set to "false" — its absence is the correct default per the ARIA spec) and the error id is removed from aria-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 the role="alert" region, but the element that receives focus is a plain <div tabindex="-1"> wrapper around it, not the role="alert" element itself — focusing an element with role="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 native disabled attribute — 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-strongsurface-raised, border-defaultsurface-raised, and focus-ringsurface-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: reduce is set, implemented via a single Tailwind motion-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 no title-attribute truncation anywhere in the product, since title is 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-react SVG 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):

  1. 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.
  2. 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).
  3. Keyboard-navigate into edit plant, change the interval, submit, and confirm focus returns to plant detail's h1 with updated values visible.
  4. Keyboard-trigger delete, confirm the modal traps focus (Tab cannot leave it), confirm Escape closes it without deleting, then confirm delete via Enter and verify focus lands per 19.4.
  5. 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 Tab presses and that the toast does not auto-dismiss while it holds focus (19.4).
  6. 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.
  7. 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.
  8. 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.
  9. Enable prefers-reduced-motion: reduce at 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 the Referer header 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 reads localStorage['hpt.theme'] (Section
    1. 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 resulting sha256-... value is the only inline script CSP permits. The build computes this hash in apps/web/vite.config.ts via a small plugin that reads the literal script string, computes sha256 over its UTF-8 bytes, base64-encodes the digest, and writes it to apps/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-src allows data: for the small inline SVG icons rendered as data: 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).
  • connect-src 'self' permits fetch() 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-src falls back to default-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 via fetch() 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 with X-Frame-Options: DENY for 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/session accepts { "accessCode": string }, validates it, and on success sets the session cookie and returns 201 with { "data": { "unlocked": true } }. DELETE /api/v1/session clears the cookie and returns 204, 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-Age representing a 30-day rolling expiry (reset on every authenticated request, so an actively used session never expires mid-use), and Secure set whenever the request was received over HTTPS or APP_TRUST_PROXY is true and the proxy reports X-Forwarded-Proto: https (20.4's HSTS logic uses the same detection). The cookie's value is a signed token (HMAC-SHA256) keyed by APP_SESSION_SECRET, containing an issue timestamp and sha256(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 configured APP_ACCESS_CODE. This makes rotating a leaked access code self-enforcing: an operator who changes APP_ACCESS_CODE because 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_SECRET is 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 the settings table (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_LIMITED with the standard envelope and must include a Retry-After header in seconds (Section 13.7's mechanism, reused here rather than duplicated); the unlock screen renders the countdown copy unlock.error.lockedOut — "Too many attempts. Try again in {minutes} minutes." — using Math.ceil(retryAfter / 60), with a singular variant unlock.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/health and GET /api/v1/health/ready (so container orchestration liveness and readiness checks keep working regardless of lock state, Section 22.5), and GET /api/v1/meta (the frontend needs accessCodeEnabled before 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 returns 401 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-sqlite3 prepared statements with bound parameters exclusively; no string-built SQL exists anywhere in the codebase (Section 7), with a single documented exception: VACUUM INTO in lib/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 dangerouslySetInnerHTML API 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_CODE unset, 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 under APP_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_CODE and APP_SESSION_SECRET are never logged, including in the startup configuration summary (Section 21.3), which explicitly masks both.
  • The cookie and authorization request 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_SECRET is 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 ci exclusively in CI and Docker builds (never npm install), against a committed package-lock.json, so builds are reproducible and no floating version can silently introduce a compromised transitive dependency.
  • npm audit --audit-level=high runs 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 #

  1. 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).
  2. The CSP header contains no unsafe-inline and no unsafe-eval anywhere in script-src.
  3. 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).
  4. robots.txt and X-Robots-Tag are both present and correctly block all indexing.
  5. No dangerouslySetInnerHTML usage exists in the frontend codebase (enforced by the ESLint rule failing CI if violated).
  6. No string-concatenated SQL exists in the backend codebase outside the documented VACUUM INTO exception 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.
  7. npm audit --audit-level=high passes with zero unresolved high/critical advisories at release time.
  8. With APP_ACCESS_CODE unset, no Set-Cookie header appears on any response.
  9. With APP_ACCESS_CODE set, the unlock endpoint enforces lockout after 5 failed attempts within 15 minutes, verified by an automated test.
  10. With APP_ACCESS_CODE set, the session cookie carries HttpOnly, SameSite=Strict, and (over HTTPS) Secure, verified by an automated test inspecting the Set-Cookie header.
  11. Both GET /api/v1/health and GET /api/v1/health/ready return 200 without a valid session even when APP_ACCESS_CODE is set, verified by an automated test that sets the access code and calls both endpoints without a session cookie.
  12. Request bodies over 64 KB (or 5 MB for import) are rejected with 413 PAYLOAD_TOO_LARGE before being written to the database, verified by an automated test.
  13. 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.
  14. APP_SESSION_SECRET and APP_ACCESS_CODE values never appear in the startup log or in any API response, verified by a test that sets both and inspects all captured output.
  15. The export endpoint's Content-Disposition filename is the fixed constant regardless of any request input, verified by an automated test.
  16. npm ci (not npm install) is the installation command used in every CI job and the Dockerfile, verified by a grep-based CI lint step.
  17. Loading / in a real browser produces zero Content-Security-Policy violations in the console, including the /manifest.webmanifest request (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.000Z

21.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 computed status and daysUntilDue/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 was due_today under the old zone might become upcoming or overdue under the new one if the two zones are on different calendar dates at that instant. This is expected and correct — the stored lastWateredOn values 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/-shm sidecars) to the new path, then update APP_DATABASE_PATH and restart — never change the variable without also moving the file.
  • APP_SESSION_SECRET — changing this while APP_ACCESS_CODE is 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 to stdout. No logger writes to a file directly — the container runtime (Section 26) is responsible for capturing stdout.
  • 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-Id header 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's requestId for the remainder of its lifecycle.
  • Otherwise, a new UUIDv7 is generated (the same generation mechanism used for primary keys, Section 7.3).
  • The resolved requestId is always echoed back in the X-Request-Id response header and always appears in the requestId field of every error envelope (Section 13.2), so a user-reported requestId from 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:

  1. If the caught error is an AppError, maps it directly to the canonical envelope (Section 13.2) using its code, status, message, and details, and logs it at warn level with event set to a value matching the error's origin where one of the catalogued events (22.2) applies (e.g. a LIMIT_EXCEEDED on plant creation still logs under the request's normal http.response line — no separate event exists for expected validation/business-rule rejections, since they are not failures of the system).
  2. If the caught error is anything else (an unexpected exception — a bug), it is logged at error level with event: "error.unhandled" and the full serialised error (message, stack, name), and the response sent to the client is always the generic INTERNAL_ERROR envelope 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.
  3. 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 #

  1. 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 responds 200, the process is alive and the problem is likely network/proxy-level, outside this app.
  2. App responds but looks broken (errors on every action). Check GET /api/v1/health/ready — a 503 names the specific failing check (database, migrations, or diskWritable) and its reason.
  3. A specific action is failing for one user report. Ask for the requestId shown 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).
  4. Suspected disk space issue. Check diskWritable under /health/ready first; if it is failing, check the host volume backing APP_DATABASE_PATH and APP_BACKUP_DIR for free space.
  5. Suspected bad deploy (migrations out of sync). Check the migrations field under /health/ready and cross-reference db.migrate.applied log lines from the most recent server.start against the migration files actually present in the deployed image (Section 7, Section 26).
  6. Unexpected lockouts reported. Search for unlock.locked_out and unlock.failure events filtered by ip to distinguish a legitimate user who mistyped their code repeatedly from a brute-force attempt from an unfamiliar IP.
  7. Backups look stale. Search for the most recent backup.completed or backup.failed event; a backup.failed line's reason field 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 dynamic import() chunk, so the initial bundle contains only what the first paint needs.
  • React.memo on list rows. Each plant row component is wrapped in React.memo with 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-display concerns 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 in index.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: auto on 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-sqlite3 opens 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_at and waterings.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-sqlite3 is 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:

  1. Scheduled run — the first poll at or after 03:15 local time in APP_TIMEZONE on 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 the 03:15 boundary by process load or a paused event loop still fires, so one missed tick never skips that day's backup.
  2. Catch-up run at process start — if the timestamp recorded in the settings table under key last_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:

  1. 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.
  2. The snapshot file is opened read-only with fileMustExist: true.
  3. PRAGMA integrity_check is run against the snapshot. The acceptance rule requires it to return exactly one row with the value ok. Any other result (multiple rows, or a row that is not ok) is a failure.
  4. Row counts are read from the snapshot for plants and waterings.
  5. Acceptance rule: the snapshot passes verification only if integrity_check returned ok and 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 than countsBefore, 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.
  6. On any verification failure, the snapshot file is deleted immediately (a corrupt file must never count toward APP_BACKUP_RETAIN or be discoverable as a restore candidate) and the failure is re-logged as backup.failed with the specific reason (integrity_check_failed or row_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 that id already exists it is left completely untouched (not updated, not overwritten); if no row with that id exists, it is inserted. merge never deletes anything.

Processing rules:

  1. The request body is capped at 5 MB (Section 5). A larger body is rejected before parsing with 413 PAYLOAD_TOO_LARGE.
  2. The body is validated against ImportPayloadSchema (Section 9.4). A formatVersion other than 1 is rejected with 400 VALIDATION_FAILED and the message "Unsupported export format version {n}. This server supports formatVersion 1." — no attempt is made to interpret an unrecognized version.
  3. Before any write, the combined size of existing active plants (for merge) or the incoming file (for replace) against the 500-plant cap is checked: if applying the import would leave more than 500 active plants, the whole import is rejected with 422 LIMIT_EXCEEDED and no row is written.
  4. An automatic snapshot is taken (reusing the exact mechanism in Section 24.2, VACUUM INTO plus verification) immediately before the import transaction begins, named app-YYYY-MM-DD-HHmmss-pre-import.db so it is visually distinguishable from a scheduled snapshot. It counts toward APP_BACKUP_RETAIN pruning like any other file matching the snapshot pattern — the SNAPSHOT_NAME_RE pattern in Section 24.2 already matches the -pre-import suffix.
  5. Any incoming plant whose lastWateredOn is in the future relative to todayInTimeZone(new Date(), APP_TIMEZONE) (server APP_TIMEZONE, never the file's own timezone field) is clamped to todayInTimeZone(new Date(), APP_TIMEZONE) before insertion. This clamp runs in this route handler, after schema validation, never inside ImportPayloadSchema itself — a future date sent directly to POST /api/v1/plants is still a hard 400 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 an IMPORT_FUTURE_DATE_CLAMPED entry to the warnings array in step 7's response.
  6. The entire load — delete-then-insert for replace, or the existence-check-then-insert loop for merge — runs inside a single better-sqlite3 transaction (db.transaction(fn)()). A SQLITE_CONSTRAINT* error during that transaction (an unexpected data shape that slipped past schema validation) is caught, the transaction rolls back automatically, and the response is 400 VALIDATION_FAILED with details: [{ "path": "plants.{i}", "message": "This record could not be imported." }] identifying the offending array index where it can be determined, rather than a bare 500 that wrongly implies a server-side fault. Any other, genuinely unexpected error during the transaction still produces 500 INTERNAL_ERROR. Either way, no partial data is ever written — the pre-import snapshot from step 4 remains available as a safety net regardless.
  7. 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:

  1. Selects every plant where deleted_at IS NOT NULL AND deleted_at < (now - 30 days).
  2. Deletes those plants (cascading to their waterings) inside a single transaction.
  3. Logs purge.completed with the count of plants purged and their ids, or purge.completed with a count of 0 when 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.
  4. Writes settings.last_purge_run_at to the current instant (Section 7.8), using the same INSERT ... ON CONFLICT DO UPDATE pattern as writeLastBackupRunAt (Section 24.2), including the updated_at column.

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/domain that needs "now" receives it as an explicit parameter (today: string or now: Date). None of them reads the system clock internally.
  • apps/api/src/server.ts accepts an injectable now: () => Date at buildServer() 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 fixed now across 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, because status is always computed server-side (Section 8.4): the E2E test server (Section 25.9) is started with APP_TIMEZONE=America/Chicago and with APP_FAKE_NOW=2026-08-05T12:00:00.000Z — the same instant page.clock.install uses — set in its environment. apps/api/src/config.ts honours APP_FAKE_NOW only when NODE_ENV=test; if it is set under any other NODE_ENV the 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 additional GET /api/v1/plants beyond 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.js adds a no-restricted-syntax rule scoped to packages/shared/src/domain/** and apps/web/src/hooks/** that forbids a bare new Date() (zero arguments) and any Date.now() call. A violation fails npm 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:

  1. Lighthouse CI (lhci autorun), run against the built apps/web/dist served locally, against the performance budget defined in Section 23.2.
  2. Bundle-size assertion (npm run size-check in apps/web), comparing the built JS and CSS bundle sizes against the ceilings in Section 23.2 and failing the build if either is exceeded.
  3. API benchmark, run against a database fixture seeded to the 500-plant cap (the maximum supported size, Section 9), measuring GET /api/v1/plants response 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):

  1. Load the app with no plants; confirm the empty state renders.
  2. Add a plant with a name, an interval, and notes; confirm it appears in the list.
  3. Add a second plant with no notes; confirm notes are optional and nothing breaks.
  4. Confirm the new plants show the status expected for "just watered today."
  5. Edit a plant's name; confirm the change is reflected immediately.
  6. Shorten a plant's interval until it becomes overdue; confirm the status and styling update.
  7. Tap "Watered today" on the overdue plant; confirm it returns to an upcoming/due status.
  8. Tap "Watered today" a second time immediately; confirm nothing breaks and no duplicate history entry appears.
  9. Open the plant's watering history; confirm the entries are listed newest-first.
  10. Delete a plant; confirm the Undo toast appears and clicking it restores the plant.
  11. Delete a plant and let the Undo toast expire; confirm it stays deleted after a page reload.
  12. Toggle dark mode; confirm the whole UI (not just some components) switches theme.
  13. Resize the browser to a narrow mobile width; confirm the layout remains usable and touch targets look appropriately sized.
  14. Export the data; open the downloaded file and confirm it is valid JSON with the expected plants.
  15. 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:

  1. npm ci — installs all three workspaces from the committed lockfile.
  2. Build packages/shared: tsc -p packages/shared/tsconfig.json, emitting compiled JS and .d.ts files to packages/shared/dist, because both apps/api and apps/web import it as a built package, not as raw TypeScript source. This runs before typecheck, lint, and test below, all of which import from packages/shared and would otherwise resolve against a dist directory that does not exist yet.
  3. npm run typechecktsc --build across the workspace project references, no emit.
  4. npm run lint — ESLint 9 flat config across all workspaces.
  5. npm test — unit and API integration tests (Section 25.2).
  6. Build apps/web: vite build, emitting the static site to apps/web/dist.
  7. Build apps/api: bundled with esbuild (chosen over plain tsc because the API needs a single fast bundling step that can mark better-sqlite3 external while still bundling packages/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:pino

Resulting 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: 128M

ports 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.js

npm 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.target

houseplant-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:

  1. Provision a host with Docker and Docker Compose installed.
  2. 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).
  3. Copy .env.example to .env and set at least APP_TIMEZONE (Section 21) to the operator's real timezone.
  4. docker compose up -d --build.
  5. Wait for the container to report healthy: docker compose ps shows healthy in the STATUS column.
  6. Verify: curl -fsS http://localhost:8080/api/v1/health/ready.
  7. Verify .env actually took effect: curl -fsS http://localhost:8080/api/v1/meta and confirm timezone matches the value just set in step 3.
  8. Put the deployment behind a reverse proxy (Section 26.5) if it will be reachable beyond localhost or a private network.
  9. Do not link the URL publicly (Section 20.1, Section 26.10).

Upgrade:

  1. Take a manual backup first (Section 26.9), in addition to relying on the automatic nightly one.
  2. Pull or build the new image: docker compose pull (registry-based) or docker compose build --no-cache app (source-based).
  3. docker compose up -d.
  4. Migrations run automatically as part of API process startup, before the HTTP listener starts accepting connections (the migration runner and its schema_migrations table are defined in Section 7). Confirm success by checking the logs for a migrate.completed entry: docker compose logs app | grep migrate.completed.
  5. Verify curl -fsS http://localhost:8080/api/v1/health/ready and that curl -fsS http://localhost:8080/api/v1/health reports the new version (Section 26.7).
  6. 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:

  1. docker compose down.
  2. 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.
  3. Redeploy the previous image tag: set the prior version in .env or the compose file's image: line, then docker compose up -d.
  4. Verify health and plant count exactly as in Section 24.7's steps 7–8.

26.9 Day-2 runbook #

  1. Checking health:
    curl -fsS http://localhost:8080/api/v1/health/ready
    curl -fsS http://localhost:8080/api/v1/health
  2. Reading logs (structured JSON via pino; pipe through pino-pretty for a human-readable view):
    docker compose logs -f --tail=200 app | npx pino-pretty
  3. 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
  4. Restoring: follow the full runbook in Section 24.7.
  5. Changing the timezone:
    # edit APP_TIMEZONE in .env, then:
    docker compose up -d
    Changing APP_TIMEZONE does 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, because docker-compose.yml loads .env via env_file rather than hardcoding values, Section 26.3): curl -fsS http://localhost:8080/api/v1/meta and confirm timezone matches what was just set.
  6. Enabling the access code:
    # set APP_ACCESS_CODE (min 8 characters) in .env, then:
    docker compose up -d
    APP_SESSION_SECRET auto-generates and persists to the settings table on first boot after enabling, if not already set (Section 21). Changing APP_ACCESS_CODE itself — 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.
  7. Rotating the session secret:
    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
    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.
  8. 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
  9. 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:
    docker compose up -d
    SQLite has no fixed pre-allocation, so no app-level action is required beyond ensuring the mount point has room.
  10. Upgrading Node: bump the node:22-alpine tag in the Dockerfile to the new version, then:
    docker compose build --no-cache app
    followed by the full upgrade procedure in Section 26.8, including the pre-upgrade backup.

26.10 Operational checklist #

Before calling a deployment done, confirm every item:

  1. APP_TIMEZONE is set to the operator's actual timezone, not left at the UTC default (Section 21).
  2. 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.
  3. 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.
  4. The data volume survives container recreation: docker compose down && docker compose up -d, then confirm the plant count is unchanged.
  5. APP_BACKUP_ENABLED=true and ./data/backups (or the mounted equivalent) is writable.
  6. A manual backup has been taken once (Section 26.9, item 3) and its backup.completed log entry confirmed.
  7. If a reverse proxy is used, it forwards X-Forwarded-Proto, and APP_TRUST_PROXY is set to match (Section 21).
  8. HTTPS is terminated at the reverse proxy if the deployment is reachable over any network beyond localhost.
  9. The reverse proxy allows a 5 MB body specifically on the import path, even though the general limit is 1 MB (Section 26.5).
  10. The manual smoke test (Section 25.13) has been run once against the live deployment, not just against a local build.
  11. docker compose ps reports the container as healthy.
  12. APP_LOG_LEVEL is info in production, not left at a more verbose debug level.
  13. The resource limits in Section 26.3 are appropriate for the host's actual available CPU and memory.
  14. The deployed image tag or git commit is recorded somewhere the operator will find it again later (Section 26.7).
  15. APP_CORS_ORIGIN is left unset unless a second, specific origin genuinely needs API access.
  16. 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.
  17. A recurring personal reminder exists to copy APP_BACKUP_DIR to 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.
  18. The ./data directory (and its contents) is owned by uid 10001, matching the container's runtime user (Section 26.2, Section 26.8 step 2) — ls -ln ./data shows owner 10001.
  19. From a machine other than the host itself, curl http://<host>:8080/ fails to connect — the published port is bound to 127.0.0.1 only (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 a SCHEMA_VERSION constant).
  • apps/api/package.json, apps/api/tsconfig.json, apps/api/src/index.ts (bootstrap, calls buildServer() and listens), apps/api/src/server.ts (stub buildServer() returning a Fastify instance with one GET /api/v1/health route returning { "data": { "status": "ok" } }), apps/api/src/config.ts (stub: reads PORT, APP_HOST only; the base ConfigSchema is 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.

  1. Initialize the git repository and the root package.json with the three workspaces.
  2. Add tsconfig.base.json; each workspace tsconfig.json extends it.
  3. 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).
  4. Scaffold packages/shared with a single exported constant and a barrel index.ts.
  5. Scaffold apps/api with a minimal Fastify 5 server exposing only the health route.
  6. Scaffold apps/web with Vite 6 + React 19 + Tailwind CSS v4 (CSS-first config in apps/web/src/styles/tokens.css via @import "tailwindcss"), rendering a static heading.
  7. Scaffold e2e/ with a Playwright config pointed at the dev server and one smoke spec that loads / and asserts the heading text is visible.
  8. Add root npm scripts: typecheck, lint, lint:fix, format, format:check, test, build, dev, each fanning out to all workspaces (npm workspaces --workspaces flag or per-package scripts invoked via npm run <script> --workspaces --if-present).
  9. Add .github/workflows/ci.yml running 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.
  10. Write the initial README.md with project name, one-paragraph description, and placeholder run instructions to be filled in as later milestones land.
  11. 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 install completes with zero errors from a clean clone.
  • npm run typecheck passes with zero errors across all three workspaces.
  • npm run lint passes with zero errors and zero warnings.
  • npm run format:check passes with zero files needing reformatting.
  • npm test passes; every workspace's placeholder test is green.
  • npm run build produces apps/web/dist/ and apps/api/dist/.
  • npx playwright test e2e/specs/smoke.spec.ts passes against npm run dev.
  • The GitHub Actions workflow file is present and every job listed in step 9 above runs on push.
  • git log shows 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.tstodayInTimeZone(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 with z.infer.
  • packages/shared/src/constants.tsMAX_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 the ERROR_CODES enum 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.

  1. Implement todayInTimeZone(now: Date, timeZone: string) per Section 8.4. It takes now as an explicit parameter and never reads the system clock; no function in packages/shared/src/domain may call new Date() or Date.now() (Section 25.3).
  2. Implement addDays as pure calendar-day arithmetic on YYYY-MM-DD strings (no timezone conversion), per Section 8.2.
  3. Implement computeStatus(nextDueOn, today) per the status table in Section 8.4: overdue when daysUntilDue < 0, due_today when = 0, due_soon when = 1, upcoming when >= 2. Implement computeNextDueOn, differenceInCalendarDays, and computeScheduleView (which composes the three above into the object form) per the same section.
  4. 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 for notes).
  5. Export inferred TypeScript types alongside each schema.
  6. Configure Vitest coverage thresholds for packages/shared at 100% for statements, branches, functions, and lines in packages/shared/vitest.config.ts.
  7. Add an ESLint override for packages/shared/** disallowing imports of fs, net, http, https, child_process, and better-sqlite3 (a no-restricted-imports rule), enforcing the "no I/O" constraint mechanically.
  8. Write unit tests: every one of the 12 worked examples in Section 30.4 as it.each cases; every status boundary (daysUntilDue of -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 -- --coverage reports 100% statements, branches, functions, and lines.
  • npm run lint --workspace=packages/shared reports zero no-restricted-imports violations.
  • All 12 worked examples in Section 30.4 pass as test cases with the exact nextDueOn, daysUntilDue, daysOverdue, and status values shown in that table.
  • npm run typecheck --workspace=packages/shared passes.
  • npm run build --workspace=packages/shared produces a dist/ consumable by the other two workspaces (verified by apps/api and apps/web successfully importing from @houseplant/shared in 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 on waterings(plant_id, watered_on) that makes watering idempotent and the index on plants(deleted_at). schema_migrations is created by the migration runner itself, not by this file (Section 7.6).
  • apps/api/src/db/connection.ts — opens better-sqlite3 at APP_DATABASE_PATH, creates the parent directory if missing, sets PRAGMA foreign_keys = ON and PRAGMA journal_mode = WAL on every new connection.
  • apps/api/src/db/migrate.ts — reads apps/api/migrations/*.sql in filename order, tracks applied versions in schema_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 base ConfigSchema parsing every environment variable in Section 21 except APP_ACCESS_CODE, APP_SESSION_SECRET, APP_CORS_ORIGIN, and APP_TRUST_PROXY, which M9 adds once the access-code feature exists.
  • Matching .test.ts files 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.

  1. Write 0001_init.sql with the three tables and the four indexes above; schema_migrations is created by the runner (Section 7.6), not by this file.
  2. Implement connection.ts, verifying the pragmas take effect via PRAGMA foreign_keys and PRAGMA journal_mode read-back queries in a test.
  3. Implement migrate.ts: apply unapplied migrations in a transaction each, record the filename and an ISO 8601 UTC applied_at timestamp in schema_migrations, and make re-running the runner a no-op when nothing is unapplied.
  4. Implement plants.repository.ts with create, findById, findAll, countActive, update, updateLastWateredOn, softDelete, restore, purgeDeletedBefore(cutoffIsoInstant), per Section 7.13's repository method names.
  5. Implement waterings.repository.ts with create, findByPlantId(plantId, limit, offset), countForPlant(plantId), deleteById, findLatestForPlant(plantId), per Section 7.13's repository method names.
  6. Implement settings.repository.ts with get(key) / set(key, value), used later for the auto-generated APP_SESSION_SECRET.
  7. Finalize config.ts: parse NODE_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, and APP_BACKUP_RETAIN with 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.
  8. 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.sql and the runner creates schema_migrations; a table-listing query against the resulting file returns exactly plants, waterings, settings, schema_migrations — the last created by the runner.
  • npm run test --workspace=apps/api -- db is green.
  • A test asserts inserting two waterings with the same plant_id and watered_on raises a constraint violation.
  • A test asserts every new connection reports foreign_keys = 1 and journal_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_PATH or APP_TIMEZONE value 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_migrations is 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.tsGET /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.tsGET /api/v1/plants/:plantId/waterings, DELETE /api/v1/plants/:plantId/waterings/:wateringId.
  • apps/api/src/routes/health.tsGET /api/v1/health.
  • apps/api/src/routes/meta.tsGET /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 — wraps APP_RATE_LIMIT_MAX / APP_RATE_LIMIT_WINDOW_MS (values finalized in M9; wired here with defaults), excluding GET /api/v1/health, GET /api/v1/health/ready, and GET /api/v1/meta per Section 13.7.
  • Matching .test.ts files per route module using fastify.inject().

Dependencies. M1 (schemas, domain functions), M2 (repositories).

Implementation steps.

  1. Wire server.ts to register the error handler, request-id, and rate-limit plugins before routes, and ignoreTrailingSlash: false per Section 5.
  2. Implement GET /api/v1/plants: fetch all active plants, compute nextDueOn/daysUntilDue/ daysOverdue/status server-side via computeScheduleView, return { data: [...], meta: { timezone: APP_TIMEZONE } }.
  3. Implement POST /api/v1/plants: validate with the plant-create schema, default lastWateredOn to todayInTimeZone(new Date(), APP_TIMEZONE) when omitted, reject with 422 LIMIT_EXCEEDED when the active-plant count is already 500, generate a UUIDv7 id application-side, insert, also insert a matching waterings row dated lastWateredOn so history is never empty for a plant that has a last-watered date, return 201.
  4. Implement GET /api/v1/plants/:plantId and PATCH /api/v1/plants/:plantId (partial update; editing wateringIntervalDays never clamps and immediately changes the computed status on the next read, per Section 8.8).
  5. Implement DELETE /api/v1/plants/:plantId (sets deleted_at, 204) and POST /api/v1/plants/:plantId/restore (clears deleted_at, 200; 404 NOT_FOUND when the plant is already active).
  6. Implement POST /api/v1/plants/:plantId/waterings: set lastWateredOn = todayInTimeZone(new Date(), APP_TIMEZONE), attempt to insert a waterings row; on a unique-constraint conflict (already watered today) catch it and return 200 with meta: { alreadyWateredToday: true, wateringId }, where wateringId is 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 with meta: { alreadyWateredToday: false, wateringId }, where wateringId is the id of the row just inserted.
  7. Implement GET /api/v1/plants/:plantId/waterings with limit/offset validated by the pagination schema, returning meta: { total, limit, offset }.
  8. Implement DELETE /api/v1/plants/:plantId/waterings/:wateringId: delete the row and recompute the parent plant's last_watered_on exactly as specified in Section 11.3.
  9. Implement GET /api/v1/meta returning 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.
  10. Implement GET /api/v1/health returning the shape defined in Section 22.
  11. Enforce content-type (415 on mismatch), body size caps (413), and malformed JSON (400 MALFORMED_JSON) globally via the error handler plugin.
  12. 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 as API-* in the API integration test matrix in Section 25.7.
  • GET /api/v1/meta returns timezone, today, plantCap, version, and accessCodeEnabled.
  • npm run test --workspace=apps/api is 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: false and a meta.wateringId, second call returns 200 with meta.alreadyWateredToday: true and the same meta.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_EXCEEDED response with no plant created.
  • A test sends a request with Content-Type: text/plain and 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 — mounts QueryClientProvider (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 for GET /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.

  1. Implement the typed fetch client: throws a typed error object carrying code, message, details, requestId on any non-2xx response.
  2. Configure the QueryClient with staleTime: 60_000, gcTime: 300_000, refetchOnWindowFocus: true, retry: 2 (exponential backoff, 500 ms base, jitter) for queries, retry: false for mutations, per Section 5.
  3. 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).
  4. Implement usePlants() wrapping GET /api/v1/plants.
  5. Implement PlantListRoute rendering PlantRow per plant, each showing name, interval, and StatusBadge (colour token + icon + text label per Section 15.6, never colour alone).
  6. Implement the midnight-rollover timer, focus/visibility refetch listeners, and 5-minute poll from Section 16.5.
  7. Build mobile-first: base layout at 360px width, sm/md/lg breakpoints per Section 12.
  8. Write component tests for PlantListRoute in loading, populated, empty, and error states using a mocked query client.
  9. 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 dev starts both apps and navigating to / renders the plant list.
  • e2e/specs/plant-list.spec.ts passes 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 PlantListRoute pass 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.

  1. Implement PlantForm using the same Zod schemas from packages/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).
  2. Implement useCreatePlant/useUpdatePlant mutations that invalidate the ['plants'] query key on success (Section 16.5).
  3. Implement useDeletePlant: on success, remove the plant from the visible list immediately (cache update) and show a Toast with an Undo action and a 10-second window; clicking Undo calls useRestorePlant; letting the toast expire leaves the plant deleted (still recoverable server-side for 30 days per Section 10, with no further UI affordance).
  4. Implement useWaterPlant(plantId, timezone): compute the optimistic date as todayInTimeZone(new Date(), timezone) — never a bare UTC timestamp — optimistically set the plant's status to reflect "watered today" in the cache via computeScheduleView before the server responds, then reconcile with the server response on settle; the server value always wins per Section 8.7.
  5. 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 using meta.wateringId from the water response (Section 16.8). When meta.alreadyWateredToday is true the toast carries no Undo action, per Section 11.2.
  6. 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.

  1. Add the detail route from Section 14.2, showing plant name, interval, notes, current status, and the "Watered today" action.
  2. Implement useWateringHistory(plantId, page) against GET /api/v1/plants/:plantId/waterings?limit=20&offset=..., rendering entries in descending watered_on order with pagination controls driven by meta.total.
  3. Wire the per-entry delete control in WateringHistoryList to useDeleteWatering (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's lastWateredOn/status may have changed (Sections 7.8, 11.3).
  4. Write an e2e test for the specific rule in Section 11.3: deleting the only watering entry for a plant resets lastWateredOn to the plant's created_at calendar date, never to null.
  5. Write an e2e test for pagination: seed a plant with more than 20 history entries, navigate to page 2, assert offset=20 was 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/:wateringId recomputes last_watered_on as 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.

  1. 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.
  2. Implement dark mode: default to prefers-color-scheme, manual override stored in localStorage under hpt.theme, toggle exposed via ThemeToggle.
  3. Implement focus management, skip-to-content link, and ARIA landmarks per Section 19.
  4. Ensure all motion respects prefers-reduced-motion: reduce (Section 12).
  5. Run @axe-core/playwright across every screen and every state in the Section 18 matrix; fix violations until zero remain.
  6. Write a keyboard-only Playwright spec covering: add a plant, water a plant, delete a plant and undo — all without a mouse.
  7. Implement the OfflineBanner driven by the navigator.onLine state 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.ts reports zero axe violations, using the A11Y-* 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. /unlock is added to this gate in M9 (Section 20.5), and /settings in M8.
  • The keyboard-only e2e spec passes: add-plant, water-plant, and delete+undo are each completed using only Tab, Shift+Tab, Enter, Space, and Escape.
  • 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.tsGET /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.

  1. 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 no checksum and no schemaVersion field.
  2. Implement GET /api/v1/export returning that document with Content-Type: application/json and the Content-Disposition filename specified in Section 24.4.
  3. Implement lib/import.ts supporting both replace and merge per Section 24.5, including the mandatory pre-import snapshot in that section's step 4. mode is a required field on the import request only — it is never present in an export document. Reject a formatVersion other than 1 with 400 VALIDATION_FAILED and leave the database unchanged.
  4. Implement POST /api/v1/import, capped at the 5 MB body limit from Section 5.
  5. Implement lib/backup.ts exactly as specified in Section 24.2, including the verification in Section 24.3: VACUUM INTO a timestamped snapshot to APP_BACKUP_DIR when APP_BACKUP_ENABLED is true, on the 03:15-daily-in-APP_TIMEZONE schedule with a boot catch-up, pruning snapshots beyond APP_BACKUP_RETAIN.
  6. 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 whose deleted_at is more than 30 days in the past, which cascades to its waterings rows via the foreign key.
  7. Build the SettingsRoute screen per Section 17.10, with an "Export data" download action and an "Import data" file input plus the required replace/merge radio group with no default, showing the error envelope's message on failure.
  8. Write a round-trip test: export the seed fixture, import it into a fresh database, assert identical plant and watering data.
  9. Write a purge test that inserts a plant with deleted_at backdated 31 days, runs the purge function directly, and asserts the plant and its waterings are gone.
  10. 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/export on the seed fixture returns a document matching the schema in Section 24.4: formatVersion, exportedAt, timezone, plants, waterings — no checksum, no schemaVersion.
  • Export-then-import into a fresh database yields identical plant and watering counts and field values (round-trip test green).
  • Importing a document whose formatVersion is not 1 returns 400 VALIDATION_FAILED and leaves the database unchanged.
  • A test that backdates a plant's deleted_at by 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 exceeds APP_BACKUP_RETAIN.
  • /settings is 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 the APP_ACCESS_CODE, APP_SESSION_SECRET, APP_CORS_ORIGIN, and APP_TRUST_PROXY entries to the ConfigSchema established in M2).
  • Finalized apps/api/src/routes/health.ts (adds GET /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.

  1. Add the APP_ACCESS_CODE, APP_SESSION_SECRET, APP_CORS_ORIGIN, and APP_TRUST_PROXY entries to the existing ConfigSchema from M2, per Section 21.
  2. Implement plugins/security-headers.ts: security headers and a strict Content-Security-Policy with no unsafe-inline for scripts and manifest-src 'self', applied to every response.
  3. Implement plugins/rate-limit.ts fully, using APP_RATE_LIMIT_MAX / APP_RATE_LIMIT_WINDOW_MS, excluding POST /api/v1/session, GET /api/v1/health, GET /api/v1/health/ready, and GET /api/v1/meta per Section 13.7.
  4. Implement lib/access-code.ts: when APP_ACCESS_CODE is unset, it is a no-op and no cookie is ever issued. When set, gate every route except POST /api/v1/session, GET /api/v1/health, GET /api/v1/health/ready, and GET /api/v1/meta (Section 20.5) behind a signed, httpOnly, SameSite=Strict (and Secure when 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. Persist APP_SESSION_SECRET via the settings repository if not supplied by the operator, generating one at first boot.
  5. Implement lib/logger.ts: pino structured JSON logs including requestId, with a redaction rule that ensures the notes field value is never included in any log line.
  6. Finalize routes/health.ts per Section 22.5: GET /api/v1/health returns 200 with status, uptimeSeconds, and version and performs no database access, so it correctly reports "alive" even during a database outage. GET /api/v1/health/ready runs the checks in Section 22.5 and returns 503 naming the failing check; it is ungated and rate-limit exempt regardless of APP_ACCESS_CODE (Section 20.5).
  7. 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-inline in script-src and manifest-src 'self' present, alongside the other headers from Section 20.
  • With APP_ACCESS_CODE unset, a test asserts zero Set-Cookie headers across every endpoint.
  • With APP_ACCESS_CODE set, a request without the code to a protected route returns 401 UNAUTHORIZED; the correct code sets a cookie with httpOnly, SameSite=Strict, and (behind HTTPS) Secure; a 6th failed attempt within 15 minutes from one IP returns 429 RATE_LIMITED; GET /api/v1/health and GET /api/v1/health/ready both still return 200 without a session.
  • A test sends a request whose notes field contains a unique marker string and asserts the marker never appears in captured log output.
  • GET /api/v1/health returns 200 with status, uptimeSeconds, and version and performs no database access, even when APP_DATABASE_PATH points at an unreadable path. GET /api/v1/health/ready returns 200 when the database is reachable and 503 naming the failing check when it is not.
  • Starting the process with an invalid APP_TIMEZONE value exits with code 78 and a message naming APP_TIMEZONE.
  • /unlock is 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 — serves apps/web/dist for any non-/api/v1 GET request.
  • Finalized .github/workflows/ci.yml (adds docker build and the e2e suite).
  • README.md finalized with full run/build/deploy instructions.

Dependencies. All prior milestones; this is the final milestone.

Implementation steps.

  1. 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.
  2. Write the multi-stage Dockerfile: a builder stage installing all workspaces and running npm run build; a final stage copying only production node_modules and the built dist directories, running as a non-root user, exposing port 8080, with CMD starting apps/api/dist/index.js.
  3. Write docker-compose.yml mounting a named volume at the container path backing APP_DATABASE_PATH and APP_BACKUP_DIR, passing through the environment variables from Section 21, and publishing port 8080.
  4. Extend the CI workflow to build the Docker image on every push and run the full Playwright e2e suite against a container instance.
  5. 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).
  6. Finalize README.md: prerequisites, npm install, npm run dev, npm test, docker compose up, and the environment variables an operator must set (APP_TIMEZONE at 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 -d starts the container and curl -f http://localhost:8080/api/v1/health returns 200 within 30 seconds of container start.
  • Creating a plant, running docker compose down then docker compose up -d again, 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 exec into the running container and running whoami returns 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:

  1. npm run typecheck passes across all workspaces touched by the milestone.
  2. npm run lint and npm run format:check pass with zero errors and zero warnings.
  3. All tests written in the milestone, and all tests from every prior milestone, pass (no regressions).
  4. Coverage gates defined in Section 25 are met for every workspace touched.
  5. No TODO, FIXME, or to be decided comment remains in any file touched by the milestone.
  6. 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.
  7. README.md is updated if the milestone changed how the project is run, built, or configured.
  8. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Given an existing plant with wateringIntervalDays = 7, when the user changes it to 3, then the plant's status recomputes immediately from its unchanged lastWateredOn.
  6. Given an existing plant, when the user deletes it, then it disappears from the list immediately and an undo toast appears for 10 seconds.
  7. Given a just-deleted plant, when the user clicks Undo within 10 seconds, then the plant reappears in the list unchanged.
  8. 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

  1. Given a plant not yet watered today, when the user taps "Watered today", then lastWateredOn becomes today and the status becomes upcoming or due_soon per its interval.
  2. Given a plant already watered today, when the user taps "Watered today" again, then the response is 200 with meta.alreadyWateredToday: true and no second history row is created.
  3. Given a plant overdue by 5 days, when the user taps "Watered today", then daysOverdue resets to 0 and the overdue highlighting is removed.
  4. Given a plant's watering history with more than one entry, when the user deletes the most recent entry, then lastWateredOn becomes the watered_on date of the new most recent remaining entry.
  5. Given a plant's watering history with exactly one entry, when the user deletes that entry, then lastWateredOn resets to the plant's created_at calendar date, never to null.
  6. 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.
  7. Given a plant's history page 1, when the user requests page 2, then offset=20 is used and meta.total reflects the true total count.
  8. 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

  1. Given a plant with daysUntilDue < 0, when the list renders, then it shows the overdue colour token, the AlertTriangle icon, and a text label stating the number of days overdue.
  2. Given a plant with daysUntilDue = 0, when the list renders, then it shows the due_today token, the Droplet icon, and a "Due today" label.
  3. Given a plant with daysUntilDue = 1, when the list renders, then it shows the due_soon token, the Clock icon, and a "Due tomorrow" label.
  4. Given a plant with daysUntilDue >= 2, when the list renders, then it shows the upcoming token, the Check icon, and a label stating the number of days remaining.
  5. Given the local date rolls past midnight in APP_TIMEZONE while the tab is open, when the scheduled timer fires, then the list refetches and statuses update without user interaction.
  6. Given the browser tab regains focus after being hidden, when the focus event fires, then the plant list refetches.
  7. Given the app is idle and visible, when 5 minutes elapse, then a background poll refetches the plant list.
  8. 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

  1. Given multiple plants with different statuses, when the user opens the app, then a summary count of due-or-overdue plants is visible.
  2. Given the user sorts or filters the list client-side, when they change the criterion, then no additional network request is made.
  3. Given 500 active plants exist, when the user loads the list, then all 500 render without pagination controls.
  4. Given 500 active plants already exist, when the user attempts to create a 501st, then a 422 LIMIT_EXCEEDED error is shown and no plant is created.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. Given the add-plant form with a non-integer wateringIntervalDays (for example 3.5), when the user submits, then an inline error is shown.
  5. Given notes of exactly 2000 characters, when the user submits, then the plant is created successfully with all 2000 characters preserved.
  6. Given notes of 2001 characters, when the user submits, then an inline error is shown and no request is sent.
  7. Given a lastWateredOn date in the future, when the user submits the add-plant form with that date, then a validation error is shown.
  8. 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

  1. Given the app loads at / with APP_ACCESS_CODE unset, when no plant is selected, then the plant list screen renders directly, with no login screen.
  2. Given a plant in the list, when the user taps it, then the app navigates to its detail route and shows its full history.
  3. Given the detail screen, when the user navigates back, then the app returns to the plant list with its prior scroll position preserved.
  4. 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.
  5. Given the user has set a manual dark-mode override, when they reload the app, then the override persists via the localStorage key hpt.theme.
  6. 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.
  7. 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)

  1. Every screen and state in Section 19's checklist passes @axe-core/playwright with zero violations (A11Y-* in Section 25.10, one per route: /, /plants/new, /plants/:plantId, /plants/:plantId/edit, /settings, /unlock).
  2. Every interactive element is reachable and operable via keyboard alone, per the keyboard map in Section 19.3.
  3. Focus is trapped correctly within any open modal and returns to the triggering element on close.
  4. No status is conveyed by colour alone; every status indicator pairs colour with an icon and a text label, verified by a DOM query.
  5. Every icon used as a status indicator has an accessible name.

Performance (Section 23)

  1. Each performance budget named in Section 23 is measured by the method that section specifies and meets its stated target.
  2. The plant list with 500 plants renders within the budget in Section 23 without virtualisation.
  3. The production frontend bundle size meets the budget in Section 23.
  4. Time-to-first-byte for GET /api/v1/plants with 500 plants meets the budget in Section 23.
  5. The Docker image size meets the budget in Section 23.

Security (Section 20)

  1. Every response includes the security headers listed in Section 20, verified by an integration test asserting header presence and CSP directive values.
  2. With APP_ACCESS_CODE unset, no cookie is ever issued by the API, verified across every endpoint.
  3. With APP_ACCESS_CODE set, an incorrect code is rejected using the constant-time comparison specified in Section 20 and returns 401 UNAUTHORIZED.
  4. Five consecutive failed access-code attempts from one IP within 15 minutes return 429 RATE_LIMITED.
  5. Every SQL statement in the codebase uses parameter binding; a static grep for string-concatenated SQL finds zero matches.
  6. Notes content is never written to log output, verified by the log-redaction test.

Observability (Section 22)

  1. GET /api/v1/health returns the shape defined in Section 22 and 200 regardless of database reachability (Section 22.5).
  2. Every log line is valid JSON matching the schema in Section 22.
  3. Every request is logged with a requestId that matches the requestId in the error envelope when an error occurs.
  4. GET /api/v1/health/ready returns 200 while the database is reachable and a non-200 status naming the failing check when it is not.

Durability (Section 24)

  1. A full export/import round trip preserves every plant and watering field exactly.
  2. The nightly backup job produces a restorable SQLite file.
  3. Backups beyond APP_BACKUP_RETAIN are pruned.
  4. A plant soft-deleted more than 30 days ago is purged, along with its waterings.

Configuration (Section 21)

  1. Starting the API with an invalid environment variable exits with code 78 and a message naming the invalid variable.
  2. 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:

  1. All milestone exit criteria in Section 27.3 (M0 through M10) pass.
  2. All 45 functional acceptance criteria in Section 28.3 pass.
  3. All 26 non-functional acceptance criteria in Section 28.4 pass.
  4. All out-of-scope guard criteria in Section 28.5 pass.
  5. npm run typecheck, npm run lint, npm run format:check, and npm test all pass from the repository root.
  6. docker build succeeds and docker compose up serves a working app whose health check returns
  7. A source-wide search finds zero TODO, FIXME, or to be decided comments.
  8. The decision log (Section 30.3) contains an entry for every choice made under Section 27.7 during the build.
  9. README.md accurately documents how to run, build, test, and deploy the finished application.
  10. 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:

  1. Sections 1 through 6 (product overview, scope, personas, technology stack, repository layout) — read in full before creating any file.
  2. Sections 7 through 9 (data model, domain logic, validation) — keep open while building M1 and M2.
  3. Section 13 (API contract) — keep open while building M3, and reference while building M8 and M9.
  4. Sections 14 through 17 (frontend architecture, design system, component architecture, screens) — keep open while building M4 through M7.
  5. Sections 18 through 20 (state matrix, accessibility, security) — keep open while building M7 through M9.
  6. Section 21 (environment variables) — keep open from M0's config stub onward; it is finalized in M9.
  7. Sections 22 through 26 (observability, performance, durability, testing, build/deploy) — keep open while building M8 through M10.
  8. Sections 27 and 28 (this milestone plan and the acceptance checklist) — keep open at all times as the working checklist.
  9. Section 29 (this section) — read once fully before starting M0, and re-read Section 29.3 at the start of every milestone.
  10. 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 #

  1. 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.
  2. Never leave a TODO, FIXME, or an any type in committed code without a comment justifying why it is unavoidable at that point and which later milestone removes it.
  3. 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.
  4. When two sections appear to disagree, the canonical section named for that concern in Section 30.2 wins.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

  1. Read the milestone's goal, files, dependencies, and implementation steps in full.
  2. 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).
  3. 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.
  4. Implement the milestone's steps in the order given.
  5. Run npm run typecheck, npm run lint, and npm test and fix everything until all three are clean.
  6. 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).
  7. Commit with a conventional message describing what the milestone added.
  8. 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:

  1. 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.
  2. 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.
  3. 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.