From ecc2620a4c45f07d5bcc8f0974b18cc4f1cbcba0 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 4 Sep 2026 17:48:54 +0200 Subject: [PATCH] feat: add Home page with user dashboard and habit tracking features - Implemented Home component with user authentication and loading states. - Created Welcome component for unauthenticated users with a sign-in option. - Developed Dashboard component to display user's habits and progress. - Added functionality for habit management, including adding, updating, and deleting habits. - Integrated HabitChart and HabitHistory components for visual representation of habits. - Introduced sharing functionality for progress via Discord integration. feat: establish Discord sharing configuration and routes - Added DiscordSharingConfig type and readDiscordSharingConfig function for environment variable management. - Created sharing contracts for input validation and data structure. - Implemented sharing routes for previewing and sending progress images to Discord. - Added tests for sharing routes to ensure authentication and proper error handling. --- .env.example | 4 + README.md | 82 +- drizzle/0004_low_carmella_unuscione.sql | 19 + drizzle/0005_bored_firelord.sql | 1 + drizzle/meta/0004_snapshot.json | 823 ++++++++++++++++++ drizzle/meta/0005_snapshot.json | 757 ++++++++++++++++ drizzle/meta/_journal.json | 14 + scripts/dashboard-preview.ts | 22 + src/App.test.tsx | 321 ++++++- src/App.tsx | 12 +- src/api.ts | 5 +- src/components/HabitForm.tsx | 228 +++++ src/components/HabitHistory.tsx | 83 +- src/components/InlineHabitEditor.tsx | 54 ++ src/components/InlineItemForm.tsx | 78 ++ src/components/InlineTaskEditor.tsx | 33 + src/components/ShareProgress.tsx | 108 +++ .../design-system/CalendarHeatmap.tsx | 36 +- src/components/design-system/HabitChart.tsx | 35 +- src/components/design-system/ItemActions.tsx | 21 + src/components/design-system/PageLayout.tsx | 50 ++ src/components/design-system/WelcomePanel.tsx | 73 ++ src/components/design-system/primitives.tsx | 19 +- src/db/schema.ts | 9 + src/habits/service.ts | 2 +- src/index.ts | 3 +- src/lib/progress-card.ts | 138 +++ src/pages/DesignSystem.tsx | 27 +- src/pages/Home.tsx | 632 ++++++++++++++ src/sharing/config.ts | 9 + src/sharing/contracts.ts | 21 + src/sharing/routes.test.ts | 105 +++ src/sharing/routes.ts | 134 +++ styles/design-system.css | 199 +++-- 34 files changed, 4017 insertions(+), 140 deletions(-) create mode 100644 drizzle/0004_low_carmella_unuscione.sql create mode 100644 drizzle/0005_bored_firelord.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/components/HabitForm.tsx create mode 100644 src/components/InlineHabitEditor.tsx create mode 100644 src/components/InlineItemForm.tsx create mode 100644 src/components/InlineTaskEditor.tsx create mode 100644 src/components/ShareProgress.tsx create mode 100644 src/components/design-system/ItemActions.tsx create mode 100644 src/components/design-system/PageLayout.tsx create mode 100644 src/components/design-system/WelcomePanel.tsx create mode 100644 src/lib/progress-card.ts create mode 100644 src/pages/Home.tsx create mode 100644 src/sharing/config.ts create mode 100644 src/sharing/contracts.ts create mode 100644 src/sharing/routes.test.ts create mode 100644 src/sharing/routes.ts diff --git a/.env.example b/.env.example index 60c6db2..4278219 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,7 @@ DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= # Generate once: openssl rand -hex 32 (at least 32 characters required) AUTH_COOKIE_SECRET= +# Bot-powered progress sharing. Invite the bot to the server and grant it +# View Channel, Send Messages, and Attach Files in this channel. +DISCORD_BOT_TOKEN= +DISCORD_SHARING_CHANNEL_ID= diff --git a/README.md b/README.md index 5f98cf6..078218f 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 currently contains only the design system, ready for a fresh page redesign. +Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router +client includes a homepage for signed-out visitors and a personal 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. @@ -13,7 +13,10 @@ bun dev Open http://127.0.0.1:3000. Set `PORT` to use a different port. -- Client page: `/design-system`. Root and all other client URLs redirect there. +- Homepage: `/`. Signed-out visitors see an introduction and interactive examples; + signed-in users see their saved habits and today’s progress. +- Component library: `/design-system`, independent of account loading. +- Unknown client URLs redirect to `/`. - Hono endpoint: `GET /api/health`. - Unknown API routes return JSON with a 404 status. @@ -43,10 +46,36 @@ The Playground, Editing, and References showcase tabs have been removed. Reusable calendar, editing, color-picker components and reference assets remain available for future pages. -The previous Landing, Home, About, and Settings pages have been removed. The -backend API, authentication infrastructure, database, and reusable components remain -available for the redesigned pages. The design system does not load account data -or call the API. +The homepage is composed from the existing design-system components and layouts: +shared page shell, WelcomePanel, Card/CardGrid, SectionHeading, HabitChart, CalendarHeatmap, +Counter, Checkbox, Field, ScheduleEditor, HabitColorPicker, and DiscordSignInButton. +It uses no page-specific styles. The shared page shell also renders the component +library. The design system does not load account data or call the API. + +The dashboard's welcome panel groups the Discord avatar, greeting, progress, and +habit actions on the left, with the account's current date and timezone on the +right. It stacks on narrow screens and shows an initial if the avatar is unavailable. + +Signed-in users can create manual, count, or task habits, choose a recurrence and +color, log today’s progress, and inspect their real calendars. Completed-versus-due +counts exclude days off. Inline controls beside habit headings edit the name, +tracking method, schedule, color, count target/unit, and unfinished-count carryover. +Each task can be renamed, rescheduled, or deleted in place; Add task also supports +its own recurrence. Both habit and task schedules support daily, selected weekdays, +day intervals, and week intervals with an editable start date and weekday. +Delete asks for confirmation in place and preserves earlier history. Tasks +remain manageable on days off, while their checkboxes stay disabled. +Mutations are saved through the authenticated API; failed +saves retain the recorded values and allow retry. Account and calendar failures +have retry states. The dashboard refreshes on window focus and every minute while +visible, using the server’s date and saved account timezone. Historical editing +and combined-chart management are not exposed on this homepage. + +For browser QA without changing application data, run +`bun scripts/dashboard-preview.ts` and visit +`http://127.0.0.1:3107/__preview/login`. This uses an in-memory database and a +separate preview cookie. The signed-out examples use a fixed September 4, 2026 +demo date; progress in those examples is never saved. `styles/globals.css` is the only stylesheet entry point; component styles are in Tailwind's components layer so utilities can @@ -131,8 +160,8 @@ Import `db` from `src/db/index.ts` in backend code to query the database. Use the matching hostname throughout each sign-in; `localhost` and `127.0.0.1` have different cookie storage. 5. Run `bun dev`, click **Sign in with Discord**, and authorize the `identify` scope. - No bot token or email scope is needed. After returning, **View my profile** opens - `/api/me`. **Sign out** revokes the current browser's session. + No bot token or email scope is needed. After returning, the homepage opens your + habit dashboard. **Sign out** revokes the current browser's session. Without the OAuth settings, public pages and `/api/health` remain usable, `/api/me` returns 401, and sign-in displays a configuration message. Restart after editing @@ -191,6 +220,41 @@ production habit data separate from development data. remain UTC and daily boundaries follow the saved timezone with preserved historical deadlines. See the API reference for travel and DST behavior. +### Progress sharing + +Use **Share progress** on the dashboard to select up to six habits and a 7-day, +30-day, yearly, or custom range (at most 366 days, ending no later than today). +The preview, PNG download, and Discord attachment use the same 1200px-wide image. +Each habit has a color legend; completion rates exclude unscheduled days. Display +name, avatar, habit names, and timezone can be hidden separately. Task names are +never included. PNGs are rendered locally in the browser and are not stored by +the server. + +Set `DISCORD_BOT_TOKEN` and `DISCORD_SHARING_CHANNEL_ID` in `.env` (or the selected +environment file), then restart the server. Invite the bot to the Discord server +and grant it View Channel, Send Messages, and Attach Files in the sharing channel. +Threads additionally require Send Messages in Threads. The app displays the +configured destination automatically; all signed-in users share to that channel. +**Send to Discord** posts only the previewed PNG as the bot, with mentions disabled. +The bot token stays in the server environment and is never stored in SQLite or +returned to the browser. No Gateway connection or message-content intent is needed. +Delivery IDs and Discord message nonces protect against duplicate posts; ambiguous +network failures ask the user to check the channel. Webhook setup has been retired, +and the migration removes saved webhook connections while retaining delivery history. + +The authenticated `/api/sharing` routes require the configured Origin for mutations: + +| Method | Route | Behavior | +| --- | --- | --- | +| POST | `/preview` | `{habitIds, from, to}` → owned calendar data, totals, and legends | +| GET | `/discord` | Bot sharing status, channel name, and channel link; no credentials | +| POST | `/discord/send` | Multipart `image` PNG (up to 4 MB) and UUID `deliveryId` | + +For isolated browser QA, `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` creates +sample history and a local Discord transport stub on port 3107. Its login route +is `/__preview/login`; `/__preview/shared-image` returns the last attachment for +inspection. This mode never contacts Discord or opens the application database. + ### API | Method | Route | Behavior | diff --git a/drizzle/0004_low_carmella_unuscione.sql b/drizzle/0004_low_carmella_unuscione.sql new file mode 100644 index 0000000..91151bb --- /dev/null +++ b/drizzle/0004_low_carmella_unuscione.sql @@ -0,0 +1,19 @@ +CREATE TABLE `discord_connections` ( + `user_id` text PRIMARY KEY NOT NULL, + `encrypted_webhook` text NOT NULL, + `name` text NOT NULL, + `channel_id` text NOT NULL, + `guild_id` text NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `discord_deliveries` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `image_hash` text NOT NULL, + `status` text NOT NULL, + `message_url` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/0005_bored_firelord.sql b/drizzle/0005_bored_firelord.sql new file mode 100644 index 0000000..4b0bbc7 --- /dev/null +++ b/drizzle/0005_bored_firelord.sql @@ -0,0 +1 @@ +DROP TABLE `discord_connections`; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..9ef5dfd --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,823 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "480b54f2-4f2f-4200-8f18-22f28b38b62b", + "prevId": "0893a83a-3004-440a-8e89-3a30f26a578d", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_connections": { + "name": "discord_connections", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_webhook": { + "name": "encrypted_webhook", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_connections_user_id_users_id_fk": { + "name": "discord_connections_user_id_users_id_fk", + "tableFrom": "discord_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_deliveries": { + "name": "discord_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_deliveries_user_id_users_id_fk": { + "name": "discord_deliveries_user_id_users_id_fk", + "tableFrom": "discord_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..d0ed9b2 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,757 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18", + "prevId": "480b54f2-4f2f-4200-8f18-22f28b38b62b", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_deliveries": { + "name": "discord_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_deliveries_user_id_users_id_fk": { + "name": "discord_deliveries_user_id_users_id_fk", + "tableFrom": "discord_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f3b744f..323aa37 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1788507928967, "tag": "0003_finalize_occurrence_deadlines", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1788533580335, + "tag": "0004_low_carmella_unuscione", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1788536554543, + "tag": "0005_bored_firelord", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts index d9a1d6b..dd6b97e 100644 --- a/scripts/dashboard-preview.ts +++ b/scripts/dashboard-preview.ts @@ -6,6 +6,19 @@ import index from "../src/index.html"; const f = fixture(); const origin = "http://127.0.0.1:3107"; const previewCookie = "minabot_dashboard_preview_session"; +const sharingPreview = process.env.SHARE_PREVIEW === "1"; +let sharedImage: Blob | null = null; +if (sharingPreview) { + f.setTime("2026-08-01T12:00:00Z"); + const reading = await f.json("/habits", "POST", { name: "A few pages, every day", method: "count", target: 10, unit: "pages", color: "#90647e" }, 201); + const walking = await f.json("/habits", "POST", { name: "An afternoon walk", method: "manual", schedule: { type: "weekdays", days: [1, 3, 5] }, color: "#397f76" }, 201); + for (let i = 0; i < 35; i++) { + const date = new Date(Date.UTC(2026, 7, 1 + i)).toISOString().slice(0, 10); + f.setTime(`${date}T12:00:00Z`); + if (i % 4 !== 0) await f.json(`/habits/${reading.id}/days/${date}/progress`, "PUT", { count: i % 3 === 0 ? 5 : 10 }); + if ([1, 3, 5].includes(new Date(`${date}T12:00:00Z`).getUTCDay()) && i % 4 !== 0) await f.json(`/habits/${walking.id}/days/${date}/progress`, "PUT", { done: true }); + } +} const app = createApi( f.db, { @@ -16,11 +29,20 @@ const app = createApi( }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), + sharingPreview ? async (_url, init) => { + if (init?.method === "POST") { + sharedImage = (init.body as FormData).get("files[0]") as Blob; + return Response.json({ id: "423456789012345678" }); + } + return Response.json({ type: 0, id: "223456789012345678", guild_id: "323456789012345678", name: "preview-only" }); + } : undefined, + sharingPreview ? { token: "preview-bot-token", channelId: "223456789012345678" } : undefined, ); const server = Bun.serve({ hostname: "127.0.0.1", port: 3107, routes: { + "/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }), "/__preview/login": () => new Response(null, { status: 302, diff --git a/src/App.test.tsx b/src/App.test.tsx index b2a39a9..46a5848 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -14,6 +14,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router"; import type { Root } from "react-dom/client"; import { App } from "./App"; import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap"; +import { fixture } from "./habits/test-fixture"; // Keep the design system independent of authentication and the local database. const dom = new Window({ url: "http://localhost:3000/" }); @@ -22,6 +23,7 @@ let createRoot: typeof import("react-dom/client").createRoot; let root: Root; let container: HTMLDivElement; let fetchMock: ReturnType>; +let apiFixture: ReturnType | undefined; beforeAll(async () => { for (const key of [ @@ -64,6 +66,8 @@ afterEach(async () => { await act(async () => root.unmount()); container.remove(); fetchMock.mockRestore(); + apiFixture?.close(); + apiFixture = undefined; }); afterAll(() => { @@ -85,7 +89,7 @@ function LocationProbe() { ; } -async function render(path = "/") { +async function render(path = "/design-system") { await act(async () => root.render( @@ -96,6 +100,317 @@ async function render(path = "/") { ); } +function connectAccount() { + apiFixture = fixture(); + const f = apiFixture; + fetchMock.mockImplementation((async (input, init) => { + const headers = new Headers(init?.headers); + headers.set("Cookie", `minabot_session=${"a".repeat(43)}`); + headers.set("Origin", f.origin); + return f.app.request(new Request(new URL(String(input), f.origin), { ...init, headers })); + }) as typeof fetch); + return f; +} + +const buttonNamed = (name: string) => [...container.querySelectorAll("button")] + .find(button => button.textContent === name || button.getAttribute("aria-label") === name)!; + +describe("homepage", () => { + test("signed-out visitors can try progress without calling private habit APIs", async () => { + await render("/"); + expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm."); + expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]); + await act(async () => buttonNamed("Increase glasses of water").click()); + expect(container.querySelector(".ds-counter output")?.textContent).toBe("4 / 8"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("OAuth errors are retained at the homepage and unknown routes go home", async () => { + await render("/?auth_error=denied"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("cancelled"); + // A new router is needed when changing initialEntries after mount. + await act(async () => root.unmount()); + root = createRoot(container); + await render("/missing"); + expect(container.querySelector('[data-testid="pathname"]')?.textContent).toBe("/"); + expect(container.querySelector("#welcome-title")).not.toBeNull(); + }); + + test("does not flash the signed-out page while the account is loading", async () => { + let resolve!: (response: Response) => void; + fetchMock.mockImplementation(Object.assign(() => new Promise(done => { resolve = done; }), { preconnect: fetch.preconnect })); + await render("/"); + expect(container.textContent).toContain("Getting things ready"); + expect(container.querySelector("#welcome-title")).toBeNull(); + await act(async () => resolve(new Response(null, { status: 401 }))); + expect(container.querySelector("#welcome-title")).not.toBeNull(); + }); + + test("account failures offer retry without pretending the user is signed out", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); + await render("/"); + expect(container.textContent).toContain("Account unavailable"); + expect(container.querySelector("#welcome-title")).toBeNull(); + await act(async () => buttonNamed("Try again").click()); + expect(container.querySelector("#welcome-title")).not.toBeNull(); + }); + + test("signed-in users create a habit from the empty state and sign out", async () => { + const f = connectAccount(); + await render("/"); + expect(container.textContent).toContain("Welcome back, alice."); + expect(container.textContent).toContain("Start with one habit."); + await act(async () => buttonNamed("Add a habit +").click()); + const input = container.querySelector("form input")!; + expect(document.activeElement).toBe(input); + await act(async () => { + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read a little"); + input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + }); + await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + expect((await f.json("/habits")).habits[0].name).toBe("Read a little"); + expect(container.querySelector("form")).toBeNull(); + expect(container.textContent).toContain("Read a little created."); + expect(container.querySelector(".ds-habit-chart")).not.toBeNull(); + await act(async () => buttonNamed("Sign out").click()); + expect(container.querySelector("#welcome-title")).not.toBeNull(); + expect((await f.request("/me")).status).toBe(401); + }); + + test("count, manual, and task progress persist and update today's completion", async () => { + const f = connectAccount(); + const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201); + await f.json(`/habits/${water.id}/days/2026-09-04/progress`, "PUT", { count: 7 }); + await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); + await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch" }] }, 201); + await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201); + await render("/"); + expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3"); + await act(async () => buttonNamed("Increase Water").click()); + expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3"); + expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses"); + const checkbox = (name: string) => [...container.querySelectorAll('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!; + expect(checkbox("Sunday walk done").disabled).toBe(true); + await act(async () => checkbox("Reading done").click()); + await act(async () => checkbox("Stretch").click()); + expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3"); + expect((await f.json("/today")).completed).toBe(3); + await act(async () => buttonNamed("Decrease Water").click()); + expect((await f.json("/today")).completed).toBe(2); + }); + + test("inline habit edits persist configuration and delete archives without losing earlier history", async () => { + const f = connectAccount(); + f.setTime("2026-09-03T12:00:00Z"); + const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201); + await f.json(`/habits/${habit.id}/days/2026-09-03/progress`, "PUT", { count: 5 }); + f.setTime("2026-09-04T12:00:00Z"); + await render("/"); + await act(async () => buttonNamed("Edit habit Water").click()); + const form = container.querySelector('form[aria-label="Edit habit Water"]')!; + expect(document.activeElement).toBe(form.querySelector("input")); + await act(async () => { + const name = form.querySelector("input")!; + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(name, "Daily water"); + name.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + const target = form.querySelector('input[type="number"]')!; + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(target, "10"); + target.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + }); + await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + const saved = await f.json(`/habits/${habit.id}`); + expect(saved.name).toBe("Daily water"); + expect(saved.target).toBe(10); + expect(container.querySelector("form")).toBeNull(); + expect(container.querySelector("h3")?.textContent).toContain("Daily water"); + await act(async () => buttonNamed("Delete habit Daily water").click()); + expect((await f.json("/habits")).habits).toHaveLength(1); + await act(async () => buttonNamed("Cancel").click()); + expect((await f.json("/habits")).habits).toHaveLength(1); + await act(async () => buttonNamed("Delete habit Daily water").click()); + await act(async () => buttonNamed("Delete habit").click()); + expect((await f.json("/habits")).habits).toHaveLength(0); + expect(container.querySelector(".ds-habit-chart")).toBeNull(); + const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); + expect(previous.name).toBe("Water"); + expect(previous.target).toBe(8); + expect(previous.value).toBe(5); + }); + + test("inline task rename keeps its completion and schedule; deletion updates totals and preserves yesterday", async () => { + const f = connectAccount(); + f.setTime("2026-09-03T12:00:00Z"); + const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch", schedule: { type: "weekdays", days: [4, 5] } }, { name: "Clear desk" }] }, 201); + const task = habit.tasks[0]; + await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${task.id}`, "PUT", { done: true }); + f.setTime("2026-09-04T12:00:00Z"); + await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true }); + await render("/"); + await act(async () => buttonNamed("Edit task Stretch").click()); + const form = container.querySelector('form[aria-label="Edit task Stretch"]')!; + await act(async () => { + const input = form.querySelector("input")!; + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Stretch gently"); + input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + }); + await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + const saved = await f.json(`/habits/${habit.id}/tasks/${task.id}`); + expect(saved.name).toBe("Stretch gently"); + expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] }); + expect((await f.json("/today")).habits[0].value).toBe(1); + await act(async () => buttonNamed("Delete task Stretch gently").click()); + await act(async () => buttonNamed("Delete task").click()); + const today = (await f.json("/today")).habits[0]; + expect(today.target).toBe(1); + expect(today.value).toBe(0); + expect(container.textContent).not.toContain("Stretch gently"); + const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); + expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).done).toBe(true); + expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).name).toBe("Stretch"); + }); + + test("task edits retain failed drafts for retry and remain available on days off", async () => { + const f = connectAccount(); + const habit = await f.json("/habits", "POST", { name: "Sunday", method: "tasks", schedule: { type: "weekdays", days: [0] }, tasks: [{ name: "Walk" }] }, 201); + await render("/"); + expect(container.textContent).toContain("Not scheduled today"); + expect(container.querySelector('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true); + await act(async () => buttonNamed("Edit task Walk").click()); + const form = container.querySelector('form[aria-label="Edit task Walk"]')!; + await act(async () => { + const input = form.querySelector("input")!; + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Long walk"); + input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + }); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Please try again" }), { status: 500 })); + await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + expect(form.querySelector("input")?.value).toBe("Long walk"); + expect(form.querySelector('[role="alert"]')?.textContent).toBe("Please try again"); + expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Walk"); + await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Long walk"); + expect(container.querySelector("form")).toBeNull(); + }); + + test("task recurrence edits round-trip every schedule type and preserve earlier check-ins", async () => { + const f = connectAccount(); + f.setTime("2026-09-03T12:00:00Z"); + const habit = await f.json("/habits", "POST", { name: "Routine", method: "tasks", tasks: [{ name: "Walk" }] }, 201); + const id = habit.tasks[0].id; + await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${id}`, "PUT", { done: true }); + f.setTime("2026-09-04T12:00:00Z"); + await render("/"); + const inputValue = async (input: HTMLInputElement, value: string) => act(async () => { + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + }); + const selectValue = async (select: HTMLSelectElement, value: string) => act(async () => { + select.value = value; + select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event); + }); + for (const schedule of [ + { type: "interval", every: 3, anchor: "2026-09-05" }, + { type: "weekly", every: 2, anchor: "2026-09-04", weekday: 5 }, + { type: "weekdays", days: [1, 2, 3, 4, 5] }, + { type: "daily" }, + ] as const) { + await act(async () => buttonNamed("Edit task Walk").click()); + const form = container.querySelector('form[aria-label="Edit task Walk"]')!; + await selectValue(form.querySelector("select")!, schedule.type); + if (schedule.type === "interval" || schedule.type === "weekly") { + await inputValue(form.querySelector('input[type="number"]')!, String(schedule.every)); + await inputValue(form.querySelector('input[type="date"]')!, schedule.anchor); + } + if (schedule.type === "weekly") await selectValue(form.querySelectorAll("select")[1]!, String(schedule.weekday)); + await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + expect((await f.json(`/habits/${habit.id}/tasks/${id}`)).schedule).toEqual(schedule); + expect(container.querySelector("form")).toBeNull(); + await act(async () => buttonNamed("Edit task Walk").click()); + const reopened = container.querySelector('form[aria-label="Edit task Walk"]')!; + expect(reopened.querySelector("select")?.value).toBe(schedule.type); + if (schedule.type === "interval" || schedule.type === "weekly") { + expect(reopened.querySelector('input[type="number"]')?.value).toBe(String(schedule.every)); + expect(reopened.querySelector('input[type="date"]')?.value).toBe(schedule.anchor); + } + await act(async () => buttonNamed("Cancel").click()); + } + const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); + expect(previous.tasks[0].done).toBe(true); + expect(previous.requirements.tasks[0].schedule).toEqual({ type: "daily" }); + }); + + test("habit method and carryover controls save, and task conversion supports adding recurring tasks", async () => { + const f = connectAccount(); + const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); + await render("/"); + const selectMethod = async (method: string) => { + const select = container.querySelector('form select')!; + await act(async () => { + select.value = method; + select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event); + }); + }; + const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); + await act(async () => buttonNamed("Edit habit Reading").click()); + await selectMethod("count"); + await act(async () => container.querySelector('form input[type="checkbox"]')!.click()); + await submit(); + expect((await f.json(`/habits/${habit.id}`)).method).toBe("count"); + expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true); + await act(async () => buttonNamed("Edit habit Reading").click()); + expect(container.querySelector('form input[type="checkbox"]')?.checked).toBe(true); + await selectMethod("tasks"); + await submit(); + expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks"); + await act(async () => buttonNamed("Add task +").click()); + const form = container.querySelector('form[aria-label="Add task"]')!; + await act(async () => { + const input = form.querySelector("input")!; + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read chapter"); + input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); + }); + await submit(); + const task = (await f.json(`/habits/${habit.id}/tasks`)).tasks[0]; + expect(task.name).toBe("Read chapter"); + expect(task.schedule).toEqual({ type: "daily" }); + expect(buttonNamed("Edit task Read chapter")).toBeDefined(); + expect((await f.json("/today")).habits[0].target).toBe(1); + }); + + test("a successful delete is not retried when the subsequent refresh fails", async () => { + const f = connectAccount(); + const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); + await render("/"); + await act(async () => buttonNamed("Delete habit Reading").click()); + fetchMock.mockImplementationOnce((async (input, init) => { + return f.request(String(input).replace("/api", ""), init?.method); + }) as typeof fetch); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); + await act(async () => buttonNamed("Delete habit").click()); + expect(container.querySelector("form")).toBeNull(); + expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh"); + expect(buttonNamed("Delete habit Reading").disabled).toBe(true); + expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true); + await act(async () => buttonNamed("Try again").click()); + expect(container.querySelector(".ds-habit-chart")).toBeNull(); + expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1); + }); + + test("failed saves retain recorded progress and allow another attempt", async () => { + const f = connectAccount(); + await f.json("/habits", "POST", { name: "Water", method: "count", target: 8 }, 201); + await render("/"); + fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 })); + await act(async () => buttonNamed("Increase Water").click()); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable"); + expect(container.querySelector(".ds-counter output")?.textContent).toBe("0 / 8"); + expect(buttonNamed("Increase Water").disabled).toBe(false); + await act(async () => buttonNamed("Increase Water").click()); + expect(container.querySelector(".ds-counter output")?.textContent).toBe("1 / 8"); + expect((await f.json("/today")).habits[0].value).toBe(1); + }); +}); + test("unscheduled dates stay inspectable without a progress-square fill", async () => { await act(async () => root.render( @@ -163,8 +478,8 @@ test("unscheduled dates stay inspectable without a progress-square fill", async ).toBe("true"); }); -describe("design-system-only routing", () => { - for (const path of ["/design-system", "/design-system/", "/", "/about", "/settings", "/missing", "/?auth_error=denied"]) { +describe("independent design-system routing", () => { + for (const path of ["/design-system", "/design-system/"]) { test(`renders only the public design system at ${path}`, async () => { await render(path); expect(container.querySelector("#ds-title")).not.toBeNull(); diff --git a/src/App.tsx b/src/App.tsx index f6c9c60..1057df8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,21 @@ import { Navigate, Route, Routes } from "react-router"; import { DesignSystem } from "./pages/DesignSystem"; +import { Home } from "./pages/Home"; +import { AuthProvider } from "./components/AuthProvider"; export function App() { return ( } /> - } /> + + + + } + /> + } /> ); } diff --git a/src/api.ts b/src/api.ts index 97626c9..b224d3a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,8 +5,10 @@ import { sql } from "drizzle-orm"; import { createAuth, type AppDatabase, type AuthEnv } from "./auth"; import type { AuthConfig } from "./auth/config"; import type { FetchDiscord } from "./auth/discord"; +import { createSharingRoutes, type DiscordFetch } from "./sharing/routes"; +import type { DiscordSharingConfig } from "./sharing/config"; -export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) { +export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }) { const app = new Hono(); const auth = createAuth(db, config, request, now); app.get("/api/health", c => { @@ -16,6 +18,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi app.route("/api/auth", auth.routes); app.get("/api/me", auth.requireAuth, c => c.json(c.get("user"))); app.route("/api", createHabitRoutes(db, auth, now ?? Date.now)); + app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest)); app.notFound(c => c.json({ error: "Not found" }, 404)); app.onError((_error, c) => { c.header("Cache-Control", "no-store"); diff --git a/src/components/HabitForm.tsx b/src/components/HabitForm.tsx new file mode 100644 index 0000000..ed1acfd --- /dev/null +++ b/src/components/HabitForm.tsx @@ -0,0 +1,228 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Button, SectionHeading } from "./design-system/primitives"; +import { Field } from "./design-system/Field"; +import { ScheduleEditor } from "./design-system/EditingWorkbench"; +import { HabitColorPicker } from "./design-system/HabitColorPicker"; +import { habitInput, type Schedule } from "../habits/contracts"; +import { habitRequest } from "../lib/dashboard"; + +/** Account behavior composed from the design system's existing form components. */ +export function HabitForm({ + date, + onCancel, + onCreated, + onExpired, +}: { + date: string; + onCancel: () => void; + onCreated: (name: string) => void; + onExpired: () => void; +}) { + const form = useRef(null); + const saving = useRef(false); + const [name, setName] = useState(""); + const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual"); + const [target, setTarget] = useState(8); + const [unit, setUnit] = useState("glasses"); + const [tasks, setTasks] = useState([""]); + const [schedule, setSchedule] = useState({ type: "daily" }); + const [color, setColor] = useState("#58765b"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + form.current?.querySelector("input")?.focus(); + return () => previous?.focus(); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (saving.current) return; + const parsed = habitInput.safeParse({ + name, + method, + schedule, + color, + ...(method === "count" ? { target, unit } : {}), + ...(method === "tasks" ? { tasks: tasks.map((name) => ({ name })) } : {}), + }); + if (!parsed.success) { + setError(parsed.error.issues.map((issue) => issue.message).join(" ")); + return; + } + saving.current = true; + setBusy(true); + setError(""); + try { + await habitRequest("/habits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsed.data), + }); + onCreated(parsed.data.name); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("session has expired") + ) + onExpired(); + else + setError( + error instanceof Error + ? error.message + : "Could not create your habit.", + ); + } finally { + saving.current = false; + setBusy(false); + } + } + + return ( +
+ + Make it yours. + + } + > + Choose what counts as complete. Your schedule follows your account’s + timezone. + +
+
+ + {(id) => ( + setName(event.target.value)} + /> + )} + + + {(id) => ( + + )} + + {method === "count" && ( +
+ + {(id) => ( + setTarget(event.target.valueAsNumber)} + /> + )} + + + {(id) => ( + setUnit(event.target.value)} + /> + )} + +
+ )} + {method === "tasks" && ( +
+ {tasks.map((task, index) => ( + + {(id) => ( +
+ + setTasks((current) => + current.map((value, i) => + i === index ? event.target.value : value, + ), + ) + } + /> + +
+ )} +
+ ))} + +

+ Tasks repeat on each scheduled day. +

+
+ )} + + +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index 077ac93..faab3b6 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import type { CalendarResponse } from "../shared/calendar"; import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard"; import { CalendarHeatmap } from "./design-system/CalendarHeatmap"; @@ -7,6 +7,7 @@ import { Button } from "./design-system/primitives"; import { HabitChart } from "./design-system/HabitChart"; import { CalendarLegend } from "./design-system/CalendarLegend"; import { shade } from "../habits/calendar"; +import { ItemActions } from "./design-system/ItemActions"; export function HabitHistory({ habit, @@ -15,17 +16,27 @@ export function HabitHistory({ onEdit, onDelete, disabled = false, + children, + tasks, + editor, + onExpired, }: { habit: TodayHabit; date: string; revision: number; - onEdit: (color: string) => void; - onDelete: () => void; + onEdit?: (color: string) => void; + onDelete?: () => void; disabled?: boolean; + children?: ReactNode; + tasks?: ReactNode; + editor?: ReactNode; + onExpired?: () => void; }) { const [calendar, setCalendar] = useState(null); const [error, setError] = useState(""); const [attempt, setAttempt] = useState(0); + const expired = useRef(onExpired); + expired.current = onExpired; useEffect(() => { const controller = new AbortController(); setError(""); @@ -38,7 +49,10 @@ export function HabitHistory({ if (!controller.signal.aborted) setCalendar(result); }) .catch((error) => { - if (!controller.signal.aborted) setError(error.message); + if (controller.signal.aborted) return; + if (error.message.includes("session has expired") && expired.current) + expired.current(); + else setError(error.message); }); return () => controller.abort(); }, [habit.habitId, date, revision, attempt]); @@ -78,7 +92,7 @@ export function HabitHistory({ ] : []; const chart = error ? ( -
+

