Compare commits

..

11 Commits

Author SHA1 Message Date
syntaxbullet
084603bb6e Add new styles for home and landing pages
- Created home.css with comprehensive styles for the home page layout, including typography, buttons, and responsive design adjustments.
- Created landing.css to style the landing page, focusing on typography, layout, and responsive behavior for various screen sizes.
2026-09-04 11:50:24 +02:00
syntaxbullet
1acf016169 test(api): verify regressions over HTTP and document UI readiness 2026-09-04 09:52:44 +02:00
syntaxbullet
f520d73bbc fix(habits): surface impossible schedules and harden calendar edge cases 2026-09-04 09:51:03 +02:00
syntaxbullet
adb1cf43c1 fix(api): serialize dated writes after request body parsing 2026-09-04 09:49:00 +02:00
syntaxbullet
18a1af9dfc fix(habits): preserve task state and original occurrence expiry 2026-09-04 09:46:22 +02:00
syntaxbullet
724b1423eb fix(api): preserve omitted fields in task and chart patches 2026-09-04 09:44:22 +02:00
syntaxbullet
42054a92f2 test(api): finalize route coverage and document REST contracts 2026-09-04 09:19:32 +02:00
syntaxbullet
ff7ec68262 feat(calendar): add Nivo data adapter and verify durable API behavior 2026-09-04 09:16:09 +02:00
syntaxbullet
9a7180ce78 feat(habits): add opt-in partial count carryover with daily reset defaults 2026-09-04 09:11:11 +02:00
syntaxbullet
e9cc591576 feat(api): implement authenticated habit tracking and combined calendars 2026-09-04 09:08:50 +02:00
syntaxbullet
7fefe0c7d8 feat(db): add versioned habits and calendar domain contracts 2026-09-04 09:00:21 +02:00
66 changed files with 11105 additions and 66 deletions

181
PRD.md Normal file
View File

@@ -0,0 +1,181 @@
# Minabot — Habit Tracking PRD
Status: Initial product requirements
Date: 2026-09-04
## Application intent
Minabot helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
Individual charts answer, “How much progress did I make on this habit?” Combined charts answer, “How many of the habits due that day did I fully complete?” Partial progress is visible and useful, but does not count as a completed habit in a combined chart.
The primary experience should make logging progress, checking tasks, and reading these calendars simple and quick.
## Scope and terminology
| Term | Meaning |
| --- | --- |
| Habit | A named activity with one completion method and a schedule. |
| Completion method | A count target, a manual done/not-done checkbox, or completion of all tasks due that day. |
| Task | A boolean todo belonging to a task-based habit, with its own recurrence rule. |
| Task occurrence | One scheduled instance of a task for a particular date. |
| Due habit | A habit included in that date's completion requirements. A task-based habit also needs at least one task due to qualify. |
| Individual chart | A calendar heatmap showing one habit's daily progress. |
| Combined chart | A calendar heatmap showing the proportion of included, due habits that are fully complete. |
| Shade level | One of the chart's discrete visual intensity steps; it is not necessarily a literal opacity percentage. |
The initial scope includes habit creation and editing, progress logging, task recurrence, individual and combined charts, historical corrections, and preservation of historical requirements. Notifications, social features, rewards, streak mechanics, integrations, and analytics beyond these charts are outside this PRD's scope.
## Confirmed product behavior
### 1. Habit configuration
Users can create and edit a habit's name, completion method, method-specific settings, and schedule.
Each habit uses exactly one completion method at a time:
| Method | User input | Completion rule | Individual chart progress |
| --- | --- | --- | --- |
| Count target | Record countable steps toward a target. | The count reaches the target. | Progress toward the target, represented by discrete shades. |
| Manual checkbox | Mark done or not done. | The checkbox is checked. | Empty or one completed shade. |
| Tasks | Check individual tasks due that day. | Every task due that day is complete. | Completed due tasks divided by total due tasks, represented by discrete shades. |
A habit cannot simultaneously require both a count target and task completion, or an additional manual confirmation. Task-based completion is derived automatically from its due tasks.
For hydration with a target of eight glasses, the individual chart provides eight positive progress steps, plus the empty state. Seven glasses show partial progress; eight glasses complete the habit. Seven glasses never count as a completed habit in a combined chart.
### 2. Habit and task schedules
Habits can have schedules. Tasks have their own recurrence rules and default to daily recurrence.
Supported scheduling behavior must express:
- Daily recurrence.
- Fixed intervals, such as every three days.
- Specific weekdays, such as every Monday.
- Week intervals with a weekday, such as every two weeks on Wednesday.
Recurrence follows a fixed calendar schedule. Completing an occurrence late, correcting its history, or missing it does not move future scheduled dates. Interval rules need an anchor date so the intended dates are unambiguous.
A task-based habit with no tasks due on a date is not due. It is excluded from that date's combined score; it is not automatically completed.
### 3. Task occurrence lifecycle
Each scheduled task occurrence has its own completion state. Checking a task completes that occurrence, not future occurrences of the recurring task.
When an occurrence's day ends, an incomplete occurrence expires. Its expiry is recorded, and it does not carry forward as an overdue task. A fresh occurrence is created when the task is next scheduled.
For example, an unchecked Monday task expires after Monday. Tuesday's daily occurrence starts unchecked. Monday's record remains available for inspection and correction.
Historical corrections are allowed after expiry. Correcting Monday must update Monday's habit and chart results without checking Tuesday's occurrence or shifting the recurrence schedule. The record that the original occurrence expired must remain available alongside the correction.
### 4. Individual habit charts
Each habit has a GitHub-style calendar heatmap with one square per date.
- Count-based habits display progress toward that date's target.
- Manual habits display done or not done.
- Task-based habits display partial progress based on the tasks due on that date.
- Completing two of three due tasks displays partial intensity, while the habit remains incomplete.
- Dates when a habit is not due are visually distinguishable from due dates with zero progress.
Inspecting a square reveals its date, progress, and completion status. The displayed details should expose the underlying values, such as “7 of 8 glasses” or “2 of 3 tasks,” rather than relying on color alone.
### 5. Combined charts
Users can create combined charts from selected habits. Each included habit has equal weight, regardless of its target or number of tasks.
For a given date:
```text
due = selected habits that are due on that date
completed = habits in due that have met their full completion requirement
score = completed / number of habits in due
shade = the discrete shade level corresponding to score
```
Partial progress contributes zero completed habits to this calculation. The combined score is not an average of individual progress ratios.
The ratio maps to discrete shade levels. A score of 75% does not require a square rendered at exactly 75% opacity. The palette and thresholds must communicate increasing completion consistently.
Only habits due that day belong in the denominator. As schedules vary, different dates can have different numbers of due habits. Both 1/1 and 4/4 are fully complete and use the full-completion shade; inspecting the square reveals the actual counts.
When no included habits are due, the score is undefined and the square uses a neutral “nothing scheduled” state. It must not appear as either a failed day or a fully completed day.
| Due habits | Fully completed | Other progress | Combined result |
| --- | --- | --- | --- |
| 4 | 3 | Fourth habit is at 7/8. | Score 3/4; use the shade corresponding to 75%. |
| 4 | 0 | All four have partial progress. | Score 0/4; no completion intensity. |
| 2 | 1 | Two additional selected habits are not due. | Score 1/2; the unscheduled habits are excluded. |
| 1 | 1 | All other selected habits are not due. | Full-completion shade; details show 1/1. |
| 0 | 0 | Nothing scheduled. | Neutral state; no completion percentage. |
### 6. Backfilling and corrections
Users can enter missing progress and correct existing progress on previous dates. This includes changing counts, checking or unchecking manual habits, and correcting historical task occurrences.
Edits recalculate the affected individual chart and every combined chart containing the affected habit. Reducing progress below a completion requirement must also remove that habit's completed contribution from combined scores.
Historical logging uses the requirements that applied on the edited date.
### 7. Changes over time
Changes to a habit's completion method, target, task list, or schedule apply from today onward. Earlier dates retain the requirements that applied at the time.
For example:
- Changing hydration's target from eight glasses to ten does not change whether eight glasses completed yesterday's habit.
- Adding a task today does not make an earlier completed day incomplete.
- Removing a task today does not erase its earlier occurrences or expiry records.
- Changing recurrence today does not add or remove requirements on earlier dates.
- Switching completion methods preserves earlier progress in its original form.
The application must retain enough configuration history to calculate and explain these results. Current configuration alone is insufficient for historical scoring.
## Core user flows
1. **Create a habit:** Enter a name, choose one completion method, configure its target or tasks where applicable, and set its schedule.
2. **Log today's progress:** See habits and tasks due today, record counts or checkboxes, and see the affected charts update.
3. **Review a habit:** Inspect its calendar and open a date to see progress and the requirements that applied that day.
4. **Combine habits:** Select habits for a shared calendar and inspect daily completed-versus-due counts.
5. **Correct history:** Open a previous date, edit progress against that date's requirements, and see individual and combined results update.
6. **Edit a habit:** Change its configuration with an explanation that the new requirements apply from today onward.
## Proposed defaults and decisions still to finalize
The following details were not explicitly settled in the product discussion. They are recommendations or implementation decisions, separate from the confirmed behavior above.
| Topic | Proposed handling / remaining decision |
| --- | --- |
| Day boundaries | Use the user's saved timezone and local midnight for scheduling and expiry. The existing project README already anticipates timezone-based daily boundaries. Define how later timezone changes affect dates before implementation. |
| Habit schedule default | Default new habits to daily; task recurrence is already confirmed to default to daily. |
| Habit and task schedule interaction | A task occurrence is due only when both its own rule and its parent habit's schedule allow it. Warn about configurations that produce no occurrences; confirm this interaction before implementing the scheduler. |
| Shade palette | Choose discrete colors and thresholds during visual design. Preserve distinguishable empty, partial, and fully complete states, including all eight positive steps for the hydration example. |
| Counts beyond the target | Treat reaching or exceeding the target as complete and cap chart intensity at full. Decide whether logging permits over-target values. |
| Same-day configuration edits | Define what happens to progress already logged today when the completion method or task list changes. Preserve the prior record and make any conversion explicit. |
| Combined chart membership edits | Decide whether changing selected habits recomputes the chart's entire history or takes effect from today. Historical habit requirements must remain preserved either way. |
| Future dates | Display upcoming dates separately from missed dates; allow progress logging only for today and past dates by default. |
| Habit retirement | Define archiving and deletion behavior before adding those controls; historical records should be preserved by default. |
## Acceptance criteria
1. A habit can use only one completion method at a time, selected during creation or editing.
2. A hydration habit with target eight displays distinct count progress; 7/8 is incomplete and 8/8 is complete.
3. A manual habit has only done and not-done progress states.
4. A task-based habit at 2/3 shows partial individual progress and contributes zero completed habits to a combined chart; 3/3 completes it automatically.
5. A task-based habit with no due tasks is excluded from combined scoring.
6. A combined chart with four due habits and three completed calculates 3/4 and maps it to an available discrete shade.
7. Combined scoring excludes selected habits not scheduled for the inspected date.
8. Dates with nothing due are distinguishable from dates with due habits and zero completions.
9. Combined squares with 1/1 and 4/4 both show full completion, and their details reveal the different counts.
10. Daily, fixed day intervals, weekday rules, and alternate-week weekday rules produce dates based on a fixed schedule, independent of completion timing.
11. An incomplete task occurrence expires at the end of its day, remains recorded, and does not carry forward. The next scheduled occurrence starts fresh.
12. Backfilling and corrections update all affected charts without changing future recurrence dates or other task occurrences.
13. Editing a target, completion method, task list, or schedule today leaves earlier requirements and results unchanged unless the user separately corrects historical progress.
14. Chart details communicate exact counts and status without requiring the user to infer them from shade alone.
## Existing application context
The workspace README documents a Bun/Hono backend, React Router client, Drizzle/SQLite persistence, Discord sign-in, and a saved user timezone. This PRD defines the habit-tracking behavior to build on that foundation; it does not claim those habit features already exist.
Habit data and chart selections should belong to the signed-in user. Persistence must represent dated progress, task occurrences and expiry, effective configuration history, and combined chart membership so the confirmed behavior can be implemented reliably.

View File

@@ -1,7 +1,10 @@
# Minabot # Minabot
Minimal Bun + Hono backend with Drizzle ORM and local SQLite, and a React Router Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The existing
client initialized with Bun's shadcn + Tailwind CSS v4 template. React Router client has a public landing page and a connected signed-in habit dashboard.
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
combined calendars, historical corrections, daily resets, and optional count carryover.
```sh ```sh
bun install bun install
@@ -14,7 +17,50 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port.
- Hono endpoint: `GET /api/health`. - Hono endpoint: `GET /api/health`.
- Unknown API routes return JSON with a 404 status. - Unknown API routes return JSON with a 404 status.
Pages contain a heading, native navigation buttons, and account controls. Tailwind utilities ### Design system preview
Open `/design-system#editing` for interactive habit settings, task recurrence,
and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest,
Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel,
archive/restore, and an in-session correction history. The backfill calendar
uses frozen historical requirements, independent of edits to today's settings.
These are unsaved, local UI examples with a fixed September 4, 2026 demo clock;
they do not call the habit API or change account data. Reload or Reset editor
clears the preview. Habit colors preview immediately across the editor and the
daily/detail charts; Save keeps the color in the demo and Cancel restores it.
Other settings and progress remain independent of the daily tracking playground.
Signed-out visitors at `/` see the landing page, with interactive habit calendars
reused from the design system, a how-it-works introduction, and Discord sign-in.
The demo is local, unsaved state and never writes to the habit API. Account loading
and retryable errors are resolved before choosing the public or signed-in home.
Signing out updates the shared account state and reveals the landing page immediately.
Discord integration currently covers identity only, not bot commands or reminders.
Signed-in users at `/` get a personalized home with their Discord avatar, tracking
date/timezone, daily completion summary, manual/count/task check-ins, a remaining
filter, and per-habit calendar history in the design system's two-column chart grid.
Each habit has its own heading, schedule, color key, and compact calendar. Daily
check-ins use compact rows with expandable tasks. New accounts get editable starter ideas and
a create-habit dialog supporting all three methods, all schedule types, and the shared
palette/custom color picker. The habit and its color are saved atomically. Progress
is saved through the habit API; history uses server-provided progress colors and dated units,
never the landing page's demo data. Visible dashboards refresh every minute and on
returning to the tab. Loading, retry, expired-session, no-schedule, and all-complete
states are handled separately. Each habit has Edit and Delete controls: edit its name,
count target/unit, schedule, and color, or confirm removal from the dashboard using
the existing archive API, which retains recorded history. Editing keeps the tracking
method and existing tasks intact. Unscheduled dates use neutral dots; upcoming dates
share the zero-progress fill while retaining their distinct inspection labels.
Archive management, task-definition editing, historical corrections, and saved
combined-chart management remain outside this home view.
For isolated browser QA, run `bun scripts/dashboard-preview.ts` and open the printed
preview login URL. It serves the real UI and API against an in-memory test database,
uses a separate test cookie, and discards its data on exit.
It is a local test harness, not a production sign-in flow.
Secondary pages retain a heading, native navigation buttons, and account controls. Tailwind utilities
and shadcn configuration are available; Tailwind Preflight is omitted to preserve and shadcn configuration are available; Tailwind Preflight is omitted to preserve
native browser styling. native browser styling.
@@ -124,8 +170,9 @@ production habit data separate from development data.
- React detects the browser timezone during initial registration. The server - React detects the browser timezone during initial registration. The server
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
update the Discord profile without changing the saved timezone. A timezone update the Discord profile without changing the saved timezone. A timezone
settings editor and habit reset logic are future work. Habit timestamps should settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
remain UTC; daily boundaries should be calculated using the user's timezone. remain UTC and daily boundaries follow the saved timezone with preserved historical
deadlines. See the API reference for travel and DST behavior.
### API ### API
@@ -178,3 +225,20 @@ stubbed Discord HTTP client. They cover registration, profile refresh, timezone
preservation, cookie security, invalid/expired state, provider failures, session preservation, cookie security, invalid/expired state, provider failures, session
expiry/rotation, logout CSRF checks, and the exact `/api/me` response. A real Discord expiry/rotation, logout CSRF checks, and the exact `/api/me` response. A real Discord
consent flow additionally requires your application credentials and browser login. consent flow additionally requires your application credentials and browser login.
## Habit API verification
```sh
bun run test:coverage
bun run typecheck
bun run test:smoke
```
The smoke command builds and starts the production server against a disposable
SQLite database, exercises authenticated HTTP requests, then restarts it to verify
persistence. It does not use a live Discord provider or modify application data.
Habit tests cover every route, all PRD acceptance behaviors, ownership and Origin
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
separate the database foundation, REST implementation, carryover, and verification.

4
bun-env.d.ts vendored
View File

