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.
This commit is contained in:
47
README.md
47
README.md
@@ -1,7 +1,7 @@
|
||||
# Minabot
|
||||
|
||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The existing
|
||||
React Router client remains a minimal authentication shell; habit frontend UI is deferred.
|
||||
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.
|
||||
@@ -17,7 +17,50 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
||||
- Hono endpoint: `GET /api/health`.
|
||||
- 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
|
||||
native browser styling.
|
||||
|
||||
|
||||
4
bun-env.d.ts
vendored
4
bun-env.d.ts
vendored
@@ -9,6 +9,10 @@ declare module "*.svg" {
|
||||
}
|
||||
|
||||
declare module "*.css" {}
|
||||
declare module "*.png" {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*.module.css" {
|
||||
/**
|
||||
|
||||
15
bun.lock
15
bun.lock
@@ -24,6 +24,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"happy-dom": "^20.14.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
@@ -160,6 +161,10 @@
|
||||
|
||||
"@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-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
@@ -202,6 +207,8 @@
|
||||
|
||||
"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-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
|
||||
@@ -240,12 +247,16 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
@@ -284,6 +295,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -52,6 +52,7 @@ Count habit, with daily resets by default:
|
||||
```json
|
||||
{
|
||||
"name": "Hydration",
|
||||
"color": "#426582",
|
||||
"method": "count",
|
||||
"target": 8,
|
||||
"unit": "glasses",
|
||||
@@ -86,6 +87,8 @@ A habit has exactly one method. Incompatible settings are rejected. A switch to
|
||||
|
||||
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.
|
||||
|
||||
45
docs/design/imagegen-prompts.md
Normal file
45
docs/design/imagegen-prompts.md
Normal 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.
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"happy-dom": "^20.14.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
|
||||
57
scripts/dashboard-preview.ts
Normal file
57
scripts/dashboard-preview.ts
Normal 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
692
src/App.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
28
src/App.tsx
28
src/App.tsx
@@ -3,10 +3,37 @@ import { AuthControls } from "./components/AuthControls";
|
||||
import { Home } from "./pages/Home";
|
||||
import { About } from "./pages/About";
|
||||
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() {
|
||||
const { pathname } = useLocation();
|
||||
if (pathname === "/design-system" || pathname === "/design-system/") return <DesignSystem />;
|
||||
return <AuthProvider><AppRoutes /></AuthProvider>;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const navigate = useNavigate();
|
||||
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 (
|
||||
<>
|
||||
@@ -14,6 +41,7 @@ export function App() {
|
||||
<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 === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
|
||||
{" "}<button type="button" onClick={() => navigate("/design-system")}>Design system</button>
|
||||
</nav>
|
||||
<AuthControls />
|
||||
<main>
|
||||
|
||||
BIN
src/assets/design/habit-detail.png
Normal file
BIN
src/assets/design/habit-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
BIN
src/assets/design/today-v2.png
Normal file
BIN
src/assets/design/today-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/assets/design/today.png
Normal file
BIN
src/assets/design/today.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 812 KiB |
BIN
src/assets/fonts/InstrumentSerif-Italic.ttf
Normal file
BIN
src/assets/fonts/InstrumentSerif-Italic.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/InstrumentSerif-Regular.ttf
Normal file
BIN
src/assets/fonts/InstrumentSerif-Regular.ttf
Normal file
Binary file not shown.
93
src/assets/fonts/OFL.txt
Normal file
93
src/assets/fonts/OFL.txt
Normal 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.
|
||||
@@ -1,58 +1,7 @@
|
||||
import { useEffect, useState } 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.",
|
||||
};
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
export function AuthControls() {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
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); }
|
||||
}
|
||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||
|
||||
return (
|
||||
<section aria-label="Account">
|
||||
@@ -62,8 +11,9 @@ export function AuthControls() {
|
||||
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
|
||||
<a href="/api/me">View my profile</a>
|
||||
</p>
|
||||
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
||||
{(error || signInError) && <p role="alert">{error || signInError}</p>}
|
||||
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{accountError && <button type="button" onClick={retry}>Try again</button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/AuthProvider.tsx
Normal file
91
src/components/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
261
src/components/CreateHabit.tsx
Normal file
261
src/components/CreateHabit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/DeleteHabit.tsx
Normal file
73
src/components/DeleteHabit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/HabitHistory.tsx
Normal file
156
src/components/HabitHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/LandingDemo.tsx
Normal file
67
src/components/LandingDemo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
src/components/design-system/CalendarHeatmap.tsx
Normal file
245
src/components/design-system/CalendarHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/design-system/CalendarLegend.tsx
Normal file
53
src/components/design-system/CalendarLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
909
src/components/design-system/EditingWorkbench.tsx
Normal file
909
src/components/design-system/EditingWorkbench.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/design-system/HabitChart.tsx
Normal file
84
src/components/design-system/HabitChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/design-system/HabitColorPicker.test.ts
Normal file
48
src/components/design-system/HabitColorPicker.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
153
src/components/design-system/HabitColorPicker.tsx
Normal file
153
src/components/design-system/HabitColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/components/design-system/calendar-model.test.ts
Normal file
124
src/components/design-system/calendar-model.test.ts
Normal 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");
|
||||
});
|
||||
139
src/components/design-system/calendar-model.ts
Normal file
139
src/components/design-system/calendar-model.ts
Normal 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",
|
||||
};
|
||||
});
|
||||
}
|
||||
71
src/components/design-system/editing-model.test.ts
Normal file
71
src/components/design-system/editing-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
src/components/design-system/editing-model.ts
Normal file
110
src/components/design-system/editing-model.ts
Normal 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;
|
||||
}
|
||||
124
src/components/design-system/primitives.tsx
Normal file
124
src/components/design-system/primitives.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ 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 };
|
||||
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(),
|
||||
@@ -27,7 +27,7 @@ export type HabitConfig = { name: string; schedule: Schedule; archived: boolean
|
||||
{ method: 'manual' } |
|
||||
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
|
||||
);
|
||||
export const habitPatch = z.object({ 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 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');
|
||||
|
||||
@@ -8,6 +8,36 @@ test('failed revision writes roll back the habit atomically and do not expose da
|
||||
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');
|
||||
|
||||
@@ -55,12 +55,14 @@ export class HabitService {
|
||||
}
|
||||
create(input: ReturnType<typeof habitInput.parse>) {
|
||||
const id = crypto.randomUUID();
|
||||
const config: HabitConfig = input.method === 'tasks'
|
||||
? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
|
||||
: { ...input, archived: false };
|
||||
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);
|
||||
@@ -80,7 +82,13 @@ export class HabitService {
|
||||
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.revise(id, config);
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
48
src/lib/dashboard.ts
Normal file
48
src/lib/dashboard.ts
Normal 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
603
src/pages/DesignSystem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,744 @@
|
||||
export function Home() {
|
||||
return <h1>Home</h1>;
|
||||
import {
|
||||
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
105
src/pages/Landing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1284
styles/design-system.css
Normal file
1284
styles/design-system.css
Normal file
File diff suppressed because it is too large
Load Diff
603
styles/home.css
Normal file
603
styles/home.css
Normal 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
64
styles/landing.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user