{error}

) : !calendar ? ( -

+

Loading your habit history…

) : ( @@ -128,29 +142,44 @@ export function HabitHistory({ unit={habit.unit} color={calendar?.settings.mainColor ?? "#196127"} calendar={chart} + tasks={tasks} + tasksLabel="Tasks" + headingLevel={3} + editor={editor} + headingActions={onEdit && onDelete ? ( + onEdit(calendar!.settings.mainColor)} onDelete={onDelete} /> + ) : undefined} > -
- - -
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ )} ); } diff --git a/src/components/InlineHabitEditor.tsx b/src/components/InlineHabitEditor.tsx new file mode 100644 index 0000000..57c16c5 --- /dev/null +++ b/src/components/InlineHabitEditor.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { HabitConfig } from "../habits/contracts"; +import { habitPatch } from "../habits/contracts"; +import { Field } from "./design-system/Field"; +import { ScheduleEditor } from "./design-system/EditingWorkbench"; +import { HabitColorPicker } from "./design-system/HabitColorPicker"; +import { InlineItemForm, type ItemMode } from "./InlineItemForm"; +import { Checkbox } from "./design-system/primitives"; + +export function InlineHabitEditor({ config, color: initialColor, date, mode, disabled, onSave, onDelete, onClose }: { + config: HabitConfig; + color: string; + date: string; + mode: ItemMode; + disabled: boolean; + onSave: (patch: Record) => Promise; + onDelete: () => Promise; + onClose: () => void; +}) { + const [schedule, setSchedule] = useState(config.schedule); + const [method, setMethod] = useState(config.method); + const [carryPartialProgress, setCarryPartialProgress] = useState(config.method === "count" && config.carryPartialProgress); + const [color, setColor] = useState(initialColor); + const [target, setTarget] = useState(config.method === "count" ? config.target : 1); + const [unit, setUnit] = useState(config.method === "count" ? config.unit : "times"); + return ( + { + const parsed = habitPatch.safeParse({ name, method, schedule, color, ...(method === "count" ? { target, unit, carryPartialProgress } : {}) }); + if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" ")); + await onSave(parsed.data); + }}> + + {(id) => } + + {method !== config.method &&

Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}

} + {method === "count" && (<> +
+ {(id) => setTarget(event.target.valueAsNumber)} />} + {(id) => setUnit(event.target.value)} />} +
+ setCarryPartialProgress(event.target.checked)} /> +

