- Implemented ThemeProvider to manage theme state and preferences. - Added ThemeControl component for users to switch between system, light, and dark themes. - Integrated localStorage for theme preference persistence. - Updated document styles based on theme changes. test: add tests for permanent deletion of habits and related records - Created tests to ensure permanent deletion of habits removes all dependent records. - Verified that deletion requires ownership, authentication, and checks for archived habits. - Added tests to confirm rollback behavior on deletion failures. test: add tests for habit palette and progress card functionality - Implemented tests for habit color progression and theme-based color shading. - Added tests for progress card calculations and calendar display logic. feat: define theme types and preferences - Introduced Theme and ThemePreference types for better type safety. - Created utility functions for resolving theme based on user preference and system settings. - Defined theme palettes for light and dark modes to ensure adequate contrast.
17 KiB
Habit REST API
All paths below start with /api. The existing Discord session cookie authenticates every habit, task, chart and settings route. Mutations additionally require Origin to exactly match APP_ORIGIN. JSON bodies require Content-Type: application/json and are limited to 64 KiB. Responses use Cache-Control: no-store. User IDs are taken from the session, never from input. Foreign and nonexistent resources both return 404.
Run bun dev to migrate the local database and serve the API. Run bun run test:coverage for the full test suite, and bun run test:smoke to build and exercise the production server over HTTP using a disposable database, including a restart.
Routes
| Method | Path | Result |
|---|---|---|
| GET | /health |
Public database health |
| GET | /auth/discord?timezone=Europe%2FBelgrade |
Start Discord sign-in |
| GET | /auth/discord/callback |
Finish Discord sign-in |
| POST | /auth/logout |
Revoke current session; 204 |
| GET | /me |
Current public user |
| PATCH | /me |
Update saved timezone: {"timezone":"Europe/Belgrade"} |
| GET | /habits |
{habits: [...]}; excludes archived by default |
| GET | /habits?archived=true |
Include archived habits |
| POST | /habits |
Create habit; 201, resource and Location header |
| GET | /habits/:id |
Current habit configuration and revision ID |
| PATCH | /habits/:id |
Change name, method, method settings, schedule or archived flag |
| DELETE | /habits/:id |
Archive from today, preserving history; 204 |
| DELETE | /habits/:id/permanent |
Permanently delete an owned archived habit and all its history; 204. Active habits return 409. Remove it from combined charts and delete charts left empty. |
| GET | /habits/:id/history |
{revisions: [...]} newest first, including superseded same-day revisions |
| GET | /habits/:id/tasks |
{tasks: [...]} for current task method |
| POST | /habits/:id/tasks |
Add task; 201 |
| GET | /habits/:id/tasks/:taskId |
Current task definition |
| PATCH | /habits/:id/tasks/:taskId |
Change task name or recurrence |
| DELETE | /habits/:id/tasks/:taskId |
Remove task from today's requirements onward; 204 |
| GET | /today |
Tracking date, timezone, all active habits with progress, due and completed counts |
| GET | /days/:date |
All owned habits for a date, including historical archived habits |
| GET | /habits/:id/days/:date |
Requirement snapshot, underlying progress, status and task occurrences |
| PUT | /habits/:id/days/:date/progress |
Replace count or manual state for that date |
| PUT | /habits/:id/days/:date/tasks/:taskId |
Replace that task occurrence's boolean completion state |
| GET | /habits/:id/days/:date/audit |
{records: [...]} with all same-day snapshots, occurrences and before/after progress events |
| GET | /habits/:id/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD |
Individual calendar with every date and its details |
| GET | /habits/:id/calendar-settings |
Effective color/shade settings |
| PUT | /habits/:id/calendar-settings |
Replace settings; omitted fields reset to defaults |
| GET | /charts |
{charts: [...]} owned combined charts |
| POST | /charts |
Create combined chart; 201, resource and Location header |
| GET | /charts/:id |
Chart name, selected habit IDs and settings |
| PATCH | /charts/:id |
Change name, selected habits or settings |
| DELETE | /charts/:id |
Delete the saved chart view; 204; underlying habits remain |
| GET | /charts/:id/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD |
Combined calendar |
| GET | /charts/:id/days/:date |
One combined square and its constituent habit details |
Dates must be real YYYY-MM-DD dates, 1970–9998. Calendar ranges are inclusive, ordered, and at most 1,830 days. Future dates are inspectable but cannot be logged. Dates before a habit was created are not due and cannot be logged. Habit creation begins on the current tracking date; there is no implicit historical creation date.
Create and configure habits
Count habit, with daily resets by default:
{
"name": "Hydration",
"color": "#426582",
"method": "count",
"target": 8,
"unit": "glasses",
"carryPartialProgress": false,
"schedule": { "type": "daily" }
}
Manual habit:
{ "name": "Read", "method": "manual" }
Task habit with independent task recurrence:
{
"name": "Routine",
"method": "tasks",
"tasks": [
{ "name": "Stretch" },
{
"name": "Clean desk",
"schedule": { "type": "weekly", "every": 2, "weekday": 3, "anchor": "2026-08-31" }
}
]
}
A habit has exactly one method. Incompatible settings are rejected. A switch to count needs a target. Switching to tasks begins with an empty task list; use the task routes to add tasks. Task IDs are generated by the server and remain stable across edits. Restore an archived habit with PATCH {"archived":false}. Task edits require restoring an archived habit first.
Names are trimmed, nonempty and at most 200 characters. Count targets are integers from 1 to 10,000. Logged counts are nonnegative integers up to 1,000,000,000 and can exceed the target; completion intensity caps at full. Units default to steps and are at most 80 characters. A task habit supports at most 100 task definitions.
Creation and habit PATCH accept an optional six-digit hex color for all three methods. It is saved atomically as the habit calendar's mainColor, separately from dated requirements. Omitting it preserves the default on creation or the current color on edit. Habit PATCH preserves all other calendar settings; the calendar-settings endpoint remains available for replacing the complete settings object.
Schedules
Omitted habit or task schedules default to daily. Both rules must match for a task occurrence to exist.
| Rule | JSON | Meaning |
|---|---|---|
| Daily | {"type":"daily"} |
Every date |
| Day interval | {"type":"interval","every":3,"anchor":"2026-09-04"} |
Sept 4, 7, 10, ...; never before anchor |
| Weekdays | {"type":"weekdays","days":[1,3,5]} |
Monday, Wednesday, Friday |
| Week interval | {"type":"weekly","every":2,"weekday":3,"anchor":"2026-08-31"} |
Wednesday in the anchor week, then every second week |
Weekdays use Sunday=0 through Saturday=6. Weekday lists must be nonempty and unique. Week intervals use Monday-start weeks, with no occurrence before the anchor date. Day intervals support 1–3,650 days; week intervals support 1–520 weeks. Neither lateness nor corrections shift recurrence.
A task habit with no matching tasks is not due, including when its parent schedule and task rules do not intersect. Habit configuration responses include warnings: no_tasks for an empty task habit, or task_never_due with a taskId when the parent and task recurrence rules can never coincide. These are informational and do not prevent saving. The check uses the exact repeating schedules, including anchored day/week intervals. Inspect projected future days or the calendar to see the resulting schedule.
Daily reset and optional carryover
Each scheduled day starts with zero count, an unchecked manual state, and unchecked task occurrences. Partial progress does not carry by default.
Count habits can opt into carryPartialProgress: true. An unfinished count carries to the next scheduled date. A completed or over-target count resets the next scheduled occurrence to zero. Manual habits have no partial state; recurring task occurrences retain the PRD's fresh-occurrence and expiry behavior.
Count writes are absolute totals, including any inherited amount. For example, if a date inherits 7 and the user adds one, write {"count":8}. Writing {"count":0} explicitly clears that date. loggedCount identifies explicit entries; carriedFrom identifies an inherited count's source date. Historical corrections recalculate inherited downstream values until an explicit entry or reset boundary. They never overwrite explicit entries on later dates. Method switches and archived dates break inheritance. Carryover configuration changes are effective today, preserving earlier behavior.
Logging and inspecting progress
PUT /api/habits/:id/days/2026-09-04/progress
{"count":7}
PUT /api/habits/:id/days/2026-09-04/progress
{"done":true}
PUT /api/habits/:id/days/2026-09-04/tasks/:taskId
{"done":true}
The first two bodies apply to count and manual habits respectively. Task progress is derived, so directly writing habit progress for a task habit returns 409. A task write identifies the recurring task ID plus date, not an occurrence ID from a superseded snapshot.
Every successful write returns the updated individual day. Important response fields:
date,habitId,name,method,revisionId,requirements: the dated configuration used to evaluate the day.timezone,endsAt: the preserved local-day boundary;endsAtis Unix milliseconds, or null for dates without a stored snapshot.due,future,status:not_due,future,empty,partial, orcomplete.value,target,unit,ratio,complete: exact progress and capped completion ratio. Not-due ratios are null.loggedCount,carriedFrom: explicit and inherited count information.tasks: due occurrences withid,taskId,name,done,expiredAt,closedAtandupdatedAt. Occurrences follow the current dated task-definition order.
Backfills use that date's method, target, schedule and task list. They automatically affect every combined chart that selects the habit. Reducing a completed habit below its requirement removes its completed contribution.
History, expiry and timezones
Revisions and prior day snapshots are retained. Changing a target, method, task list or schedule takes effect on the current tracking date. Same-method edits retain today's explicit count/manual state; surviving task IDs retain their occurrence state across task edits. Temporarily archiving the habit or disabling its schedule preserves same-day task states for restoration. Changing methods starts a fresh current-day record, retaining the superseded record for audit. Configuration responses include effectiveDate and revisionId so clients can explain this behavior.
Incomplete task occurrences expire at the preserved local midnight. Expiry is reconciled lazily before authenticated tracking requests, including after downtime, and stores the original deadline rather than the time the API was accessed. No scheduler process is required. The closedAt timestamp finalizes the original outcome at the deadline exactly once, including tasks that were completed. A historical correction changes done without clearing or inventing the original expiredAt. Tasks completed at their original deadline have no expiry record. Old occurrences never become overdue tasks on a later date.
Timezone updates first reconcile existing dates under the old timezone. Existing snapshots and deadlines remain fixed; subsequent newly opened dates use the new timezone. The tracking date never moves backward when traveling west across a date boundary. /today.date is authoritative during such a transition, and the new timezone catches up naturally. DST boundaries use actual local midnight, including 23-hour and 25-hour days. UTC millisecond timestamps and local date strings serve different purposes and should not be interchanged.
Combined charts
{
"name": "Daily essentials",
"habitIds": ["owned-habit-uuid-1", "owned-habit-uuid-2"],
"settings": { "mainColor": "#196127", "shadeCount": 4 }
}
Select 1–100 unique owned habit IDs. Membership edits recompute the entire chart view using the selected habits' historical requirements; they do not alter those requirements. Archived habits remain selectable for historical review.
Each day reports due, completed, ratio, level, shadeCount, color, status, future and habits. Completed habits have equal weight. Partial progress counts as zero completed habits. A zero denominator has a null ratio and neutral color. Full completion is the highest shade, whether the underlying counts are 1/1 or 4/4. Combined shade levels reserve the highest level for full completion.
Calendar settings and Nivo
The API intentionally stores only product-level appearance preferences:
| Field | Default | Allowed |
|---|---|---|
mainColor |
#196127 |
Six-digit hex color |
shadeCount |
"auto" |
"auto" or integer 2–20 |
emptyColor |
#ebedf0 |
Due with no progress |
notDueColor |
#f5f5f5 |
Nothing scheduled |
futureColor |
#dbeafe |
Upcoming date |
Positive colors are shades of the main color; very light main colors use darker intermediate shades so white and near-white choices remain distinct. auto uses the dated count target or number of due tasks for individual charts and four positive shades for combined charts. Thus an eight-count habit has eight positive levels plus empty. Explicit shade counts offer a simpler display without changing underlying progress. Manual habits always remain binary. Layout, borders, spacing, typography, callbacks and animation are not persisted settings. PUT replaces individual settings; the settings object in a chart PATCH is also a replacement, with defaults for omitted settings fields.
Calendar responses include kind, from, to, today, timezone, settings, days, and a numeric data summary. Summary values are positive shade levels, 0 for due/empty, -1 for not due, and -2 for future. Exact counts always live in days.
The installed @nivo/calendar integration contract is in src/shared/calendar.ts. It imports:
import { ResponsiveCalendar } from '@nivo/calendar';
toResponsiveCalendarProps(response) returns typed data, an exact per-date color scale, and readable value formatting for this component. It uses distinct numeric indices internally because Nivo's color scale receives only a value, and the same shade number under different historical targets can require different colors. Consumers must use the adapter rather than Nivo's automatic min/max scaling on the summary data. Use days for date inspection and detailed tooltip content. The adapter renders no component or page; frontend UI remains deferred.
Errors
Errors are JSON objects with an error string:
| Status | Meaning |
|---|---|
| 400 | Malformed JSON or missing/wrong JSON content type |
| 401 | Missing, invalid or expired session |
| 403 | Missing or mismatched Origin on mutation |
| 404 | Missing resource, another user's resource or unknown route |
| 409 | Future/unscheduled write, wrong current task method, direct task-habit progress write, or task editing while archived |
| 413 | Body exceeds 64 KiB |
| 422 | Invalid fields, unsupported configuration, invalid dates/range, or a progress body inconsistent with the dated method |
| 500 | Unexpected failure; internal details are not exposed |
Schemas reject unknown body properties. PATCH preserves all omitted top-level fields and rejects an empty patch. Request bodies are fully read before taking the dated configuration snapshot, so overlapping mutations in the single Bun server do not reuse stale configuration. A request streaming across midnight uses the date at which its write is processed. Explicit progress writes remain absolute totals; simultaneous edits of the same field use the last processed value. Failed domain transactions roll back their partial writes. Progress audit events record before/after values; they are not overwritten by corrections.
API readiness review
The API regression suite and production smoke checks cover the known defects found before UI work: omitted PATCH fields, same-day task restoration, immutable expiry, correction audit values, overlapping configuration writes, streaming requests across midnight/timezone changes, stable task ordering, and pale calendar colors. Migration 0003 reconstructs deadline outcomes for existing active occurrences using the first post-deadline correction's before-state when present.
| PRD acceptance criteria | Evidence |
|---|---|
| 1: exclusive completion method | security.test.ts, calendar.test.ts |
| 2–3: count target and binary manual progress | api.test.ts, calendar.test.ts |
| 4–5: derived task progress and no-due exclusion | api.test.ts |
| 6–9: equal-weight combined ratios, neutral and full states | api.test.ts, shared/calendar.test.ts |
| 10: fixed daily, day-interval, weekday and week-interval recurrence | calendar.test.ts, api.test.ts |
| 11: occurrence expiry and fresh next occurrence | api.test.ts, regressions.test.ts, migrations.test.ts |
| 12: historical corrections without shifting recurrence | api.test.ts, carryover.test.ts, regressions.test.ts |
| 13: preserved historical requirements | api.test.ts, resilience.test.ts, regressions.test.ts |
| 14: exact values and status for inspection | api.test.ts, shared/calendar.test.ts |
Habit test filenames above live in src/habits, migration tests in src/db.
The test:smoke command independently checks built production HTTP behavior and
persistence after restart. Line coverage is a supplementary metric; these behavior
checks are the acceptance evidence. Actual calendar rendering, interaction,
responsive layout, and accessibility remain UI-layer work. Live Discord consent
continues to require the configured provider and a real user login; tests stub its
network calls. Optional account export, reminders, streaks and other features
outside the PRD are not prerequisites for the UI layer.