@@ -9,6 +9,10 @@ declare module "*.svg" {
} }
declare module "*.css" {} declare module "*.css" {}
declare module "*.png" {
const path: string;
export default path;
}
declare module "*.module.css" { declare module "*.module.css" {
/** /**

View File

@@ -5,6 +5,7 @@
"": { "": {
"name": "bun-react-template", "name": "bun-react-template",
"dependencies": { "dependencies": {
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2", "bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -16,12 +17,14 @@
"react-router": "^8.3.1", "react-router": "^8.3.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.5.4",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^7.0.2", "typescript": "^7.0.2",
}, },
@@ -86,6 +89,20 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
"@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="],
"@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="],
"@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="],
"@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="],
"@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="],
@@ -110,14 +127,44 @@
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="],
"@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="],
"@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="],
"@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="],
"@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="],
"@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="],
"@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="],
"@types/d3-time": ["@types/d3-time@1.1.4", "", {}, "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g=="],
"@types/d3-time-format": ["@types/d3-time-format@3.0.4", "", {}, "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg=="],
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="], "@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -160,6 +207,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="], "bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
@@ -174,18 +223,46 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@1.1.0", "", {}, "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="],
"d3-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
"happy-dom": ["happy-dom@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ=="],
"hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="], "hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="], "lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -194,6 +271,8 @@
"react-router": ["react-router@8.3.1", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A=="], "react-router": ["react-router@8.3.1", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A=="],
"react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -214,8 +293,20 @@
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"use-debounce": ["use-debounce@10.1.1", "", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"d3-scale/d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-scale/d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],

233
docs/API.md Normal file
View File

@@ -0,0 +1,233 @@
# 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 |
| 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:
```json
{
"name": "Hydration",
"color": "#426582",
"method": "count",
"target": 8,
"unit": "glasses",
"carryPartialProgress": false,
"schedule": { "type": "daily" }
}
```
Manual habit:
```json
{ "name": "Read", "method": "manual" }
```
Task habit with independent task recurrence:
```json
{
"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
```text
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; `endsAt` is Unix milliseconds, or null for dates without a stored snapshot.
- `due`, `future`, `status`: `not_due`, `future`, `empty`, `partial`, or `complete`.
- `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 with `id`, `taskId`, `name`, `done`, `expiredAt`, `closedAt` and `updatedAt`. 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
```json
{
"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:
```ts
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.

View File

@@ -0,0 +1,45 @@
# Minabot visual studies
Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are visual references, not data-accurate calendar renders. Two independent generations; no input images. The no-card, monochromatic editorial layouts informed the scoped design-system stylesheet and reusable components. Fonts are bundled locally under src/assets/fonts with their OFL license.
## Today
Saved asset: `src/assets/design/today.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
## Habit detail
Saved asset: `src/assets/design/habit-detail.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across Jan–Sep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
## Interactive implementation
Visit `/design-system`. All examples use local React state and deterministic fixture dates ending September 5, 2026 (demo today is September 4). They do not call the habit APIs or persist user data. The three preview modes share count, checkbox, task, and chart-membership state. Counts are capped at their target for this demo; production over-target policy remains a separate decision. Combined-chart membership filters the illustrative history in the demo only, without defining production membership-history behavior.
Reusable primitives: `Button`, `Checkbox`, `Counter`, `SectionHeading`, `HabitRow`, and `CalendarHeatmap` in `src/components/design-system`. Calendar days provide exact values with keyboard navigation (arrows, Home, End), selected-date inspection, eight positive grayscale steps for target eight, solid-outline not-due dates, and dashed-outline future dates. The existing Nivo/server calendar adapter is unchanged.
Styling is scoped to `.ds-root`; existing application routes retain their styling. No authentication is required to inspect the design system.
## Today revision 2
### Month-range controls
Small-text refinement: the final minimum caption/metadata scale is 12px, with supporting labels at 14px. This covers eyebrows, calendar labels and legends, status copy, specimens, and mobile overrides without changing editorial headings. Legends and narrow-screen toolbars wrap as needed; browser checks confirm no document or legend overflow at 480px.
Chart legends: every live calendar includes a compact color-matched legend directly beneath its cells. It shows the shared zero/upcoming fill, attainable progress shades through completion, and an outlined not-scheduled state. Manual habits show only empty/complete; target-eight habits show all eight positive shades. The shared `CalendarLegend` also appears in detail and combined previews and wraps on narrow screens. Verified in-browser with no legend overflow at 480px; typecheck, build, and all 78 tests pass.
Empty-state visual refinement: upcoming dates and due dates with zero progress now share the same `#eeeeee` fill and transparent border. Upcoming dates no longer have dashed outlines. Their inspection text and accessible labels still say Upcoming, and not-due dates retain their distinct outline. Browser computed styles were compared across all four habit charts; their zero and upcoming fills and borders match exactly.
Annual-layout refinement: selecting 12 months now makes that habit chart span the entire Today grid row. Its calendar uses the available width without a minimum-width overflow or horizontal scrollbar; other ranges retain the two-column layout. Returning to a shorter range restores its normal width without changing progress. Verified annual scrollWidth equals clientWidth at desktop and a 480px narrow viewport, with all 365 dates rendered.
Each live calendar now has its own 3 / 4 / 6 / 12-month selector, defaulting to 6 months. Ranges are whole calendar months including demo today (September 4, 2026): July–September, June–September, April–September, or October 2025–September 2026. Demo fixtures cover all 12 months and preserve values across range changes. Weekday padding aligns partial weeks correctly; remaining dates in September are future states. Long grids scroll within their chart and keep the selected day in view. A selected historical date outside a smaller range temporarily falls back to today and returns when the range expands. Presets are local demo UI state, not saved account preferences. Typecheck, build, and 77 tests pass; browser checks cover all presets, independent chart selections, keyboard activation, and preserved progress.
Validation: `bun run typecheck`, `bun run build`, and all 75 tests pass. Browser checks confirmed four differently colored 182-day calendars, a two-column/two-row desktop grid, a single-column layout at a measured 480px viewport with no document overflow, count and task updates, keyboard accordion expansion/collapse, selected-day keyboard navigation, combined scoring, empty chart selection, local form feedback, and loaded image references. No browser warning/error logs were recorded. Browser viewport overrides were reset after testing.
The updated Today preview uses four independent habit charts in a two-column, multi-row open grid (one column below 760px). Every chart has its own color. The task-based chart exposes today's checkboxes through a native keyboard-accessible details/summary accordion. The reusable `HabitChart` component composes `CalendarHeatmap` and logging controls. Each live chart shows 26 weeks of illustrative history; this deterministic calendar is authoritative over the image mockup's approximate grid geometry.
New saved asset: `src/assets/design/today-v2.png`. Original `today.png` is preserved; the gallery now uses v2. Built-in imagegen prompt:
Use case: ui-mockup. Asset type: high fidelity desktop Minabot habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "minabot", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.

View File

@@ -0,0 +1,75 @@
CREATE TABLE `combined_charts` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`habit_ids` text NOT NULL,
`settings` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `combined_charts_user_idx` ON `combined_charts` (`user_id`);--> statement-breakpoint
CREATE TABLE `habit_calendar_settings` (
`habit_id` text PRIMARY KEY NOT NULL,
`settings` text NOT NULL,
FOREIGN KEY (`habit_id`) REFERENCES `habits`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `habit_days` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`habit_id` text NOT NULL,
`revision_id` integer NOT NULL,
`date` text NOT NULL,
`timezone` text NOT NULL,
`ends_at` integer NOT NULL,
`count` integer DEFAULT 0 NOT NULL,
`done` integer DEFAULT false NOT NULL,
FOREIGN KEY (`habit_id`) REFERENCES `habits`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`revision_id`) REFERENCES `habit_revisions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `habit_days_lookup_idx` ON `habit_days` (`habit_id`,`date`,`revision_id`);--> statement-breakpoint
CREATE TABLE `habit_revisions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`habit_id` text NOT NULL,
`effective_date` text NOT NULL,
`config` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`habit_id`) REFERENCES `habits`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `habit_revisions_date_idx` ON `habit_revisions` (`habit_id`,`effective_date`,`id`);--> statement-breakpoint
CREATE TABLE `habits` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`created_date` text NOT NULL,
`materialized_through` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `habits_user_idx` ON `habits` (`user_id`);--> statement-breakpoint
CREATE TABLE `progress_events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`day_id` integer NOT NULL,
`occurrence_id` text,
`before` text NOT NULL,
`after` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`day_id`) REFERENCES `habit_days`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `progress_events_day_idx` ON `progress_events` (`day_id`);--> statement-breakpoint
CREATE TABLE `task_occurrences` (
`id` text PRIMARY KEY NOT NULL,
`day_id` integer NOT NULL,
`task_id` text NOT NULL,
`name` text NOT NULL,
`done` integer DEFAULT false NOT NULL,
`expired_at` integer,
`updated_at` integer NOT NULL,
FOREIGN KEY (`day_id`) REFERENCES `habit_days`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `task_occurrences_day_idx` ON `task_occurrences` (`day_id`);

View File

@@ -0,0 +1,4 @@
ALTER TABLE `habit_days` ADD `count_set` integer DEFAULT false NOT NULL;--> statement-breakpoint
UPDATE habit_days SET count_set = 1 WHERE count != 0 OR EXISTS (SELECT 1 FROM progress_events e WHERE e.day_id = habit_days.id AND json_type(e.after, '$.count') IS NOT NULL);
--> statement-breakpoint
UPDATE habit_revisions SET config = json_set(config, '$.carryPartialProgress', json('false')) WHERE json_extract(config, '$.method') = 'count';

View File

@@ -0,0 +1,17 @@
ALTER TABLE `task_occurrences` ADD `closed_at` integer;--> statement-breakpoint
-- Recover the state at the deadline from the first correction after it, when present.
-- This also repairs expiry previously inferred from an uncheck made after completion.
UPDATE task_occurrences
SET closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id),
expired_at = CASE WHEN COALESCE(
(SELECT json_extract(e.before, '$.done') FROM progress_events e
WHERE e.occurrence_id = task_occurrences.id
AND e.created_at >= (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
ORDER BY e.created_at, e.id LIMIT 1), done) = 0
THEN (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
ELSE NULL END
WHERE day_id IN (
SELECT d.id FROM habit_days d
WHERE d.ends_at <= CAST(unixepoch('subsec') * 1000 AS INTEGER)
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date)
);

View File

@@ -0,0 +1,676 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c1f6559a-c6f4-491e-8201-4f622718de35",
"prevId": "1d46cf45-94a2-434f-b5d7-bc0346cc1fdd",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,684 @@
{
"version": "6",
"dialect": "sqlite",
"id": "79583aaf-5f48-44cd-b105-407d229c3ced",
"prevId": "c1f6559a-c6f4-491e-8201-4f622718de35",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count_set": {
"name": "count_set",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,691 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0893a83a-3004-440a-8e89-3a30f26a578d",
"prevId": "79583aaf-5f48-44cd-b105-407d229c3ced",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count_set": {
"name": "count_set",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"closed_at": {
"name": "closed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,27 @@
"when": 1788501609255, "when": 1788501609255,
"tag": "0000_discord_auth", "tag": "0000_discord_auth",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1788505163294,
"tag": "0001_habit_history",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1788505800600,
"tag": "0002_optional_count_carryover",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1788507928967,
"tag": "0003_finalize_occurrence_deadlines",
"breakpoints": true
} }
] ]
} }

View File

@@ -11,9 +11,12 @@
"db:generate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development x --bun drizzle-kit generate", "db:generate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development x --bun drizzle-kit generate",
"db:migrate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts", "db:migrate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts",
"test": "bun test", "test": "bun test",
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts" "db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
"test:coverage": "bun test --coverage",
"test:smoke": "bun run build && bun scripts/api-smoke.ts"
}, },
"dependencies": { "dependencies": {
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2", "bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -24,13 +27,15 @@
"react-dom": "^19", "react-dom": "^19",
"react-router": "^8.3.1", "react-router": "^8.3.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"zod": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^7.0.2" "typescript": "^7.0.2"
} }

68
scripts/api-smoke.ts Normal file
View File

@@ -0,0 +1,68 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Database } from 'bun:sqlite';
import { hashToken } from '../src/auth';
const directory = mkdtempSync(join(tmpdir(), 'minabot-api-smoke-'));
const databasePath = join(directory, 'test.sqlite');
const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() });
const port = probe.port!; probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
const token = 's'.repeat(43);
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
async function boot() {
processHandle = Bun.spawn([process.execPath, 'scripts/start.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
for (let attempt = 0; attempt < 100; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) return; } catch { /* Wait for startup. */ }
if (processHandle.exitCode !== null) throw new Error(`Production server exited with ${processHandle.exitCode}`);
await Bun.sleep(50);
}
throw new Error('Production server did not become healthy');
}
async function stop() { if (processHandle) { processHandle.kill(); await processHandle.exited; processHandle = undefined; } }
async function request(path: string, method = 'GET', body?: unknown, status = 200) {
const response = await fetch(`${origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${token}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
const data = await response.json();
if (response.status !== status) throw new Error(`${method} ${path}: ${response.status}: ${JSON.stringify(data)}`);
return data as any;
}
function check(value: unknown, message: string): asserts value { if (!value) throw new Error(message); }
try {
const migration = Bun.spawn([process.execPath, 'scripts/migrate.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
check(await migration.exited === 0, 'Fresh production migrations failed');
const sqlite = new Database(databasePath); const now = Date.now();
sqlite.query('INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run('smoke', 'smoke', 'Smoke', 'UTC', now, now);
sqlite.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(hashToken(token), 'smoke', now, now + 3600000);
sqlite.close();
await boot();
const date = (await request('/today')).date;
const h = await request('/habits', 'POST', { name: 'HTTP hydration', method: 'count', target: 8 }, 201);
const schedule = { type: 'weekdays', days: [new Date(`${date}T00:00:00Z`).getUTCDay()] };
const t = await request('/habits', 'POST', { name: 'HTTP tasks', method: 'tasks', tasks: [{ name: 'Check', schedule }] }, 201);
const renamedTask = await request(`/habits/${t.id}/tasks/${t.tasks[0].id}`, 'PATCH', { name: 'Renamed task' });
check(JSON.stringify(renamedTask.schedule) === JSON.stringify(schedule), 'Task rename reset recurrence');
await Promise.all([request(`/habits/${h.id}`, 'PATCH', { name: 'Concurrent hydration' }), request(`/habits/${h.id}`, 'PATCH', { unit: 'glasses' })]);
const updated = await request(`/habits/${h.id}`);
check(updated.name === 'Concurrent hydration' && updated.unit === 'glasses', 'Overlapping patches lost unrelated settings');
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 7 });
await request(`/habits/${t.id}/days/${date}/tasks/${t.tasks[0].id}`, 'PUT', { done: true });
const c = await request('/charts', 'POST', { name: 'HTTP combined', habitIds: [h.id, t.id], settings: { mainColor: '#ff0000', shadeCount: 8 } }, 201);
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Partial habit incorrectly contributed to combined score');
const renamedChart = await request(`/charts/${c.id}`, 'PATCH', { name: 'Renamed chart' });
check(renamedChart.settings.mainColor === '#ff0000' && renamedChart.settings.shadeCount === 8, 'Chart rename reset settings');
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
await stop(); await boot();
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 8 });
const result = await request(`/charts/${c.id}/calendar?from=${date}&to=${date}`);
check(result.days[0].ratio === 1, 'Correction did not update combined calendar');
check((await fetch(`${origin}/api/habits`)).status === 401, 'HTTP authentication was bypassed');
check((await fetch(`${origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${token}`, Origin: 'https://evil.example', 'Content-Type': 'application/json' }, body: '{}' })).status === 403, 'HTTP Origin enforcement was bypassed');
console.log('Production HTTP smoke passed: migrations, count/task logs, combined scores, correction, PATCH preservation, overlapping writes, archive/restore, authentication, CSRF, and persistence across restart.');
} finally { await stop(); rmSync(directory, { recursive: true, force: true }); }

View File

@@ -0,0 +1,57 @@
// Isolated browser QA server. Never opens or changes the user's database.
import { fixture } from "../src/habits/test-fixture";
import { createApi } from "../src/api";
import index from "../src/index.html";
const f = fixture();
const origin = "http://127.0.0.1:3107";
const previewCookie = "minabot_dashboard_preview_session";
const app = createApi(
f.db,
{
origin,
clientId: "",
clientSecret: "",
cookieSecret: "preview-only-secret-at-least-32-characters",
},
undefined,
() => Date.parse("2026-09-04T12:00:00Z"),
);
const server = Bun.serve({
hostname: "127.0.0.1",
port: 3107,
routes: {
"/__preview/login": () =>
new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": `${previewCookie}=${"a".repeat(43)}; Path=/; HttpOnly; SameSite=Lax`,
},
}),
"/api/*": async (request) => {
// Translate only the preview cookie; never use or overwrite a real session.
const cookie = request.headers
.get("cookie")
?.split(";")
.map((value) => value.trim())
.find((value) => value.startsWith(`${previewCookie}=`));
const headers = new Headers(request.headers);
headers.set(
"cookie",
cookie ? cookie.replace(previewCookie, "minabot_session") : "",
);
const response = await app.fetch(new Request(request, { headers }));
const setCookie = response.headers.get("set-cookie");
if (setCookie)
response.headers.set(
"set-cookie",
setCookie.replace("minabot_session", previewCookie),
);
return response;
},
"/*": index,
},
development: true,
});
console.log(`Disposable dashboard preview: ${server.url}__preview/login`);

692
src/App.test.tsx Normal file
View File

@@ -0,0 +1,692 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
spyOn,
test,
} from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { MemoryRouter } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
import { fixture } from "./habits/test-fixture";
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
// A simulated DOM keeps auth regression checks independent of Discord and the local database.
const dom = new Window({ url: "http://localhost:3000/" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
const account = {
id: "test-user",
discordId: "123",
username: "demo",
displayName: "Demo User",
avatarUrl: null,
timezone: "UTC",
};
const emptyToday = {
date: "2026-09-04",
timezone: "UTC",
habits: [],
due: 0,
completed: 0,
};
beforeAll(async () => {
for (const key of [
"window",
"document",
"navigator",
"HTMLElement",
"HTMLInputElement",
"Element",
"Node",
"Event",
"MouseEvent",
"IS_REACT_ACT_ENVIRONMENT",
]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value:
key === "window"
? dom
: key === "IS_REACT_ACT_ENVIRONMENT"
? true
: (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
fetchMock = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 401 }),
);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
fetchMock.mockRestore();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
async function render(path = "/") {
await act(async () =>
root.render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
),
);
}
async function click(label: string) {
const button = Array.from(container.querySelectorAll("button")).find(
(element) =>
element.textContent?.trim() === label ||
element.getAttribute("aria-label") === label,
);
expect(button).toBeDefined();
await act(async () => button!.click());
}
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
await act(async () =>
root.render(
<CalendarHeatmap
label="Reading history"
unit="pages"
compact
emptyColor="#ebedf0"
days={[
{
date: "2026-09-03",
value: 0,
target: 0,
state: "not-due",
color: "#f5f5f5",
},
{
date: "2026-09-04",
value: 0,
target: 20,
state: "due",
color: "#ebedf0",
},
{
date: "2026-09-05",
value: 0,
target: 20,
state: "future",
color: "#dbeafe",
},
]}
/>,
),
);
const restDay =
container.querySelector<HTMLButtonElement>(".ds-day--not-due")!;
expect(restDay.style.backgroundColor).toBe("transparent");
expect(restDay.getAttribute("aria-label")).toContain(
"Not included in the score",
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");
expect(restDay.tabIndex).toBe(0);
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain(
"Nothing scheduled",
);
await act(async () =>
restDay.dispatchEvent(
new dom.KeyboardEvent("keydown", {
key: "ArrowDown",
bubbles: true,
}) as unknown as KeyboardEvent,
),
);
expect(
container.querySelector(".ds-day--due")?.getAttribute("aria-pressed"),
).toBe("true");
});
describe("home route authentication", () => {
test("signed-out visitors see the landing page with Discord and demo content", async () => {
await render();
expect(container.querySelector("h1")?.textContent).toBe(
"A little today.A rhythm for life.",
);
expect(container.querySelector("#discord")?.textContent).toContain(
"Discord integration currently covers sign-in",
);
expect(container.querySelector("#demo")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("an unresolved account request never flashes the landing page", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockResolvedValue(Response.json(emptyToday)).mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
await render();
expect(container.querySelector('[role="status"]')?.textContent).toBe(
"Loading your account…",
);
expect(container.querySelector("#landing-title")).toBeNull();
await act(async () => resolve(Response.json(account)));
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.querySelector("#landing-title")).toBeNull();
});
test("signed-in users keep Home and signing out reveals the landing page", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(Response.json(emptyToday))
.mockResolvedValueOnce(Response.json({ ok: true }));
await render();
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.textContent).toContain("Demo User");
await click("Sign out");
expect(fetchMock).toHaveBeenLastCalledWith("/api/auth/logout", {
method: "POST",
});
expect(container.querySelector("#landing-title")).not.toBeNull();
});
test("logout failure keeps the signed-in view and shows an error", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(Response.json(emptyToday))
.mockResolvedValueOnce(new Response(null, { status: 500 }));
await render();
await click("Sign out");
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not sign out",
);
});
test("an account error is not treated as signed out and can be retried", async () => {
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(new Response(null, { status: 401 }));
await render();
expect(container.querySelector("#landing-title")).toBeNull();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not load your account",
);
await click("Try again");
expect(container.querySelector("#landing-title")).not.toBeNull();
});
test("OAuth cancellation remains visible on the landing page", async () => {
await render("/?auth_error=denied");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Discord sign-in was cancelled",
);
});
test("all sign-in calls to action use Discord OAuth with the browser timezone", async () => {
const assign = spyOn(dom.location, "assign").mockImplementation(() => {});
try {
await render();
const buttons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.startsWith("Sign in"),
);
expect(buttons).toHaveLength(3);
for (const button of buttons) await act(async () => button.click());
expect(assign).toHaveBeenCalledTimes(3);
for (const [destination] of assign.mock.calls) {
const url = new URL(destination, "http://localhost:3000");
expect(url.pathname).toBe("/api/auth/discord");
expect(url.searchParams.get("timezone")).toBe(
Intl.DateTimeFormat().resolvedOptions().timeZone,
);
}
} finally {
assign.mockRestore();
}
});
test("other routes keep their shell and can navigate back to the public home", async () => {
await render("/about");
expect(container.querySelector("h1")?.textContent).toBe("About");
await click("Home");
expect(container.querySelector("#landing-title")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("the design-system route stays public and independent of account loading", async () => {
await render("/design-system/");
expect(container.querySelector("#ds-title")).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
test("the demo updates completion, switches views, and resets without API writes", async () => {
await render();
await click("Increase glasses of water");
expect(
container.querySelector(".ds-preview-heading")?.textContent,
).toContain("1 of 2 habits complete");
expect(
container.querySelector<HTMLButtonElement>(
'[aria-label="Increase glasses of water"]',
)?.disabled,
).toBe(true);
await click("Combined progress");
expect(
container.querySelector('[aria-label="Combined habit calendar"]'),
).not.toBeNull();
const reset = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Reset demo"),
)!;
await act(async () => reset.click());
expect(
container.querySelector(".ds-preview-heading")?.textContent,
).toContain("0 of 2 habits complete");
expect(
container.querySelector('[aria-label="Increase glasses of water"]'),
).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("signed-in dashboard with the real habit API", () => {
let api: ReturnType<typeof fixture>;
beforeEach(() => {
api = fixture();
fetchMock.mockImplementation((async (
input: RequestInfo | URL,
options?: RequestInit,
) => {
const path = String(input).replace(/^\/api/, "");
if (path === "/me") return Response.json(account);
return api.request(
path,
options?.method ?? "GET",
options?.body ? JSON.parse(String(options.body)) : undefined,
);
}) as typeof fetch);
});
afterEach(() => api.close());
async function input(selector: string, value: string) {
const element = container.querySelector<HTMLInputElement>(selector)!;
expect(element).not.toBeNull();
await act(async () => {
const prototype =
element.tagName === "SELECT"
? dom.HTMLSelectElement.prototype
: element.tagName === "TEXTAREA"
? dom.HTMLTextAreaElement.prototype
: dom.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
element,
value,
);
element.dispatchEvent(
new Event(element.tagName === "SELECT" ? "change" : "input", {
bubbles: true,
}),
);
});
}
test("empty accounts get useful starters without invented history", async () => {
await render();
expect(container.textContent).toContain(
"No habits yet. No catching up to do.",
);
expect(container.querySelectorAll(".home-starters > button")).toHaveLength(
3,
);
expect(container.querySelector("#rhythm")).toBeNull();
expect(container.textContent).not.toContain("Demo history");
expect(container.textContent).toContain("Europe/Belgrade");
});
test("uses the Discord avatar and falls back only when it fails", async () => {
const avatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png";
fetchMock.mockResolvedValueOnce(Response.json({ ...account, avatarUrl }));
await render();
const avatar =
container.querySelector<HTMLImageElement>("img.home-avatar")!;
expect(avatar.src).toBe(avatarUrl);
expect(avatar.alt).toBe("Demo User’s Discord avatar");
await act(async () => avatar.dispatchEvent(new Event("error")));
expect(container.querySelector("img.home-avatar")).toBeNull();
expect(container.querySelector(".home-avatar")?.textContent).toBe("D");
});
test("a starter is editable and creates a persistent first habit", async () => {
await render();
await act(async () =>
container
.querySelector<HTMLButtonElement>(".home-starters > button")!
.click(),
);
expect(
container.querySelector<HTMLInputElement>("#habit-name")!.value,
).toBe("Read a little");
await input("#habit-name", "Read five pages");
await click("Dusk #79618d");
await act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
expect(container.querySelector("dialog")).toBeNull();
const created = (await api.json("/habits")).habits[0];
expect(created.name).toBe("Read five pages");
expect(
(await api.json(`/habits/${created.id}/calendar-settings`)).mainColor,
).toBe("#79618d");
expect(container.querySelector(".home-habit")?.textContent).toContain(
"Read five pages",
);
expect(container.textContent).toContain("Your recorded progress");
expect(container.textContent).not.toContain("No habits yet");
const chart = container.querySelector(`#history-${created.id}`)!;
expect(chart.classList.contains("ds-habit-chart")).toBe(true);
expect(
chart.querySelector<HTMLElement>(".ds-habit-chart-heading h4 > span")!
.style.backgroundColor,
).toBe("#79618d");
});
test("edit controls prefill settings, cancel safely, and persist validated changes", async () => {
const habit = await api.json(
"/habits",
"POST",
{
name: "Water",
method: "count",
target: 8,
unit: "glasses",
carryPartialProgress: true,
color: "#426582",
schedule: { type: "interval", every: 2, anchor: "2026-09-04" },
},
201,
);
await render();
await click("Edit Water");
expect(
container.querySelector<HTMLInputElement>("#habit-target")!.value,
).toBe("8");
expect(
container.querySelector<HTMLSelectElement>("#habit-method")!.disabled,
).toBe(true);
await input("#habit-name", "Not saved");
await click("Cancel");
expect((await api.json(`/habits/${habit.id}`)).name).toBe("Water");
await click("Edit Water");
await input("#habit-name", "Drink water");
await input("#habit-target", "10");
await click("Dusk #79618d");
const submit = async () =>
act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Try saving again" }, { status: 503 }),
);
await submit();
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
"Try saving again",
);
expect(
container.querySelector<HTMLInputElement>("#habit-name")!.value,
).toBe("Drink water");
await submit();
expect(container.querySelector("dialog")).toBeNull();
const updated = await api.json(`/habits/${habit.id}`);
expect(updated.name).toBe("Drink water");
expect(updated.target).toBe(10);
expect(updated.carryPartialProgress).toBe(true);
expect(updated.schedule).toEqual(habit.schedule);
expect(
(await api.json(`/habits/${habit.id}/calendar-settings`)).mainColor,
).toBe("#79618d");
expect(
container.querySelector(`#history-${habit.id} h4`)?.textContent,
).toBe("Drink water");
});
test("editing a task habit preserves task IDs and schedules", async () => {
const habit = await api.json(
"/habits",
"POST",
{
name: "Reset",
method: "tasks",
tasks: [
{ name: "Clear desk", schedule: { type: "weekdays", days: [5] } },
],
},
201,
);
await render();
await click("Edit Reset");
await input("#habit-name", "Evening reset");
await act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
expect(container.querySelector("dialog")).toBeNull();
const updated = await api.json(`/habits/${habit.id}`);
expect(updated.name).toBe("Evening reset");
expect(updated.tasks).toEqual(habit.tasks);
});
test("deletion requires confirmation, supports retry, and handles an empty dashboard", async () => {
const habit = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
await render();
await click("Delete Read");
expect(container.querySelector("dialog")?.textContent).toContain(
"not permanently erased",
);
expect((await api.json("/habits")).habits).toHaveLength(1);
await click("Keep habit");
expect(container.querySelector("dialog")).toBeNull();
await click("Delete Read");
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Could not delete" }, { status: 503 }),
);
await click("Delete habit");
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
"Could not delete",
);
expect(container.querySelector(`#history-${habit.id}`)).not.toBeNull();
await click("Delete habit");
expect(container.querySelector("dialog")).toBeNull();
expect(container.querySelector(`#history-${habit.id}`)).toBeNull();
expect(container.textContent).toContain("No habits yet");
expect((await api.json("/habits")).habits).toHaveLength(0);
expect((await api.json(`/habits/${habit.id}`)).archived).toBe(true);
});
test("all tracking methods save, undo, and exclude unscheduled habits", async () => {
const manual = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
const count = await api.json(
"/habits",
"POST",
{ name: "Water", method: "count", target: 8, unit: "glasses" },
201,
);
const tasks = await api.json(
"/habits",
"POST",
{ name: "Reset", method: "tasks", tasks: [{ name: "Clear desk" }] },
201,
);
await api.json(
"/habits",
"POST",
{
name: "Sunday walk",
method: "manual",
schedule: { type: "weekdays", days: [0] },
},
201,
);
await render();
expect(container.querySelectorAll(".home-habit")).toHaveLength(3);
expect(
container.querySelectorAll(
"#rhythm .ds-habit-chart-grid > .ds-habit-chart",
),
).toHaveLength(4);
expect(container.querySelector("#history-habit")).toBeNull();
expect(
container.querySelectorAll("#rhythm .ds-calendar--compact"),
).toHaveLength(4);
expect(
container.querySelector(".home-task-details")?.hasAttribute("open"),
).toBe(false);
expect(container.querySelector(".home-off-day")?.textContent).toContain(
"Sunday walk",
);
await act(async () =>
container
.querySelector<HTMLInputElement>(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(1);
await click("Increase Water (glasses)");
expect((await api.json(`/habits/${count.id}/days/2026-09-04`)).value).toBe(
1,
);
await act(async () =>
container
.querySelector(`#habit-${tasks.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(2);
await click("Remaining 1");
expect(container.querySelectorAll(".home-habit")).toHaveLength(1);
await click("All today 3");
await act(async () =>
container
.querySelector(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(1);
});
test("all-complete and no-schedule days are different from having no habits", async () => {
const habit = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
await api.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", {
done: true,
});
await render();
expect(container.textContent).toContain(
"Everything scheduled for today is complete",
);
await click("Remaining 0");
expect(container.textContent).toContain("You’re all caught up.");
await api.json(`/habits/${habit.id}`, "PATCH", {
schedule: { type: "weekdays", days: [0] },
});
await act(async () => window.dispatchEvent(new Event("focus")));
expect(container.textContent).toContain("Nothing is scheduled today");
expect(container.textContent).not.toContain("No habits yet");
});
test("failed logging preserves progress and offers a refresh", async () => {
await api.json(
"/habits",
"POST",
{ name: "Water", method: "count", target: 8, unit: "glasses" },
201,
);
await render();
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Could not save progress" }, { status: 503 }),
);
await click("Increase Water (glasses)");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not save progress",
);
expect((await api.json("/today")).habits[0].value).toBe(0);
await click("Try again");
await click("Increase Water (glasses)");
expect((await api.json("/today")).habits[0].value).toBe(1);
});
test("a failed initial load is not shown as an empty account", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(new Response(null, { status: 503 }));
await render();
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(container.textContent).not.toContain("No habits yet");
await click("Try again");
expect(container.textContent).toContain("No habits yet");
});
});

View File

@@ -3,10 +3,37 @@ import { AuthControls } from "./components/AuthControls";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import { About } from "./pages/About"; import { About } from "./pages/About";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
import { DesignSystem } from "./pages/DesignSystem";
import { AuthProvider, useAuth } from "./components/AuthProvider";
import { Landing } from "./pages/Landing";
import { Button } from "./components/design-system/primitives";
export function App() { export function App() {
const { pathname } = useLocation();
if (pathname === "/design-system" || pathname === "/design-system/") return <DesignSystem />;
return <AuthProvider><AppRoutes /></AuthProvider>;
}
function AppRoutes() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { user, loading, accountError, retry } = useAuth();
if (pathname === "/") {
if (loading || accountError) return (
<div className="ds-root landing-root">
<main className="landing-account-state">
<a className="ds-wordmark" href="/">minabot.</a>
{loading ? <p role="status">Loading your account…</p> : <>
<p role="alert">{accountError}</p>
<Button variant="secondary" onClick={retry}>Try again</Button>
</>}
</main>
</div>
);
if (!user) return <Landing />;
return <Home />;
}
return ( return (
<> <>
@@ -14,6 +41,7 @@ export function App() {
<button type="button" disabled={pathname === "/"} onClick={() => navigate("/")}>Home</button>{" "} <button type="button" disabled={pathname === "/"} onClick={() => navigate("/")}>Home</button>{" "}
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "} <button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button> <button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
{" "}<button type="button" onClick={() => navigate("/design-system")}>Design system</button>
</nav> </nav>
<AuthControls /> <AuthControls />
<main> <main>

View File

@@ -1,3 +1,5 @@
import { createHabitRoutes } from "./habits/routes";
import { ApiError } from "./habits/service";
import { Hono } from "hono"; import { Hono } from "hono";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { createAuth, type AppDatabase, type AuthEnv } from "./auth"; import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
@@ -13,9 +15,11 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
}); });
app.route("/api/auth", auth.routes); app.route("/api/auth", auth.routes);
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user"))); app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
app.notFound(c => c.json({ error: "Not found" }, 404)); app.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => { app.onError((_error, c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
return c.json({ error: "Internal server error" }, 500); return c.json({ error: "Internal server error" }, 500);
}); });
return app; return app;

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
src/assets/design/today.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

Binary file not shown.

93
src/assets/fonts/OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -17,12 +17,13 @@ let app: ReturnType<typeof createApi>;
let timestamp: number; let timestamp: number;
let calls: { url: string; init: RequestInit }[]; let calls: { url: string; init: RequestInit }[];
let profile: DiscordProfile; let profile: DiscordProfile;
let failure: "token" | "profile" | "malformed" | "throw" | undefined; let failure: "token" | "invalid_token" | "profile" | "malformed" | "throw" | undefined;
const request: FetchDiscord = async (url, init) => { const request: FetchDiscord = async (url, init) => {
calls.push({ url, init }); calls.push({ url, init });
if (failure === "throw") throw new Error("provider secret response"); if (failure === "throw") throw new Error("provider secret response");
if (url.endsWith("/token")) { if (url.endsWith("/token")) {
if (failure === "token") return new Response("provider secret response", { status: 400 }); if (failure === "token") return new Response("provider secret response", { status: 400 });
if (failure === "invalid_token") return Response.json({ access_token: "secret-access", token_type: "Bearer", scope: "email" });
return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" }); return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" });
} }
if (failure === "profile") return new Response("provider secret response", { status: 503 }); if (failure === "profile") return new Response("provider secret response", { status: 503 });
@@ -149,7 +150,7 @@ describe("Discord sign-in and sessions", () => {
expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0); expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0);
}); });
test.each(["token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => { test.each(["token", "invalid_token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => {
failure = value; failure = value;
const result = await login(); const result = await login();
expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable"); expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable");

View File

@@ -1,58 +1,7 @@
import { useEffect, useState } from "react"; import { useAuth } from "./AuthProvider";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
not_configured: "Discord sign-in is not configured yet.",
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
denied: "Discord sign-in was cancelled. You can try again when ready.",
invalid_code: "Discord did not return a sign-in code. Please try again.",
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
};
export function AuthControls() { export function AuthControls() {
const [user, setUser] = useState<PublicUser | null>(null); const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { search } = useLocation();
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch("/api/me", { signal: controller.signal });
if (response.status === 401) return;
if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
setUser(await response.json());
} catch (error) {
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadUser();
return () => controller.abort();
}, []);
function signIn() {
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
}
async function signOut() {
setBusy(true);
setError("");
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
} catch (error) {
setError(error instanceof Error ? error.message : "Could not sign out.");
} finally { setBusy(false); }
}
return ( return (
<section aria-label="Account"> <section aria-label="Account">
@@ -62,8 +11,9 @@ export function AuthControls() {
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "} <button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<a href="/api/me">View my profile</a> <a href="/api/me">View my profile</a>
</p> </p>
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>} ) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
{(error || signInError) && <p role="alert">{error || signInError}</p>} {error && <p role="alert">{error}</p>}
{accountError && <button type="button" onClick={retry}>Try again</button>}
</section> </section>
); );
} }

View File

@@ -0,0 +1,91 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
not_configured: "Discord sign-in is not configured yet.",
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
denied: "Discord sign-in was cancelled. You can try again when ready.",
invalid_code: "Discord did not return a sign-in code. Please try again.",
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
};
function signIn() {
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
}
type AuthState = {
user: PublicUser | null;
loading: boolean;
busy: boolean;
accountError: string;
error: string;
signIn: () => void;
signOut: () => Promise<void>;
retry: () => void;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState("");
const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0);
const { search } = useLocation();
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch("/api/me", { signal: controller.signal });
if (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account);
} catch (error) {
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadUser();
return () => controller.abort();
}, [attempt]);
function retry() {
setAccountError("");
setLoading(true);
setAttempt((current) => current + 1);
}
async function signOut() {
setBusy(true);
setActionError("");
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Could not sign out.");
} finally { setBusy(false); }
}
return (
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const auth = useContext(AuthContext);
if (!auth) throw new Error("useAuth must be used within AuthProvider");
return auth;
}

View File

@@ -0,0 +1,261 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Button } from "./design-system/primitives";
import { ScheduleEditor } from "./design-system/EditingWorkbench";
import {
habitInput,
habitPatch,
type HabitConfig,
type Schedule,
} from "../habits/contracts";
import { habitRequest } from "../lib/dashboard";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
export type HabitStarter = {
name: string;
method: "manual" | "count" | "tasks";
target?: number;
unit?: string;
tasks?: string;
color?: string;
};
export function CreateHabit({
date,
starter,
onClose,
onCreated,
editing,
}: {
date: string;
starter: HabitStarter;
onClose: () => void;
onCreated: (name: string) => void;
editing?: { id: string; config: HabitConfig };
}) {
const dialog = useRef<HTMLDialogElement>(null);
const saving = useRef(false);
const [name, setName] = useState(starter.name);
const [method, setMethod] = useState(starter.method);
const [target, setTarget] = useState(starter.target ?? 8);
const [unit, setUnit] = useState(starter.unit ?? "glasses");
const [tasks, setTasks] = useState(starter.tasks ?? "");
const [color, setColor] = useState(starter.color ?? "#58765b");
const [schedule, setSchedule] = useState<Schedule>(
editing?.config.schedule ?? { type: "daily" },
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialog.current?.showModal();
dialog.current?.querySelector<HTMLInputElement>("#habit-name")?.focus();
return () => {
previous?.focus();
};
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
const taskNames = tasks
.split("\n")
.map((name) => name.trim())
.filter(Boolean);
if (!editing && method === "tasks" && !taskNames.length) {
setError("Add at least one task to get started.");
return;
}
const input = {
name,
method,
schedule,
color,
...(method === "count"
? { target, unit }
: method === "tasks" && !editing
? { tasks: taskNames.map((name) => ({ name })) }
: {}),
};
const parsed = editing
? habitPatch.safeParse(input)
: habitInput.safeParse(input);
if (!parsed.success) {
setError(parsed.error.issues.map((issue) => issue.message).join(" "));
return;
}
saving.current = true;
setBusy(true);
setError("");
try {
await habitRequest(editing ? `/habits/${editing.id}` : "/habits", {
method: editing ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed.data),
});
onCreated(parsed.data.name!);
} catch (error) {
setError(
error instanceof Error ? error.message : "Could not save your habit.",
);
saving.current = false;
setBusy(false);
}
}
return (
<dialog
ref={dialog}
className="home-create"
aria-labelledby="create-title"
onCancel={(event) => {
event.preventDefault();
if (!saving.current) onClose();
}}
>
<div className="home-create-heading">
<p className="ds-eyebrow">
{editing ? "SHAPE YOUR RHYTHM" : "A LITTLE ROOM FOR SOMETHING GOOD"}
</p>
<Button
variant="text"
aria-label={editing ? "Close habit editor" : "Close new habit"}
disabled={busy}
onClick={onClose}
>
×
</Button>
</div>
<h2 id="create-title">
{editing ? (
<>
Edit your <em>habit.</em>
</>
) : (
<>
Make it <em>yours.</em>
</>
)}
</h2>
<p className="ds-muted">
{editing
? "Changes start today. Earlier targets and schedules stay as they were."
: "Start small. You don’t need a perfect plan."}
</p>
<form onSubmit={submit}>
<fieldset disabled={busy} className="home-form-fields">
<div className="ds-field">
<label htmlFor="habit-name">Habit name</label>
<input
id="habit-name"
required
maxLength={200}
placeholder="Something you want to make time for"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="ds-field">
<label htmlFor="habit-method">How will you track it?</label>
<select
id="habit-method"
disabled={!!editing}
value={method}
onChange={(event) =>
setMethod(event.target.value as HabitStarter["method"])
}
>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
</select>
{editing && (
<small>
Tracking method stays the same. Create another habit to track it
differently.
</small>
)}
</div>
{method === "count" && (
<div className="ds-form-grid">
<div className="ds-field">
<label htmlFor="habit-target">Daily target</label>
<input
id="habit-target"
type="number"
required
min={1}
max={10000}
step={1}
value={Number.isNaN(target) ? "" : target}
onChange={(event) => setTarget(event.target.valueAsNumber)}
/>
</div>
<div className="ds-field">
<label htmlFor="habit-unit">Unit</label>
<input
id="habit-unit"
required
maxLength={80}
value={unit}
onChange={(event) => setUnit(event.target.value)}
/>
</div>
</div>
)}
{method === "tasks" && !editing && (
<div className="ds-field">
<label htmlFor="habit-tasks">Tasks · one per line</label>
<textarea
id="habit-tasks"
required
rows={4}
value={tasks}
onChange={(event) => setTasks(event.target.value)}
placeholder={"Clear desk\nPlan tomorrow\nStretch"}
/>
<small>
These tasks repeat on each scheduled day. Up to 100 tasks.
</small>
</div>
)}
<ScheduleEditor
value={schedule}
onChange={setSchedule}
anchorDate={date}
/>
{method === "tasks" && editing && (
<p className="ds-footnote">
Your existing tasks and their individual schedules are kept.
</p>
)}
<HabitColorPicker
value={color}
onChange={setColor}
mode={editing ? "edit" : "create"}
/>
</fieldset>
{error && (
<p className="home-error" role="alert">
{error}
</p>
)}
<div className="home-form-actions">
<Button variant="secondary" disabled={busy} onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={busy}>
{busy
? editing
? "Saving…"
: "Creating…"
: editing
? "Save changes"
: "Create habit"}{" "}
<span aria-hidden="true">↗</span>
</Button>
</div>
</form>
</dialog>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, type TodayHabit } from "../lib/dashboard";
import { Button } from "./design-system/primitives";
export function DeleteHabit({
habit,
onClose,
onDeleted,
}: {
habit: TodayHabit;
onClose: () => void;
onDeleted: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const saving = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialog.current?.showModal();
return () => previous?.focus();
}, []);
async function remove() {
if (saving.current) return;
saving.current = true;
setBusy(true);
setError("");
try {
await habitRequest(`/habits/${habit.habitId}`, { method: "DELETE" });
onDeleted();
} catch (error) {
setError(
error instanceof Error ? error.message : "Could not delete your habit.",
);
saving.current = false;
setBusy(false);
}
}
return (
<dialog
ref={dialog}
className="home-create home-delete"
aria-labelledby="delete-title"
aria-describedby="delete-description"
onCancel={(event) => {
event.preventDefault();
if (!saving.current) onClose();
}}
>
<p className="ds-eyebrow">MAKE A LITTLE ROOM</p>
<h2 id="delete-title">
Delete this <em>habit?</em>
</h2>
<p id="delete-description" className="ds-muted">
<strong>{habit.name}</strong> will leave your dashboard and stop future
check-ins. Your recorded history is kept, not permanently erased.
</p>
{error && (
<p className="home-error" role="alert">
{error}
</p>
)}
<div className="home-form-actions">
<Button variant="secondary" disabled={busy} onClick={onClose} autoFocus>
Keep habit
</Button>
<Button disabled={busy} onClick={() => void remove()}>
{busy ? "Deleting…" : "Delete habit"}
</Button>
</div>
</dialog>
);
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow } from "./design-system/calendar-model";
import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar";
export function HabitHistory({
habit,
date,
revision,
onEdit,
onDelete,
disabled = false,
}: {
habit: TodayHabit;
date: string;
revision: number;
onEdit: (color: string) => void;
onDelete: () => void;
disabled?: boolean;
}) {
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
setError("");
const range = monthWindow(date, 12);
habitRequest<CalendarResponse>(
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
{ signal: controller.signal },
)
.then((result) => {
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (!controller.signal.aborted) setError(error.message);
});
return () => controller.abort();
}, [habit.habitId, date, revision, attempt]);
const days =
calendar?.days.map((day) => ({
date: day.date,
color: day.color,
unit: day.habits[0]?.unit,
value: day.habits[0]?.value ?? 0,
target: day.habits[0]?.target ?? 0,
state: day.future
? ("future" as const)
: day.due
? ("due" as const)
: ("not-due" as const),
})) ?? [];
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ??
1,
);
const shades = calendar
? [
calendar.settings.emptyColor,
...Array.from(
{ length: Math.min(8, steps) },
(_, index) =>
shade(
Math.ceil(((index + 1) * steps) / Math.min(8, steps)) / steps,
steps,
calendar.settings,
true,
).color,
),
]
: [];
const chart = error ? (
<div className="home-state">
<p role="alert">{error}</p>
<Button
variant="secondary"
onClick={() => setAttempt((value) => value + 1)}
>
Retry history
</Button>
</div>
) : !calendar ? (
<p className="home-state" role="status">
Loading your habit history…
</p>
) : (
<CalendarHeatmap
compact
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
days={days}
color={calendar.settings.mainColor}
shades={shades}
/>
}
/>
);
return (
<HabitChart
id={`history-${habit.habitId}`}
name={habit.name ?? "Habit"}
method={
habit.method === "count"
? "Count target"
: habit.method === "tasks"
? "Task-based"
: "Simple check-in"
}
schedule={scheduleLabel(habit.requirements?.schedule)}
due={habit.due}
value={habit.value}
target={habit.target ?? 0}
unit={habit.unit}
color={calendar?.settings.mainColor ?? "#196127"}
calendar={chart}
>
<div
className="home-habit-actions"
role="group"
aria-label={`${habit.name} actions`}
>
<Button
variant="text"
aria-label={`Edit ${habit.name}`}
disabled={disabled || !calendar}
onClick={() => onEdit(calendar!.settings.mainColor)}
>
Edit
</Button>
<Button
variant="text"
aria-label={`Delete ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Delete
</Button>
</div>
</HabitChart>
);
}

View File

@@ -0,0 +1,67 @@
import { useState } from "react";
import { Button, Checkbox, Counter } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { combinedProgress, demoCalendar, HABIT_COLORS } from "./design-system/calendar-model";
export function LandingDemo() {
const [water, setWater] = useState(7);
const [tasks, setTasks] = useState([true, true, false]);
const [view, setView] = useState<"Today" | "Combined progress">("Today");
const taskCount = tasks.filter(Boolean).length;
const total = combinedProgress([
{ value: water, target: 8, due: true },
{ value: taskCount, target: 3, due: true },
]);
return (
<section className="ds-section landing-demo" id="demo" aria-labelledby="demo-title">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / A LITTLE, EVERY DAY</span>
<h2 id="demo-title">Small steps. <em>Visible progress.</em></h2>
</div>
<span className="ds-demo-note">Interactive demo · nothing is saved</span>
</div>
<div className="ds-preview-toolbar">
<div className="ds-view-switch" role="group" aria-label="Demo view">
{(["Today", "Combined progress"] as const).map((item) => (
<button key={item} type="button" aria-pressed={view === item} onClick={() => setView(item)}>{item}</button>
))}
</div>
<Button variant="text" onClick={() => { setWater(7); setTasks([true, true, false]); setView("Today"); }}>
Reset demo <span aria-hidden="true">↺</span>
</Button>
</div>
<div className="ds-preview-heading">
<div>
<p className="ds-eyebrow">A SAMPLE DAY / SEPTEMBER 4, 2026</p>
<h3>{view === "Today" ? "Make a little room for yourself." : "See your habits, together."}</h3>
</div>
<p aria-live="polite">{total.completed} of {total.due} habits complete</p>
</div>
{view === "Today" ? (
<div className="ds-habit-chart-grid">
<HabitChart name="Drink water" method="Count target" value={water} target={8} unit="glasses" color={HABIT_COLORS.water}>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart name="Evening reset" method="Task-based" value={taskCount} target={3} unit="tasks" color={HABIT_COLORS.tasks}
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map((label, index) => (
<Checkbox key={label} label={label} checked={tasks[index]} onChange={(event) => {
const checked = event.target.checked;
setTasks((current) => current.map((done, i) => i === index ? checked : done));
}} />
))}
/>
</div>
) : (
<CalendarHeatmap days={demoCalendar(total.completed, total.due)} label="Combined habit calendar" unit="habits complete" />
)}
<p className="ds-footnote landing-demo-hint">
{view === "Today"
? "Try adding a glass of water, or open the evening tasks and tick off a small win. Select any day to take a closer look."
: "Each fully completed habit counts equally. Partial progress appears in its own calendar; unscheduled habits stay out of the combined score."}
</p>
</section>
);
}

View File

@@ -0,0 +1,245 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import {
describeDay,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
type CalendarDay,
type MonthView,
} from "./calendar-model";
export function CalendarHeatmap({
days: allDays,
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
legend,
}: {
days: CalendarDay[];
unit: string;
label: string;
color?: string;
emptyColor?: string;
compact?: boolean;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
}) {
const [months, setMonths] = useState<MonthView>(6);
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.at(-1)?.date;
// A date selected in the editor must stay visible even outside the current window.
const anchor =
controlledDate && allDays.some((day) => day.date === controlledDate)
? controlledDate
: latestDate;
const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
const selectedDate = controlledDate ?? localSelectedDate;
function setSelectedDate(date: string) {
if (onSelectDate) onSelectDate(date);
else setLocalSelectedDate(date);
}
const days = anchor ? visibleCalendarDays(allDays, months, anchor) : [];
const selected =
days.find((day) => day.date === selectedDate) ??
days.find((day) => day.date === anchor) ??
days[0];
const offset = days[0]
? new Date(`${days[0].date}T12:00:00Z`).getUTCDay()
: 0;
const weeks = Math.ceil((days.length + offset) / 7);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const scrollArea = useRef<HTMLDivElement | null>(null);
const inspectorId = useId();
const selectedIndex = selected ? days.indexOf(selected) : -1;
useEffect(() => {
const container = scrollArea.current;
const button = buttons.current[selectedIndex];
if (!container || !button) return;
const cellBounds = button.getBoundingClientRect();
const bounds = container.getBoundingClientRect();
if (cellBounds.right > bounds.right - 5)
container.scrollLeft += cellBounds.right - bounds.right + 5;
else if (cellBounds.left < bounds.left + 5)
container.scrollLeft += cellBounds.left - bounds.left - 5;
}, [months, selectedIndex]);
if (!selected) return <p>No calendar dates to display.</p>;
return (
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
>
<div className="ds-calendar-range-toolbar">
<span className="ds-calendar-range-label" aria-live="polite">
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
year: "numeric",
timeZone: "UTC",
})}{" "}
–{" "}
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
year: "numeric",
timeZone: "UTC",
})}
</span>
<div
className="ds-month-views"
role="group"
aria-label={`${label} time range`}
>
{MONTH_VIEWS.map((value) => (
<button
key={value}
type="button"
aria-label={`${value} months`}
aria-pressed={months === value}
onClick={() => setMonths(value)}
>
{value}m
</button>
))}
</div>
</div>
<div
ref={scrollArea}
className="ds-calendar-scroll"
role="group"
aria-label={label}
>
<div
className="ds-calendar-inner"
style={{
minWidth:
months === 12
? 0
: Math.max(compact ? 280 : 600, weeks * 14 + 38),
}}
>
<div
className="ds-calendar-months"
aria-hidden="true"
style={{
gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))`,
}}
>
{days.map(
(day, index) =>
(index === 0 || day.date.endsWith("-01")) && (
<span
key={day.date}
style={{ gridColumn: Math.floor((index + offset) / 7) + 1 }}
>
{new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
"en",
{ month: "short", timeZone: "UTC" },
)}
</span>
),
)}
</div>
<div className="ds-calendar-body">
<div className="ds-calendar-weekdays" aria-hidden="true">
<span>Mon</span>
<span>Wed</span>
<span>Fri</span>
</div>
<div
className="ds-calendar-grid"
style={{
gridTemplateColumns: `repeat(${weeks}, 1fr)`,
}}
>
{Array.from({ length: offset }, (_, index) => (
<span key={`padding-${index}`} aria-hidden="true" />
))}
{days.map((day, index) => (
<button
key={day.date}
ref={(element) => {
buttons.current[index] = element;
}}
type="button"
className={`ds-day ds-day--${day.state}`}
style={{
backgroundColor:
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
aria-pressed={day.date === selected.date}
aria-describedby={inspectorId}
tabIndex={day.date === selected.date ? 0 : -1}
onClick={() => setSelectedDate(day.date)}
onKeyDown={(event) => {
const offset = {
ArrowDown: 1,
ArrowUp: -1,
ArrowRight: 7,
ArrowLeft: -7,
}[event.key];
const next =
event.key === "Home"
? 0
: event.key === "End"
? days.length - 1
: offset !== undefined
? Math.max(
0,
Math.min(days.length - 1, index + offset),
)
: undefined;
if (next !== undefined) {
event.preventDefault();
setSelectedDate(days[next]!.date);
buttons.current[next]?.focus();
}
}}
/>
))}
</div>
</div>
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months} months{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<span className="ds-muted">
{compact
? "Select a day · Arrow keys"
: "Select a day to inspect · Arrow keys to move"}
</span>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<span>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})}
</span>
<span>{describeDay(selected, unit)}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { legendShades, type CalendarDay } from "./calendar-model";
export function CalendarLegend({
days,
color,
label,
shades: suppliedShades,
}: {
days: CalendarDay[];
color: string;
label: string;
shades?: string[];
}) {
const shades = suppliedShades ?? legendShades(days, color);
return (
<div
className="ds-calendar-legend"
role="group"
aria-label={`${label} legend`}
>
<div className="ds-legend-scale">
<span>0 / Upcoming</span>
<span
className="ds-legend-swatches"
role="img"
aria-label={
shades.length > 2
? "Empty, then increasing partial progress through complete"
: shades.length === 2
? "Empty or complete"
: "Empty"
}
>
{shades.map((shade) => (
<span
key={shade}
className="ds-legend-swatch"
style={{ backgroundColor: shade }}
/>
))}
</span>
{shades.length > 1 && <span>Complete</span>}
</div>
<span className="ds-legend-neutral">
<span
className="ds-legend-swatch ds-legend-swatch--not-due"
aria-hidden="true"
/>
Not scheduled
</span>
</div>
);
}

View File

@@ -0,0 +1,909 @@
import { useEffect, useId, useState, type ReactNode } from "react";
import type { HabitConfig, Schedule } from "../../habits/contracts";
import { Button, Checkbox } from "./primitives";
import { CalendarHeatmap } from "./CalendarHeatmap";
import {
HabitColorPicker,
isHabitColor,
previewHabitColors,
type HabitColors,
} from "./HabitColorPicker";
import {
EDITOR_HABITS,
EDITOR_START,
EDITOR_TODAY,
backfillError,
historicalDays,
validateEditor,
validateTasks,
} from "./editing-model";
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: (id: string) => ReactNode;
}) {
const id = useId();
return (
<div className="ds-field">
<label htmlFor={id}>{label}</label>
{children(id)}
{hint && <small>{hint}</small>}
</div>
);
}
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function ScheduleEditor({
value,
onChange,
anchorDate = EDITOR_TODAY,
}: {
value: Schedule;
onChange: (schedule: Schedule) => void;
anchorDate?: string;
}) {
return (
<div className="ds-schedule-editor">
<Field label="Repeats">
{(id) => (
<select
id={id}
value={value.type}
onChange={(event) => {
const type = event.target.value;
onChange(
type === "daily"
? { type }
: type === "weekdays"
? { type, days: [1, 2, 3, 4, 5] }
: type === "interval"
? { type, every: 2, anchor: anchorDate }
: {
type: "weekly",
every: 1,
weekday: 1,
anchor: anchorDate,
},
);
}}
>
<option value="daily">Every day</option>
<option value="weekdays">Selected weekdays</option>
<option value="interval">Every few days</option>
<option value="weekly">Every few weeks</option>
</select>
)}
</Field>
{value.type === "weekdays" && (
<fieldset className="ds-weekday-field">
<legend>Scheduled days · choose at least one</legend>
<div className="ds-weekdays">
{WEEKDAYS.map((day, index) => (
<button
type="button"
key={day}
aria-label={day}
aria-pressed={value.days.includes(index)}
onClick={() =>
onChange({
...value,
days: value.days.includes(index)
? value.days.filter((item) => item !== index)
: [...value.days, index].sort(),
})
}
>
{day}
</button>
))}
</div>
</fieldset>
)}
{(value.type === "interval" || value.type === "weekly") && (
<div className="ds-form-grid">
<Field
label={value.type === "interval" ? "Every (days)" : "Every (weeks)"}
>
{(id) => (
<input
id={id}
type="number"
required
min={1}
max={value.type === "interval" ? 3650 : 520}
value={Number.isNaN(value.every) ? "" : value.every}
onChange={(event) =>
onChange({ ...value, every: event.target.valueAsNumber })
}
/>
)}
</Field>
<Field label="Starting on">
{(id) => (
<input
id={id}
type="date"
required
min="1970-01-01"
max="9998-12-31"
value={value.anchor}
onInput={(event) =>
onChange({ ...value, anchor: event.currentTarget.value })
}
/>
)}
</Field>
</div>
)}
{value.type === "weekly" && (
<Field label="On weekday">
{(id) => (
<select
id={id}
value={value.weekday}
onChange={(event) =>
onChange({ ...value, weekday: Number(event.target.value) })
}
>
{WEEKDAYS.map((day, index) => (
<option key={day} value={index}>
{day}
</option>
))}
</select>
)}
</Field>
)}
</div>
);
}
function SaveBar({
dirty,
error,
onCancel,
label = "Save changes",
disabled = false,
}: {
dirty: boolean;
error: string | null;
onCancel: () => void;
label?: string;
disabled?: boolean;
}) {
return (
<>
<p className="ds-form-feedback" role={error ? "alert" : "status"}>
{error || (dirty ? "Unsaved changes" : "No unsaved changes")}
</p>
<div className="ds-form-actions">
<Button type="submit" disabled={!dirty || disabled}>
{label} <span aria-hidden="true">↗</span>
</Button>
<Button variant="text" onClick={onCancel} disabled={!dirty}>
Cancel
</Button>
</div>
</>
);
}
type EditorTab = "Habit settings" | "Tasks" | "Backfill progress";
type TaskConfig = Extract<HabitConfig, { method: "tasks" }>;
type Correction = {
habitId: string;
name: string;
date: string;
before: number;
after: number;
target: number;
unit: string;
};
export function EditingWorkbench({
onColorsChange,
}: {
onColorsChange?: (colors: HabitColors) => void;
}) {
const [tab, setTab] = useState<EditorTab>("Habit settings");
const [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS));
const [selectedId, setSelectedId] = useState("water");
const selected = habits.find((habit) => habit.id === selectedId)!;
const [draftColor, setDraftColor] = useState(selected.color);
const previewColor = isHabitColor(draftColor) ? draftColor : selected.color;
useEffect(() => {
onColorsChange?.(previewHabitColors(habits, selectedId, previewColor));
}, [habits, selectedId, previewColor, onColorsChange]);
const [draft, setDraft] = useState<HabitConfig>(() =>
structuredClone(selected.config),
);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [corrections, setCorrections] = useState<Correction[]>([]);
const [entries, setEntries] = useState<
Record<string, { value: number; tasks: boolean[] }>
>({});
const [date, setDate] = useState("2026-09-03");
const historical = EDITOR_HABITS.find(
(habit) => habit.id === selectedId,
)!.config;
const [progress, setProgress] = useState(
() => historicalDays("water").find((day) => day.date === date)!.value,
);
const [taskChecks, setTaskChecks] = useState<boolean[]>([]);
const entryKey = `${selectedId}:${date}`;
const day = historicalDays(selectedId).find((day) => day.date === date);
const savedProgress = entries[entryKey]?.value ?? day?.value ?? 0;
const savedChecks =
entries[entryKey]?.tasks ??
(historical.method === "tasks"
? historical.tasks.map((_, index) => index < savedProgress)
: []);
const configDirty =
JSON.stringify(draft) !== JSON.stringify(selected.config) ||
draftColor.toLowerCase() !== selected.color.toLowerCase();
const progressDirty =
progress !== savedProgress ||
(historical.method === "tasks" &&
JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
const blocked = backfillError(selectedId, date, progress);
function loadProgress(id: string, nextDate: string) {
const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
const entry = entries[`${id}:${nextDate}`];
const value =
entry?.value ??
historicalDays(id).find((day) => day.date === nextDate)?.value ??
0;
setProgress(value);
setTaskChecks(
entry?.tasks ??
(original.method === "tasks"
? original.tasks.map((_, index) => index < value)
: []),
);
setError(null);
}
function canLeave() {
return !dirty || window.confirm("Discard unsaved changes in this preview?");
}
function selectHabit(id: string) {
if (!canLeave()) return;
setSelectedId(id);
setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
setDraftColor(habits.find((habit) => habit.id === id)!.color);
loadProgress(id, date);
setNotice("");
}
function selectTab(next: EditorTab) {
if (next === tab || !canLeave()) return;
const id = next === "Tasks" ? "tasks" : selectedId;
setTab(next);
setSelectedId(id);
setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
setDraftColor(habits.find((habit) => habit.id === id)!.color);
loadProgress(id, date);
setNotice("");
}
function selectDate(next: string) {
if (next === date || !canLeave()) return;
setDate(next);
loadProgress(selectedId, next);
setNotice("");
}
function saveConfig() {
if (!isHabitColor(draftColor)) {
setError("Use a six-digit hex color, such as #426582.");
return;
}
const validation =
tab === "Tasks" && draft.method === "tasks"
? validateTasks(draft.tasks)
: validateEditor(draft);
if (validation) {
setError(validation);
return;
}
const clean = {
...draft,
name: draft.name.trim(),
...(draft.method === "count" ? { unit: draft.unit.trim() } : {}),
...(draft.method === "tasks"
? {
tasks: draft.tasks.map((task) => ({
...task,
name: task.name.trim(),
})),
}
: {}),
} as HabitConfig;
setHabits((current) =>
current.map((habit) =>
habit.id === selectedId
? {
...habit,
config: structuredClone(clean),
color: draftColor.toLowerCase(),
}
: habit,
),
);
setDraft(clean);
setDraftColor(draftColor.toLowerCase());
setError(null);
setNotice(
`${tab === "Tasks" ? "Tasks" : clean.name} saved in this preview. Effective September 4; earlier dates are unchanged.`,
);
}
function updateTask(id: string, patch: Partial<TaskConfig["tasks"][number]>) {
if (draft.method === "tasks")
setDraft({
...draft,
tasks: draft.tasks.map((task) =>
task.id === id ? { ...task, ...patch } : task,
),
});
setError(null);
}
return (
<section
className="ds-section"
id="editing"
aria-labelledby="editing-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / EDITING & HISTORY</span>
<h2 id="editing-title">
Room for <em>real life.</em>
</h2>
</div>
<span className="ds-demo-note">
Independent editor demo · not saved
</span>
</div>
<div className="ds-preview-toolbar">
<div className="ds-view-switch" role="group" aria-label="Editing view">
{(["Habit settings", "Tasks", "Backfill progress"] as const).map(
(item) => (
<button
key={item}
type="button"
aria-pressed={tab === item}
onClick={() => selectTab(item)}
>
{item}
</button>
),
)}
</div>
<Button
variant="text"
onClick={() => {
if (
!window.confirm(
"Reset all editor changes and corrections? This only affects the preview.",
)
)
return;
setHabits(structuredClone(EDITOR_HABITS));
setSelectedId("water");
setDraft(structuredClone(EDITOR_HABITS[0]!.config));
setDraftColor(EDITOR_HABITS[0]!.color);
setTab("Habit settings");
setEntries({});
setCorrections([]);
setDate("2026-09-03");
setProgress(
historicalDays("water").find((day) => day.date === "2026-09-03")!
.value,
);
setTaskChecks([]);
setError(null);
setNotice("Editor demo reset.");
}}
>
Reset editor ↺
</Button>
</div>
<div className="ds-edit-layout">
<aside className="ds-edit-context">
<span className="ds-eyebrow">
{tab === "Backfill progress"
? "A DATE, NOT A RESET"
: "MAKE IT YOURS"}
</span>
<h3>
{tab === "Habit settings"
? "Shape your rhythm."
: tab === "Tasks"
? "Small steps, clearly defined."
: "A missed log is not a missed day."}
</h3>
<p>
{tab === "Habit settings"
? "Adjust a name, target, or schedule. Changes start today; your history stays as it was."
: tab === "Tasks"
? "Give each task its own repeat rule. It is due only when both the habit and task schedules match."
: "Choose a past date and enter what actually happened. That date keeps its original requirements."}
</p>
{tab !== "Tasks" && (
<Field label="Habit">
{(id) => (
<select
id={id}
value={selectedId}
onChange={(event) => selectHabit(event.target.value)}
>
{habits.map((habit) => (
<option value={habit.id} key={habit.id}>
{habit.config.name}
{habit.config.archived ? " · Archived" : ""}
</option>
))}
</select>
)}
</Field>
)}
<div className="ds-edit-meta">
<span
className="ds-habit-dot"
style={{ background: previewColor }}
/>
<span>
{tab === "Backfill progress"
? historical.name
: selected.config.name}
</span>
</div>
<p className="ds-footnote">
Demo clock · September 4, 2026
<br />
Tracking timezone · Europe/Belgrade
</p>
</aside>
<div className="ds-edit-content">
{tab === "Habit settings" && (
<form
onSubmit={(event) => {
event.preventDefault();
saveConfig();
}}
>
<div className="ds-spec-heading">
<h3>Edit habit</h3>
<span className="ds-code">Effective today</span>
</div>
<Field label="Habit name">
{(id) => (
<input
id={id}
required
maxLength={200}
value={draft.name}
onChange={(event) => {
setDraft({ ...draft, name: event.target.value });
setError(null);
}}
/>
)}
</Field>
<HabitColorPicker
value={draftColor}
onChange={(color) => {
setDraftColor(color);
setError(null);
setNotice("");
}}
/>
<Field
label="Completion method"
hint="Method is fixed in this preview. Choose another habit to try its editor."
>
{(id) => (
<input
id={id}
readOnly
value={
draft.method === "count"
? "Count target"
: draft.method === "manual"
? "Manual checkbox"
: "Task checklist"
}
/>
)}
</Field>
{draft.method === "count" && (
<>
<div className="ds-form-grid">
<Field label="Daily target">
{(id) => (
<input
id={id}
type="number"
required
min={1}
max={10000}
value={Number.isNaN(draft.target) ? "" : draft.target}
onChange={(event) =>
setDraft({
...draft,
target: event.target.valueAsNumber,
})
}
/>
)}
</Field>
<Field label="Unit">
{(id) => (
<input
id={id}
required
maxLength={80}
value={draft.unit}
onChange={(event) =>
setDraft({ ...draft, unit: event.target.value })
}
/>
)}
</Field>
</div>
<Checkbox
label="Carry unfinished counts to the next scheduled day"
checked={draft.carryPartialProgress}
onChange={(event) =>
setDraft({
...draft,
carryPartialProgress: event.target.checked,
})
}
/>
<p className="ds-footnote">
Completed counts reset. Earlier explicit logs are never
overwritten.
</p>
</>
)}
<div className="ds-form-section">
<ScheduleEditor
value={draft.schedule}
onChange={(schedule) => {
setDraft({ ...draft, schedule });
setError(null);
}}
/>
</div>
<div className="ds-form-section">
<Checkbox
label="Archive this habit"
checked={draft.archived}
onChange={(event) =>
setDraft({ ...draft, archived: event.target.checked })
}
/>
<p className="ds-footnote">
Stops tracking from today. History is kept. Uncheck and save
to restore.
</p>
</div>
<SaveBar
dirty={configDirty}
error={error}
onCancel={() => {
setDraft(structuredClone(selected.config));
setDraftColor(selected.color);
setError(null);
}}
/>
</form>
)}
{tab === "Tasks" && draft.method === "tasks" && (
<form
onSubmit={(event) => {
event.preventDefault();
saveConfig();
}}
>
<div className="ds-spec-heading">
<h3>Edit tasks</h3>
<span className="ds-code">
{draft.tasks.length} / 100 tasks
</span>
</div>
{draft.archived && (
<p className="ds-form-feedback" role="status">
This habit is archived. Restore it in Habit settings before
editing tasks.
</p>
)}
<fieldset
className="ds-task-editor-fieldset"
disabled={draft.archived}
aria-label="Task definitions"
>
{draft.tasks.length === 0 && (
<p className="ds-empty-state">
No tasks yet. Add a first step; a habit with no due tasks is
not scored.
</p>
)}
{draft.tasks.map((task, index) => (
<div className="ds-edit-task" key={task.id}>
<div className="ds-spec-heading">
<span className="ds-code">
STEP {String(index + 1).padStart(2, "0")}
</span>
<Button
variant="text"
aria-label={`Remove ${task.name || "unnamed task"}`}
onClick={() =>
setDraft({
...draft,
tasks: draft.tasks.filter(
(item) => item.id !== task.id,
),
})
}
>
Remove −
</Button>
</div>
<Field label="Task name">
{(id) => (
<input
id={id}
required
maxLength={200}
value={task.name}
onChange={(event) =>
updateTask(task.id, { name: event.target.value })
}
/>
)}
</Field>
<ScheduleEditor
value={task.schedule}
onChange={(schedule) => updateTask(task.id, { schedule })}
/>
</div>
))}
<Button
variant="secondary"
disabled={draft.tasks.length >= 100}
onClick={() =>
setDraft({
...draft,
tasks: [
...draft.tasks,
{
id: crypto.randomUUID(),
name: "",
schedule: { type: "daily" },
},
],
})
}
>
Add task +
</Button>
</fieldset>
<p className="ds-footnote">
Removing a task takes effect when you save. Earlier checklists
and their completion records remain available for backfilling.
</p>
<SaveBar
dirty={configDirty}
error={error}
disabled={draft.archived}
onCancel={() => {
setDraft(structuredClone(selected.config));
setDraftColor(selected.color);
setError(null);
}}
label="Save tasks"
/>
</form>
)}
{tab === "Backfill progress" && (
<>
<div className="ds-spec-heading">
<h3>Correct a past day</h3>
<span className="ds-code">Historical snapshot</span>
</div>
<Field
label="Progress date"
hint="Select a date here or in the calendar below."
>
{(id) => (
<input
id={id}
type="date"
required
min={EDITOR_START}
max="2026-09-03"
value={date}
onInput={(event) => selectDate(event.currentTarget.value)}
/>
)}
</Field>
<form
onSubmit={(event) => {
event.preventDefault();
if (blocked) {
setError(blocked);
return;
}
const correction = {
habitId: selectedId,
name: historical.name,
date,
before: savedProgress,
after: progress,
target: day!.target,
unit:
historical.method === "count"
? historical.unit
: historical.method === "tasks"
? "tasks"
: "session",
};
setEntries((current) => ({
...current,
[entryKey]: { value: progress, tasks: [...taskChecks] },
}));
setCorrections((current) => [correction, ...current]);
setError(null);
setNotice(
`Correction saved in this preview for ${date}. The calendar below is updated.`,
);
}}
>
{blocked && (
<p className="ds-form-feedback" role="status">
{blocked}
</p>
)}
{day?.state === "due" && date < EDITOR_TODAY && (
<>
<div className="ds-history-requirement">
<span>Required on {date}</span>
<strong>
{day.target}{" "}
{historical.method === "count"
? historical.unit
: historical.method === "tasks"
? "tasks"
: "session"}
</strong>
</div>
{historical.method === "count" ? (
<Field
label="Actual count"
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
>
{(id) => (
<input
id={id}
type="number"
required
min={0}
max={1_000_000_000}
step={1}
value={Number.isNaN(progress) ? "" : progress}
onChange={(event) => {
setProgress(event.target.valueAsNumber);
setError(null);
}}
/>
)}
</Field>
) : historical.method === "manual" ? (
<Checkbox
label="Completed on this date"
checked={progress === 1}
onChange={(event) =>
setProgress(Number(event.target.checked))
}
/>
) : (
<div className="ds-backfill-tasks">
{historical.tasks.map((task, index) => (
<Checkbox
key={task.id}
label={task.name}
checked={taskChecks[index] ?? false}
onChange={(event) => {
const next = taskChecks.map((done, i) =>
i === index ? event.target.checked : done,
);
setTaskChecks(next);
setProgress(next.filter(Boolean).length);
}}
/>
))}
</div>
)}
<p className="ds-correction-summary">
{savedProgress} →{" "}
{Number.isFinite(progress) ? progress : "—"} /{" "}
{day.target}
<span>
{progress >= day.target ? "Complete" : "Incomplete"}
</span>
</p>
</>
)}
<SaveBar
dirty={progressDirty}
error={error}
disabled={!!blocked}
label="Save correction"
onCancel={() => loadProgress(selectedId, date)}
/>
</form>
<div className="ds-backfill-calendar">
<CalendarHeatmap
key={selectedId}
compact
color={selected.color}
days={historicalDays(selectedId).map((item) => ({
...item,
value:
entries[`${selectedId}:${item.date}`]?.value ??
item.value,
}))}
unit={
historical.method === "count"
? historical.unit
: historical.method === "tasks"
? "tasks"
: "session"
}
label="Backfill history"
selectedDate={date}
onSelectDate={selectDate}
/>
</div>
<p className="ds-footnote">
This preview updates only the calendar here. In the connected
app, corrections also recalculate combined charts and inherited
counts.
</p>
</>
)}
<p className="ds-form-feedback ds-save-notice" role="status">
{notice}
</p>
</div>
</div>
{corrections.length > 0 && (
<div className="ds-correction-log">
<div className="ds-spec-heading">
<h3>Correction history</h3>
<span className="ds-code">This preview only · newest first</span>
</div>
<ol>
{corrections.map((item, index) => (
<li key={corrections.length - index}>
<time dateTime={item.date}>{item.date}</time>
<span>{item.name}</span>
<span>
{item.before} → {item.after} / {item.target} {item.unit}
</span>
</li>
))}
</ol>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,84 @@
import { useId, type ReactNode } from "react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Open chart section: intentionally no card surface or enclosing border. */
export function HabitChart({
name,
method,
value,
target,
unit,
color,
children,
tasks,
calendar,
schedule = "Every day",
due = true,
id,
}: {
name: string;
method: string;
value: number;
target: number;
unit: string;
color: string;
children?: ReactNode;
tasks?: ReactNode;
calendar?: ReactNode;
schedule?: string;
due?: boolean;
id?: string;
}) {
const headingId = useId();
return (
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
<header className="ds-habit-chart-heading">
<div>
<h4 id={headingId}>
<span style={{ backgroundColor: color }} aria-hidden="true" />
{name}
</h4>
<p>
{method} · {schedule}
</p>
</div>
{children}
</header>
<p className="ds-habit-chart-progress" aria-live="polite">
<span>
{due
? `${value} of ${target} ${unit}`
: "A day off isn’t a missed day."}
</span>
<span>
{!due
? "Not scheduled"
: value >= target
? "Complete"
: "In progress"}
</span>
</p>
{calendar ?? (
<CalendarHeatmap
days={demoCalendar(value, target)}
label={`${name} progress calendar`}
unit={unit}
color={color}
compact
/>
)}
{tasks && (
<details className="ds-task-accordion">
<summary>
Tasks for today{" "}
<span>
{value} / {target}
</span>
</summary>
<div className="ds-task-accordion-content">{tasks}</div>
</details>
)}
</section>
);
}

View File

@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import {
HABIT_PALETTES,
isHabitColor,
previewHabitColors,
} from "./HabitColorPicker";
import { HABIT_COLORS } from "./calendar-model";
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
expect(HABIT_PALETTES.length).toBe(8);
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
expect(new Set(colors).size).toBe(32);
expect(colors.every(isHabitColor)).toBe(true);
});
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
for (const color of ["#123456", "#abcdef", "#ABCDEF", "#ffffff", "#000000"])
expect(isHabitColor(color)).toBe(true);
for (const color of ["", "red", "123456", "#fff", "#gggggg", "#12345678"])
expect(isHabitColor(color)).toBe(false);
});
test("a draft color updates only its habit and cancel restores the saved color", () => {
const habits = [
{ id: "water", color: HABIT_COLORS.water },
{ id: "reading", color: "#123456" },
];
const preview = previewHabitColors(habits, "water", "#9c647c");
expect(preview.water).toBe("#9c647c");
expect(preview.reading).toBe("#123456");
expect(preview.movement).toBe(HABIT_COLORS.movement);
expect(previewHabitColors(habits, "water", habits[0]!.color).water).toBe(
HABIT_COLORS.water,
);
expect(habits[0]!.color).toBe(HABIT_COLORS.water);
});
test("invalid drafts fall back to saved colors and saved edits survive selecting another habit", () => {
const habits = [
{ id: "water", color: "#9c647c" },
{ id: "reading", color: HABIT_COLORS.reading },
];
expect(previewHabitColors(habits, "water", "#zz").water).toBe("#9c647c");
expect(previewHabitColors(habits, "reading", "#654321").water).toBe(
"#9c647c",
);
});

View File

@@ -0,0 +1,153 @@
import { useId } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
export type HabitColors = {
-readonly [K in keyof typeof HABIT_COLORS]: string;
};
export function previewHabitColors(
habits: { id: string; color: string }[],
selectedId: string,
draftColor: string,
): HabitColors {
const colors: HabitColors = { ...HABIT_COLORS };
for (const habit of habits) {
if (!Object.hasOwn(colors, habit.id)) continue;
colors[habit.id as keyof HabitColors] =
habit.id === selectedId && isHabitColor(draftColor)
? draftColor
: habit.color;
}
return colors;
}
export const HABIT_PALETTES = [
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
] as const;
export function isHabitColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value);
}
export function HabitColorPicker({
value,
onChange,
mode = "demo",
}: {
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
}) {
const id = useId();
const valid = isHabitColor(value);
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote">
A color for this habit and its calendar. Choose a suggested shade or
make it your own.
</p>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
<div
key={palette.name}
className="ds-color-palette"
role="group"
aria-label={`${palette.name} palette`}
>
<span>{palette.name}</span>
<div className="ds-color-swatches">
{palette.colors.map((color) => (
<button
key={color}
type="button"
aria-label={`${palette.name} ${color}`}
aria-pressed={value.toLowerCase() === color}
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
))}
</div>
</div>
))}
</div>
<div className="ds-custom-color">
<div className="ds-field">
<label htmlFor={`${id}-native`}>Custom color</label>
<input
id={`${id}-native`}
type="color"
value={valid ? value : "#426582"}
onInput={(event) => onChange(event.currentTarget.value)}
/>
</div>
<div className="ds-field">
<label htmlFor={`${id}-hex`}>Hex color</label>
<input
id={`${id}-hex`}
type="text"
spellCheck={false}
autoComplete="off"
maxLength={7}
pattern="#[0-9a-fA-F]{6}"
required
placeholder="#426582"
value={value}
aria-invalid={!valid}
aria-describedby={!valid ? `${id}-error` : undefined}
onChange={(event) => onChange(event.target.value)}
/>
</div>
</div>
{valid && (
<div
className="ds-color-live-preview"
aria-label="Live calendar color preview"
>
<span className="ds-muted">Calendar preview</span>
<div
className="ds-color-preview-shades"
role="img"
aria-label={`Progress shades using ${value}`}
>
{Array.from({ length: 9 }, (_, count) => (
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
}}
/>
))}
</div>
<span className="ds-muted">0 → complete</span>
</div>
)}
<p className="ds-footnote">
{mode === "create"
? "Your chosen color is saved with your habit and used in its progress calendar."
: mode === "edit"
? "Save changes to apply this color to your habit’s calendar. Cancel keeps your current color."
: "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
</p>
{!valid && (
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
Use a six-digit hex color, such as #426582.
</p>
)}
</fieldset>
);
}

View File

@@ -0,0 +1,124 @@
import { expect, test } from "bun:test";
import {
combinedProgress,
demoCalendar,
describeDay,
progressShade,
PROGRESS_SHADES,
monthWindow,
visibleCalendarDays,
MONTH_VIEWS,
legendShades,
} from "./calendar-model";
test("legends match habit colors and only display possible progress shades", () => {
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
for (const target of [1, 3, 4, 8, 20]) {
const shades = legendShades(demoCalendar(0, target), color);
expect(shades).toHaveLength(Math.min(target, 8) + 1);
expect(shades[0]).toBe(PROGRESS_SHADES[0]);
expect(shades.at(-1)).toBe(color);
for (let value = 0; value <= target; value++) {
expect(shades).toContain(
progressShade({ date: "", value, target, state: "due" }, color),
);
}
}
}
expect(legendShades(demoCalendar(0, 0))).toEqual([PROGRESS_SHADES[0]]);
expect(legendShades([])).toEqual([PROGRESS_SHADES[0]]);
});
test("eight glasses have eight positive shades plus empty; 7/8 remains incomplete", () => {
const days = Array.from({ length: 9 }, (_, value) => ({
date: "2026-09-04",
value,
target: 8,
state: "due" as const,
}));
expect(new Set(days.map((day) => progressShade(day))).size).toBe(9);
for (const color of ["#426582", "#977344", "#79618d", "#58765b"]) {
expect(new Set(days.map((day) => progressShade(day, color))).size).toBe(9);
expect(progressShade(days[8]!, color)).toBe(color);
}
expect(describeDay(days[7]!, "glasses")).toBe("7 of 8 glasses · Incomplete");
expect(progressShade({ ...days[8]!, value: 10 })).toBe(PROGRESS_SHADES[8]);
});
test("combined progress counts only completed due habits with equal weights", () => {
expect(
combinedProgress([
{ value: 7, target: 8, due: true },
{ value: 1, target: 1, due: true },
{ value: 2, target: 3, due: true },
{ value: 0, target: 1, due: false },
]),
).toEqual({ completed: 1, due: 3 });
expect(combinedProgress([{ value: 0, target: 0, due: true }])).toEqual({
completed: 0,
due: 0,
});
});
test("future and zero share a fill while inspection retains their distinct meaning", () => {
const days = demoCalendar(7, 8);
expect(days).toHaveLength(365);
expect(days.find((day) => day.date === "2026-09-04")?.value).toBe(7);
expect(
describeDay(
days.find((day) => day.state === "not-due")!,
"glasses",
),
).toContain("Nothing scheduled");
expect(describeDay(days.at(-1)!, "glasses")).toContain("Upcoming");
const zero = {
date: "2026-09-04",
value: 0,
target: 8,
state: "due" as const,
};
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
expect(progressShade(days.at(-1)!, color)).toBe(progressShade(zero, color));
expect(progressShade(days.at(-1)!, color)).toBe(PROGRESS_SHADES[0]);
}
expect(describeDay(zero, "glasses")).toBe("0 of 8 glasses · Incomplete");
});
test("month presets include exact whole months and cross year and leap-year boundaries", () => {
expect(
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
).toEqual([
{ from: "2026-07-01", to: "2026-09-30" },
{ from: "2026-06-01", to: "2026-09-30" },
{ from: "2026-04-01", to: "2026-09-30" },
{ from: "2025-10-01", to: "2026-09-30" },
]);
expect(monthWindow("2024-02-29", 3)).toEqual({
from: "2023-12-01",
to: "2024-02-29",
});
});
test("switching month views preserves dated values, complete coverage and future states", () => {
const days = demoCalendar(7, 8);
expect(
MONTH_VIEWS.map(
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
),
).toEqual([92, 122, 183, 365]);
for (const months of MONTH_VIEWS) {
const visible = visibleCalendarDays(days, months, "2026-09-04");
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
days.find((day) => day.date === "2026-09-04"),
);
expect(visible.filter((day) => day.state === "future")).toHaveLength(26);
expect(new Set(visible.map((day) => day.date)).size).toBe(visible.length);
}
expect(visibleCalendarDays([], 3, "2026-09-04")).toEqual([]);
});
test("live calendars preserve server colors and historical units", () => {
const day = { date: "2026-09-03", value: 3, target: 8, state: "due" as const, color: "#abcdef", unit: "pages" };
expect(progressShade(day, "#111111")).toBe("#abcdef");
expect(describeDay(day, "glasses")).toBe("3 of 8 pages · Incomplete");
});

View File

@@ -0,0 +1,139 @@
export const PROGRESS_SHADES = [
"#eeeeee",
"#d8d8d8",
"#bfbfbf",
"#a5a5a5",
"#8a8a8a",
"#707070",
"#555555",
"#363636",
"#111111",
] as const;
export type CalendarDay = {
color?: string;
unit?: string;
date: string;
value: number;
target: number;
state: "due" | "not-due" | "future";
};
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
export type MonthView = (typeof MONTH_VIEWS)[number];
/** Whole calendar months, including the month containing the anchor date. */
export function monthWindow(anchor: string, months: MonthView) {
const date = new Date(`${anchor}T12:00:00Z`);
const first = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months + 1, 1),
);
const last = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
from: first.toISOString().slice(0, 10),
to: last.toISOString().slice(0, 10),
};
}
export function visibleCalendarDays(
days: CalendarDay[],
months: MonthView,
anchor: string,
) {
const range = monthWindow(anchor, months);
return days.filter((day) => day.date >= range.from && day.date <= range.to);
}
export const HABIT_COLORS = {
water: "#426582",
reading: "#977344",
tasks: "#79618d",
movement: "#58765b",
} as const;
export function progressShade(day: CalendarDay, color = "#111111") {
if (day.color) return day.color;
if (day.state === "not-due") return "#ffffff";
if (day.state === "future") return PROGRESS_SHADES[0];
if (day.target <= 0 || day.value <= 0) return PROGRESS_SHADES[0];
const step = Math.max(
1,
Math.min(8, Math.round((day.value / day.target) * 8)),
);
if (color === "#111111" || !/^#[0-9a-f]{6}$/i.test(color))
return PROGRESS_SHADES[step]!;
const weight = step / 8;
return `#${[1, 3, 5]
.map((offset) =>
Math.round(
238 + (parseInt(color.slice(offset, offset + 2), 16) - 238) * weight,
)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}
export function describeDay(day: CalendarDay, unit: string) {
if (day.state === "future") return "Upcoming · Logging is not available yet";
if (day.state === "not-due")
return "Nothing scheduled · Not included in the score";
return `${day.value} of ${day.target} ${day.unit ?? unit} · ${day.value >= day.target ? "Complete" : "Incomplete"}`;
}
/** Only show shades that the chart's completion targets can produce. */
export function legendShades(days: CalendarDay[], color = "#111111") {
const levels = new Set<number>();
for (const target of new Set(days.map((day) => day.target))) {
if (!Number.isFinite(target) || target <= 0) continue;
for (let value = 1; value <= Math.min(target, 8); value++) {
levels.add(
target > 8 ? value : Math.max(1, Math.round((value / target) * 8)),
);
}
}
return [
PROGRESS_SHADES[0],
...[...levels]
.sort((a, b) => a - b)
.map((value) =>
progressShade({ date: "", value, target: 8, state: "due" }, color),
),
];
}
export function combinedProgress(
habits: { value: number; target: number; due: boolean }[],
) {
const due = habits.filter((habit) => habit.due && habit.target > 0);
return {
completed: due.filter((habit) => habit.value >= habit.target).length,
due: due.length,
};
}
/** A full 12 calendar months of stable demo history, including future dates. */
export function demoCalendar(value: number, target: number): CalendarDay[] {
return Array.from({ length: 365 }, (_, index) => {
const timestamp = Date.UTC(2025, 9, 1 + index);
// Keep the previously displayed dates' fixture values unchanged.
const fixtureIndex = (timestamp - Date.UTC(2026, 2, 8)) / 86_400_000;
const rawValue = fixtureIndex * 17 + Math.floor(fixtureIndex / 7) * 3;
const date = new Date(timestamp).toISOString().slice(0, 10);
return {
date,
target,
value:
date === "2026-09-04"
? value
: ((rawValue % (target + 1)) + target + 1) % (target + 1),
state:
date > "2026-09-04"
? "future"
: fixtureIndex % 19 === 0
? "not-due"
: "due",
};
});
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import {
EDITOR_HABITS,
backfillError,
historicalDays,
validateEditor,
validateTasks,
} from "./editing-model";
describe("design-system editors", () => {
test("all initial configurations are valid", () => {
for (const habit of EDITOR_HABITS)
expect(validateEditor(habit.config)).toBeNull();
});
test("requires a name, valid target, and nonempty weekday schedule", () => {
const config = EDITOR_HABITS[0]!.config;
expect(validateEditor({ ...config, name: " " })).not.toBeNull();
if (config.method !== "count") throw new Error("Expected count fixture");
for (const target of [0, 1.5, NaN, 10001])
expect(validateEditor({ ...config, target })).not.toBeNull();
expect(
validateEditor({ ...config, schedule: { type: "weekdays", days: [] } }),
).not.toBeNull();
});
test("validates task names and independent recurrence", () => {
expect(
validateTasks([{ id: "1", name: "", schedule: { type: "daily" } }]),
).not.toBeNull();
expect(
validateTasks([
{
id: "1",
name: "Read",
schedule: { type: "interval", every: 0, anchor: "2026-09-04" },
},
]),
).not.toBeNull();
expect(validateTasks([])).toBeNull();
});
test("backfills reject today, future, nonexistent and unscheduled dates", () => {
for (const date of [
"2026-09-04",
"2026-09-05",
"2026-02-30",
"2025-09-30",
"",
])
expect(backfillError("water", date, 1)).not.toBeNull();
const notDue = historicalDays("water").find(
(day) => day.state === "not-due",
)!;
expect(backfillError("water", notDue.date, 1)).not.toBeNull();
});
test("corrections allow zero and over-target counts but reject invalid totals", () => {
for (const value of [0, 8, 12, 1_000_000_000])
expect(backfillError("water", "2026-09-03", value)).toBeNull();
for (const value of [-1, 1.5, NaN, 1_000_000_001])
expect(backfillError("water", "2026-09-03", value)).not.toBeNull();
expect(backfillError("reading", "2026-09-03", 2)).not.toBeNull();
expect(backfillError("tasks", "2026-09-03", 4)).not.toBeNull();
});
test("editing a current target or task list does not rewrite historical requirements", () => {
const copy = structuredClone(EDITOR_HABITS);
const water = copy[0]!.config;
const tasks = copy[2]!.config;
if (water.method === "count") water.target = 12;
if (tasks.method === "tasks") tasks.tasks.pop();
expect(historicalDays("water")[0]!.target).toBe(8);
expect(historicalDays("tasks")[0]!.target).toBe(3);
});
});

View File

@@ -0,0 +1,110 @@
import {
dateSchema,
habitInput,
taskInput,
type HabitConfig,
} from "../../habits/contracts";
import { demoCalendar, HABIT_COLORS } from "./calendar-model";
export const EDITOR_TODAY = "2026-09-04";
export const EDITOR_START = "2025-10-01";
export const EDITOR_HABITS: {
id: string;
color: string;
config: HabitConfig;
}[] = [
{
id: "water",
color: HABIT_COLORS.water,
config: {
name: "Drink water",
method: "count",
target: 8,
unit: "glasses",
carryPartialProgress: false,
schedule: { type: "daily" },
archived: false,
},
},
{
id: "reading",
color: HABIT_COLORS.reading,
config: {
name: "Read a little",
method: "manual",
schedule: { type: "daily" },
archived: false,
},
},
{
id: "tasks",
color: HABIT_COLORS.tasks,
config: {
name: "Evening reset",
method: "tasks",
schedule: { type: "daily" },
archived: false,
tasks: ["Clear desk", "Plan tomorrow", "Stretch"].map((name, index) => ({
id: `task-${index}`,
name,
schedule: { type: "daily" },
})),
},
},
];
export function validateEditor(config: HabitConfig): string | null {
const { archived: _, ...input } = config;
const result = habitInput.safeParse(
config.method === "tasks"
? { ...input, tasks: config.tasks.map(({ id: _, ...task }) => task) }
: input,
);
return result.success
? null
: (result.error.issues[0]?.message ?? "Check the habit settings.");
}
export function validateTasks(
tasks: Extract<HabitConfig, { method: "tasks" }>["tasks"],
): string | null {
if (tasks.length > 100) return "Use no more than 100 tasks.";
for (const { id: _, ...task } of tasks) {
const result = taskInput.safeParse(task);
if (!result.success)
return result.error.issues[0]?.message ?? "Check each task.";
}
return null;
}
/** Frozen historical requirements deliberately do not read today's edited config. */
export function historicalDays(id: string) {
const config = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
return demoCalendar(
0,
config.method === "count"
? config.target
: config.method === "tasks"
? config.tasks.length
: 1,
);
}
export function backfillError(
id: string,
date: string,
value: number,
): string | null {
if (!dateSchema.safeParse(date).success) return "Choose a valid date.";
if (date >= EDITOR_TODAY)
return "Choose a past date. Today is edited in the daily view.";
if (date < EDITOR_START) return "This habit did not exist on that date.";
const day = historicalDays(id).find((day) => day.date === date);
if (!day || day.state !== "due")
return "Nothing was scheduled on this date. There is no progress to correct.";
if (!Number.isInteger(value) || value < 0 || value > 1_000_000_000)
return "Enter a whole number from 0 to 1,000,000,000.";
if (id !== "water" && value > day.target)
return "Progress cannot exceed the scheduled items.";
return null;
}

View File

@@ -0,0 +1,124 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
export function Button({
variant = "primary",
className = "",
type = "button",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "text";
}) {
return (
<button
type={type}
className={`ds-button ds-button--${variant} ${className}`}
{...props}
/>
);
}
export function SectionHeading({
number,
title,
children,
}: {
number: string;
title: string;
children?: ReactNode;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
<h2>{title}</h2>
{children && <p>{children}</p>}
</header>
);
}
export function Checkbox({
label,
className = "",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
return (
<label className={`ds-checkbox ${className}`}>
<input type="checkbox" {...props} />
<span>{label}</span>
</label>
);
}
export function Counter({
label,
value,
target,
onChange,
disabled = false,
}: {
label: string;
value: number;
target: number;
onChange: (value: number) => void;
disabled?: boolean;
}) {
return (
<div className="ds-counter" role="group" aria-label={label}>
<Button
variant="text"
aria-label={`Decrease ${label}`}
disabled={disabled || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
>
−
</Button>
<output aria-live="polite">
<span>{value}</span>
<span className="ds-muted"> / {target}</span>
</output>
<Button
variant="text"
aria-label={`Increase ${label}`}
disabled={disabled || value >= target}
onClick={() => onChange(Math.min(target, value + 1))}
>
+
</Button>
</div>
);
}
export function HabitRow({
name,
description,
complete,
children,
tasks,
}: {
name: string;
description: string;
complete: boolean;
children: ReactNode;
tasks?: ReactNode;
}) {
return (
<div className="ds-habit-row">
<div className="ds-habit-main">
<div>
<h4>{name}</h4>
<p>{description}</p>
</div>
<div className="ds-habit-control">
{children}
<span className="ds-status">
{complete ? "Complete" : "In progress"}
</span>
</div>
</div>
{tasks && <div className="ds-task-list">{tasks}</div>}
</div>
);
}

53
src/db/migrations.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { expect, test } from 'bun:test';
import { Database } from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
test('carryover migration preserves pre-existing counts and explicit zero corrections', () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-upgrade-'));
const sqlite = new Database(':memory:'); sqlite.exec('PRAGMA foreign_keys = ON'); const db = drizzle(sqlite);
try {
mkdirSync(join(directory, 'meta'));
const journal = JSON.parse(readFileSync('drizzle/meta/_journal.json', 'utf8'));
journal.entries = journal.entries.slice(0, 2);
writeFileSync(join(directory, 'meta/_journal.json'), JSON.stringify(journal));
for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(directory, `${entry.tag}.sql`));
migrate(db, { migrationsFolder: directory });
sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('u', 'u', 'u', 0, 0)");
sqlite.exec("INSERT INTO habits (id, user_id, created_date, created_at) VALUES ('h', 'u', '2026-09-04', 0)");
sqlite.query('INSERT INTO habit_revisions (habit_id, effective_date, config, created_at) VALUES (?, ?, ?, ?)').run('h', '2026-09-04', JSON.stringify({ name: 'Old count', method: 'count', target: 8, unit: 'steps', schedule: { type: 'daily' }, archived: false }), 0);
for (const [date, count] of [['2026-09-04', 7], ['2026-09-05', 0], ['2026-09-06', 0]] as const) sqlite.query('INSERT INTO habit_days (habit_id, revision_id, date, timezone, ends_at, count) VALUES (?, ?, ?, ?, ?, ?)').run('h', 1, date, 'UTC', 0, count);
sqlite.query('INSERT INTO progress_events (day_id, before, after, created_at) VALUES (?, ?, ?, ?)').run(2, '{"count":2}', '{"count":0}', 0);
migrate(db, { migrationsFolder: './drizzle' }); migrate(db, { migrationsFolder: './drizzle' });
expect(sqlite.query('SELECT count, count_set FROM habit_days ORDER BY id').all()).toEqual([{ count: 7, count_set: 1 }, { count: 0, count_set: 1 }, { count: 0, count_set: 0 }]);
const config = (sqlite.query('SELECT config FROM habit_revisions').get() as { config: string }).config;
expect(JSON.parse(config).carryPartialProgress).toBe(false);
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); }
});
test('deadline migration repairs historical expiry using the original state before corrections', () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-expiry-upgrade-'));
const sqlite = new Database(':memory:'); const db = drizzle(sqlite);
try {
mkdirSync(join(directory, 'meta'));
const journal = JSON.parse(readFileSync('drizzle/meta/_journal.json', 'utf8'));
journal.entries = journal.entries.slice(0, 3);
writeFileSync(join(directory, 'meta/_journal.json'), JSON.stringify(journal));
for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(directory, `${entry.tag}.sql`));
migrate(db, { migrationsFolder: directory });
sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('u', 'u', 'u', 0, 0)");
sqlite.exec("INSERT INTO habits (id, user_id, created_date, created_at) VALUES ('h', 'u', '2020-01-01', 0)");
sqlite.query('INSERT INTO habit_revisions (habit_id, effective_date, config, created_at) VALUES (?, ?, ?, ?)').run('h', '2020-01-01', JSON.stringify({ name: 'Tasks', method: 'tasks', tasks: [], schedule: { type: 'daily' }, archived: false }), 0);
sqlite.exec("INSERT INTO habit_days (habit_id, revision_id, date, timezone, ends_at) VALUES ('h', 1, '2020-01-01', 'UTC', 100)");
sqlite.exec("INSERT INTO task_occurrences (id, day_id, task_id, name, done, expired_at, updated_at) VALUES ('a', 1, 'a', 'Originally completed', 0, 100, 200), ('b', 1, 'b', 'Originally incomplete', 1, 100, 200)");
sqlite.query('INSERT INTO progress_events (day_id, occurrence_id, before, after, created_at) VALUES (?, ?, ?, ?, ?)').run(1, 'a', '{"done":true}', '{"done":false}', 200);
sqlite.query('INSERT INTO progress_events (day_id, occurrence_id, before, after, created_at) VALUES (?, ?, ?, ?, ?)').run(1, 'b', '{"done":false}', '{"done":true}', 200);
migrate(db, { migrationsFolder: './drizzle' });
expect(sqlite.query('SELECT id, expired_at, closed_at, done FROM task_occurrences ORDER BY id').all()).toEqual([
{ id: 'a', expired_at: null, closed_at: 100, done: 0 }, { id: 'b', expired_at: 100, closed_at: 100, done: 1 },
]);
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); }
});