Completed counts reset. Explicitly recorded counts are kept.

+ )} + + +

Changes start today. Earlier targets and schedules stay as they were.

+
+ ); +} diff --git a/src/components/InlineItemForm.tsx b/src/components/InlineItemForm.tsx new file mode 100644 index 0000000..8dd2804 --- /dev/null +++ b/src/components/InlineItemForm.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Button } from "./design-system/primitives"; +import { Field } from "./design-system/Field"; + +export type ItemMode = "edit" | "delete"; + +/** In-place editing and confirmation, shared by habit headings and task rows. */ +export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: { + name: string; + kind: "habit" | "task"; + mode: ItemMode; + disabled?: boolean; + children?: ReactNode; + submitLabel?: string; + formLabel?: string; + onSave: (name: string) => Promise; + onDelete: () => Promise; + onClose: () => void; +}) { + const form = useRef(null); + const saving = useRef(false); + const [draft, setDraft] = useState(name); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + form.current?.querySelector(mode === "edit" ? "input" : "button")?.focus(); + return () => { if (previous?.isConnected) previous.focus(); }; + }, [mode]); + + return ( +
{ + if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); } + }} + onSubmit={async (event) => { + event.preventDefault(); + if (saving.current || disabled) return; + if (mode === "edit" && !draft.trim()) { setError("Enter a name."); return; } + saving.current = true; + setBusy(true); + setError(""); + try { + if (mode === "delete") await onDelete(); + else await onSave(draft.trim()); + onClose(); + } catch (error) { + setError(error instanceof Error ? error.message : "Could not save your change. Try again."); + } finally { + saving.current = false; + setBusy(false); + } + }}> + {mode === "edit" ? ( +
+ + {(id) => setDraft(event.target.value)} />} + + {children} +
+ ) : ( +

+ Delete {name}? {kind === "habit" + ? "This removes the habit from your dashboard. Earlier history is kept." + : "This removes the task from today and future check-ins. Earlier history is kept."} +

+ )} + {error &&

{error}

} +
+ {mode === "delete" && } + + {mode === "edit" && } +
+
+ ); +} diff --git a/src/components/InlineTaskEditor.tsx b/src/components/InlineTaskEditor.tsx new file mode 100644 index 0000000..aa30f89 --- /dev/null +++ b/src/components/InlineTaskEditor.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { taskPatch, type Schedule } from "../habits/contracts"; +import { scheduleLabel } from "../lib/dashboard"; +import { ScheduleEditor } from "./design-system/EditingWorkbench"; +import { InlineItemForm, type ItemMode } from "./InlineItemForm"; + +export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule, date, mode, disabled, creating = false, onSave, onDelete, onClose }: { + name: string; + schedule: Schedule; + habitSchedule: Schedule; + date: string; + mode: ItemMode; + disabled: boolean; + creating?: boolean; + onSave: (patch: { name?: string; schedule?: Schedule }) => Promise; + onDelete: () => Promise; + onClose: () => void; +}) { + const [schedule, setSchedule] = useState(savedSchedule); + return ( + { + const parsed = taskPatch.safeParse({ name, schedule }); + if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" ")); + await onSave(parsed.data); + }}> + +

