diff --git a/README.md b/README.md index 2c8d40b..daa2be4 100644 --- a/README.md +++ b/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. diff --git a/bun-env.d.ts b/bun-env.d.ts index 3111ba5..6d4416c 100644 --- a/bun-env.d.ts +++ b/bun-env.d.ts @@ -9,6 +9,10 @@ declare module "*.svg" { } declare module "*.css" {} +declare module "*.png" { + const path: string; + export default path; +} declare module "*.module.css" { /** diff --git a/bun.lock b/bun.lock index 26ca54d..1b73201 100644 --- a/bun.lock +++ b/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=="], diff --git a/docs/API.md b/docs/API.md index 8232faf..a245d3e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/docs/design/imagegen-prompts.md b/docs/design/imagegen-prompts.md new file mode 100644 index 0000000..a583bb7 --- /dev/null +++ b/docs/design/imagegen-prompts.md @@ -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. diff --git a/package.json b/package.json index a3a8beb..02363a4 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts new file mode 100644 index 0000000..d9a1d6b --- /dev/null +++ b/scripts/dashboard-preview.ts @@ -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`); diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..c689811 --- /dev/null +++ b/src/App.test.tsx @@ -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(); +let createRoot: typeof import("react-dom/client").createRoot; +let root: Root; +let container: HTMLDivElement; +let fetchMock: ReturnType>; +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)[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( + + + , + ), + ); +} + +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( + , + ), + ); + const restDay = + container.querySelector(".ds-day--not-due")!; + expect(restDay.style.backgroundColor).toBe("transparent"); + expect(restDay.getAttribute("aria-label")).toContain( + "Not included in the score", + ); + expect( + container.querySelector(".ds-day--due")!.style.backgroundColor, + ).toBe("#ebedf0"); + expect( + container.querySelector(".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( + '[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; + 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(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("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(".home-starters > button")! + .click(), + ); + expect( + container.querySelector("#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(".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("#habit-target")!.value, + ).toBe("8"); + expect( + container.querySelector("#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("#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(`#habit-${manual.id}`)! + .closest("article")! + .querySelector('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('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('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"); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 5059900..d6bf752 100644 --- a/src/App.tsx +++ b/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 ; + return ; +} + +function AppRoutes() { const navigate = useNavigate(); const { pathname } = useLocation(); + const { user, loading, accountError, retry } = useAuth(); + + if (pathname === "/") { + if (loading || accountError) return ( +
+
+ minabot. + {loading ?

Loading your account…

: <> +

{accountError}

+ + } +
+
+ ); + if (!user) return ; + return ; + } return ( <> @@ -14,6 +41,7 @@ export function App() { {" "} {" "} + {" "}
diff --git a/src/assets/design/habit-detail.png b/src/assets/design/habit-detail.png new file mode 100644 index 0000000..bfefefb Binary files /dev/null and b/src/assets/design/habit-detail.png differ diff --git a/src/assets/design/today-v2.png b/src/assets/design/today-v2.png new file mode 100644 index 0000000..9854981 Binary files /dev/null and b/src/assets/design/today-v2.png differ diff --git a/src/assets/design/today.png b/src/assets/design/today.png new file mode 100644 index 0000000..66e0f0b Binary files /dev/null and b/src/assets/design/today.png differ diff --git a/src/assets/fonts/InstrumentSerif-Italic.ttf b/src/assets/fonts/InstrumentSerif-Italic.ttf new file mode 100644 index 0000000..192c3ed Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Italic.ttf differ diff --git a/src/assets/fonts/InstrumentSerif-Regular.ttf b/src/assets/fonts/InstrumentSerif-Regular.ttf new file mode 100644 index 0000000..36fac00 Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Regular.ttf differ diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..669ce79 --- /dev/null +++ b/src/assets/fonts/OFL.txt @@ -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. diff --git a/src/components/AuthControls.tsx b/src/components/AuthControls.tsx index dda4f64..4e8c77a 100644 --- a/src/components/AuthControls.tsx +++ b/src/components/AuthControls.tsx @@ -1,58 +1,7 @@ -import { useEffect, useState } from "react"; -import { useLocation } from "react-router"; -import type { PublicUser } from "../shared/user"; - -const authErrors: Record = { - 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(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 (
@@ -62,8 +11,9 @@ export function AuthControls() { {" "} View my profile

- ) :

} - {(error || signInError) &&

{error || signInError}

} + ) : !accountError &&

} + {error &&

{error}

} + {accountError && }
); } diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..24d3976 --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -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 = { + 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; + retry: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const auth = useContext(AuthContext); + if (!auth) throw new Error("useAuth must be used within AuthProvider"); + return auth; +} diff --git a/src/components/CreateHabit.tsx b/src/components/CreateHabit.tsx new file mode 100644 index 0000000..fc98222 --- /dev/null +++ b/src/components/CreateHabit.tsx @@ -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(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( + 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("#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 ( + { + event.preventDefault(); + if (!saving.current) onClose(); + }} + > +
+

+ {editing ? "SHAPE YOUR RHYTHM" : "A LITTLE ROOM FOR SOMETHING GOOD"} +

+ +
+

+ {editing ? ( + <> + Edit your habit. + + ) : ( + <> + Make it yours. + + )} +

+

+ {editing + ? "Changes start today. Earlier targets and schedules stay as they were." + : "Start small. You don’t need a perfect plan."} +

+
+
+
+ + setName(event.target.value)} + /> +
+
+ + + {editing && ( + + Tracking method stays the same. Create another habit to track it + differently. + + )} +
+ {method === "count" && ( +
+
+ + setTarget(event.target.valueAsNumber)} + /> +
+
+ + setUnit(event.target.value)} + /> +
+
+ )} + {method === "tasks" && !editing && ( +
+ +