View File

@@ -20,3 +20,34 @@ export const sessions = sqliteTable("sessions", {
index("sessions_user_id_idx").on(table.userId), index("sessions_user_id_idx").on(table.userId),
index("sessions_expires_at_idx").on(table.expiresAt), index("sessions_expires_at_idx").on(table.expiresAt),
]); ]);
// Revisions are append-only, including multiple edits on the same local date.
export const habits = sqliteTable('habits', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
}, t => [index('habits_user_idx').on(t.userId)]);
export const habitRevisions = sqliteTable('habit_revisions', {
id: integer('id').primaryKey({ autoIncrement: true }), habitId: text('habit_id').notNull().references(() => habits.id),
effectiveDate: text('effective_date').notNull(), config: text('config', { mode: 'json' }).$type<import('../habits/contracts').HabitConfig>().notNull(), createdAt: integer('created_at').notNull(),
}, t => [index('habit_revisions_date_idx').on(t.habitId, t.effectiveDate, t.id)]);
export const habitDays = sqliteTable('habit_days', {
id: integer('id').primaryKey({ autoIncrement: true }), habitId: text('habit_id').notNull().references(() => habits.id),
revisionId: integer('revision_id').notNull().references(() => habitRevisions.id), date: text('date').notNull(),
timezone: text('timezone').notNull(), endsAt: integer('ends_at').notNull(), countSet: integer('count_set', { mode: 'boolean' }).notNull().default(false), count: integer('count').notNull().default(0), done: integer('done', { mode: 'boolean' }).notNull().default(false),
}, t => [index('habit_days_lookup_idx').on(t.habitId, t.date, t.revisionId)]);
export const taskOccurrences = sqliteTable('task_occurrences', {
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(),
}, t => [index('task_occurrences_day_idx').on(t.dayId)]);
export const progressEvents = sqliteTable('progress_events', {
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),
}, t => [index('progress_events_day_idx').on(t.dayId)]);
export const habitCalendarSettings = sqliteTable('habit_calendar_settings', {
habitId: text('habit_id').primaryKey().references(() => habits.id), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
});
export const combinedCharts = sqliteTable('combined_charts', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), name: text('name').notNull(),
habitIds: text('habit_ids', { mode: 'json' }).$type<string[]>().notNull(), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(),
}, t => [index('combined_charts_user_idx').on(t.userId)]);