This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.

+ {!creating &&

Changes start today. Earlier check-ins keep their original schedule.

} +
+ ); +} diff --git a/src/components/ShareProgress.tsx b/src/components/ShareProgress.tsx new file mode 100644 index 0000000..385c854 --- /dev/null +++ b/src/components/ShareProgress.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from "react"; +import { Download, Send, X } from "lucide-react"; +import { Button, Checkbox } from "./design-system/primitives"; +import { Field } from "./design-system/Field"; +import { habitRequest, type TodayResponse } from "../lib/dashboard"; +import { addDays } from "../habits/calendar"; +import { renderProgressCard, type CardPrivacy } from "../lib/progress-card"; +import type { PublicUser } from "../shared/user"; +import type { Delivery, DiscordConnection, ShareData } from "../sharing/contracts"; + +export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) { + const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId)); + const [range, setRange] = useState("30"); + const [from, setFrom] = useState(addDays(today.date, -29)); + const [to, setTo] = useState(today.date); + const [privacy, setPrivacy] = useState({ name: true, avatar: true, habitNames: true, timezone: false }); + const [card, setCard] = useState<{ key: string; url: string; blob: Blob; alt: string; deliveryId: string; avatarMissing: boolean } | null>(null); + const [error, setError] = useState(""); + const [renderAttempt, setRenderAttempt] = useState(0); + const [connection, setConnection] = useState(null); + const [connectionError, setConnectionError] = useState(""); + const [connectionAttempt, setConnectionAttempt] = useState(0); + const [sending, setSending] = useState(false); + const [delivery, setDelivery] = useState<(Delivery & { id: string }) | null>(null); + const [sendError, setSendError] = useState(""); + const lock = useRef(false); + const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null); + const heading = useRef(null); + const key = JSON.stringify({ ids, from, to, privacy, revision, user }); + const ready = card?.key === key ? card : null; + const sent = ready && delivery?.id === ready.deliveryId ? delivery : null; + const busy = sending; + useEffect(() => { heading.current?.focus(); }, []); + useEffect(() => { + const controller = new AbortController(); + setConnectionError(""); + setConnection(null); + habitRequest("/sharing/discord", { signal: controller.signal }).then(value => { + if (!controller.signal.aborted) setConnection(value); + }).catch(e => { if (!controller.signal.aborted) setConnectionError(e.message); }); + return () => controller.abort(); + }, [connectionAttempt]); + useEffect(() => { + const controller = new AbortController(); + let objectUrl: string | undefined; + setError(""); setSendError(""); + const timer = setTimeout(async () => { + if (!ids.length) { setError("Choose at least one habit for your card."); return; } + try { + const data = await habitRequest("/sharing/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ habitIds: ids, from, to }), signal: controller.signal }); + if (controller.signal.aborted) return; + const image = await renderProgressCard(data, user, privacy); + const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await image.blob.arrayBuffer())), byte => byte.toString(16).padStart(2, "0")).join(""); + if (controller.signal.aborted) return; + objectUrl = URL.createObjectURL(image.blob); + const deliveryId = lastImage.current?.hash === hash ? lastImage.current.deliveryId : crypto.randomUUID(); + lastImage.current = { hash, deliveryId }; + setCard({ ...image, key, url: objectUrl, deliveryId }); + } catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); } + }, 200); + return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); }; + }, [key, renderAttempt]); + + async function send() { + if (!ready || !connection?.connected || lock.current || sent) return; + lock.current = true; setSending(true); setSendError(""); + const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "minabot-progress.png"); + try { const result = await habitRequest("/sharing/discord/send", { method: "POST", body: form }); setDelivery({ ...result, id: ready.deliveryId }); } + catch (e) { setSendError(e instanceof Error ? e.message : "Could not send. Retrying this card will not post it twice."); } + finally { lock.current = false; setSending(false); } + } + return ( +
+
+

A LITTLE PROGRESS, WORTH SHARING

Your progress, in a picture.

+ +
+
+
+
+ Choose your habits +

Up to six per card. Task names are never included.

+
{today.habits.map(habit => = 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}
+ {id => } + {range === "custom" &&
{id => setFrom(e.target.value)} />}{id => setTo(e.target.value)} />}
} +
+
Show on the card + {([ ["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"] ] as const).map(([key, label]) => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)} +
+
+
+
+ {error ?

{error}

: ready ? {ready.alt} :

Creating your card…

} +
+

This exact image will be downloaded or sent. Days off are excluded from the completion rate.

+ {ready?.avatarMissing &&

Your avatar couldn’t load. The card uses a neutral profile icon.

} +
+ {ready ? : } + +
+ {connectionError ?

{connectionError}

: connection?.connected ?

The bot will post this card to {connection.name}.

:

{connection?.message ?? "Loading the Discord sharing channel…"}

} + {sendError &&

{sendError}

} + {sent &&

{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && View in Discord ↗} : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? your channel : "your channel"} before creating another card; this attempt won’t be resent.}

} +
+
+
+ ); +} diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index 9ff9c62..aa6a39d 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type Reac import { CalendarLegend } from "./CalendarLegend"; import { Field } from "./Field"; import { Button } from "./primitives"; +import { CalendarDays, Keyboard } from "lucide-react"; import { describeDay, calendarTimeline, @@ -87,7 +88,7 @@ export function CalendarHeatmap({
@@ -273,20 +274,29 @@ export function CalendarHeatmap({ (compact ? "Demo history" : "Illustrative history")} - - Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date. - +
+ +

+ Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date. +

+
- - {new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", { - month: "long", - day: "numeric", - year: "numeric", - timeZone: "UTC", - })} - - {describeDay(selected, unit)} +
+
); diff --git a/src/components/design-system/HabitChart.tsx b/src/components/design-system/HabitChart.tsx index b966bfe..8bef858 100644 --- a/src/components/design-system/HabitChart.tsx +++ b/src/components/design-system/HabitChart.tsx @@ -1,4 +1,5 @@ -import { useId, type ReactNode } from "react"; +import { useId, type CSSProperties, type ReactNode } from "react"; +import { ChevronDown, ListChecks } from "lucide-react"; import { CalendarHeatmap } from "./CalendarHeatmap"; import { demoCalendar } from "./calendar-model"; @@ -11,11 +12,15 @@ export function HabitChart({ unit, color, children, + headingActions, + editor, tasks, + tasksLabel = "Tasks for today", calendar, schedule = "Every day", due = true, id, + headingLevel = 4, }: { name: string; method: string; @@ -24,27 +29,36 @@ export function HabitChart({ unit: string; color: string; children?: ReactNode; + headingActions?: ReactNode; + editor?: ReactNode; tasks?: ReactNode; + tasksLabel?: string; calendar?: ReactNode; schedule?: string; due?: boolean; id?: string; + headingLevel?: 3 | 4; }) { const headingId = useId(); + const Heading = `h${headingLevel}` as const; return ( -
+
-

-

+
+ + + {headingActions} +

{method} · {schedule}

{children}
+ {editor}

{due @@ -71,10 +85,13 @@ export function HabitChart({ {tasks && (

- Tasks for today{" "} - - {value} / {target} +
{tasks}
diff --git a/src/components/design-system/ItemActions.tsx b/src/components/design-system/ItemActions.tsx new file mode 100644 index 0000000..c4927a7 --- /dev/null +++ b/src/components/design-system/ItemActions.tsx @@ -0,0 +1,21 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { Button } from "./primitives"; + +export function ItemActions({ name, kind, disabled, onEdit, onDelete }: { + name: string; + kind: "habit" | "task"; + disabled?: boolean; + onEdit: () => void; + onDelete: () => void; +}) { + return ( + + + + + ); +} diff --git a/src/components/design-system/PageLayout.tsx b/src/components/design-system/PageLayout.tsx new file mode 100644 index 0000000..4921f1b --- /dev/null +++ b/src/components/design-system/PageLayout.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import { Button } from "./primitives"; + +/** The existing design-system page shell, shared without page-specific styling. */ +export function PageLayout({ + children, + header, + mainId = "main", + wordmarkTo = "/", +}: { + children: ReactNode; + header?: ReactNode; + mainId?: string; + wordmarkTo?: string; +}) { + return ( +
+ { + event.preventDefault(); + document.getElementById(mainId)?.focus(); + }} + > + Skip to content + +
+ + minabot + + {header} +
+
+ {children} +
+ minabot. + A little, every day. + +
+
+
+ ); +} diff --git a/src/components/design-system/WelcomePanel.tsx b/src/components/design-system/WelcomePanel.tsx new file mode 100644 index 0000000..cecf59a --- /dev/null +++ b/src/components/design-system/WelcomePanel.tsx @@ -0,0 +1,73 @@ +import { useState, type ReactNode } from "react"; +import { Globe2 } from "lucide-react"; +import { formatTrackingDate } from "../../lib/dashboard"; + +export function WelcomePanel({ + name, + avatarUrl, + date, + timezone, + children, +}: { + name: string; + avatarUrl: string | null; + date?: string; + timezone: string; + children: ReactNode; +}) { + const [failedAvatar, setFailedAvatar] = useState(null); + // The API date already belongs to the account's timezone. Keep its date parts + // intact instead of interpreting them in the browser's timezone. + const day = date ? new Date(`${date}T12:00:00Z`) : null; + + return ( +
+
+
+
+ {avatarUrl && failedAvatar !== avatarUrl ? ( + {`${name}’s setFailedAvatar(avatarUrl)} + /> + ) : ( + + {Array.from(name.trim())[0]?.toUpperCase() || "?"} + + )} +
+

YOUR DAILY CHECK-IN

+
+

+ Welcome back, {name}. +

+

Make time for today.

+ {children} +
+
+ {day && date ? ( + + ) : ( +

Your day is loading…

+ )} +

+

+
+
+ ); +} diff --git a/src/components/design-system/primitives.tsx b/src/components/design-system/primitives.tsx index 37cb102..65cbfcc 100644 --- a/src/components/design-system/primitives.tsx +++ b/src/components/design-system/primitives.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import type { ButtonHTMLAttributes, AnchorHTMLAttributes, @@ -46,7 +47,7 @@ export function SectionHeading({ return (
{number} -

{title}

+

{title}

{children &&

{children}

}
); @@ -54,13 +55,23 @@ export function SectionHeading({ export function Checkbox({ label, + description, className = "", ...props -}: Omit, "type"> & { label: string }) { +}: Omit, "type"> & { label: string; description?: string }) { + const descriptionId = useId(); return ( ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 98be8bf..efceb74 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -21,6 +21,15 @@ export const sessions = sqliteTable("sessions", { index("sessions_expires_at_idx").on(table.expiresAt), ]); +export const discordDeliveries = sqliteTable("discord_deliveries", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + imageHash: text("image_hash").notNull(), + status: text("status").notNull(), + messageUrl: text("message_url"), + createdAt: integer("created_at").notNull(), +}); + // Revisions are append-only, including multiple edits on the same local date. export const habits = sqliteTable('habits', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), diff --git a/src/habits/service.ts b/src/habits/service.ts index 91ef649..b37d938 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -6,7 +6,7 @@ import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts'; export class ApiError extends Error { - constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); } + constructor(public status: 400 | 404 | 409 | 422 | 429 | 502, message: string) { super(message); } } const missing = () => new ApiError(404, 'Not found'); export type Habit = typeof habits.$inferSelect; diff --git a/src/index.ts b/src/index.ts index 6663a50..85e8a89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { db } from "./db"; import { createApi } from "./api"; import { readAuthConfig } from "./auth/config"; +import { readDiscordSharingConfig } from "./sharing/config"; import index from "./index.html"; -const app = createApi(db, readAuthConfig()); +const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig()); const server = Bun.serve({ hostname: "127.0.0.1", diff --git a/src/lib/progress-card.ts b/src/lib/progress-card.ts new file mode 100644 index 0000000..becf695 --- /dev/null +++ b/src/lib/progress-card.ts @@ -0,0 +1,138 @@ +import type { ShareData } from "../sharing/contracts"; +import type { PublicUser } from "../shared/user"; +import { dayNumber } from "../habits/calendar"; + +export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean }; +export const cardDate = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); +export const cardStats = (data: ShareData) => { + const completed = data.habits.reduce((n, h) => n + h.completed, 0); + const scheduled = data.habits.reduce((n, h) => n + h.scheduled, 0); + return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null }; +}; + +async function loadAvatar(url: string): Promise { + return new Promise(resolve => { + const img = new Image(); + const finish = (value: HTMLImageElement | null) => { clearTimeout(timer); img.onload = null; img.onerror = null; resolve(value); }; + const timer = setTimeout(() => finish(null), 5000); + img.crossOrigin = "anonymous"; + img.onload = () => finish(img); + img.onerror = () => finish(null); + img.src = url; + }); +} + +/** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */ +export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> { + await document.fonts.load('76px "Instrument Serif"'); + const [avatar, botAvatar] = await Promise.all([ + privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null, + data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null, + ]); + const canvas = document.createElement("canvas"); + const longRange = data.habits[0]!.days.length > 42; + const titleGraphGap = 12; + const rowHeight = (longRange ? 280 : 250) + titleGraphGap; + canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Your browser could not create the image. Try another browser."); + const left = 56; + const right = 1144; + const chartLeft = 80; + const chartWidth = right - chartLeft; + const legendColumns = [chartLeft, chartLeft + 266, chartLeft + 532, chartLeft + 798] as const; + const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + const text = (value: string, x: number, y: number, size = 22, color = "#666", serif = false, maxWidth = 1088, align: CanvasTextAlign = "left") => { + ctx.font = `${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`; + ctx.fillStyle = color; + ctx.textAlign = align; + let fitted = value; + if (ctx.measureText(fitted).width > maxWidth) { + const chars = Array.from(value); + while (chars.length && ctx.measureText(`${chars.join("")}…`).width > maxWidth) chars.pop(); + fitted = `${chars.join("")}…`; + } + ctx.fillText(fitted, x, y); + }; + const rect = (x: number, y: number, w: number, h: number, color: string) => { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }; + rect(0, 0, 1200, canvas.height, "#fff"); + rect(0, 0, 1200, 8, data.habits[0]?.color ?? "#111"); + let profileX = 56; + if (privacy.avatar) { + ctx.save(); ctx.beginPath(); ctx.arc(88, 84, 32, 0, Math.PI * 2); ctx.clip(); + rect(56, 52, 64, 64, "#f1f1f1"); + if (avatar) ctx.drawImage(avatar, 56, 52, 64, 64); + else { ctx.fillStyle = "#888"; ctx.beginPath(); ctx.arc(88, 77, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(88, 109, 22, 0, Math.PI * 2); ctx.fill(); } + ctx.restore(); profileX = 138; + } + text(privacy.name ? user.displayName || user.username : "A little, every day.", profileX, 94, 26, "#111", false, 700); + if (botAvatar) { + ctx.font = '34px "Instrument Serif", Georgia, serif'; + const avatarX = right - ctx.measureText("minabot.").width - 12 - 40; + ctx.save(); + ctx.beginPath(); + ctx.roundRect(avatarX, 64, 40, 40, 4); + ctx.clip(); + ctx.drawImage(botAvatar, avatarX, 64, 40, 40); + ctx.restore(); + } + text("minabot.", right, 94, 34, "#111", true, 112, "right"); + text("Small steps, adding up.", left, 213, 76, "#111", true); + text(`${cardDate(data.from)} — ${cardDate(data.to)}`, left, 260, 23); + const stats = cardStats(data); + text(`${stats.completed} / ${stats.scheduled} scheduled check-ins complete`, left, 317, 24, "#111", false, 800); + text(stats.percent === null ? "No days scheduled" : `${stats.percent}% complete`, right, 317, 24, "#111", false, 240, "right"); + data.habits.forEach((habit, index) => { + const top = 358 + index * rowHeight; + rect(left, top, right - left, 1, "#dedede"); + rect(left, top + 27, 6, 24, habit.color); + text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, chartLeft, top + 48, 24, "#111", false, 760); + text(`${habit.completed} / ${habit.scheduled} days`, right, top + 48, 21, "#666", false, 185, "right"); + ctx.save(); + ctx.translate(0, titleGraphGap); + if (habit.days.length <= 42) { + const step = (chartWidth + 5) / habit.days.length; + habit.days.forEach((day, i) => { + const x = chartLeft + i * step; + if (day.due) rect(x, top + 78, Math.max(8, step - 5), 62, day.color); + else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 5) / 2, top + 109, 3, 0, Math.PI * 2); ctx.fill(); } + if (habit.days.length <= 7 || i % 7 === 0) text(day.date.slice(8), x + (step - 5) / 2, top + 169, 17, "#666", false, step - 5, "center"); + }); + } else { + const offset = (new Date(`${data.from}T12:00:00Z`).getUTCDay() + 6) % 7; + const columns = Math.ceil((offset + habit.days.length) / 7); + const step = (chartWidth + 4) / columns; + const startX = chartLeft; + text("M", left, top + 95, 14); text("W", left, top + 135, 14); text("F", left, top + 175, 14); + let previousMonthX = -100; + habit.days.forEach((day, i) => { + const n = offset + i, x = startX + Math.floor(n / 7) * step, y = top + 82 + n % 7 * 20; + if ((i === 0 || day.date.endsWith("-01")) && x - previousMonthX > 45 && x < 1108) { + text(new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, top + 70, 14); + previousMonthX = x; + } + if (day.due) rect(x, y, step - 4, 16, day.color); + else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 4) / 2, y + 8, 2, 0, Math.PI * 2); ctx.fill(); } + }); + } + const legendY = top + (longRange ? 241 : 209); + rect(legendColumns[0], legendY, 16, 16, habit.legend.empty); + text("No progress", legendColumns[0] + 26, legendY + 15, 18); + if (habit.legend.partial.length) { + habit.legend.partial.forEach((color, i) => rect(legendColumns[1] + i * 19, legendY, 16, 16, color)); + text("Partial", legendColumns[1] + habit.legend.partial.length * 19 + 10, legendY + 15, 18); + } + rect(legendColumns[2], legendY, 16, 16, habit.legend.complete); + text("Complete", legendColumns[2] + 26, legendY + 15, 18); + ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(legendColumns[3] + 8, legendY + 8, 3, 0, Math.PI * 2); ctx.fill(); + text("Not scheduled", legendColumns[3] + 26, legendY + 15, 18); + ctx.restore(); + }); + const footerY = canvas.height - 46; + text("Days off don’t count against you.", left, footerY, 19); + text(privacy.timezone ? data.timezone : `${dayNumber(data.to) - dayNumber(data.from) + 1} days of small steps`, right, footerY, 19, "#666", false, 350, "right"); + const blob = await new Promise((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png")); + const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`; + return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar, + alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` }; +} diff --git a/src/pages/DesignSystem.tsx b/src/pages/DesignSystem.tsx index f2545d0..4016e3d 100644 --- a/src/pages/DesignSystem.tsx +++ b/src/pages/DesignSystem.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router"; +import { useNavigate } from "react-router"; import { Button, Checkbox, @@ -14,6 +14,8 @@ import { HABIT_COLORS, } from "../components/design-system/calendar-model"; +import { PageLayout } from "../components/design-system/PageLayout"; + import { DesignSystemTabs } from "../components/design-system/DesignSystemTabs"; export function DesignSystem() { @@ -23,18 +25,7 @@ export function DesignSystem() { const [habitName, setHabitName] = useState(""); const [savedName, setSavedName] = useState(""); return ( -
- { - event.preventDefault(); - document.getElementById("ds-main")?.focus(); - }}>Skip to content -
- - minabot - - Interface library / 01 -
-
+ Interface library / 01}>

MINABOT — INTERFACE LANGUAGE

@@ -300,14 +291,6 @@ export function DesignSystem() { ) }, ]} /> -
- minabot. - A little, every day. - -
-
-
+ ); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..6896466 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,632 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuth } from "../components/AuthProvider"; +import { DiscordSignInButton } from "../components/DiscordSignInButton"; +import { PageLayout } from "../components/design-system/PageLayout"; +import { + Button, + ButtonLink, + Checkbox, + Counter, + SectionHeading, +} from "../components/design-system/primitives"; +import { Card, CardGrid } from "../components/design-system/Card"; +import { HabitChart } from "../components/design-system/HabitChart"; +import { HABIT_COLORS } from "../components/design-system/calendar-model"; +import { HabitForm } from "../components/HabitForm"; +import { habitRequest, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard"; +import { HabitHistory } from "../components/HabitHistory"; +import { WelcomePanel } from "../components/design-system/WelcomePanel"; +import type { PublicUser } from "../shared/user"; +import { ItemActions } from "../components/design-system/ItemActions"; +import { type ItemMode } from "../components/InlineItemForm"; +import { InlineHabitEditor } from "../components/InlineHabitEditor"; +import { InlineTaskEditor } from "../components/InlineTaskEditor"; +import type { Schedule } from "../habits/contracts"; +import { ShareProgress } from "../components/ShareProgress"; + +export function Home() { + const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth(); + return ( + Loading account… + ) : user ? ( + + ) : ( + !accountError && + ) + } + > + {error && ( +
+ {error} +
+ )} + {loading ? ( +
+

Getting things ready…

+
+ ) : accountError ? ( +
+

Let’s try that again.

+ +

Your account couldn’t be loaded.

+
+ +
+
+
+ ) : user ? ( + + ) : ( + + )} +
+ ); +} + +function Welcome({ onSignIn }: { onSignIn: () => void }) { + const [water, setWater] = useState(3); + const [read, setRead] = useState(false); + return ( + <> +
+
+

A LITTLE, EVERY DAY

+

+ Small steps. +
+ Lasting rhythm. +

+
+ +

+ Check in, count a little more, or work through a few tasks. See your progress grow, one + day at a time. +

+
+ + Try it below ↓ +
+

Sign in with your Discord account to save your habits.

+
+
+
+
+ + A little progress. Made visible. + + } + /> + Example · September 4, 2026 · not saved +
+
+ + + + + setRead(event.target.checked)} + /> + +
+
+
+ + +

+ Pick a check-in, a count target, or a task list. Repeat daily, on selected weekdays, + or at your own interval. +

+
+ +

+ Explore your calendar to see the progress behind each square. Days off stay distinct + from missed days. +

+
+
+
+ + ); +} + +function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) { + const [today, setToday] = useState(null); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [revision, setRevision] = useState(0); + const [attempt, setAttempt] = useState(0); + const [loading, setLoading] = useState(true); + const [adding, setAdding] = useState(false); + const [sharing, setSharing] = useState(false); + const [busy, setBusy] = useState(false); + const [needsRefresh, setNeedsRefresh] = useState(false); + const saving = useRef(false); + const mounted = useRef(true); + const expired = useRef(onExpired); + expired.current = onExpired; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const reportError = useCallback((error: unknown) => { + if (error instanceof Error && error.message.includes("session has expired")) expired.current(); + else + setError( + error instanceof Error ? error.message : "Could not load your habits. Please try again." + ); + }, []); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + habitRequest("/today", { signal: controller.signal }) + .then((data) => { + if (!controller.signal.aborted) { + setToday(data); + setNeedsRefresh(false); + setRevision((value) => value + 1); + } + }) + .catch((error) => { + if (!controller.signal.aborted) reportError(error); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [attempt, reportError]); + + // Refresh after returning to the page and across the account's local midnight. + useEffect(() => { + const refresh = () => { + if (!saving.current && document.visibilityState === "visible") + setAttempt((value) => value + 1); + }; + const timer = window.setInterval(refresh, 60_000); + window.addEventListener("focus", refresh); + return () => { + window.clearInterval(timer); + window.removeEventListener("focus", refresh); + }; + }, []); + + async function update( + habit: TodayHabit, + body: { count: number } | { done: boolean }, + taskId?: string + ) { + if (saving.current || loading || needsRefresh || !today) return; + saving.current = true; + setBusy(true); + setError(""); + setNotice(""); + try { + const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`; + const updated = await habitRequest(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!mounted.current) return; + setToday((current) => { + if (!current) 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, + }; + }); + setRevision((value) => value + 1); + setNotice(`${habit.name} saved.`); + } catch (error) { + if (mounted.current) reportError(error); + } finally { + saving.current = false; + if (mounted.current) setBusy(false); + } + } + + async function manage( + habit: TodayHabit, + patch: Record | null, + taskId?: string, + createTask = false + ) { + if (saving.current || loading || needsRefresh) + throw new Error("Please wait for the dashboard to refresh."); + saving.current = true; + setBusy(true); + setError(""); + setNotice(""); + try { + await habitRequest( + `/habits/${habit.habitId}${createTask ? "/tasks" : taskId ? `/tasks/${taskId}` : ""}`, + { + method: createTask ? "POST" : patch ? "PATCH" : "DELETE", + ...(patch + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) } + : {}), + } + ); + // A refresh failure must never turn a successful delete into a retryable delete. + try { + const refreshed = await habitRequest("/today"); + if (mounted.current) { + setToday(refreshed); + setNeedsRefresh(false); + setRevision((value) => value + 1); + } + } catch (refreshError) { + if (refreshError instanceof Error && refreshError.message.includes("session has expired")) + expired.current(); + if (mounted.current) { + setNeedsRefresh(true); + setError( + "Your change was saved, but the dashboard could not refresh. Try again to load the latest data." + ); + } + } + if (mounted.current) { + setNotice( + `${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : "deleted"}.` + ); + if (!patch) + window.requestAnimationFrame(() => { + ( + document.getElementById("habits-title") ?? document.getElementById("add-habit") + )?.focus(); + }); + } + } catch (error) { + if (error instanceof Error && error.message.includes("session has expired")) + expired.current(); + throw error; + } finally { + saving.current = false; + if (mounted.current) setBusy(false); + } + } + + return ( + <> + + {today && ( + <> +
+ {today.due > 0 ? ( + <> + + {today.completed} / {today.due} + + + habits complete today + {today.completed === today.due && ( + A little, all done. + )} + + + ) : ( +

+ {today.habits.length + ? "Nothing scheduled today. Enjoy a little breathing room." + : "Start with one habit. Your first small step starts here."} +

+ )} +
+
+ + {today.habits.length > 0 && ( + <> + + View your habits ↓ + + )} +
+ + )} +
+ {sharing && today && { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />} + {error && ( +
+

{error}

+ +
+ )} + {!today && loading && ( +
+

Loading your habits…

+
+ )} + {today && ( + <> + {adding && ( + setAdding(false)} + onExpired={() => expired.current()} + onCreated={(name) => { + setAdding(false); + setNotice(`${name} created.`); + setAttempt((value) => value + 1); + }} + /> + )} +

+ {notice} +

+ {today.habits.length > 0 && ( +
+
+ + One day at a time. + + } + /> +
+
+ {today.habits.map((habit) => ( + expired.current()} + /> + ))} +
+
+ )} + + )} + + ); +} + +function SavedHabit({ + habit, + date, + revision, + disabled, + onUpdate, + onManage, + onExpired, +}: { + habit: TodayHabit; + date: string; + revision: number; + disabled: boolean; + onUpdate: ( + habit: TodayHabit, + body: { count: number } | { done: boolean }, + taskId?: string + ) => Promise; + onExpired: () => void; + onManage: ( + habit: TodayHabit, + patch: Record | null, + taskId?: string, + createTask?: boolean + ) => Promise; +}) { + const [editing, setEditing] = useState<{ mode: ItemMode; color: string } | null>(null); + const [addingTask, setAddingTask] = useState(false); + const blocked = disabled || !habit.due; + return ( + setEditing({ mode: "edit", color })} + onDelete={() => setEditing({ mode: "delete", color: "#196127" })} + editor={ + editing && habit.requirements ? ( + setEditing(null)} + onSave={(patch) => onManage(habit, patch)} + onDelete={() => onManage(habit, null)} + /> + ) : undefined + } + tasks={ + habit.requirements?.method === "tasks" ? ( + <> + {habit.requirements.tasks.map((config) => { + const occurrence = habit.tasks.find((task) => task.taskId === config.id); + return ( + void onUpdate(habit, { done }, config.id)} + onSave={(patch) => onManage(habit, patch, config.id)} + onDelete={() => onManage(habit, null, config.id)} + /> + ); + })} + {addingTask ? ( + setAddingTask(false)} + onSave={(patch) => onManage(habit, patch, undefined, true)} + onDelete={async () => {}} + /> + ) : ( +
+ {!habit.requirements.tasks.length && ( +

Add a task to start checking in.

+ )} + +
+ )} + + ) : undefined + } + > + {habit.method === "count" ? ( + void onUpdate(habit, { count })} + /> + ) : habit.method === "manual" ? ( + void onUpdate(habit, { done: event.target.checked })} + /> + ) : undefined} +
+ ); +} + +function SavedTask({ + task, + schedule, + habitSchedule, + date, + disabled, + blocked, + scheduled, + onCheck, + onSave, + onDelete, +}: { + task: Pick; + disabled: boolean; + blocked: boolean; + scheduled: boolean; + schedule: Schedule; + habitSchedule: Schedule; + date: string; + onCheck: (done: boolean) => void; + onSave: (patch: { name?: string; schedule?: Schedule }) => Promise; + onDelete: () => Promise; +}) { + const [mode, setMode] = useState(null); + return ( +
+
+ onCheck(event.target.checked)} + /> + setMode("edit")} + onDelete={() => setMode("delete")} + /> +
+ {mode && ( + setMode(null)} + /> + )} +
+ ); +} diff --git a/src/sharing/config.ts b/src/sharing/config.ts new file mode 100644 index 0000000..6d0d97a --- /dev/null +++ b/src/sharing/config.ts @@ -0,0 +1,9 @@ +export type DiscordSharingConfig = { token: string; channelId: string }; + +/** Read only from the server entrypoint; never include credentials in public config. */ +export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig { + return { + token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""), + channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(), + }; +} diff --git a/src/sharing/contracts.ts b/src/sharing/contracts.ts new file mode 100644 index 0000000..ad08bb9 --- /dev/null +++ b/src/sharing/contracts.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { dateSchema } from "../habits/contracts"; +import { dayNumber } from "../habits/calendar"; + +export const shareInput = z.object({ + habitIds: z.array(z.string().uuid()).min(1).max(6).refine(ids => new Set(ids).size === ids.length), + from: dateSchema, + to: dateSchema, +}).strict().refine(v => v.from <= v.to && dayNumber(v.to) - dayNumber(v.from) < 366, "Choose up to 366 days in chronological order"); + +export type ShareData = { + from: string; to: string; timezone: string; + botAvatarUrl?: string | null; + habits: { + name: string; color: string; completed: number; scheduled: number; + legend: { empty: string; partial: string[]; complete: string }; + days: { date: string; color: string; due: boolean; complete: boolean; ratio: number | null }[]; + }[]; +}; +export type DiscordConnection = { connected: boolean; name?: string; channelUrl?: string; message?: string }; +export type Delivery = { status: "sent" | "uncertain"; messageUrl?: string }; diff --git a/src/sharing/routes.test.ts b/src/sharing/routes.test.ts new file mode 100644 index 0000000..75091a9 --- /dev/null +++ b/src/sharing/routes.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, test } from "bun:test"; +import { createApi } from "../api"; +import { fixture } from "../habits/test-fixture"; +import type { DiscordFetch } from "./routes"; +import type { DiscordSharingConfig } from "./config"; +import { cardStats } from "../lib/progress-card"; + +const fixtures: ReturnType[] = []; +afterEach(() => { for (const f of fixtures.splice(0)) f.close(); }); +const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789012345678" }; +const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" }; +function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) { + const f = fixture(); fixtures.push(f); + const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config); + const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) }); + return { f, call, connect: () => call("/discord") }; +} +// A small real PNG is not 1200px wide; use a header fixture for transport validation. +function upload(id = crypto.randomUUID()) { + const header = Buffer.alloc(40); Buffer.from("89504e470d0a1a0a", "hex").copy(header); header.write("IHDR", 12); header.writeUInt32BE(1200, 16); header.writeUInt32BE(940, 20); + const data = new FormData(); data.set("image", new Blob([header], { type: "image/png" }), "progress.png"); data.set("deliveryId", id); return data; +} + +test("sharing requires authentication and same-origin mutations", async () => { + const { call } = setup(); + expect((await call("/discord", "GET", undefined, "x")).status).toBe(401); + expect((await call("/discord/send", "POST", upload(), "a", "https://evil.example")).status).toBe(403); + expect((await call("/preview", "POST", {}, "a", "https://evil.example")).status).toBe(403); +}); +test("missing or invalid bot configuration cannot make outbound requests", async () => { + for (const config of [{ token: "", channelId: channel.id }, { token: "test", channelId: "https://evil.example" }, { token: "bad\nheader", channelId: channel.id }]) { + let requests = 0; + const { call } = setup(async () => { requests++; return Response.json(channel); }, config); + expect((await (await call("/discord")).json()).connected).toBe(false); + expect((await call("/discord/send", "POST", upload())).status).toBe(422); + expect(requests).toBe(0); + } +}); +test("bot channel status exposes only the configured destination and caches channel metadata", async () => { + let requests = 0; + const { call } = setup(async (url, init) => { + requests++; expect(url).toBe(`https://discord.com/api/v10/channels/${channel.id}`); + expect(init?.redirect).toBe("error"); expect(new Headers(init?.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`); + return Response.json(channel); + }); + const expected = { connected: true, name: "#progress", channelUrl: `https://discord.com/channels/${channel.guild_id}/${channel.id}` }; + const response = await call("/discord"); expect(response.status).toBe(200); expect(await response.json()).toEqual(expected); + expect(await (await call("/discord", "GET", undefined, "b")).json()).toEqual(expected); + expect(requests).toBe(1); + expect((await call("/discord", "PUT", { url: "https://evil.example" })).status).toBe(404); + expect((await call("/discord", "DELETE")).status).toBe(404); +}); +test("bot errors do not expose the token or provider response", async () => { + const { call } = setup(async () => { throw new Error(botConfig.token); }); + const response = await call("/discord"); expect(response.status).toBe(502); expect(await response.text()).not.toContain(botConfig.token); +}); +test("card data respects ownership, recurrence, privacy boundaries, and date limits", async () => { + const { f, call } = setup(); + f.setTime("2026-09-03T12:00:00Z"); + const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "private task name" }], schedule: { type: "weekdays", days: [4] } }, 201); + await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${habit.tasks[0].id}`, "PUT", { done: true }); + f.setTime("2026-09-04T12:00:00Z"); + const input = { habitIds: [habit.id], from: "2026-09-01", to: "2026-09-04" }; + const response = await call("/preview", "POST", input); expect(response.status).toBe(200); + const data = await response.json(); expect(JSON.stringify(data)).not.toContain("private task name"); + expect(cardStats(data)).toEqual({ completed: 1, scheduled: 1, percent: 100 }); + expect(data.habits[0].legend).toEqual({ empty: "#ebedf0", partial: [], complete: "#196127" }); + expect(data.habits[0].days[3].due).toBe(false); + expect((await call("/preview", "POST", input, "b")).status).toBe(404); + for (const patch of [{ to: "2026-09-05" }, { from: "2024-01-01" }, { from: "2026-09-05" }, { habitIds: [] }, { habitIds: [habit.id, habit.id] }]) expect((await call("/preview", "POST", { ...input, ...patch })).status).toBe(422); +}); +test("delivery sends exactly the PNG with mentions disabled and deduplicates retries", async () => { + let posts = 0; + const { call, connect } = setup(async (target, init) => { + if (init?.method !== "POST") return Response.json(channel); + posts++; expect(target).toBe(`https://discord.com/api/v10/channels/${channel.id}/messages`); + expect(new Headers(init.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`); + const form = init.body as FormData; const payload = JSON.parse(form.get("payload_json") as string); + expect(payload.allowed_mentions).toEqual({ parse: [] }); expect(payload.username).toBeUndefined(); expect(payload.enforce_nonce).toBe(true); expect(payload.nonce).toHaveLength(24); + expect(payload.content).toBeUndefined(); expect((form.get("files[0]") as File).type).toBe("image/png"); + return Response.json({ id: "423456789012345678" }); + }); + await connect(); const id = crypto.randomUUID(); + const first = await call("/discord/send", "POST", upload(id)); expect(first.status).toBe(200); + expect(await first.json()).toEqual({ status: "sent", messageUrl: "https://discord.com/channels/323456789012345678/223456789012345678/423456789012345678" }); + expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("sent"); expect(posts).toBe(1); +}); +test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => { + let posts = 0; + const { call, connect } = setup(async (_, init) => { + if (init?.method !== "POST") return Response.json(channel); + posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending"); + }); + await connect(); const id = crypto.randomUUID(); + expect((await call("/discord/send", "POST", upload(id))).status).toBe(429); + expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); + expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2); +}); +test("invalid files and absent bot configuration cannot send", async () => { + let posts = 0; const { call, connect } = setup(async (_, init) => { if (init?.method === "POST") posts++; return Response.json(channel); }); + const disabled = setup(undefined, { token: "", channelId: "" }); + expect((await disabled.call("/discord/send", "POST", upload())).status).toBe(422); + await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png"); + expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0); +}); diff --git a/src/sharing/routes.ts b/src/sharing/routes.ts new file mode 100644 index 0000000..733140a --- /dev/null +++ b/src/sharing/routes.ts @@ -0,0 +1,134 @@ +import { createHash } from "node:crypto"; +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import type { AppDatabase, AuthEnv, createAuth } from "../auth"; +import { discordDeliveries } from "../db/schema"; +import { HabitService, ApiError } from "../habits/service"; +import { shade } from "../habits/calendar"; +import { shareInput, type ShareData } from "./contracts"; +import type { DiscordSharingConfig } from "./config"; + +export type DiscordFetch = (url: string, init?: RequestInit) => Promise; +const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +const snowflake = /^\d{17,20}$/; + +export function createSharingRoutes(db: AppDatabase, auth: ReturnType, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) { + const app = new Hono(); + const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId)); + const channelEndpoint = `https://discord.com/api/v10/channels/${config.channelId}`; + const headers = { Authorization: `Bot ${config.token}` }; + let cachedBotAvatar: { url: string | null; expiresAt: number } | null = null; + let loadingBotAvatar: Promise | null = null; + async function botAvatarUrl(): Promise { + if (!configured) return null; + if (cachedBotAvatar && cachedBotAvatar.expiresAt > now()) return cachedBotAvatar.url; + if (loadingBotAvatar) return loadingBotAvatar; + loadingBotAvatar = (async () => { + let url: string | null = null; + try { + const response = await request("https://discord.com/api/v10/users/@me", { headers, redirect: "error", signal: AbortSignal.timeout(5000) }); + const bot = response.ok ? await response.json() as { id?: string; bot?: boolean; avatar?: string | null; discriminator?: string } : null; + if (bot?.bot && snowflake.test(bot.id ?? "")) { + if (bot.avatar && /^(?:a_)?[a-fA-F0-9]{32}$/.test(bot.avatar)) { + url = `https://cdn.discordapp.com/avatars/${bot.id}/${bot.avatar}.png?size=128`; + } else if (!bot.avatar) { + const index = bot.discriminator && /^\d{4}$/.test(bot.discriminator) && bot.discriminator !== "0000" + ? Number(bot.discriminator) % 5 : Number((BigInt(bot.id!) >> 22n) % 6n); + url = `https://cdn.discordapp.com/embed/avatars/${index}.png`; + } + } + } catch { /* Keep card exports available when Discord cannot supply the avatar. */ } + cachedBotAvatar = { url, expiresAt: now() + (url ? 300000 : 30000) }; + return url; + })(); + try { return await loadingBotAvatar; } finally { loadingBotAvatar = null; } + } + let cachedChannel: { name: string; guildId: string; channelUrl: string; expiresAt: number } | null = null; + async function channel() { + if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server."); + if (cachedChannel && cachedChannel.expiresAt > now()) return cachedChannel; + let response: Response; + try { response = await request(channelEndpoint, { headers, redirect: "error", signal: AbortSignal.timeout(10000) }); } + catch { throw new ApiError(502, "Could not reach Discord. Please try again."); } + if (!response.ok) { + const message = response.status === 401 ? "The Discord bot token is invalid. Update the server configuration." + : response.status === 403 || response.status === 404 ? "The bot cannot access the sharing channel. Check its channel permissions." + : response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Could not load the Discord sharing channel."; + throw new ApiError(response.status === 429 ? 429 : 502, message); + } + const data = await response.json().catch(() => null) as { id?: string; type?: number; name?: string; guild_id?: string } | null; + if (data?.id !== config.channelId || !snowflake.test(data.guild_id ?? "") || ![0, 5, 10, 11, 12].includes(data.type ?? -1)) + throw new ApiError(422, "Set the sharing channel to a Discord server text channel or thread."); + cachedChannel = { name: data.name ? `#${data.name}` : "Discord sharing channel", guildId: data.guild_id!, channelUrl: `https://discord.com/channels/${data.guild_id}/${config.channelId}`, expiresAt: now() + 60000 }; + return cachedChannel; + } + app.use("*", auth.requireAuth); + app.use("*", async (c, next) => c.req.method === "GET" ? next() : auth.requireSameOrigin(c, next)); + app.use("*", bodyLimit({ maxSize: MAX_IMAGE_BYTES + 65536, onError: c => c.json({ error: "Choose a progress image smaller than 4 MB." }, 413) })); + async function json(req: Request, schema: T): Promise> { + if (req.headers.get("Content-Type")?.split(";")[0] !== "application/json") throw new ApiError(400, "Expected application/json"); + const value = await req.json().catch(() => { throw new ApiError(400, "Malformed JSON"); }); + const parsed = schema.safeParse(value); + if (!parsed.success) throw new ApiError(422, "Check your selection and try again."); + return parsed.data; + } + + app.post("/preview", async c => { + const input = await json(c.req.raw, shareInput); + const service = new HabitService(db, c.get("user"), now()); service.sync(); + if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today."); + const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => { + const habit = service.current(id); + const chart = service.calendar([id], input.from, input.to, service.settings(id), false); + return { name: habit.name, color: chart.settings.mainColor, + legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor, + partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] }, + completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length, + days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) }; + }) }; + result.botAvatarUrl = await botAvatarUrl(); + return c.json(result); + }); + app.get("/discord", async c => { + if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." }); + const destination = await channel(); + return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl }); + }); + app.post("/discord/send", async c => { + const userId = c.get("user").id; + if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server."); + const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); }); + const id = z.string().uuid().safeParse(form.get("deliveryId")); + const image = form.get("image"); + if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB."); + const bytes = Buffer.from(await image.arrayBuffer()); + if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending."); + const destination = await channel(); + const imageHash = createHash("sha256").update(bytes).update(`bot:${config.channelId}`).digest("hex"); + const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get(); + if (previous) { + if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again."); + return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined }); + } + db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run(); + const attachment = new FormData(); + attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] })); + attachment.set("files[0]", image, "minabot-progress.png"); + let response: Response; + try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); } + catch { return c.json({ status: "uncertain" }); } + if (!response.ok) { + if (response.status >= 500) return c.json({ status: "uncertain" }); + db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run(); + cachedChannel = null; + throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads)."); + } + const message = await response.json().catch(() => null) as { id?: string } | null; + const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null; + db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run(); + return c.json({ status: "sent", messageUrl: messageUrl ?? undefined }); + }); + return app; +} diff --git a/styles/design-system.css b/styles/design-system.css index eac07b2..737dfc5 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -136,6 +136,61 @@ } .ds-library-intro p:not(.ds-eyebrow) { color: var(--ds-secondary); } .ds-library-note { max-width: 180px; text-align: right; } +.ds-welcome-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 48px; + margin: 36px 0 40px; + padding: 40px; + background: var(--ds-surface); +} +.ds-welcome-content { min-width: 0; } +.ds-welcome-identity { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; } +.ds-avatar { + flex: 0 0 64px; + width: 64px; + height: 64px; + display: grid; + place-items: center; + overflow: hidden; + border-radius: 50%; + background: var(--ds-empty); + @apply type-title; +} +.ds-avatar img { display: block; width: 100%; height: 100%; object-fit: cover; } +.ds-welcome-content h1 { overflow-wrap: anywhere; } +.ds-welcome-content h1 em { display: block; } +.ds-welcome-subtitle { margin-top: 12px !important; color: var(--ds-secondary); @apply type-lead; } +.ds-welcome-progress { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 28px; color: var(--ds-secondary); } +.ds-welcome-progress > .type-title { color: var(--ds-ink); white-space: nowrap; } +.ds-welcome-complete { display: block; @apply type-small; } +.ds-welcome-content .ds-actions { margin-top: 24px; } +.ds-welcome-calendar { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + padding-left: 40px; + border-left: 1px solid var(--ds-rule); + text-align: center; +} +.ds-date-sheet { display: flex; flex-direction: column; } +.ds-date-month { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--ds-rule); @apply type-small; } +.ds-date-number { padding-top: 24px; } +.ds-date-weekday { margin-top: 4px; @apply type-lead; } +.ds-date-caption { padding: 24px 0; border-bottom: 1px solid var(--ds-rule); } +.ds-welcome-timezone { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 20px !important; color: var(--ds-secondary); @apply type-small; } +.ds-welcome-timezone svg { flex-shrink: 0; } +.ds-welcome-timezone span { overflow-wrap: anywhere; } +@media (max-width: 900px) { + .ds-welcome-panel { grid-template-columns: minmax(0, 1fr) 220px; gap: 28px; padding: 28px; } + .ds-welcome-calendar { padding-left: 28px; } +} +@media (max-width: 700px) { + .ds-welcome-panel { grid-template-columns: minmax(0, 1fr); gap: 32px; padding: 24px; margin-top: 24px; } + .ds-welcome-calendar { padding: 28px 0 0; border-left: 0; border-top: 1px solid var(--ds-rule); } + .ds-date-sheet { width: 100%; max-width: 280px; align-self: center; } +} .ds-tab-bar { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -921,15 +976,26 @@ height: 11px; flex: 0 0 11px; } +/* Calendar detail and task controls share a compact, readable hierarchy. */ +.ds-calendar-help { color: var(--ds-secondary); max-width: 30rem; } +.ds-calendar-help summary { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; list-style: none; } +.ds-calendar-help summary::-webkit-details-marker { display: none; } +.ds-calendar-help summary:hover { color: var(--ds-ink); } +.ds-calendar-help p { padding: 4px 0 12px; } +.ds-calendar-caption { align-items: baseline; margin-bottom: 16px; } .ds-date-inspector { - border-top: 1px solid var(--ds-rule); - border-bottom: 1px solid var(--ds-rule); - padding: 16px 0; + background: var(--ds-surface); + border-left: 3px solid var(--calendar-color, var(--ds-ink)); + padding: 18px 20px; display: flex; - justify-content: space-between; + align-items: center; gap: 16px; @apply type-small; } +.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; } +.ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); } +.ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; } +.ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; } .ds-skip-link { position: absolute; top: 0; @@ -1056,11 +1122,7 @@ .ds-chart-selection { gap: 10px 18px; } - .ds-calendar-caption, - .ds-date-inspector { - flex-direction: column; - gap: 8px; - } + .ds-calendar-caption { flex-direction: column; gap: 0; } .ds-big-value small { margin-left: 10px; } @@ -1111,7 +1173,7 @@ .ds-habit-chart-heading > div { min-width: 0; } -.ds-habit-chart-heading h4 { +.ds-habit-chart-heading :is(h3, h4) { overflow-wrap: anywhere; font-family: var(--ds-serif); @apply type-title; @@ -1119,7 +1181,7 @@ align-items: center; gap: 10px; } -.ds-habit-chart-heading h4 > span { +.ds-habit-chart-heading :is(h3, h4) > span { width: 7px; height: 7px; flex-shrink: 0; @@ -1146,51 +1208,40 @@ grid-column: 1 / -1; margin-top: -12px !important; } -.ds-calendar--compact .ds-date-inspector { - flex-direction: column; - gap: 5px; - @apply type-small; - border-bottom: 0; - padding-bottom: 0; -} -.ds-calendar--compact .ds-calendar-caption { - @apply type-small; - margin-bottom: 15px; -} +.ds-calendar--compact .ds-calendar-caption { margin-bottom: 12px; } .ds-task-accordion { - margin-top: 18px; - border-top: 1px solid var(--ds-rule); - border-bottom: 1px solid var(--ds-rule); + margin-top: 16px; + border: 1px solid var(--ds-rule); } -.ds-task-accordion summary { +.ds-task-accordion > summary { cursor: pointer; list-style: none; display: flex; - gap: 16px; + gap: 12px; align-items: center; - min-height: 46px; - @apply type-small; + min-height: 64px; + padding: 16px 20px; } -.ds-task-accordion summary::-webkit-details-marker { - display: none; -} -.ds-task-accordion summary > span { - margin-left: auto; - color: var(--ds-secondary); - @apply type-small; -} -.ds-task-accordion summary::after { - content: "+"; - @apply type-lead; - width: 16px; - text-align: center; -} -.ds-task-accordion[open] summary::after { - content: "−"; -} -.ds-task-accordion-content { - display: grid; - padding: 0 0 14px; +.ds-task-accordion > summary::-webkit-details-marker { display: none; } +.ds-task-accordion > summary:hover { background: var(--ds-surface); } +.ds-task-accordion > summary > svg { flex-shrink: 0; color: var(--ds-secondary); } +.ds-task-accordion-title { @apply type-ui-heading; } +.ds-task-accordion-progress { margin-left: auto; display: flex; align-items: center; gap: 12px; color: var(--ds-secondary); @apply type-small; white-space: nowrap; font-variant-numeric: tabular-nums; } +.ds-task-accordion-progress progress { appearance: none; width: 64px; height: 4px; border: 0; background: var(--ds-empty); color: var(--habit-color, var(--ds-ink)); } +.ds-task-accordion-progress progress::-webkit-progress-bar { background: var(--ds-empty); } +.ds-task-accordion-progress progress::-webkit-progress-value { background: var(--habit-color, var(--ds-ink)); } +.ds-task-accordion-progress progress::-moz-progress-bar { background: var(--habit-color, var(--ds-ink)); } +.ds-task-accordion[open] .ds-task-accordion-chevron { transform: rotate(180deg); } +.ds-task-accordion-content { display: grid; padding: 0 20px 8px; } +.ds-task-accordion-content .ds-checkbox { min-width: 0; min-height: 52px; padding: 12px 4px; gap: 12px; border-top: 1px solid var(--ds-rule); @apply type-body; } +.ds-task-accordion-content .ds-checkbox:hover { background: var(--ds-surface); } +.ds-task-accordion-content .ds-checkbox span { overflow-wrap: anywhere; } +.ds-task-accordion-content .ds-checkbox:has(input:checked) .ds-checkbox-label { color: var(--ds-secondary); text-decoration: line-through; text-decoration-color: var(--ds-control-border); } +.ds-task-accordion-content .ds-checkbox:has(input:disabled) { cursor: not-allowed; } +@media (max-width: 640px) { + .ds-task-accordion > summary { padding: 14px 16px; gap: 10px; } + .ds-task-accordion-content { padding-inline: 16px; } + .ds-task-accordion-progress progress { display: none; } } .ds-habit-color-key { display: flex; @@ -1334,3 +1385,53 @@ .ds-month-views [aria-pressed="true"], .ds-weekdays [aria-pressed="true"] { border-color: Highlight; } } + +/* Inline management keeps actions visible without competing with the habit. */ +.ds-item-actions { display: inline-flex; flex: 0 0 auto; gap: 2px; align-items: center; } +.ds-item-actions .ds-icon-button { display: inline-grid; place-items: center; width: 44px; height: 44px; min-height: 44px; padding: 0; color: var(--ds-secondary); } +.ds-item-actions .ds-icon-button:hover { background: var(--ds-surface); color: var(--ds-ink); } +.ds-habit-title-row { display: flex; align-items: center; gap: 12px; min-width: 0; } +.ds-habit-title-row :is(h3, h4) { min-width: 0; } +.ds-inline-item-form { padding: 20px; background: var(--ds-surface); margin: 16px 0; min-width: 0; } +.ds-inline-item-fields { border: 0; padding: 0; margin: 0; min-width: 0; } +.ds-inline-item-form--habit { max-width: 720px; } +.ds-inline-item-form--task { margin: 0 0 12px; padding: 16px; } +.ds-inline-item-form .ds-field input { min-width: 0; } +.ds-inline-form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.ds-inline-delete-copy { @apply type-small; overflow-wrap: anywhere; } +.ds-inline-item-form .ds-footnote { margin-top: 12px; } +.ds-editable-task { min-width: 0; border-top: 1px solid var(--ds-rule); } +.ds-editable-task-row { display: flex; align-items: center; gap: 12px; min-width: 0; padding-block: 4px; } +.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; } +.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; } +.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; } +.ds-share-panel { margin-top: 32px; padding: 32px; border: 1px solid var(--ds-rule); } +.ds-share-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 32px; } +.ds-share-heading h2 { margin-top: 8px; } +.ds-share-heading > .ds-button { flex: 0 0 auto; padding: 12px; } +.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; } +.ds-share-controls { display: grid; gap: 24px; min-width: 0; } +.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; } +.ds-share-fieldset legend { padding: 0; margin-bottom: 10px; } +.ds-share-fieldset input:not([type="checkbox"]), .ds-share-fieldset select { width: 100%; min-width: 0; } +.ds-share-habits { display: grid; max-height: 220px; overflow: auto; margin-bottom: 8px; } +.ds-share-habits .ds-checkbox-copy { overflow-wrap: anywhere; } +.ds-share-dates { display: grid; gap: 12px; } +.ds-share-feedback { color: var(--ds-ink); @apply type-small; } +.ds-share-preview-column { display: grid; gap: 14px; min-width: 0; } +.ds-share-preview { display: grid; place-items: center; padding: 22px; min-height: 240px; background: var(--ds-surface); border: 1px solid var(--ds-rule); } +.ds-share-preview img { display: block; width: 100%; height: auto; box-shadow: 0 3px 14px #0000000d; } +.ds-share-preview > div { display: grid; gap: 16px; } +.ds-share-actions { display: flex; flex-wrap: wrap; gap: 12px; } +.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; } +@media (max-width: 900px) { + .ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; } + .ds-share-panel { padding: 20px; } + .ds-share-preview { padding: 12px; } +} +.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; } +@media (max-width: 640px) { + .ds-habit-title-row { gap: 8px; flex-wrap: wrap; } + .ds-editable-task-row { gap: 4px; } + .ds-inline-item-form { padding: 16px; } +}