157
src/habits/api.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); });
afterEach(() => f.close());
const create = (input: object) => f.json('/habits', 'POST', input, 201);
const count = () => create({ name: 'Hydration', method: 'count', target: 8, unit: 'glasses' });
const manual = () => create({ name: 'Read', method: 'manual' });
const tasks = () => create({ name: 'Routine', method: 'tasks', tasks: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] });
const day = (id: string, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}`);
const log = (id: string, input: object, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/progress`, 'PUT', input);
const tick = (id: string, taskId: string, done = true, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/tasks/${taskId}`, 'PUT', { done });
const combined = (ids: string[]) => f.json('/charts', 'POST', { name: 'Combined', habitIds: ids }, 201);
const square = (id: string, date = '2026-09-04') => f.json(`/charts/${id}/days/${date}`);
test('habit CRUD, defaults, archive, restore, history, and date inspection', async () => {
const h = await count(); expect(h.schedule).toEqual({ type: 'daily' }); expect(h.archived).toBe(false);
expect((await f.json('/habits')).habits).toHaveLength(1);
expect((await f.json(`/habits/${h.id}`)).name).toBe('Hydration');
expect((await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Water' })).name).toBe('Water');
expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2);
expect((await f.json('/days/2026-09-04')).habits).toHaveLength(1);
expect((await f.request(`/habits/${h.id}`, 'DELETE')).status).toBe(204);
expect((await f.json('/habits')).habits).toHaveLength(0);
expect((await f.json('/habits?archived=true')).habits).toHaveLength(1);
expect((await day(h.id)).due).toBe(false);
await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
expect((await day(h.id)).due).toBe(true);
});
test('count exact underlying values, over-target logging, and correction', async () => {
const h = await count(); const c = await combined([h.id]);
expect((await log(h.id, { count: 7 })).ratio).toBe(7 / 8);
expect((await square(c.id)).completed).toBe(0);
expect((await log(h.id, { count: 8 })).complete).toBe(true);
expect((await square(c.id)).ratio).toBe(1);
expect((await log(h.id, { count: 12 })).value).toBe(12);
await log(h.id, { count: 2 }); expect((await square(c.id)).ratio).toBe(0);
const detail = await day(h.id); expect(detail.target).toBe(8); expect(detail.unit).toBe('glasses');
const audit = await f.json(`/habits/${h.id}/days/2026-09-04/audit`);
expect(audit.records[0].events).toHaveLength(4);
});
test('manual habits expose only binary progress', async () => {
const h = await manual(); expect((await day(h.id)).ratio).toBe(0);
expect((await log(h.id, { done: true })).ratio).toBe(1);
expect((await log(h.id, { done: false })).ratio).toBe(0);
expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 1 })).status).toBe(422);
});
test('task completion is derived; 2/3 is partial and contributes zero to every combined chart', async () => {
const h = await tasks(); const a = await combined([h.id]); const b = await combined([h.id]);
await tick(h.id, h.tasks[0].id); const partial = await tick(h.id, h.tasks[1].id);
expect(partial.ratio).toBe(2 / 3); expect(partial.complete).toBe(false);
expect((await square(a.id)).ratio).toBe(0); expect((await square(b.id)).ratio).toBe(0);
await tick(h.id, h.tasks[2].id); expect((await square(a.id)).ratio).toBe(1);
await tick(h.id, h.tasks[1].id, false); expect((await square(b.id)).ratio).toBe(0);
expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true })).status).toBe(409);
});
test('combined equal weights, 3/4, partial contributes zero, scheduled denominator, full and neutral', async () => {
const a = await manual(); const b = await manual(); const c = await manual(); const d = await count();
const chart = await combined([a.id,b.id,c.id,d.id]);
for (const h of [a,b,c]) await log(h.id, { done: true });
await log(d.id, { count: 7 });
expect(await square(chart.id)).toMatchObject({ due: 4, completed: 3, ratio: 0.75, level: 3 });
await log(d.id, { count: 8 }); const full = await square(chart.id);
expect(full).toMatchObject({ due: 4, completed: 4, ratio: 1 });
const one = await combined([a.id]); expect((await square(one.id)).color).toBe(full.color);
for (const h of [b,c,d]) await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
expect(await square(chart.id)).toMatchObject({ due: 1, completed: 1, ratio: 1 });
await f.json(`/habits/${a.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
expect(await square(chart.id)).toMatchObject({ due: 0, ratio: null, status: 'not_due', level: null });
});
test('task schedule intersects parent and zero due tasks never auto-completes', async () => {
const h = await create({ name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Friday', schedule: { type: 'weekdays', days: [5] } }] });
const c = await combined([h.id]); expect((await square(c.id)).ratio).toBeNull();
expect((await day(h.id)).tasks).toHaveLength(0);
expect((await day(h.id, '2026-09-07')).due).toBe(false);
const empty = await create({ name: 'Empty', method: 'tasks' }); expect((await day(empty.id)).due).toBe(false);
});
test('incomplete occurrences expire at exact local midnight and correction retains expiry', async () => {
const h = await tasks(); await tick(h.id, h.tasks[0].id);
f.setTime('2026-09-04T21:59:59.999Z'); expect((await day(h.id)).tasks.every((t: any) => t.expiredAt === null)).toBe(true);
f.setTime('2026-09-04T22:00:00Z');
const previous = await day(h.id); const a = previous.tasks.find((t: any) => t.taskId === h.tasks[0].id); const b = previous.tasks.find((t: any) => t.taskId === h.tasks[1].id);
expect(a.expiredAt).toBeNull(); expect(b.expiredAt).toBe(Date.parse('2026-09-04T22:00Z'));
expect((await day(h.id, '2026-09-05')).tasks.every((t: any) => !t.done && t.expiredAt === null)).toBe(true);
const corrected = await tick(h.id, h.tasks[1].id); expect(corrected.tasks.find((t: any) => t.taskId === h.tasks[1].id).expiredAt).toBe(b.expiredAt);
expect((await day(h.id, '2026-09-05')).value).toBe(0);
});
test('downtime materializes missing occurrences with original expiry deadlines', async () => {
const h = await tasks(); f.setTime('2026-09-08T12:00Z');
expect((await day(h.id, '2026-09-06')).tasks.every((t: any) => t.expiredAt === Date.parse('2026-09-06T22:00Z'))).toBe(true);
expect((await day(h.id, '2026-09-08')).tasks.every((t: any) => t.expiredAt === null)).toBe(true);
});
test('target and method edits preserve yesterday, same-day carry and superseded audit records', async () => {
const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z');
await log(h.id, { count: 8 }, '2026-09-05');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 });
expect(await day(h.id)).toMatchObject({ value: 8, target: 8, complete: true });
expect(await day(h.id, '2026-09-05')).toMatchObject({ value: 8, target: 10, complete: false });
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
expect(await day(h.id, '2026-09-05')).toMatchObject({ method: 'manual', value: 0 });
await log(h.id, { count: 7 }); expect((await day(h.id)).ratio).toBe(7 / 8);
const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
expect(audit.records).toHaveLength(3); expect(audit.records[0].count).toBe(8);
expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'count' })).status).toBe(422);
});
test('task CRUD and recurrence edits preserve historical tasks, expiry and requirements', async () => {
const h = await tasks(); const t = h.tasks[0];
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(3);
expect((await f.json(`/habits/${h.id}/tasks/${t.id}`)).name).toBe('A');
await tick(h.id, t.id); f.setTime('2026-09-05T12:00Z');
const newTask = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'New' }, 201);
expect((await day(h.id)).target).toBe(3); expect((await day(h.id, '2026-09-05')).target).toBe(4);
await f.json(`/habits/${h.id}/tasks/${newTask.id}`, 'PATCH', { schedule: { type: 'interval', every: 3, anchor: '2026-09-05' }, name: 'Every three days' });
expect((await f.request(`/habits/${h.id}/tasks/${t.id}`, 'DELETE')).status).toBe(204);
expect((await day(h.id)).tasks.find((v: any) => v.taskId === t.id).done).toBe(true);
expect((await f.request(`/habits/${h.id}/tasks/${t.id}`)).status).toBe(404);
await tick(h.id, t.id, false); expect((await day(h.id)).value).toBe(0);
expect((await day(h.id, '2026-09-06')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(false);
expect((await day(h.id, '2026-09-08')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(true);
});
test('schedule edits and archiving apply today while earlier logs remain correctable', async () => {
const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
expect((await day(h.id)).complete).toBe(true); expect((await day(h.id, '2026-09-05')).due).toBe(false);
await f.request(`/habits/${h.id}`, 'DELETE'); await log(h.id, { count: 6 }); expect((await day(h.id)).value).toBe(6);
});
test('future and pre-creation dates are inspectable but not writable', async () => {
const h = await count(); expect((await day(h.id, '2026-09-05')).status).toBe('future');
expect((await day(h.id, '2026-09-03')).status).toBe('not_due');
for (const d of ['2026-09-05', '2026-09-03']) expect((await f.request(`/habits/${h.id}/days/${d}/progress`, 'PUT', { count: 8 })).status).toBe(409);
});
test('combined CRUD, membership edits recompute history, calendar data and settings', async () => {
const h = await count(); const m = await manual(); await log(h.id, { count: 8 });
const c = await combined([h.id]); expect((await f.json('/charts')).charts).toHaveLength(1);
expect((await f.json(`/charts/${c.id}`)).habitIds).toEqual([h.id]);
await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Both', habitIds: [h.id, m.id], settings: { mainColor: '#ff0000', shadeCount: 8 } });
expect((await square(c.id)).ratio).toBe(0.5);
const cal = await f.json(`/charts/${c.id}/calendar?from=2026-09-03&to=2026-09-05`);
expect(cal.data).toHaveLength(3); expect(cal.data[0].value).toBe(-1); expect(cal.data[2].value).toBe(-2);
expect(cal.settings.mainColor).toBe('#ff0000');
const settings = await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#123456', shadeCount: 6 });
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toEqual(settings);
const individual = await f.json(`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-04`);
expect(individual.data).toEqual([{ day: '2026-09-04', value: 6 }]); expect(individual.days[0].color).toBe('#123456');
expect((await f.request(`/charts/${c.id}`, 'DELETE')).status).toBe(204);
expect((await f.request(`/charts/${c.id}`)).status).toBe(404);
});
test('timezone changes preserve recorded deadlines and never rewind the tracking date', async () => {
const h = await tasks(); const before = await day(h.id);
expect((await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' })).timezone).toBe('America/Los_Angeles');
expect((await f.json('/me')).timezone).toBe('America/Los_Angeles');
expect((await day(h.id)).endsAt).toBe(before.endsAt);
f.setTime('2026-09-05T12:00Z'); const next = await day(h.id, '2026-09-05');
expect(next.timezone).toBe('America/Los_Angeles'); expect(next.endsAt).toBe(Date.parse('2026-09-06T07:00Z'));
await f.json('/me', 'PATCH', { timezone: 'Pacific/Honolulu' });
expect((await f.json('/today')).date).toBe('2026-09-05');
});

View File

@@ -0,0 +1,53 @@
import { expect, test } from 'bun:test';
import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from './calendar';
import { calendarSettingsSchema, dateSchema, habitInput, scheduleSchema } from './contracts';
test('fixed calendar recurrence uses anchors and Monday weeks independently of completions', () => {
expect(scheduled({ type: 'daily' }, '2026-09-04')).toBe(true);
expect(scheduled({ type: 'interval', every: 3, anchor: '2026-09-04' }, '2026-09-07')).toBe(true);
expect(scheduled({ type: 'interval', every: 3, anchor: '2026-09-04' }, '2026-09-01')).toBe(false);
expect(scheduled({ type: 'interval', every: 3, anchor: '2026-09-04' }, '2026-09-06')).toBe(false);
expect(scheduled({ type: 'weekdays', days: [1] }, '2026-09-07')).toBe(true);
expect(scheduled({ type: 'weekdays', days: [1] }, '2026-09-08')).toBe(false);
const rule = { type: 'weekly' as const, every: 2, weekday: 3, anchor: '2026-08-31' };
expect(scheduled(rule, '2026-09-02')).toBe(true);
expect(scheduled(rule, '2026-09-09')).toBe(false);
expect(scheduled(rule, '2026-09-16')).toBe(true);
});
test('real dates, strict settings, and exclusive methods are validated', () => {
for (const d of ['2026-02-29', '2026-13-01', '2026-9-1', 'garbage']) expect(dateSchema.safeParse(d).success).toBe(false);
expect(addDays('2024-02-28', 1)).toBe('2024-02-29');
expect(habitInput.safeParse({ name: 'mixed', method: 'manual', target: 8 }).success).toBe(false);
expect(scheduleSchema.safeParse({ type: 'weekdays', days: [1, 1] }).success).toBe(false);
expect(calendarSettingsSchema.safeParse({ daySpacing: 4 }).success).toBe(false);
});
test('local midnight handles spring, autumn, and fractional timezone offsets', () => {
expect(endOfDay('2026-03-29', 'Europe/Belgrade') - endOfDay('2026-03-28', 'Europe/Belgrade')).toBe(23 * 3600000);
expect(endOfDay('2026-10-25', 'Europe/Belgrade') - endOfDay('2026-10-24', 'Europe/Belgrade')).toBe(25 * 3600000);
expect(new Date(endOfDay('2026-09-04', 'Asia/Kathmandu')).toISOString()).toBe('2026-09-04T18:15:00.000Z');
expect(localDate(Date.parse('2026-09-03T22:00Z'), 'Europe/Belgrade')).toBe('2026-09-04');
});
test('eight positive count shades, full completion, neutral and empty stay distinct', () => {
const settings = calendarSettingsSchema.parse({});
const values = Array.from({ length: 8 }, (_, i) => shade((i + 1) / 8, 8, settings, true));
expect(new Set(values.map(v => v.color)).size).toBe(8);
expect(values.map(v => v.level)).toEqual([1,2,3,4,5,6,7,8]);
expect(shade(1, 1, settings, true).color).toBe(settings.mainColor);
expect(shade(0, 8, settings, true).color).toBe(settings.emptyColor);
expect(shade(null, 8, settings, true).color).toBe(settings.notDueColor);
expect(shade(0.75, 4, settings, false).level).toBe(3);
});
test('white and pale main colors still produce distinct positive shades', () => {
for (const mainColor of ['#ffffff', '#fefdfc', '#ffff00', '#000000']) {
const settings = calendarSettingsSchema.parse({ mainColor, shadeCount: 20 });
const colors = Array.from({ length: 20 }, (_, i) => shade((i + 1) / 20, 20, settings, true).color);
expect(new Set(colors).size).toBe(20); expect(colors.at(-1)).toBe(mainColor);
}
});
test('schedule intersection detects incompatible anchored day and week cycles', () => {
expect(schedulesOverlap({ type: 'interval', every: 7, anchor: '2026-09-04' }, { type: 'weekdays', days: [1] })).toBe(false);
expect(schedulesOverlap({ type: 'interval', every: 3, anchor: '2026-09-04' }, { type: 'weekdays', days: [1] })).toBe(true);
expect(schedulesOverlap({ type: 'weekly', every: 2, weekday: 3, anchor: '2026-08-31' }, { type: 'weekly', every: 2, weekday: 3, anchor: '2026-09-07' })).toBe(false);
expect(schedulesOverlap({ type: 'weekly', every: 2, weekday: 3, anchor: '2026-08-31' }, { type: 'weekly', every: 3, weekday: 3, anchor: '2026-09-07' })).toBe(true);
expect(schedulesOverlap({ type: 'daily' }, { type: 'weekly', every: 520, weekday: 0, anchor: '2026-09-04' })).toBe(true);
});

63
src/habits/calendar.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { Schedule, CalendarSettings } from './contracts';
const DAY = 86400000;
export const dayNumber = (date: string) => Date.parse(`${date}T00:00:00Z`) / DAY;
export const addDays = (date: string, n: number) => new Date((dayNumber(date) + n) * DAY).toISOString().slice(0, 10);
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
export function localDate(timestamp: number, timezone: string): string {
let formatter = dateFormatters.get(timezone);
if (!formatter) { formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }); dateFormatters.set(timezone, formatter); }
return formatter.format(timestamp);
}
// Search an instant boundary rather than adding 24h: DST days can be 23 or 25h.
export function endOfDay(date: string, timezone: string): number {
const target = addDays(date, 1);
let lo = Date.parse(`${target}T00:00:00Z`) - 36 * 3600000;
let hi = lo + 72 * 3600000;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
if (localDate(mid, timezone) < target) lo = mid; else hi = mid;
}
return hi;
}
export function scheduled(rule: Schedule, date: string): boolean {
const day = dayNumber(date);
const weekday = new Date(day * DAY).getUTCDay();
if (rule.type === 'daily') return true;
if (rule.type === 'weekdays') return rule.days.includes(weekday);
const anchor = dayNumber(rule.anchor);
if (day < anchor) return false;
if (rule.type === 'interval') return (day - anchor) % rule.every === 0;
// Weeks start Monday; the anchor identifies week zero, even if not the due weekday.
const monday = (n: number) => n - ((new Date(n * DAY).getUTCDay() + 6) % 7);
return weekday === rule.weekday && (monday(day) - monday(anchor)) / 7 % rule.every === 0;
}
/** Each rule is a set of residues on a finite repeating period. */
export function schedulesOverlap(a: Schedule, b: Schedule): boolean {
const mod = (n: number, period: number) => ((n % period) + period) % period;
function cycle(rule: Schedule): { period: number; residues: number[] } {
if (rule.type === 'daily') return { period: 1, residues: [0] };
// Unix day zero is Thursday (weekday 4).
if (rule.type === 'weekdays') return { period: 7, residues: rule.days.map(day => mod(day - 4, 7)) };
const anchor = dayNumber(rule.anchor);
if (rule.type === 'interval') return { period: rule.every, residues: [mod(anchor, rule.every)] };
const monday = anchor - ((new Date(anchor * DAY).getUTCDay() + 6) % 7);
return { period: 7 * rule.every, residues: [mod(monday + (rule.weekday + 6) % 7, 7 * rule.every)] };
}
const left = cycle(a); const right = cycle(b);
let x = left.period; let y = right.period;
while (y) { const remainder = x % y; x = y; y = remainder; }
// Congruences intersect iff their residues agree modulo the gcd. Anchors
// only restrict the lower bound; a compatible repeating solution recurs.
return left.residues.some(l => right.residues.some(r => mod(l - r, x) === 0));
}
export function shade(ratio: number | null, steps: number, settings: CalendarSettings, exactSteps: boolean) {
if (ratio === null) return { level: null, color: settings.notDueColor };
if (ratio <= 0) return { level: 0, color: settings.emptyColor };
const total = steps;
const level = exactSteps ? Math.min(total, Math.round(ratio * total)) : ratio >= 1 ? total : Math.min(total - (total > 1 ? 1 : 0), Math.max(1, Math.floor(ratio * total)));
const intensity = total <= 1 ? 1 : 0.2 + 0.8 * (level - 1) / (total - 1);
const luminance = (0.2126 * parseInt(settings.mainColor.slice(1, 3), 16) + 0.7152 * parseInt(settings.mainColor.slice(3, 5), 16) + 0.0722 * parseInt(settings.mainColor.slice(5, 7), 16)) / 255;
const base = luminance > 0.75 ? 0 : 255;
const color = '#' + [1, 3, 5].map(i => Math.round(base * (1 - intensity) + parseInt(settings.mainColor.slice(i, i + 2), 16) * intensity).toString(16).padStart(2, '0')).join('');
return { level, color };
}

View File

@@ -0,0 +1,42 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
const create = (extra = {}) => f.json('/habits', 'POST', { name: 'Practice', method: 'count', target: 8, ...extra }, 201);
const day = (id: string, date: string) => f.json(`/habits/${id}/days/${date}`);
const log = (id: string, date: string, count: number) => f.json(`/habits/${id}/days/${date}/progress`, 'PUT', { count });
test('all default habits reset daily including partial counts and completed tasks', async () => {
const h = await create(); expect(h.carryPartialProgress).toBe(false); await log(h.id, '2026-09-04', 7);
const m = await f.json('/habits', 'POST', { name: 'Manual', method: 'manual' }, 201);
await f.json(`/habits/${m.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const t = await f.json('/habits', 'POST', { name: 'Task', method: 'tasks', tasks: [{ name: 'One' }, { name: 'Two' }] }, 201);
await f.json(`/habits/${t.id}/days/2026-09-04/tasks/${t.tasks[0].id}`, 'PUT', { done: true });
f.setTime('2026-09-05T12:00Z');
for (const id of [h.id, m.id, t.id]) expect((await day(id, '2026-09-05')).value).toBe(0);
});
test('opt-in partial counts carry to the next due date and completed counts reset', async () => {
const h = await create({ carryPartialProgress: true, schedule: { type: 'interval', every: 3, anchor: '2026-09-04' } });
await log(h.id, '2026-09-04', 7); f.setTime('2026-09-07T12:00Z');
expect(await day(h.id, '2026-09-05')).toMatchObject({ due: false, value: 0 });
expect(await day(h.id, '2026-09-07')).toMatchObject({ value: 7, carriedFrom: '2026-09-04', loggedCount: null });
await log(h.id, '2026-09-07', 8); f.setTime('2026-09-10T12:00Z');
expect((await day(h.id, '2026-09-10')).value).toBe(0);
});
test('backfills recompute inherited values but never overwrite explicit daily logs', async () => {
const h = await create({ carryPartialProgress: true }); await log(h.id, '2026-09-04', 4);
f.setTime('2026-09-07T12:00Z'); expect((await day(h.id, '2026-09-07')).value).toBe(4);
await log(h.id, '2026-09-04', 7); expect((await day(h.id, '2026-09-06')).value).toBe(7);
await log(h.id, '2026-09-06', 2); await log(h.id, '2026-09-04', 1);
expect((await day(h.id, '2026-09-05')).value).toBe(1); expect((await day(h.id, '2026-09-07')).value).toBe(2);
await log(h.id, '2026-09-06', 0); expect((await day(h.id, '2026-09-07')).value).toBe(0);
});
test('carryover configuration is dated and method switches break inheritance', async () => {
const h = await create({ carryPartialProgress: true }); await log(h.id, '2026-09-04', 7);
f.setTime('2026-09-05T12:00Z'); expect((await day(h.id, '2026-09-05')).value).toBe(7);
await f.json(`/habits/${h.id}`, 'PATCH', { carryPartialProgress: false });
expect((await day(h.id, '2026-09-05')).value).toBe(0); expect((await day(h.id, '2026-09-04')).value).toBe(7);
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
f.setTime('2026-09-06T12:00Z'); await f.json(`/habits/${h.id}`, 'PATCH', { method: 'count', target: 8, carryPartialProgress: true });
expect((await day(h.id, '2026-09-06')).value).toBe(0);
expect((await f.request('/habits', 'POST', { name: 'No partial state', method: 'manual', carryPartialProgress: true })).status).toBe(422);
});

45
src/habits/contracts.ts Normal file
View File

@@ -0,0 +1,45 @@
import { z } from "zod";
export const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(value => {
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(+date) && date.toISOString().slice(0, 10) === value && value >= '1970-01-01' && value <= '9998-12-31';
}, 'Expected a real calendar date from 1970 through 9998');
const name = z.string().trim().min(1).max(200);
export const scheduleSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('daily') }).strict(),
z.object({ type: z.literal('interval'), every: z.number().int().min(1).max(3650), anchor: dateSchema }).strict(),
z.object({ type: z.literal('weekdays'), days: z.array(z.number().int().min(0).max(6)).min(1).max(7).refine(a => new Set(a).size === a.length) }).strict(),
z.object({ type: z.literal('weekly'), every: z.number().int().min(1).max(520), weekday: z.number().int().min(0).max(6), anchor: dateSchema }).strict(),
]);
export type Schedule = z.infer<typeof scheduleSchema>;
const schedule = scheduleSchema.default({ type: 'daily' });
export const taskInput = z.object({ name, schedule }).strict();
// Creation defaults must not become writes when PATCH omits a property.
export const taskPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional() }).strict().refine(v => Object.keys(v).length > 0);
const common = { name, schedule, color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color').optional() };
export const habitInput = z.discriminatedUnion('method', [
z.object({ ...common, method: z.literal('count'), carryPartialProgress: z.boolean().default(false), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
z.object({ ...common, method: z.literal('manual') }).strict(),
z.object({ ...common, method: z.literal('tasks'), tasks: z.array(taskInput).max(100).default([]) }).strict(),
]);
export type HabitConfig = { name: string; schedule: Schedule; archived: boolean } & (
{ method: 'count'; carryPartialProgress: boolean; target: number; unit: string } |
{ method: 'manual' } |
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
);
export const habitPatch = z.object({ color: common.color, carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
export const doneInput = z.object({ done: z.boolean() }).strict();
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
// Product preferences only; layout and callbacks belong to the future UI.
export const calendarSettingsSchema = z.object({
mainColor: color.default('#196127'),
shadeCount: z.union([z.literal('auto'), z.number().int().min(2).max(20)]).default('auto'),
emptyColor: color.default('#ebedf0'),
notDueColor: color.default('#f5f5f5'),
futureColor: color.default('#dbeafe'),
}).strict();
export type CalendarSettings = z.infer<typeof calendarSettingsSchema>;
export const chartInput = z.object({ name, habitIds: z.array(z.string().uuid()).min(1).max(100).refine(a => new Set(a).size === a.length), settings: calendarSettingsSchema.default(() => calendarSettingsSchema.parse({})) }).strict();
export const chartPatch = z.object({ name: name.optional(), habitIds: chartInput.shape.habitIds.optional(), settings: calendarSettingsSchema.optional() }).strict().refine(v => Object.keys(v).length > 0);
export const timezoneInput = z.object({ timezone: z.string().min(1).max(100).refine(v => { try { return !/^[+-]/.test(v) && !!new Intl.DateTimeFormat('en', { timeZone: v }); } catch { return false; } }) }).strict();

View File

@@ -0,0 +1,121 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
const taskHabit = () => f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Weekly', schedule: { type: 'weekdays', days: [5] } }] }, 201);
const detail = (id: string) => f.json(`/habits/${id}/days/2026-09-04`);
const tick = (h: any, done: boolean) => f.json(`/habits/${h.id}/days/2026-09-04/tasks/${h.tasks[0].id}`, 'PUT', { done });
test('task PATCH preserves omitted recurrence and rejects an empty patch', async () => {
const h = await taskHabit(); const path = `/habits/${h.id}/tasks/${h.tasks[0].id}`;
expect((await f.json(path, 'PATCH', { name: 'Renamed' })).schedule).toEqual({ type: 'weekdays', days: [5] });
expect((await f.request(path, 'PATCH', {})).status).toBe(422);
expect((await f.json(path, 'PATCH', { schedule: { type: 'daily' } })).name).toBe('Renamed');
});
test('chart PATCH preserves omitted settings and rejects an empty patch', async () => {
const h = await taskHabit();
const c = await f.json('/charts', 'POST', { name: 'Chart', habitIds: [h.id], settings: { mainColor: '#ff0000', shadeCount: 8 } }, 201);
expect((await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Renamed' })).settings).toEqual(c.settings);
expect((await f.request(`/charts/${c.id}`, 'PATCH', {})).status).toBe(422);
expect((await f.json(`/charts/${c.id}`, 'PATCH', { settings: {} })).settings.mainColor).toBe('#196127');
});
test('archiving and restoring retains same-day task states and combined completion', async () => {
const h = await taskHabit(); await tick(h, true);
const c = await f.json('/charts', 'POST', { name: 'Combined', habitIds: [h.id] }, 201);
await f.request(`/habits/${h.id}`, 'DELETE');
expect((await detail(h.id)).due).toBe(false);
await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
expect((await detail(h.id)).value).toBe(1);
expect((await f.json(`/charts/${c.id}/days/2026-09-04`)).ratio).toBe(1);
await tick(h, false); await f.request(`/habits/${h.id}`, 'DELETE'); await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
expect((await detail(h.id)).value).toBe(0);
});
test('temporarily disabling the schedule retains same-day occurrence progress', async () => {
const h = await taskHabit(); await tick(h, true);
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'daily' } });
expect((await detail(h.id)).value).toBe(1);
});
test('post-deadline corrections never invent expiry for an originally completed occurrence', async () => {
const h = await taskHabit(); await tick(h, true); f.setTime('2026-09-05T12:00Z');
expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
await tick(h, false);
for (let i = 0; i < 2; i++) expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
});
test('expired occurrences preserve original expiry through repeated historical corrections', async () => {
const h = await taskHabit(); f.setTime('2026-09-05T12:00Z');
const expired = (await detail(h.id)).tasks[0].expiredAt;
expect(expired).toBe(Date.parse('2026-09-04T22:00Z'));
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
await tick(h, false); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
});
test('overlapping patches preserve unrelated changes', async () => {
const h = await f.json('/habits', 'POST', { name: 'Original', method: 'count', target: 8 }, 201);
await Promise.all([
f.json(`/habits/${h.id}`, 'PATCH', { name: 'Renamed' }),
f.json(`/habits/${h.id}`, 'PATCH', { target: 10 }),
]);
expect(await f.json(`/habits/${h.id}`)).toMatchObject({ name: 'Renamed', target: 10 });
});
test('count correction audit records inherited progress rather than the raw zero default', async () => {
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setTime('2026-09-05T12:00Z');
await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 8 });
const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
expect(audit.records[0].events[0].before.count).toBe(7);
});
function delayedPatch(path: string, input: object) {
let controller!: ReadableStreamDefaultController<Uint8Array>;
let began!: () => void;
const started = new Promise<void>(resolve => { began = resolve; });
const encoded = new TextEncoder().encode(JSON.stringify(input));
const stream = new ReadableStream<Uint8Array>({ start(value) { controller = value; }, pull() { began(); } });
const response = f.app.request(`${f.origin}/api${path}`, { method: 'PATCH', body: stream,
headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json', 'Content-Length': String(encoded.length) } });
return { started, response, finish() { controller.enqueue(encoded); controller.close(); } };
}
test('a request streaming across midnight applies configuration on its actual write date', async () => {
const h = await taskHabit(); f.setTime('2026-09-04T21:59:59Z');
const pending = delayedPatch(`/habits/${h.id}`, { name: 'Tomorrow' }); await pending.started;
f.setTime('2026-09-04T22:01Z'); pending.finish();
expect((await pending.response).status).toBe(200);
expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
expect((await detail(h.id)).name).toBe('Routine');
});
test('a streaming request observes timezone changes completed before its write', async () => {
const h = await taskHabit();
const pending = delayedPatch(`/habits/${h.id}`, { name: 'After travel' }); await pending.started;
await f.json('/me', 'PATCH', { timezone: 'Pacific/Kiritimati' }); pending.finish();
expect((await pending.response).status).toBe(200);
expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).timezone).toBe('Pacific/Kiritimati');
expect((await detail(h.id)).name).toBe('Routine');
});
test('task occurrences retain definition order across dates and revisions', async () => {
const h = await f.json('/habits', 'POST', { name: 'Order', method: 'tasks', tasks: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }] }, 201);
for (let i = 0; i < 3; i++) f.sqlite.query('UPDATE task_occurrences SET id = ? WHERE task_id = ?').run(String(3 - i), h.tasks[i].id);
expect((await detail(h.id)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Rename' });
f.setTime('2026-09-05T12:00Z');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
});
test('impossible schedule intersections expose warnings while valid intervals remain allowed', async () => {
const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [
{ name: 'Friday', schedule: { type: 'weekdays', days: [5] } },
{ name: 'Every three days', schedule: { type: 'interval', every: 3, anchor: '2026-09-04' } },
] }, 201);
expect(h.warnings).toHaveLength(1); expect(h.warnings[0]).toMatchObject({ code: 'task_never_due', taskId: h.tasks[0].id });
await f.json(`/habits/${h.id}/tasks/${h.tasks[0].id}`, 'PATCH', { schedule: { type: 'daily' } });
expect((await f.json(`/habits/${h.id}`)).warnings).toEqual([]);
const empty = await f.json('/habits', 'POST', { name: 'Empty', method: 'tasks' }, 201);
expect(empty.warnings[0].code).toBe('no_tasks');
});
test('revisions preserve a finalized null expiry when a timezone transition extends the local date', async () => {
const h = await taskHabit(); await tick(h, true);
await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' });
f.setTime('2026-09-04T23:00Z'); // Still Sept 4 locally, past its preserved Belgrade deadline.
await tick(h, false);
await f.json(`/habits/${h.id}/tasks/${h.tasks[0].id}`, 'PATCH', { name: 'Renamed after deadline' });
expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
});

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
test('failed revision writes roll back the habit atomically and do not expose database errors', async () => {
f.sqlite.exec("CREATE TRIGGER reject_revision BEFORE INSERT ON habit_revisions BEGIN SELECT RAISE(ABORT, 'private database detail'); END");
const response = await f.request('/habits', 'POST', { name: 'Rollback', method: 'manual' });
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
expect((await f.json('/habits')).habits).toHaveLength(0);
});
test('creation saves color atomically and never puts presentation into requirements', async () => {
for (const method of ['manual', 'count', 'tasks']) {
const h = await f.json('/habits', 'POST', { name: method, method, color: '#79618d', ...(method === 'count' ? { target: 8 } : {}) }, 201);
expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).requirements.color).toBeUndefined();
}
expect((await f.request('/habits', 'POST', { name: 'Invalid', method: 'manual', color: 'red' })).status).toBe(422);
expect((await f.json('/habits')).habits).toHaveLength(3);
f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: 'Internal server error' });
expect((await f.json('/habits')).habits).toHaveLength(3);
});
test('edits save color with requirements atomically, preserving history and other settings', async () => {
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, color: '#426582' }, 201);
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#426582', emptyColor: '#fafafa', shadeCount: 4 });
f.setTime('2026-09-05T12:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10, color: '#79618d' });
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).target).toBe(8);
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toMatchObject({ mainColor: '#79618d', emptyColor: '#fafafa', shadeCount: 4 });
expect((await f.json(`/habits/${h.id}`)).color).toBeUndefined();
expect((await f.request(`/habits/${h.id}`, 'PATCH', { color: 'red' })).status).toBe(422);
f.sqlite.exec("CREATE TRIGGER reject_color_update BEFORE UPDATE ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
expect((await f.request(`/habits/${h.id}`, 'PATCH', { name: 'Should roll back', target: 12, color: '#58765b' })).status).toBe(500);
expect((await f.json(`/habits/${h.id}`))).toMatchObject({ name: 'Water', target: 10 });
expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
});
test('same-day method switches reset inherited progress even when returning to count', async () => {
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); f.setTime('2026-09-05T12:00Z');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(7);
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'count', target: 8, carryPartialProgress: true });
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
});
test('timezone travel across the date line never changes yesterday requirements', async () => {
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
f.setTime('2026-09-05T00:30Z'); await f.json('/today');
await f.json('/me', 'PATCH', { timezone: 'Pacific/Honolulu' });
expect((await f.json('/today')).date).toBe('2026-09-05');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 });
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).target).toBe(8);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).target).toBe(10);
});
test('a year of multiple habit history materializes and returns every calendar square', async () => {
const ids: string[] = [];
for (let i = 0; i < 12; i++) ids.push((await f.json('/habits', 'POST', { name: `Habit ${i}`, method: i % 2 ? 'manual' : 'tasks', ...(i % 2 ? {} : { tasks: [{ name: 'Daily' }] }) }, 201)).id);
const c = await f.json('/charts', 'POST', { name: 'Year', habitIds: ids }, 201);
f.setTime('2027-09-04T12:00Z');
const calendar = await f.json(`/charts/${c.id}/calendar?from=2026-09-04&to=2027-09-04`);
expect(calendar.days).toHaveLength(366); expect(calendar.days.every((d: any) => d.due === 12 && d.completed === 0)).toBe(true);
const first = calendar.days[0].habits.find((h: any) => h.method === 'tasks');
expect(first.tasks[0].expiredAt).toBe(Date.parse('2026-09-04T22:00Z'));
});

109
src/habits/routes.ts Normal file
View File

@@ -0,0 +1,109 @@
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import type { AppDatabase, AuthEnv, createAuth } from '../auth';
import { HabitService, ApiError } from './service';
import { calendarSettingsSchema, chartInput, chartPatch, dateSchema, doneInput, habitInput, habitPatch, progressInput, taskInput, taskPatch, timezoneInput } from './contracts';
import { dayNumber } from './calendar';
type Env = AuthEnv & { Variables: { service: HabitService; payload: { value: unknown; malformed: boolean } } };
function body<T extends z.ZodType>(c: Context<Env>, schema: T): z.output<T> {
if (c.req.header('Content-Type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') throw new ApiError(400, 'Expected application/json');
const { value, malformed } = c.get('payload');
if (malformed) throw new ApiError(400, 'Malformed JSON');
const result = schema.safeParse(value);
if (!result.success) throw new ApiError(422, result.error.issues.map(i => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '));
return result.data;
}
function date(c: Context<Env>) {
const result = dateSchema.safeParse(c.req.param('date'));
if (!result.success) throw new ApiError(422, 'Expected a real YYYY-MM-DD date');
return result.data;
}
function range(c: Context<Env>) {
const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query());
if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days');
return result.data;
}
export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
const app = new Hono<Env>();
const protectedPath = (path: string, method: string) => /^\/api\/(habits|charts|today|days)(\/|$)/.test(path) || (path === '/api/me' && method === 'PATCH');
const authenticate = auth.requireAuth as unknown as MiddlewareHandler<Env>;
const origin = auth.requireSameOrigin as unknown as MiddlewareHandler<Env>;
const limit = bodyLimit({ maxSize: 65536, onError: c => c.json({ error: 'Request body exceeds 64 KiB' }, 413) });
app.use('*', async (c, next) => {
if (!protectedPath(c.req.path, c.req.method)) return next();
return authenticate(c, async () => {
const proceed = async () => { const response = await limit(c, async () => {
// Finish all asynchronous body reads before taking a dated database snapshot.
// The handlers below then read, validate and write synchronously in one turn.
const payload: Env['Variables']['payload'] = { value: undefined, malformed: false };
if (['POST', 'PATCH', 'PUT'].includes(c.req.method)) {
try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
}
c.set('payload', payload);
const service = new HabitService(db, c.get('user'), now());
service.sync(); c.set('service', service); await next();
}); if (response) c.res = response; };
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
const response = await origin(c, proceed); if (response) c.res = response;
} else await proceed();
});
});
const service = (c: Context<Env>) => c.get('service');
const id = (c: Context<Env>) => c.req.param('id')!;
app.get('/habits', c => {
const q = z.object({ archived: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query());
if (!q.success) throw new ApiError(422, 'archived must be true or false');
return c.json({ habits: service(c).list(q.data.archived === 'true') });
});
app.post('/habits', c => {
const habit = service(c).create(body(c, habitInput));
c.header('Location', `/api/habits/${habit.id}`); return c.json(habit, 201);
});
app.get('/habits/:id', c => c.json(service(c).current(id(c))));
app.patch('/habits/:id', c => c.json(service(c).edit(id(c), body(c, habitPatch))));
app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); });
app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) }));
app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) }));
app.post('/habits/:id/tasks', c => c.json(service(c).changeTask(id(c), undefined, body(c, taskInput)), 201));
app.get('/habits/:id/tasks/:taskId', c => {
const task = service(c).tasks(id(c)).find(t => t.id === c.req.param('taskId'));
if (!task) throw new ApiError(404, 'Not found'); return c.json(task);
});
app.patch('/habits/:id/tasks/:taskId', c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), body(c, taskPatch))));
app.delete('/habits/:id/tasks/:taskId', c => { service(c).changeTask(id(c), c.req.param('taskId'), null); return c.body(null, 204); });
app.get('/habits/:id/days/:date', c => c.json(service(c).day(id(c), date(c))));
app.get('/habits/:id/days/:date/audit', c => c.json({ records: service(c).audit(id(c), date(c)) }));
app.put('/habits/:id/days/:date/progress', c => c.json(service(c).progress(id(c), date(c), body(c, progressInput))));
app.put('/habits/:id/days/:date/tasks/:taskId', c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (body(c, doneInput)).done)));
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
app.get('/habits/:id/calendar', c => {
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false));
});
app.get('/today', c => {
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
return c.json({ date: s.today, timezone: s.user.timezone, habits, due: habits.filter(h => h.due).length, completed: habits.filter(h => h.complete).length });
});
app.get('/days/:date', c => {
const s = service(c); const day = date(c); return c.json({ date: day, habits: s.list(true).map(h => s.day(h.id, day)) });
});
app.get('/charts', c => c.json({ charts: service(c).charts() }));
app.post('/charts', c => {
const chart = service(c).saveChart(body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201);
});
app.get('/charts/:id', c => c.json(service(c).chart(id(c))));
app.patch('/charts/:id', c => {
const patch = body(c, chartPatch); const old = service(c).chart(id(c));
return c.json(service(c).saveChart({ name: patch.name ?? old.name, habitIds: patch.habitIds ?? old.habitIds, settings: patch.settings ?? old.settings }, id(c)));
});
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
app.get('/charts/:id/calendar', c => {
const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true));
});
app.get('/charts/:id/days/:date', c => {
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);
});
app.patch('/me', c => c.json(service(c).setTimezone((body(c, timezoneInput)).timezone)));
return app;
}

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
test('every protected resource route enforces authentication, ownership and mutation origin', async () => {
const h = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201);
const t = h.tasks[0].id;
const chart = await f.json('/charts', 'POST', { name: 'Private chart', habitIds: [h.id] }, 201);
const cases: [string, string, any?][] = [
[`/habits/${h.id}`, 'GET'], [`/habits/${h.id}`, 'PATCH', { name: 'Stolen' }], [`/habits/${h.id}`, 'DELETE'],
[`/habits/${h.id}/history`, 'GET'], [`/habits/${h.id}/tasks`, 'GET'], [`/habits/${h.id}/tasks`, 'POST', { name: 'Bad' }],
[`/habits/${h.id}/tasks/${t}`, 'GET'], [`/habits/${h.id}/tasks/${t}`, 'PATCH', { name: 'Bad' }], [`/habits/${h.id}/tasks/${t}`, 'DELETE'],
[`/habits/${h.id}/days/2026-09-04`, 'GET'], [`/habits/${h.id}/days/2026-09-04/audit`, 'GET'],
[`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true }], [`/habits/${h.id}/days/2026-09-04/tasks/${t}`, 'PUT', { done: true }],
[`/habits/${h.id}/calendar-settings`, 'GET'], [`/habits/${h.id}/calendar-settings`, 'PUT', {}], [`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'],
[`/charts/${chart.id}`, 'GET'], [`/charts/${chart.id}`, 'PATCH', { name: 'Bad' }], [`/charts/${chart.id}`, 'DELETE'],
[`/charts/${chart.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'], [`/charts/${chart.id}/days/2026-09-04`, 'GET'],
];
for (const [path, method, body] of cases) {
const unauthorized = await f.request(path, method, body, 'z'); expect(unauthorized.status).toBe(401);
expect(unauthorized.headers.get('Cache-Control')).toBe('no-store');
expect((await f.request(path, method, body, 'b')).status).toBe(404);
if (method !== 'GET') for (const origin of ['', 'https://evil.example']) expect((await f.request(path, method, body, 'a', { Origin: origin })).status).toBe(403);
}
for (const path of ['/habits', '/charts', '/today', '/days/2026-09-04']) expect((await f.request(path, 'GET', undefined, 'z')).status).toBe(401);
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
expect((await f.json('/charts', 'GET', undefined, 200, 'b')).charts).toHaveLength(0);
expect((await f.json('/today', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
expect((await f.json('/days/2026-09-04', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
expect((await f.request('/charts', 'POST', { name: 'Steal', habitIds: [h.id] }, 'b')).status).toBe(404);
for (const path of ['/habits', '/charts', '/me']) {
const method = path === '/me' ? 'PATCH' : 'POST';
expect((await f.request(path, method, {}, 'z')).status).toBe(401);
expect((await f.request(path, method, {}, 'a', { Origin: '' })).status).toBe(403);
}
});
test('strict JSON, dates, bounds, methods, task membership and non-render settings validation', async () => {
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
const invalidHabits = [{}, { name: ' ', method: 'manual' }, { name: 'Mixed', method: 'manual', target: 8 }, { name: 'Water', method: 'count', target: -1 }, { name: 'Huge', method: 'count', target: 10001 }, { name: 'Odd', method: 'manual', schedule: { type: 'interval', every: 0, anchor: '2026-09-04' } }];
for (const body of invalidHabits) expect((await f.request('/habits', 'POST', body)).status).toBe(422);
for (const body of [{ count: -1 }, { count: 1.5 }, { done: true }, { count: 2, done: true }, { count: 1e20 }]) expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', body)).status).toBe(422);
for (const date of ['2026-02-30', 'garbage', '2026-9-4']) expect((await f.request(`/habits/${h.id}/days/${date}`)).status).toBe(422);
for (const query of ['', '?from=2026-09-04', '?from=2026-09-05&to=2026-09-04', '?from=2020-01-01&to=2026-01-01', '?from=2026-09-04&to=2026-09-05&bad=x']) expect((await f.request(`/habits/${h.id}/calendar${query}`)).status).toBe(422);
expect((await f.request('/habits?archived=yes')).status).toBe(422);
expect((await f.request(`/habits/${h.id}`, 'PATCH', {})).status).toBe(422);
expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'manual', target: 4 })).status).toBe(422);
expect((await f.request(`/habits/${h.id}/tasks`)).status).toBe(409);
expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'Wrong method' })).status).toBe(409);
expect((await f.request(`/habits/${h.id}/days/2026-09-04/tasks/missing`, 'PUT', { done: true })).status).toBe(404);
for (const settings of [{ mainColor: 'red' }, { shadeCount: 1 }, { daySpacing: 5 }]) expect((await f.request(`/habits/${h.id}/calendar-settings`, 'PUT', settings)).status).toBe(422);
for (const timezone of ['', 'invalid', '+02:00']) expect((await f.request('/me', 'PATCH', { timezone })).status).toBe(422);
for (const habitIds of [[], [h.id, h.id], ['not-uuid']]) expect((await f.request('/charts', 'POST', { name: 'Bad', habitIds })).status).toBe(422);
const response = await f.app.request(`${f.origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' }, body: '{' }); expect(response.status).toBe(400);
expect((await f.request('/habits', 'POST', {}, 'a', { 'Content-Type': 'text/plain' })).status).toBe(400);
expect((await f.request('/habits', 'POST', { name: 'x'.repeat(70000) })).status).toBe(413);
});
test('missing tasks and archived editing fail without partial writes', async () => {
const h = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks' }, 201);
expect((await f.request(`/habits/${h.id}/tasks/nope`, 'PATCH', { name: 'No' })).status).toBe(404);
await f.request(`/habits/${h.id}`, 'DELETE');
expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'No' })).status).toBe(409);
expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2);
});

312
src/habits/service.ts Normal file
View File

@@ -0,0 +1,312 @@
import { and, asc, desc, eq, lte, sql } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import type { PublicUser } from '../shared/user';
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from './calendar';
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
export class ApiError extends Error {
constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); }
}
const missing = () => new ApiError(404, 'Not found');
export type Habit = typeof habits.$inferSelect;
export type Revision = typeof habitRevisions.$inferSelect;
export type HabitDay = typeof habitDays.$inferSelect;
export class HabitService {
readonly today: string;
private ownedCache = new Map<string, Habit>();
private revisionCache = new Map<string, Revision[]>();
private deadlineCache = new Map<string, number>();
private recordsCache = new Map<string, Map<string, HabitDay>>();
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// A different request may update the timezone while this request's body streams in.
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone;
this.user = { ...user, timezone };
// Travel west must never reopen or rewrite earlier requirements.
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
this.today = [localDate(now, this.user.timezone), latest ?? ''].sort().at(-1)!;
}
owned(id: string): Habit {
const cached = this.ownedCache.get(id); if (cached) return cached;
const habit = this.db.select().from(habits).where(and(eq(habits.id, id), eq(habits.userId, this.user.id))).get();
if (!habit) throw missing();
this.ownedCache.set(id, habit); return habit;
}
revision(id: string, date = this.today): Revision | undefined {
let revisions = this.revisionCache.get(id);
if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); }
return revisions.find(r => r.effectiveDate <= date);
}
current(id: string) {
const habit = this.owned(id); const revision = this.revision(id)!;
const config = revision.config;
const warnings = config.method !== 'tasks' ? [] : config.tasks.length === 0
? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }]
: config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule))
.map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` }));
return { id: habit.id, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
}
list(includeArchived = false) {
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
.map(h => this.current(h.id)).filter(h => includeArchived || !h.archived);
}
create(input: ReturnType<typeof habitInput.parse>) {
const id = crypto.randomUUID();
const { color, ...requirements } = input;
const config: HabitConfig = requirements.method === 'tasks'
? { ...requirements, archived: false, tasks: requirements.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
: { ...requirements, archived: false };
this.db.transaction(() => {
this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run();
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
if (color) this.saveSettings(id, calendarSettingsSchema.parse({ mainColor: color }));
this.materialize(this.owned(id));
});
return this.current(id);
}
edit(id: string, patch: Record<string, unknown>) {
const old = this.current(id);
const method = patch.method ?? old.method;
const base: Record<string, unknown> = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method };
if (method === 'count') {
base.carryPartialProgress = patch.carryPartialProgress ?? (old.method === 'count' ? old.carryPartialProgress : false);
base.target = patch.target ?? (old.method === 'count' ? old.target : undefined);
base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps');
} else if ('target' in patch || 'unit' in patch || 'carryPartialProgress' in patch) throw new ApiError(422, 'Target, unit and carryPartialProgress require the count method');
if (method === 'tasks') base.tasks = [];
const parsed = habitInput.safeParse(base);
if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings');
const config: HabitConfig = parsed.data.method === 'tasks'
? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean }
: { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean };
return this.db.transaction(() => {
const updated = this.revise(id, config);
if (patch.color !== undefined) {
this.saveSettings(id, calendarSettingsSchema.parse({ ...this.settings(id), mainColor: patch.color }));
}
return updated;
});
}
revise(id: string, config: HabitConfig) {
this.invalidate(id);
this.owned(id);
this.db.transaction(() => {
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
this.materialize(this.owned(id));
});
return this.current(id);
}
tasks(id: string) {
const config = this.current(id);
if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks');
return config.tasks;
}
changeTask(id: string, taskId: string | undefined, input: { name?: string; schedule?: HabitConfig['schedule'] } | null) {
const config = this.revision(this.owned(id).id)!.config;
if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks');
if (config.archived) throw new ApiError(409, 'Restore the habit before editing tasks');
if (taskId && !config.tasks.some(t => t.id === taskId)) throw missing();
const nextId = taskId ?? crypto.randomUUID();
const tasks = taskId ? config.tasks.flatMap(t => t.id !== taskId ? [t] : input ? [{ ...t, ...input }] : [])
: [...config.tasks, { id: nextId, name: input!.name!, schedule: input!.schedule! }];
if (tasks.length > 100) throw new ApiError(422, 'A habit supports at most 100 tasks');
this.revise(id, { ...config, tasks });
return input ? tasks.find(t => t.id === nextId)! : undefined;
}
sync() {
this.db.transaction(() => {
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
// Record the original deadline even after downtime. Only active day snapshots expire.
this.db.run(sql`UPDATE task_occurrences SET
expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END,
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
WHERE closed_at IS NULL AND day_id IN (
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
});
}
private invalidate(id: string) {
this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
}
private deadline(date: string) {
let value = this.deadlineCache.get(date);
if (value === undefined) { value = endOfDay(date, this.user.timezone); this.deadlineCache.set(date, value); }
return value;
}
private records(id: string) {
let records = this.recordsCache.get(id);
if (!records) {
records = new Map();
for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day);
this.recordsCache.set(id, records);
}
return records;
}
private occurrences(id: string) {
let occurrences = this.occurrencesCache.get(id);
if (!occurrences) {
occurrences = new Map();
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all();
for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); }
this.occurrencesCache.set(id, occurrences);
}
return occurrences;
}
private materialize(habit: Habit) {
let date = habit.materializedThrough ?? habit.createdDate;
while (date <= this.today) {
const revision = this.revision(habit.id, date)!;
const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
if (!existing || existing.revisionId !== revision.id) {
const prior = existing ? this.db.select().from(habitRevisions).where(eq(habitRevisions.id, existing.revisionId)).get() : undefined;
const sameMethod = prior?.config.method === revision.config.method;
const day = this.db.insert(habitDays).values({ habitId: habit.id, revisionId: revision.id, date,
timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? this.deadline(date),
countSet: existing && !sameMethod && revision.config.method === 'count' ? true : sameMethod ? existing!.countSet : false, count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) {
// A temporary archive or schedule change can leave the immediately prior snapshot empty.
// Stable task IDs recover the latest occurrence from this date only.
const previous = existing ? this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences)
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
const old = previous.find(t => t.taskId === task.id);
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run();
}
}
}
date = addDays(date, 1);
}
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
}
private counts(id: string) {
const cached = this.countCache.get(id); if (cached) return cached;
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays)
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id))
.where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`))
.orderBy(asc(habitDays.date)).all();
const result = new Map<string, { value: number; carriedFrom: string | null }>();
let carry = 0; let source: string | null = null;
for (const { day, config } of rows) {
if (config.method !== 'count' || config.archived) { carry = 0; source = null; continue; }
if (!scheduled(config.schedule, day.date)) continue;
const value = day.countSet ? day.count : config.carryPartialProgress ? carry : 0;
result.set(day.date, { value, carriedFrom: !day.countSet && config.carryPartialProgress && carry > 0 ? source : null });
carry = value < config.target ? value : 0; source = carry > 0 ? day.date : null;
}
this.countCache.set(id, result); return result;
}
day(id: string, date: string) {
this.owned(id);
const revision = this.revision(id, date);
const record = this.records(id).get(date);
const config = revision?.config;
const future = date > this.today;
const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date)
? record ? config.tasks.flatMap(task => (this.occurrences(id).get(record.id) ?? []).filter(occurrence => occurrence.taskId === task.id))
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, closedAt: null, updatedAt: null })) : [];
const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0);
const counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined;
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
const ratio = due ? Math.min(1, value / target!) : null;
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
}
writable(id: string, date: string) {
const detail = this.day(id, date);
if (detail.future) throw new ApiError(409, 'Future progress cannot be logged');
if (!detail.due) throw new ApiError(409, 'Nothing is scheduled for this date');
const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get()!;
return { detail, record };
}
progress(id: string, date: string, input: { count: number } | { done: boolean }) {
const { detail, record } = this.writable(id, date);
if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences');
if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date');
this.countCache.delete(id);
this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run();
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
});
this.invalidate(id);
return this.day(id, date);
}
completeTask(id: string, date: string, taskId: string, done: boolean) {
const { record } = this.writable(id, date);
const occurrence = this.db.select().from(taskOccurrences).where(and(eq(taskOccurrences.dayId, record.id), eq(taskOccurrences.taskId, taskId))).get();
if (!occurrence) throw missing();
this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run();
this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
});
this.invalidate(id);
return this.day(id, date);
}
history(id: string) {
this.owned(id);
return this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.id)).all();
}
audit(id: string, date: string) {
this.owned(id);
return this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(asc(habitDays.id)).all().map(day => ({
...day, requirements: this.db.select().from(habitRevisions).where(eq(habitRevisions.id, day.revisionId)).get()!.config,
occurrences: this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, day.id)).all(),
events: this.db.select().from(progressEvents).where(eq(progressEvents.dayId, day.id)).orderBy(asc(progressEvents.id)).all(),
}));
}
settings(id: string): CalendarSettings {
this.owned(id);
return this.db.select().from(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).get()?.settings ?? calendarSettingsSchema.parse({});
}
saveSettings(id: string, settings: CalendarSettings) {
this.owned(id);
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
return settings;
}
chart(id: string) {
const chart = this.db.select().from(combinedCharts).where(and(eq(combinedCharts.id, id), eq(combinedCharts.userId, this.user.id))).get();
if (!chart) throw missing();
return chart;
}
charts() { return this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).orderBy(asc(combinedCharts.createdAt), asc(combinedCharts.id)).all(); }
saveChart(input: { name: string; habitIds: string[]; settings: CalendarSettings }, id?: string) {
if (id) this.chart(id);
input.habitIds.forEach(h => this.owned(h));
if (id) this.db.update(combinedCharts).set({ ...input, updatedAt: this.now }).where(eq(combinedCharts.id, id)).run();
else { id = crypto.randomUUID(); this.db.insert(combinedCharts).values({ ...input, id, userId: this.user.id, createdAt: this.now, updatedAt: this.now }).run(); }
return this.chart(id);
}
deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); }
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) {
ids.forEach(id => this.owned(id));
const days = [];
for (let date = from; date <= to; date = addDays(date, 1)) {
const details = ids.map(id => this.day(id, date));
const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length;
const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio;
const manual = !combined && details[0]!.method === 'manual';
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
const future = date > this.today;
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details });
}
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
}
setTimezone(timezone: string) {
this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run();
return { ...this.user, timezone };
}
}

View File

@@ -0,0 +1,26 @@
import { Database } from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
import { createApi } from '../api';
import { hashToken } from '../auth';
import * as schema from '../db/schema';
export function fixture(path = ':memory:') {
const sqlite = new Database(path); sqlite.exec('PRAGMA foreign_keys = ON');
const db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: './drizzle' });
let time = Date.parse('2026-09-04T12:00:00Z');
const origin = 'http://127.0.0.1:3000';
const app = createApi(db, { origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, () => time);
for (const [id, token] of [['alice', 'a'], ['bob', 'b']]) {
db.insert(schema.users).values({ id: id!, discordId: id!, username: id!, timezone: 'Europe/Belgrade', createdAt: time, updatedAt: time }).onConflictDoNothing().run();
db.insert(schema.sessions).values({ tokenHash: hashToken(token!.repeat(43)), userId: id!, createdAt: time, expiresAt: time + 10 * 365 * 86400000 }).onConflictDoNothing().run();
}
async function request(path: string, method = 'GET', body?: unknown, user = 'a', extra?: Record<string, string>) {
return app.request(`${origin}/api${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, 'Content-Type': 'application/json', ...extra }, body: body === undefined ? undefined : JSON.stringify(body) });
}
async function json(path: string, method = 'GET', body?: unknown, status = 200, user = 'a'): Promise<any> {
const response = await request(path, method, body, user); const data = await response.json();
if (response.status !== status) throw new Error(`${method} ${path}: expected ${status}, got ${response.status}: ${JSON.stringify(data)}`);
return data;
}
return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => sqlite.close() };
}

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minabot</title> <title>Minabot</title>
<meta name="description" content="A quiet place to track your habits and see your progress. Explore Minabot’s interactive habit calendars and connect with Discord." />
<script type="module" src="./frontend.tsx" async></script> <script type="module" src="./frontend.tsx" async></script>
</head> </head>
<body> <body>

48
src/lib/dashboard.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { HabitService } from "../habits/service";
import type { Schedule } from "../habits/contracts";
export type TodayHabit = ReturnType<HabitService["day"]>;
export type TodayResponse = {
date: string;
timezone: string;
habits: TodayHabit[];
due: number;
completed: number;
};
export async function habitRequest<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const response = await fetch(`/api${path}`, options);
if (!response.ok) {
if (response.status === 401)
throw new Error("Your session has expired. Sign in again to continue.");
const body = await response.json().catch(() => null);
throw new Error(
body?.error || "Could not reach your workspace. Please try again.",
);
}
return response.status === 204
? (undefined as T)
: (response.json() as Promise<T>);
}
export function scheduleLabel(schedule?: Schedule) {
if (!schedule || schedule.type === "daily") return "Every day";
if (schedule.type === "weekdays")
return schedule.days
.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day])
.join(", ");
if (schedule.type === "interval") return `Every ${schedule.every} days`;
return `Every ${schedule.every === 1 ? "week" : `${schedule.every} weeks`} · ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][schedule.weekday]}`;
}
export function formatTrackingDate(date: string) {
return new Date(`${date}T12:00:00Z`).toLocaleDateString("en", {
weekday: "long",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}

603
src/pages/DesignSystem.tsx Normal file
View File

@@ -0,0 +1,603 @@
import { useState } from "react";
import { Link } from "react-router";
import {
Button,
Checkbox,
Counter,
SectionHeading,
} from "../components/design-system/primitives";
import { CalendarHeatmap } from "../components/design-system/CalendarHeatmap";
import { HabitChart } from "../components/design-system/HabitChart";
import { EditingWorkbench } from "../components/design-system/EditingWorkbench";
import type { HabitColors } from "../components/design-system/HabitColorPicker";
import {
combinedProgress,
demoCalendar,
PROGRESS_SHADES,
HABIT_COLORS,
} from "../components/design-system/calendar-model";
import todayImage from "../assets/design/today-v2.png";
import habitImage from "../assets/design/habit-detail.png";
import "../../styles/design-system.css";
type View = "Today" | "Habit detail" | "Combined chart";
export function DesignSystem() {
const [habitColors, setHabitColors] = useState<HabitColors>({ ...HABIT_COLORS });
const [view, setView] = useState<View>("Today");
const [water, setWater] = useState(7);
const [read, setRead] = useState(true);
const [tasks, setTasks] = useState([true, true, false]);
const [movement, setMovement] = useState(10);
const [included, setIncluded] = useState([true, true, true, true]);
const [exampleChecked, setExampleChecked] = useState(false);
const [exampleCount, setExampleCount] = useState(3);
const [habitName, setHabitName] = useState("");
const [savedName, setSavedName] = useState("");
const taskCount = tasks.filter(Boolean).length;
const habits = [
{ value: water, target: 8, due: true },
{ value: Number(read), target: 1, due: true },
{ value: taskCount, target: 3, due: true },
{ value: movement, target: 20, due: true },
];
const total = combinedProgress(habits);
const combined = combinedProgress(
habits.filter((_, index) => included[index]),
);
const isIndividual = view === "Habit detail";
const chartDays = demoCalendar(
isIndividual ? water : combined.completed,
isIndividual ? 8 : combined.due,
).map((day) =>
!isIndividual && combined.due === 0 && day.state !== "future"
? { ...day, state: "not-due" as const }
: day,
);
function resetDemo() {
setWater(7);
setRead(true);
setTasks([true, true, false]);
setMovement(10);
setIncluded([true, true, true, true]);
}
return (
<div className="ds-root" id="top">
<a className="ds-skip-link" href="#ds-main">
Skip to content
</a>
<header className="ds-header">
<Link className="ds-wordmark" to="/">
minabot<span aria-hidden="true">.</span>
</Link>
<nav aria-label="Design system navigation">
<a href="#editing">Editing</a>
<a href="#foundations">Foundations</a>
<a href="#components">Components</a>
<a href="#views">
Views <span aria-hidden="true">↗</span>
</a>
</nav>
<span className="ds-edition">DESIGN SYSTEM / 01</span>
</header>
<main id="ds-main" className="ds-main">
<section className="ds-intro" aria-labelledby="ds-title">
<div>
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
<h1 id="ds-title">
Less interface.
<br />
<em>More intention.</em>
</h1>
<p className="ds-intro-copy">
A quiet foundation for everyday progress.
<br />A quiet canvas. A color for every habit.
</p>
</div>
<div className="ds-intro-note">
<span className="ds-tiny-cross" aria-hidden="true">
+
</span>
<p>
Flat by design.
<br />
Space, not containers.
<br />
Clarity in every state.
</p>
<a href="#playground">
Explore the system <span aria-hidden="true">↓</span>
</a>
</div>
</section>
<section
className="ds-section"
id="playground"
aria-labelledby="playground-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / IN PRACTICE</span>
<h2 id="playground-title">
A little, <em>every day.</em>
</h2>
</div>
<span className="ds-demo-note">
<span aria-hidden="true">◦</span> Interactive demo · not saved
</span>
</div>
<div className="ds-preview-toolbar">
<div
className="ds-view-switch"
role="group"
aria-label="Preview view"
>
{(["Today", "Habit detail", "Combined chart"] as const).map(
(item) => (
<button
type="button"
key={item}
aria-pressed={view === item}
onClick={() => setView(item)}
>
{item}
</button>
),
)}
</div>
<Button variant="text" onClick={resetDemo}>
Reset demo <span aria-hidden="true">↺</span>
</Button>
</div>
<div className="ds-preview-heading">
<div>
<p className="ds-eyebrow">FRIDAY, SEPTEMBER 4, 2026</p>
<h3>
{view === "Today"
? "Your habits, at a glance."
: isIndividual
? "Drink water"
: "Your rhythm, together."}
</h3>
</div>
<p aria-live="polite">
{view === "Today"
? `${total.completed} of ${total.due} complete`
: isIndividual
? "Daily · 8 glasses"
: combined.due
? `${combined.completed} of ${combined.due} habits complete`
: "No habits selected"}
</p>
</div>
{view === "Today" ? (
<div className="ds-habit-chart-grid">
<HabitChart
name="Drink water"
method="Count target"
value={water}
target={8}
unit="glasses"
color={habitColors.water}
>
<Counter
label="glasses of water"
value={water}
target={8}
onChange={setWater}
/>
</HabitChart>
<HabitChart
name="Read a little"
method="Manual checkbox"
value={Number(read)}
target={1}
unit="reading session"
color={habitColors.reading}
>
<Checkbox
label="Mark reading done"
className="ds-checkbox--icon"
checked={read}
onChange={(event) => setRead(event.target.checked)}
/>
</HabitChart>
<HabitChart
name="Evening reset"
method="Task-based"
value={taskCount}
target={3}
unit="tasks"
color={habitColors.tasks}
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map(
(label, index) => (
<Checkbox
key={label}
label={label}
checked={tasks[index]}
onChange={(event) =>
setTasks((current) =>
current.map((done, i) =>
i === index ? event.target.checked : done,
),
)
}
/>
),
)}
/>
<HabitChart
name="Move a little"
method="Count target"
value={movement}
target={20}
unit="minutes"
color={habitColors.movement}
>
<Counter
label="minutes of movement"
value={movement}
target={20}
onChange={setMovement}
/>
</HabitChart>
<p className="ds-footnote ds-habit-chart-grid-note">
Each habit has its own rhythm. Expand tasks to log your day;
only fully completed habits count in a combined chart.
</p>
</div>
) : (
<div className="ds-chart-preview">
{isIndividual ? (
<div className="ds-detail-count">
<span className="ds-big-value">
{water}
<span className="ds-muted"> / 8</span>
<small>glasses</small>
</span>
<Counter
label="detail glasses"
value={water}
target={8}
onChange={setWater}
/>
</div>
) : (
<div
className="ds-chart-selection"
role="group"
aria-label="Included habits"
>
{[
"Drink water",
"Read a little",
"Evening reset",
"Move a little",
].map((label, index) => (
<Checkbox
key={label}
label={label}
checked={included[index]}
onChange={(event) =>
setIncluded((current) =>
current.map((checked, i) =>
i === index ? event.target.checked : checked,
),
)
}
/>
))}
</div>
)}
<CalendarHeatmap
key={view}
days={chartDays}
unit={isIndividual ? "glasses" : "habits complete"}
label={
isIndividual
? "Drink water progress calendar"
: "Combined habit calendar"
}
color={isIndividual ? habitColors.water : "#111111"}
/>
<p className="ds-footnote">
{isIndividual
? "Partial progress is visible here. Only 8 of 8 glasses counts as complete in a combined chart."
: "Equal weight. Only fully completed, due habits count. Nothing scheduled means no score, not a missed day."}
</p>
</div>
)}
</section>
<EditingWorkbench onColorsChange={setHabitColors} />
<section className="ds-section ds-split-section" id="foundations">
<SectionHeading number="03 / FOUNDATIONS" title="The essentials.">
A small vocabulary.
<br />
Used with intention.
</SectionHeading>
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>Typography</h3>
<span className="ds-code">Instrument Serif + system sans</span>
</div>
<div className="ds-type-specimen">
<span className="ds-type-display">
Small steps.
<br />
<em>Lasting rhythm.</em>
</span>
<p>Instrument Serif · Regular & italic · Display</p>
</div>
<div className="ds-body-specimen">
<span>Room to breathe. A clear next step.</span>
<p>System sans · 14–16px · Body & controls</p>
</div>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>Monochrome</h3>
<span className="ds-code">The interface stays neutral</span>
</div>
<div className="ds-palette">
{[
{ name: "Ink", value: "#111111" },
{ name: "Secondary", value: "#666666" },
{ name: "Rule", value: "#DEDEDE" },
{ name: "Paper", value: "#FFFFFF" },
].map((color) => (
<div key={color.name}>
<div
className="ds-swatch"
style={{ background: color.value }}
/>
<p>
{color.name}
<span className="ds-code">{color.value}</span>
</p>
</div>
))}
</div>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>Habit colors</h3>
<span className="ds-code">Identity, not decoration</span>
</div>
<div className="ds-habit-color-key">
{Object.entries(HABIT_COLORS).map(([name, color]) => (
<span key={name}>
<i style={{ backgroundColor: color }} aria-hidden="true" />
{name}
</span>
))}
</div>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>Space & structure</h3>
<span className="ds-code">4px base</span>
</div>
<div className="ds-spacing">
{[4, 8, 16, 24, 32, 48].map((space) => (
<div key={space}>
<span style={{ width: space }} />
<code>{space}</code>
</div>
))}
</div>
<p className="ds-footnote">
Square corners. One-pixel rules. No cards, shadows, or decorative
surfaces.
</p>
</div>
</section>
<section className="ds-section ds-split-section" id="components">
<SectionHeading number="04 / COMPONENTS" title="Only what matters.">
Purpose-built primitives.
<br />
Try them for yourself.
</SectionHeading>
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>Actions</h3>
<span className="ds-code">Button</span>
</div>
<div className="ds-actions">
<Button
onClick={() => {
document.getElementById("habit-name")?.focus();
}}
>
Add a habit <span aria-hidden="true">+</span>
</Button>
<Button
variant="secondary"
onClick={() => {
setExampleCount(3);
setExampleChecked(false);
setHabitName("");
setSavedName("");
}}
>
Reset examples
</Button>
<Button
variant="text"
onClick={() => {
setView("Habit detail");
document.getElementById("playground")?.scrollIntoView();
}}
>
View habit <span aria-hidden="true">↗</span>
</Button>
<Button disabled>Unavailable</Button>
</div>
<div className="ds-component-row">
<div>
<h3>A simple yes</h3>
<span className="ds-code">Checkbox</span>
</div>
<Checkbox
label={exampleChecked ? "Done for today" : "Mark as done"}
checked={exampleChecked}
onChange={(event) => setExampleChecked(event.target.checked)}
/>
</div>
<div className="ds-component-row">
<div>
<h3>A little more</h3>
<span className="ds-code">Counter · bounded 0–8</span>
</div>
<Counter
label="example count"
value={exampleCount}
target={8}
onChange={setExampleCount}
/>
</div>
<form
className="ds-example-form"
onSubmit={(event) => {
event.preventDefault();
if (habitName.trim()) setSavedName(habitName.trim());
}}
>
<div className="ds-spec-heading">
<label htmlFor="habit-name">Give it a name</label>
<span className="ds-code">Text input</span>
</div>
<div className="ds-input-row">
<input
id="habit-name"
required
maxLength={80}
placeholder="e.g. Read ten pages"
value={habitName}
onChange={(event) => {
setHabitName(event.target.value);
setSavedName("");
}}
aria-describedby="name-hint"
/>
<Button
type="submit"
variant="secondary"
disabled={!habitName.trim()}
>
Try it <span aria-hidden="true">↗</span>
</Button>
</div>
<p id="name-hint" className="ds-footnote" role="status">
{savedName
? `“${savedName}” — preview accepted. No habit was created.`
: "Keep it short and personal. This is a form preview, not a new habit."}
</p>
</form>
</div>
</section>
<section className="ds-section ds-split-section" id="calendar-states">
<SectionHeading
number="05 / PROGRESS LANGUAGE"
title="Every shade counts."
>
Progress is a spectrum.
<br />
Completion is a clear rule.
</SectionHeading>
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>One glass at a time</h3>
<span className="ds-code">CalendarHeatmap</span>
</div>
<div className="ds-shade-scale">
{PROGRESS_SHADES.map((color, index) => (
<div key={color}>
<span style={{ backgroundColor: color }} />
<small>{index}/8</small>
</div>
))}
</div>
<div className="ds-scale-caption">
<span>Due · no progress</span>
<span>Partial progress</span>
<span>Complete</span>
</div>
<div className="ds-neutral-states">
<div>
<span className="ds-state-square ds-state-square--not-due" />
<div>
<h4>Nothing scheduled</h4>
<p>A neutral dot, not a missed day. Excluded from the score.</p>
</div>
</div>
<div>
<span className="ds-state-square ds-state-square--future" />
<div>
<h4>Upcoming</h4>
<p>Same fill as zero. Labeled Upcoming on inspection.</p>
</div>
</div>
</div>
<p className="ds-footnote">
Calendar cells expose exact values and status on selection, hover,
and keyboard focus. Shade is never the only signal.
</p>
</div>
</section>
<section className="ds-section" id="views">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">06 / VISUAL DIRECTION</span>
<h2>From idea to interface.</h2>
</div>
<p className="ds-muted">
Two imagegen studies, grounded in the PRD.
</p>
</div>
<div className="ds-reference-grid">
<figure>
<a href={todayImage} target="_blank" rel="noreferrer">
<img
src={todayImage}
alt="Generated Today view: four colored habit charts in a two-column grid with an expanded task accordion, serif headings, and no cards"
loading="lazy"
/>
</a>
<figcaption>
<span>01 — Today</span>
<span className="ds-muted">
Daily progress <span aria-hidden="true">↗</span>
</span>
</figcaption>
</figure>
<figure>
<a href={habitImage} target="_blank" rel="noreferrer">
<img
src={habitImage}
alt="Generated Drink water detail view: large seven-of-eight count and monochromatic calendar"
loading="lazy"
/>
</a>
<figcaption>
<span>02 — Habit detail</span>
<span className="ds-muted">
A closer look <span aria-hidden="true">↗</span>
</span>
</figcaption>
</figure>
</div>
<p className="ds-footnote">
Visual references, not authoritative calendar data. The interactive
components above implement exact counts and distinct date states.
</p>
</section>
<footer className="ds-footer">
<span className="ds-wordmark">minabot.</span>
<span>A little, every day.</span>
<a href="#top">Back to top ↑</a>
</footer>
</main>
</div>
);
}

View File

@@ -1,3 +1,744 @@
export function Home() { import {
return <h1>Home</h1>; useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { Link } from "react-router";
import { DeleteHabit } from "../components/DeleteHabit";
import { useAuth } from "../components/AuthProvider";
import {
Button,
Checkbox,
Counter,
} from "../components/design-system/primitives";
import { CreateHabit, type HabitStarter } from "../components/CreateHabit";
import { HabitHistory } from "../components/HabitHistory";
import {
formatTrackingDate,
habitRequest,
scheduleLabel,
type TodayHabit,
type TodayResponse,
} from "../lib/dashboard";
import "../../styles/design-system.css";
import "../../styles/home.css";
const starters: (HabitStarter & { description: string; symbol: string })[] = [
{
name: "Read a little",
method: "manual",
description: "A few pages. A moment for yourself.",
symbol: "↗",
color: "#977344",
},
{
name: "Drink water",
method: "count",
target: 8,
unit: "glasses",
description: "One glass at a time, throughout the day.",
symbol: "+",
color: "#426582",
},
{
name: "Evening reset",
method: "tasks",
tasks: "Clear desk\nPlan tomorrow\nStretch",
description: "A small routine to close the day.",
symbol: "☾",
color: "#79618d",
},
];
function CountEntry({
habit,
disabled,
onSave,
}: {
habit: TodayHabit;
disabled: boolean;
onSave: (value: number) => void;
}) {
const [value, setValue] = useState(String(habit.value));
useEffect(() => setValue(String(habit.value)), [habit.value]);
function submit(event: FormEvent) {
event.preventDefault();
if (
value.trim() &&
Number.isInteger(Number(value)) &&
Number(value) >= 0 &&
Number(value) <= 1_000_000_000
)
onSave(Number(value));
}
return (
<details className="home-count-entry">
<summary>Set a total</summary>
<form onSubmit={submit}>
<label htmlFor={`count-${habit.habitId}`}>Total {habit.unit}</label>
<input
id={`count-${habit.habitId}`}
type="number"
min={0}
max={1_000_000_000}
step={1}
required
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
/>
<Button type="submit" variant="secondary" disabled={disabled}>
Save total
</Button>
</form>
</details>
);
}
export function Home() {
const { user, signOut, busy: authBusy, error: authError, signIn } = useAuth();
const [today, setToday] = useState<TodayResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<"all" | "remaining">("all");
const [starter, setStarter] = useState<HabitStarter | null>(null);
const [editing, setEditing] = useState<{
habit: TodayHabit;
color: string;
} | null>(null);
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
const [revision, setRevision] = useState(0);
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
const requestId = useRef(0);
const mutation = useRef(false);
const mounted = useRef(true);
const refresh = useCallback(async () => {
const id = ++requestId.current;
try {
const result = await habitRequest<TodayResponse>("/today");
if (mounted.current && id === requestId.current) {
setToday(result);
setError("");
setRevision((value) => value + 1);
}
} catch (error) {
if (mounted.current && id === requestId.current)
setError(
error instanceof Error
? error.message
: "Could not load your habits.",
);
} finally {
if (mounted.current && id === requestId.current) setLoading(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void refresh();
const refreshVisible = () => {
if (!mutation.current && document.visibilityState !== "hidden")
void refresh();
};
const timer = window.setInterval(refreshVisible, 60_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
mounted.current = false;
++requestId.current;
window.clearInterval(timer);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refresh]);
async function log(
habit: TodayHabit,
value: { count: number } | { done: boolean },
taskId?: string,
) {
if (mutation.current || !today) return;
mutation.current = true;
++requestId.current;
setBusy(true);
setError("");
setNotice("");
try {
const updated = await habitRequest<TodayHabit>(
`/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
},
);
if (!mounted.current) return;
setToday((current) => {
if (!current || current.date !== updated.date) return current;
const habits = current.habits.map((item) =>
item.habitId === updated.habitId ? updated : item,
);
return {
...current,
habits,
due: habits.filter((item) => item.due).length,
completed: habits.filter((item) => item.complete).length,
};
});
setNotice(`${habit.name} updated. Your progress is saved.`);
await refresh();
} catch (error) {
if (mounted.current)
setError(
error instanceof Error
? error.message
: "Could not save progress. Please try again.",
);
} finally {
mutation.current = false;
if (mounted.current) setBusy(false);
}
}
if (!user) return null;
const empty = today?.habits.length === 0;
const due = today?.habits.filter((habit) => habit.due) ?? [];
const offDay = today?.habits.filter((habit) => !habit.due) ?? [];
const shown =
filter === "remaining" ? due.filter((habit) => !habit.complete) : due;
const remaining = today ? today.due - today.completed : 0;
const name = user.displayName || user.username;
return (
<div className="ds-root home-root" id="top">
<a className="ds-skip-link" href="#home-main">
Skip to content
</a>
<header className="ds-header home-header">
<Link className="ds-wordmark" to="/" aria-label="Minabot home">
minabot.
</Link>
<nav aria-label="Main navigation">
<Link to="/" aria-current="page">
Home
</Link>
<a href="#today">Today</a>
<a href={empty ? "#first-habit" : "#rhythm"}>
{empty ? "Get started" : "Your rhythm"}
</a>
</nav>
<div className="home-account">
{user.avatarUrl && failedAvatar !== user.avatarUrl ? (
<img
className="home-avatar"
src={user.avatarUrl}
alt={`${name}’s Discord avatar`}
width={30}
height={30}
referrerPolicy="no-referrer"
onError={() => setFailedAvatar(user.avatarUrl)}
/>
) : (
<span className="home-avatar" aria-hidden="true">
{name.slice(0, 1).toUpperCase()}
</span>
)}
<span className="home-account-name" title={name}>
{name}
</span>
<Button
variant="text"
disabled={authBusy || busy || !!starter}
onClick={() => void signOut()}
>
{authBusy ? "Signing out…" : "Sign out"}
</Button>
</div>
</header>
<main className="ds-main" id="home-main">
<section className="home-welcome" aria-labelledby="home-title">
<div>
<p className="ds-eyebrow">YOUR SPACE / YOUR OWN PACE</p>
<h1 id="home-title">
{empty ? "Welcome home," : "A little today,"}
<br />
<em>{name}.</em>
</h1>
<p className="ds-intro-copy">
{empty
? "This is your space to build a rhythm. Let’s start with one small thing."
: "Pick up where you are. Make room for what matters."}
</p>
</div>
<div className="home-date">
<span className="ds-tiny-cross" aria-hidden="true">
+
</span>
{today ? (
<>
<time dateTime={today.date}>
{formatTrackingDate(today.date)}
</time>
<p>{today.timezone.replaceAll("_", " ")}</p>
</>
) : (
<p>Your daily home base.</p>
)}
<span className="ds-footnote">A new day. Your own pace.</span>
</div>
</section>
{authError && (
<p className="home-error" role="alert">
{authError}
</p>
)}
{error && (
<div className="home-error" role="alert">
<p>
{error}
{today && " The view below may be out of date."}
</p>
{error.includes("session has expired") ? (
<Button variant="secondary" onClick={signIn}>
Sign in again
</Button>
) : (
<Button
variant="secondary"
disabled={busy}
onClick={() => {
setLoading(!today);
void refresh();
}}
>
Try again
</Button>
)}
</div>
)}
<p className="home-notice" role="status">
{notice}
</p>
{loading && (
<div className="home-state" role="status">
Getting your day ready…
</div>
)}
{today && (
<>
<section className="home-overview" aria-label="Today at a glance">
<div>
<span className="ds-eyebrow">
{empty ? "A FRESH START" : "TODAY’S PROGRESS"}
</span>
<p className="home-stat">
{today.completed}
<span> / {today.due}</span>
</p>
<p>habits complete</p>
<progress
value={today.completed}
max={today.due || 1}
aria-label="Habits completed today"
/>
</div>
<div>
<span className="ds-eyebrow">
{empty ? "ONE SMALL STEP" : "STILL TO COME"}
</span>
<p className="home-stat">
{empty ? "01" : String(remaining).padStart(2, "0")}
</p>
<p>
{empty
? "is all it takes to begin"
: remaining === 1
? "habit left for today"
: "habits left for today"}
</p>
</div>
<div className="home-overview-note">
<span className="ds-eyebrow">
{empty
? "NO PERFECT START REQUIRED"
: today.due === 0
? "ROOM TO REST"
: remaining === 0
? "ENOUGH FOR TODAY"
: "A GENTLE REMINDER"}
</span>
<h2>
{empty ? (
<>
Small is <em>a good start.</em>
</>
) : today.due === 0 ? (
<>
A day off <em>counts, too.</em>
</>
) : remaining === 0 ? (
<>
You showed up. <em>Enjoy that.</em>
</>
) : (
<>
A little is <em>still something.</em>
</>
)}
</h2>
<p>
{empty
? "Choose something easy enough to come back to tomorrow."
: today.due === 0
? "Nothing is scheduled today. Your habits will be here when it’s time."
: remaining === 0
? "Everything scheduled for today is complete. No extra credit needed."
: "Check in as you go. Every bit of progress belongs here."}
</p>
</div>
</section>
<section
className="ds-section home-today"
id="today"
aria-labelledby="today-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / YOUR DAILY CHECK-IN</span>
<h2 id="today-title">
{empty ? (
<>
Begin with <em>a little.</em>
</>
) : (
<>
Make today <em>your own.</em>
</>
)}
</h2>
</div>
<Button
disabled={busy || !!error}
onClick={() => setStarter({ name: "", method: "manual" })}
>
{empty ? "Create your first habit" : "New habit"}
<span aria-hidden="true">+</span>
</Button>
</div>
{empty ? (
<div id="first-habit" className="home-empty">
<p>No habits yet. No catching up to do.</p>
<p className="ds-muted">
Create your own, or use an idea below as a starting point.
You can make it yours before saving.
</p>
<div className="home-starters">
{starters.map((item) => (
<button
type="button"
key={item.name}
disabled={!!error}
onClick={() => setStarter(item)}
>
<span
className="home-starter-symbol"
aria-hidden="true"
>
{item.symbol}
</span>
<h3>{item.name}</h3>
<p>{item.description}</p>
<span className="home-starter-action">
Make it mine <span aria-hidden="true">↗</span>
</span>
</button>
))}
</div>
</div>
) : (
<>
<div className="ds-preview-toolbar">
<div
className="ds-view-switch"
role="group"
aria-label="Today’s habits"
>
<button
type="button"
aria-pressed={filter === "all"}
onClick={() => setFilter("all")}
>
All today <span>{today.due}</span>
</button>
<button
type="button"
aria-pressed={filter === "remaining"}
onClick={() => setFilter("remaining")}
>
Remaining <span>{remaining}</span>
</button>
</div>
<span className="home-autosave">Saved as you go</span>
</div>
{shown.length === 0 && (
<div className="home-state">
<h3>
{today.due === 0
? "A little breathing room."
: "You’re all caught up."}
</h3>
<p>
{today.due === 0
? "No habits are scheduled for today. A day off isn’t a missed day."
: "All of today’s habits are complete. Take a moment for yourself."}
</p>
{filter === "remaining" && today.due > 0 && (
<Button variant="text" onClick={() => setFilter("all")}>
View completed habits →
</Button>
)}
</div>
)}
<div className="home-habits">
{shown.map((habit) => (
<article
className={`home-habit${habit.complete ? " home-habit--complete" : ""}`}
key={habit.habitId}
aria-labelledby={`habit-${habit.habitId}`}
>
<div className="home-habit-heading">
<div>
<h3 id={`habit-${habit.habitId}`}>{habit.name}</h3>
<p className="home-habit-status">
{habit.method === "manual"
? "SIMPLE CHECK-IN"
: habit.method === "count"
? "COUNT TARGET"
: "TASK ROUTINE"}{" "}
· {scheduleLabel(habit.requirements?.schedule)}
{habit.complete && " · Complete"}
{habit.carriedFrom && (
<span>
{" "}
· Includes progress from {habit.carriedFrom}
</span>
)}
</p>
</div>
<div className="home-check-in">
{habit.method === "manual" ? (
<Checkbox
label="Done"
aria-label={`Mark ${habit.name} complete`}
checked={habit.complete}
disabled={busy || !!error}
onChange={(event) =>
void log(habit, {
done: event.target.checked,
})
}
/>
) : habit.method === "count" ? (
<Counter
label={`${habit.name} (${habit.unit})`}
value={habit.value}
target={habit.target ?? 1}
disabled={busy || !!error}
onChange={(count) => void log(habit, { count })}
/>
) : (
<span className="home-task-count">
{habit.value}
<span> / {habit.target}</span>
</span>
)}
<a
className="home-history-link"
href={`#history-${habit.habitId}`}
aria-label={`View ${habit.name} history`}
>
↗
</a>
</div>
</div>
{habit.method === "tasks" && (
<details className="home-task-details">
<summary>
Tasks for today{" "}
<span>
{habit.value} / {habit.target}
</span>
</summary>
<div className="home-tasks">
{habit.tasks.map((task) => (
<Checkbox
key={task.taskId}
label={task.name}
checked={task.done}
disabled={busy || !!error}
onChange={(event) =>
void log(
habit,
{ done: event.target.checked },
task.taskId,
)
}
/>
))}
</div>
</details>
)}
{habit.method === "count" && (
<CountEntry
habit={habit}
disabled={busy || !!error}
onSave={(count) => void log(habit, { count })}
/>
)}
</article>
))}
</div>
{offDay.length > 0 && (
<details className="home-off-day">
<summary>
Not scheduled today <span>{offDay.length}</span>
</summary>
<p className="ds-footnote">
These habits aren’t included in today’s progress.
</p>
{offDay.map((habit) => (
<div key={habit.habitId}>
<span>
<strong>{habit.name}</strong>
<small>
{scheduleLabel(habit.requirements?.schedule)}
{habit.method === "tasks" &&
" · No tasks due today"}
</small>
</span>
<a href={`#history-${habit.habitId}`}>
View history ↗
</a>
</div>
))}
</details>
)}
</>
)}
</section>
{!empty && (
<section
className="ds-section home-rhythm"
id="rhythm"
aria-labelledby="rhythm-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / THE BIGGER PICTURE</span>
<h2 id="rhythm-title">
Find <em>your rhythm.</em>
</h2>
</div>
</div>
<div className="ds-habit-chart-grid">
{today.habits.map((habit) => (
<HabitHistory
key={habit.habitId}
habit={habit}
date={today.date}
revision={revision}
disabled={busy || !!error}
onEdit={(color) => setEditing({ habit, color })}
onDelete={() => setDeleting(habit)}
/>
))}
</div>
<p className="ds-footnote">
Your real progress, one day at a time. Select a date to look
closer. Days off stay out of your score.
</p>
</section>
)}
</>
)}
<footer className="ds-footer home-footer">
<Link className="ds-wordmark" to="/">
minabot.
</Link>
<span>A little, every day.</span>
<a href="#top">Back to top ↑</a>
</footer>
</main>
{starter && today && (
<CreateHabit
date={today.date}
starter={starter}
onClose={() => setStarter(null)}
onCreated={(name) => {
setStarter(null);
setNotice(`${name} is ready. Your rhythm starts here.`);
setFilter("all");
void refresh();
}}
/>
)}
{editing?.habit.requirements && today && (
<CreateHabit
date={today.date}
editing={{
id: editing.habit.habitId,
config: editing.habit.requirements,
}}
starter={{
name: editing.habit.requirements.name,
method: editing.habit.requirements.method,
color: editing.color,
...(editing.habit.requirements.method === "count"
? {
target: editing.habit.requirements.target,
unit: editing.habit.requirements.unit,
}
: {}),
}}
onClose={() => setEditing(null)}
onCreated={(name) => {
setEditing(null);
setNotice(`${name} updated. Your earlier history is kept.`);
void refresh();
}}
/>
)}
{deleting && (
<DeleteHabit
habit={deleting}
onClose={() => setDeleting(null)}
onDeleted={() => {
setNotice(
`${deleting.name} removed from your dashboard. Recorded history is kept.`,
);
// Reflect a successful deletion even if the following refresh fails.
setToday((current) => {
if (!current) return current;
const habits = current.habits.filter(
(habit) => habit.habitId !== deleting.habitId,
);
return {
...current,
habits,
due: habits.filter((habit) => habit.due).length,
completed: habits.filter((habit) => habit.complete).length,
};
});
setDeleting(null);
void refresh();
}}
/>
)}
</div>
);
} }

105
src/pages/Landing.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { Link } from "react-router";
import { useAuth } from "../components/AuthProvider";
import { Button } from "../components/design-system/primitives";
import { LandingDemo } from "../components/LandingDemo";
import "../../styles/design-system.css";
import "../../styles/landing.css";
export function Landing() {
const { signIn, error } = useAuth();
return (
<div className="ds-root landing-root" id="top">
<a className="ds-skip-link" href="#landing-main">Skip to content</a>
<header className="ds-header landing-header">
<Link className="ds-wordmark" to="/" aria-label="Minabot home">minabot<span aria-hidden="true">.</span></Link>
<nav aria-label="Main navigation">
<a href="#demo">Try it out</a>
<a href="#how-it-works">How it works</a>
<a href="#discord">Discord</a>
</nav>
<Button variant="secondary" onClick={signIn}>Sign in <span aria-hidden="true">↗</span></Button>
</header>
<main className="ds-main" id="landing-main">
{error && <p className="landing-auth-error" role="alert">{error}</p>}
<section className="ds-intro landing-hero" aria-labelledby="landing-title">
<div>
<p className="ds-eyebrow">YOUR HABITS. YOUR OWN PACE.</p>
<h1 id="landing-title">A little today.<br /><em>A rhythm for life.</em></h1>
<p className="ds-intro-copy">A quiet place to track your habits, notice your progress,<br className="landing-desktop-break" /> and keep showing up for the things that matter.</p>
<div className="landing-hero-actions">
<Button onClick={signIn}>Sign in with Discord <span aria-hidden="true">↗</span></Button>
<a className="landing-text-link" href="#demo">Try the demo <span aria-hidden="true">↓</span></a>
</div>
<p className="landing-signin-note">Your Discord account. One less password.</p>
</div>
<aside className="ds-intro-note landing-hero-note" aria-label="Our approach">
<span className="ds-tiny-cross" aria-hidden="true">+</span>
<p>Not a perfect streak.<br />Not another competition.<br />Just a little more intention.</p>
<a href="#how-it-works">Find your rhythm <span aria-hidden="true">↓</span></a>
</aside>
</section>
<LandingDemo />
<section className="ds-section landing-how" id="how-it-works" aria-labelledby="how-title">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / HOW IT WORKS</span>
<h2 id="how-title">A habit, <em>not a project.</em></h2>
</div>
</div>
<ol className="landing-steps">
<li>
<span className="ds-eyebrow" aria-hidden="true">01 — MAKE IT YOURS</span>
<h3>Start with something small.</h3>
<p>A glass of water. A few pages. A calmer evening. Choose a simple checkbox, a count target, or a short list of tasks, with a schedule that fits your days.</p>
</li>
<li>
<span className="ds-eyebrow" aria-hidden="true">02 — SHOW UP</span>
<h3>Log a little along the way.</h3>
<p>Check it off, add to your count, or finish a task. Each habit keeps its own progress, and daily boundaries follow your saved timezone.</p>
</li>
<li>
<span className="ds-eyebrow" aria-hidden="true">03 — SEE YOUR RHYTHM</span>
<h3>Let the days tell the story.</h3>
<p>A color for every habit. A square for every day. Look closer at one habit or bring them together in a combined calendar. A day off isn’t a missed day.</p>
</li>
</ol>
<p className="ds-footnote">Explore the demo above, then sign in to create your own habits and keep your progress in one place.</p>
</section>
<section className="ds-section landing-discord" id="discord" aria-labelledby="discord-title">
<div>
<span className="ds-eyebrow">03 / CONNECTED WITH DISCORD</span>
<h2 id="discord-title">One familiar account.<br /><em>A little space for you.</em></h2>
</div>
<div className="landing-discord-copy">
<p>Start with the Discord account you already use. Minabot connects your Discord profile to your own account here, without another password to remember.</p>
<dl className="landing-discord-details">
<div><dt>Your profile, connected.</dt><dd>Sign-in uses your Discord identity, not access to your messages or email.</dd></div>
<div><dt>No server setup.</dt><dd>You don’t need to add a bot to a server to sign in. Habit tracking lives here on the web.</dd></div>
</dl>
<p className="ds-footnote">Discord integration currently covers sign-in. Bot commands and reminders aren’t available yet.</p>
</div>
</section>
<section className="ds-section landing-closing" aria-labelledby="closing-title">
<div>
<p className="ds-eyebrow">NO PERFECT START REQUIRED</p>
<h2 id="closing-title">Begin with <em>a little.</em></h2>
</div>
<div>
<Button onClick={signIn}>Sign in with Discord <span aria-hidden="true">↗</span></Button>
<p className="landing-signin-note">Or explore the demo. No account needed.</p>
</div>
</section>
<footer className="ds-footer landing-footer">
<Link className="ds-wordmark" to="/">minabot.</Link>
<span>A little, every day.</span>
<a href="#top">Back to top ↑</a>
</footer>
</main>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { expect, test } from 'bun:test';
import { fixture } from '../habits/test-fixture';
import { toResponsiveCalendarProps } from './calendar';
test('Nivo adapter preserves per-date colors, neutral/future states and readable exact counts', async () => {
const f = fixture();
try {
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setTime('2026-09-05T12:00Z'); await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 });
await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 7 });
const payload = await f.json(`/habits/${h.id}/calendar?from=2026-09-03&to=2026-09-06`);
const props = toResponsiveCalendarProps(payload);
expect(props.data).toHaveLength(4);
expect(props.colorScale(1)).not.toBe(props.colorScale(2));
expect(props.colorScale(0)).toBe(payload.settings.notDueColor); expect(props.colorScale(3)).toBe(payload.settings.futureColor);
expect(props.valueFormat(0)).toBe('Nothing scheduled'); expect(props.valueFormat(1)).toBe('7 of 8 steps');
expect(props.valueFormat(2)).toBe('7 of 10 steps'); expect(props.valueFormat(3)).toBe('Upcoming');
expect(props.valueFormat(99)).toBe(''); expect(props.colorScale(99)).toBe(payload.settings.notDueColor); expect(props.colorScale.ticks()).toEqual([]);
const c = await f.json('/charts', 'POST', { name: 'One', habitIds: [h.id] }, 201);
const combined = await f.json(`/charts/${c.id}/calendar?from=2026-09-04&to=2026-09-04`);
expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete');
} finally { f.close(); }
});

33
src/shared/calendar.ts Normal file
View File

@@ -0,0 +1,33 @@
import { ResponsiveCalendar } from '@nivo/calendar';
import type { ComponentProps } from 'react';
import type { HabitService } from '../habits/service';
export type CalendarResponse = ReturnType<HabitService['calendar']>;
export type ResponsiveCalendarProps = ComponentProps<typeof ResponsiveCalendar>;
/** Data adapter only: no frontend component or page is rendered here. */
export function toResponsiveCalendarProps(calendar: CalendarResponse) {
// Nivo calls colorScale with a value, not a date. Give each date a stable
// index so identical shade levels under different historical targets cannot
// collapse into one color. Actual progress remains in calendar.days.
const data = calendar.days.map((day, index) => ({ day: day.date, value: index }));
const colorScale = Object.assign(
(value: number | { valueOf(): number }) => calendar.days[Number(value)]?.color ?? calendar.settings.notDueColor,
{ ticks: () => [] as number[] },
);
const props = {
from: calendar.from, to: calendar.to, data, colorScale,
emptyColor: calendar.settings.notDueColor,
valueFormat: (index: number) => {
const day = calendar.days[index];
if (!day) return '';
if (day.future) return 'Upcoming';
if (day.ratio === null) return 'Nothing scheduled';
// Counts for combined charts are always equal-weight completed habits.
if (calendar.kind === 'combined') return `${day.completed} of ${day.due} habits complete`;
const habit = day.habits[0]!;
return `${habit.value} of ${habit.target} ${habit.unit}`;
},
} satisfies ResponsiveCalendarProps;
return props;
}

1284
styles/design-system.css Normal file

File diff suppressed because it is too large Load Diff

603
styles/home.css Normal file
View File

@@ -0,0 +1,603 @@
.home-root {
font-size: 15px;
}
.home-root .ds-eyebrow {
font-size: 10px;
}
.home-root .ds-button {
font-size: 13px !important;
}
.home-header nav {
font-size: 13px;
}
.home-header nav [aria-current] {
border-bottom: 1px solid var(--ds-ink);
padding-bottom: 5px;
}
.home-account {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.home-avatar {
display: grid;
place-items: center;
background: #f1f0ed;
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 50%;
font-family: var(--ds-serif);
font-size: 19px;
}
.home-avatar:is(img) {
object-fit: cover;
}
.home-account-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.home-account .ds-button {
margin-left: 12px;
white-space: nowrap;
}
.home-welcome {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 40px;
padding: 64px 0 48px;
}
.home-welcome > div:first-child {
min-width: 0;
}
.home-welcome h1 {
font-size: clamp(52px, 6vw, 78px);
letter-spacing: -1.7px;
margin: 18px 0 22px;
overflow-wrap: anywhere;
}
.home-welcome .ds-intro-copy {
max-width: 480px;
}
.home-date {
flex: 0 0 235px;
font-size: 13px;
padding-bottom: 5px;
}
.home-date .ds-tiny-cross {
margin-bottom: 14px;
}
.home-date p {
color: var(--ds-secondary);
margin: 5px 0 17px;
font-size: 12px;
overflow-wrap: anywhere;
}
.home-date .ds-footnote {
font-size: 12px;
}
.home-overview {
display: grid;
grid-template-columns: 1fr 1fr 1.7fr;
border-top: 1px solid var(--ds-rule);
padding: 28px 0 34px;
gap: 32px;
}
.home-overview > div + div {
border-left: 1px solid var(--ds-rule);
padding-left: 32px;
}
.home-overview p:not(.home-stat) {
color: var(--ds-secondary);
font-size: 12px;
}
.home-stat {
font-family: var(--ds-serif);
font-size: 54px;
line-height: 1;
margin: 18px 0 10px !important;
}
.home-stat span {
color: #777;
font-size: 32px;
}
.home-overview progress {
display: block;
height: 3px;
margin-top: 18px;
width: 100%;
max-width: 185px;
border: 0;
background: #eee;
accent-color: #111;
}
.home-overview progress::-webkit-progress-bar {
background: #eee;
}
.home-overview progress::-webkit-progress-value {
background: #111;
}
.home-overview-note h2 {
font-size: 32px;
margin: 19px 0 13px;
}
.home-overview-note p {
max-width: 280px;
line-height: 1.8;
}
.home-today {
padding-top: 34px;
}
.home-empty > p:first-child {
font-size: 17px;
margin-bottom: 9px;
}
.home-empty > .ds-muted {
max-width: 610px;
font-size: 14px;
line-height: 1.8;
}
.home-starters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 28px;
margin-top: 32px;
}
.home-starters > button {
background: transparent;
border: 0;
border-top: 1px solid var(--ds-rule);
border-bottom: 1px solid var(--ds-rule);
text-align: left;
color: var(--ds-ink);
padding: 22px 4px 18px;
font: inherit;
}
.home-starters > button:hover {
background: #fafaf8;
}
.home-starter-symbol {
font-family: var(--ds-serif);
font-size: 27px;
display: block;
margin-bottom: 20px;
}
.home-starters h3 {
font-family: var(--ds-serif);
font-size: 28px;
margin-bottom: 9px;
}
.home-starters p {
color: var(--ds-secondary);
font-size: 13px;
min-height: 40px;
}
.home-starter-action {
display: flex;
justify-content: space-between;
margin-top: 24px;
font-size: 12px;
}
.home-root .ds-view-switch button {
font-size: 13px;
}
.home-root .ds-view-switch button span {
font-size: 11px;
color: var(--ds-secondary);
margin-left: 8px;
}
.home-autosave {
align-self: center;
color: var(--ds-secondary);
font-size: 11px;
}
.home-habit {
padding: 12px 0;
border-bottom: 1px solid var(--ds-rule);
}
.home-habit-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.home-habit-heading > div:first-child {
min-width: 0;
}
.home-habit .ds-eyebrow {
color: var(--ds-secondary);
font-size: 9px;
}
.home-habit h3 {
font-family: var(--ds-serif);
font-size: 24px;
line-height: 1.15;
margin: 0 0 4px;
overflow-wrap: anywhere;
}
.home-habit-status {
font-size: 11px;
color: var(--ds-secondary);
}
.home-habit--complete .home-habit-status {
color: #386641;
}
.home-check-in {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.home-check-in .ds-checkbox span {
max-width: 250px;
overflow-wrap: anywhere;
}
.home-task-count {
font-family: var(--ds-serif);
font-size: 26px;
}
.home-task-count > span {
color: var(--ds-secondary);
font-size: 20px;
}
.home-history-link {
display: grid;
place-items: center;
width: 32px;
min-height: 44px;
color: var(--ds-secondary);
}
.home-task-details {
margin-top: 6px;
font-size: 12px;
}
.home-task-details summary {
cursor: pointer;
padding: 4px 0;
}
.home-task-details summary span {
color: var(--ds-secondary);
margin-left: 8px;
}
.home-tasks {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
margin-top: 8px;
}
.home-tasks .ds-checkbox {
min-height: 36px;
}
.home-tasks .ds-checkbox span {
overflow-wrap: anywhere;
}
.home-count-entry {
font-size: 11px;
color: var(--ds-secondary);
margin-top: 6px;
}
.home-count-entry summary,
.home-off-day summary {
cursor: pointer;
}
.home-count-entry form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 12px 0;
}
.home-count-entry input {
width: 120px;
padding: 10px;
border: 1px solid var(--ds-rule);
}
.home-off-day {
margin-top: 26px;
}
.home-off-day summary {
font-size: 14px;
}
.home-off-day summary > span {
margin-left: 8px;
color: var(--ds-secondary);
font-size: 12px;
}
.home-off-day > p {
margin-top: 16px;
}
.home-off-day > div {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
padding: 16px 0;
border-bottom: 1px solid var(--ds-rule);
}
.home-off-day strong {
font-weight: 400;
overflow-wrap: anywhere;
}
.home-off-day small {
display: block;
color: var(--ds-secondary);
margin-top: 4px;
}
.home-off-day a {
flex-shrink: 0;
font-size: 12px;
}
.home-rhythm > .ds-footnote {
margin-top: 24px;
font-size: 12px;
}
.home-state {
padding: 36px 0;
color: var(--ds-secondary);
}
.home-state h3 {
font-family: var(--ds-serif);
font-size: 30px;
color: var(--ds-ink);
margin-bottom: 10px;
}
.home-state .ds-button {
margin-top: 16px;
}
.home-error {
border-left: 2px solid #963e33;
padding: 12px 16px;
background: #fcf6f3;
margin-bottom: 20px !important;
color: #772d25;
}
.home-error .ds-button {
margin-top: 12px;
}
.home-notice {
font-size: 12px;
color: #386641;
}
.home-notice:not(:empty) {
padding: 0 0 20px;
}
.home-footer {
margin-top: 12px;
}
.home-habit-actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.home-habit-actions .ds-button {
min-height: 36px;
padding: 0;
color: var(--ds-secondary);
}
.home-habit-actions .ds-button:hover {
color: var(--ds-ink);
}
.home-create {
color: var(--ds-ink);
background: var(--ds-paper);
border: 1px solid var(--ds-rule);
padding: 28px 32px;
width: min(560px, calc(100% - 32px));
max-height: calc(100dvh - 48px);
overflow-y: auto;
}
.home-create::backdrop {
background: rgb(0 0 0 / 35%);
}
.home-delete {
max-width: 520px;
}
.home-delete h2 {
margin-top: 20px;
}
.home-delete strong {
color: var(--ds-ink);
overflow-wrap: anywhere;
}
.home-create-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.home-create-heading .ds-button {
font-size: 26px !important;
min-width: 36px;
}
.home-create h2 {
font-size: 46px;
margin: 8px 0 14px;
}
.home-create > .ds-muted {
font-size: 13px;
}
.home-form-fields {
border: 0;
margin: 24px 0;
padding: 0;
display: grid;
gap: 22px;
min-width: 0;
}
.home-form-fields .ds-field {
min-width: 0;
}
.home-form-fields textarea {
resize: vertical;
width: 100%;
border: 1px solid var(--ds-rule);
padding: 10px 12px;
font: inherit;
}
.home-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid var(--ds-rule);
padding-top: 22px;
}
@media (max-width: 900px) {
.home-header {
flex-wrap: wrap;
}
.home-header nav {
order: 3;
flex-basis: 100%;
}
.home-welcome {
gap: 24px;
}
.home-date {
flex-basis: 190px;
}
.home-overview {
gap: 20px;
}
.home-overview > div + div {
padding-left: 20px;
}
.home-starters {
gap: 18px;
}
}
@media (max-width: 680px) {
.home-header {
padding: 22px 24px;
gap: 20px;
}
.home-header nav {
justify-content: space-between;
gap: 20px;
}
.home-account-name {
display: none;
}
.home-account {
gap: 8px;
}
.home-account .ds-button {
margin-left: 0;
}
.home-root .ds-main {
padding: 0 24px;
}
.home-welcome {
display: block;
padding: 30px 0 24px;
}
.home-welcome h1 {
font-size: clamp(46px, 10vw, 60px);
margin: 14px 0;
}
.home-date {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
margin-top: 18px;
padding: 0;
font-size: 12px;
}
.home-date .ds-tiny-cross,
.home-date > span:last-child {
display: none;
}
.home-date p {
margin: 0;
}
.home-overview {
grid-template-columns: 1fr 1fr;
gap: 18px;
padding: 20px 0;
}
.home-overview > .home-overview-note {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid var(--ds-rule);
padding: 16px 0 0;
}
.home-overview-note .ds-eyebrow {
display: none;
}
.home-overview-note h2 {
font-size: 28px;
margin: 0 0 8px;
}
.home-overview-note p {
max-width: none;
}
.home-root .ds-section-top {
align-items: flex-start;
flex-direction: column;
gap: 24px;
}
.home-root .ds-section-top h2 {
font-size: 38px;
}
.home-starters {
grid-template-columns: 1fr;
gap: 0;
}
.home-starters > button {
padding: 22px 0;
border-bottom: 0;
}
.home-starters > button:last-child {
border-bottom: 1px solid var(--ds-rule);
}
.home-starter-symbol {
float: right;
margin: 0;
}
.home-starters p {
min-height: 0;
}
.home-starter-action {
margin-top: 18px;
}
.home-autosave {
display: none;
}
.home-habit-heading {
flex-wrap: wrap;
gap: 8px 12px;
}
.home-habit h3 {
font-size: 23px;
}
.home-check-in {
margin-left: auto;
gap: 8px;
}
.home-check-in .ds-checkbox span {
max-width: none;
}
.home-tasks {
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.home-create {
padding: 20px;
}
.home-create .ds-form-grid {
grid-template-columns: 1fr;
}
.home-create .ds-weekdays {
flex-wrap: wrap;
}
.home-footer {
flex-wrap: wrap;
}
}

64
styles/landing.css Normal file
View File

@@ -0,0 +1,64 @@
.landing-root { font-size: 16px; }
.landing-root .ds-eyebrow { font-size: 11px; }
.landing-root .ds-button { font-size: 14px !important; }
.landing-header nav { font-size: 14px; }
.landing-header > .ds-button { gap: 24px; }
.landing-hero { padding: 88px 0 80px; }
.landing-hero h1 { font-size: clamp(64px, 7.5vw, 100px); }
.landing-hero .ds-intro-copy { max-width: 580px; font-size: 16px; }
.landing-hero-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 28px; margin-top: 30px; }
.landing-text-link { display: inline-flex; align-items: center; gap: 20px; padding: 10px 0; font-size: 14px; }
.landing-signin-note { margin-top: 14px !important; color: var(--ds-secondary); font-size: 12px; }
.landing-hero-note { flex: 0 0 200px; padding-bottom: 43px; font-size: 14px; }
.landing-root .ds-demo-note { font-size: 12px; white-space: normal; }
.landing-root .ds-footnote { font-size: 13px; }
.landing-root .ds-preview-heading .ds-eyebrow { font-size: 11px; }
.landing-root .ds-preview-heading > p { font-size: 13px; }
.landing-root .ds-view-switch button { font-size: 14px; }
.landing-root .ds-counter .ds-button { font-size: 23px !important; }
.landing-demo-hint { max-width: 740px; }
.landing-steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 44px; list-style: none; padding: 0; margin: 44px 0 24px; }
.landing-steps li { border-top: 1px solid var(--ds-rule); padding-top: 22px; }
.landing-steps .ds-eyebrow { color: var(--ds-secondary); }
.landing-steps h3 { font-family: var(--ds-serif); font-size: 29px; line-height: 1.15; margin: 22px 0 14px; }
.landing-steps p, .landing-discord-copy > p { color: var(--ds-secondary); line-height: 1.8; }
.landing-discord { display: grid; grid-template-columns: 1fr 1fr; gap: 64px; padding-top: 56px; padding-bottom: 64px; }
.landing-discord h2 { font-size: clamp(38px, 4.5vw, 56px); margin-top: 24px; }
.landing-discord-copy { padding-top: 4px; }
.landing-discord-details { margin: 30px 0 0; }
.landing-discord-details > div { border-top: 1px solid var(--ds-rule); padding: 18px 0; }
.landing-discord-details dt { margin-bottom: 6px; font-size: 14px; }
.landing-discord-details dd { margin: 0; color: var(--ds-secondary); font-size: 14px; line-height: 1.7; }
.landing-closing { display: flex; align-items: center; justify-content: space-between; gap: 32px; padding: 56px 0 64px; }
.landing-closing h2 { font-size: clamp(44px, 5.5vw, 64px); margin-top: 22px; }
.landing-closing > div:last-child { text-align: right; }
.landing-footer { font-size: 13px; }
.landing-auth-error { border-bottom: 1px solid var(--ds-rule); padding: 20px 0; font-size: 14px; }
.landing-account-state { max-width: 600px; margin: auto; padding: 96px 24px; }
.landing-account-state p { margin: 32px 0 20px; color: var(--ds-secondary); }
@media (max-width: 900px) {
.landing-hero-note { flex-basis: 160px; }
.landing-steps { gap: 24px; }
.landing-discord { gap: 32px; }
}
@media (max-width: 680px) {
.landing-header { flex-wrap: wrap; gap: 20px; padding: 22px 24px; }
.landing-header nav { order: 3; flex-basis: 100%; justify-content: space-between; gap: 16px; }
.landing-root .ds-main { padding: 0 24px; }
.landing-hero { padding: 52px 0; }
.landing-hero h1 { font-size: clamp(54px, 12vw, 78px); letter-spacing: -1.5px; }
.landing-hero-note { display: none; }
.landing-desktop-break { display: none; }
.landing-root .ds-section-top { align-items: flex-start; }
.landing-root .ds-section-top h2 { font-size: 38px; }
.landing-root .ds-preview-toolbar { flex-wrap: wrap; gap: 8px 20px; }
.landing-root .ds-view-switch { gap: 20px; }
.landing-root .ds-preview-heading { align-items: flex-start; flex-direction: column; gap: 10px; }
.landing-steps { grid-template-columns: 1fr; gap: 28px; margin-top: 32px; }
.landing-steps h3 { margin-top: 14px; }
.landing-discord { grid-template-columns: 1fr; gap: 28px; padding: 40px 0; }
.landing-closing { align-items: flex-start; flex-direction: column; padding: 40px 0; }
.landing-closing > div:last-child { text-align: left; }
.landing-footer { flex-wrap: wrap; gap: 20px; }
}