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.
This commit is contained in:
syntaxbullet
2026-09-04 17:48:54 +02:00
parent dde5e77317
commit ecc2620a4c
34 changed files with 4017 additions and 140 deletions

View File

@@ -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=

View File

@@ -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 todays 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 todays 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 servers 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 |

View File

@@ -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
);

View File

@@ -0,0 +1 @@
DROP TABLE `discord_connections`;

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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,

View File

@@ -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<typeof spyOn<typeof globalThis, "fetch">>;
let apiFixture: ReturnType<typeof fixture> | 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(
<MemoryRouter initialEntries={[path]}>
@@ -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<HTMLButtonElement>("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<Response>(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<HTMLInputElement>("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<HTMLInputElement>('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<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => {
const name = form.querySelector<HTMLInputElement>("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<HTMLInputElement>('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<HTMLFormElement>('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<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Edit task Walk").click());
const form = container.querySelector<HTMLFormElement>('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<HTMLFormElement>('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<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
expect(reopened.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe(String(schedule.every));
expect(reopened.querySelector<HTMLInputElement>('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<HTMLSelectElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLFormElement>('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();

View File

@@ -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 (
<Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route path="*" element={<Navigate to="/design-system" replace />} />
<Route
path="/"
element={
<AuthProvider>
<Home />
</AuthProvider>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -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<AuthEnv>();
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");

View File

@@ -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<HTMLFormElement>(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<Schedule>({ 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<HTMLInputElement>("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 (
<section
className="ds-section ds-split-section"
aria-labelledby="new-habit-title"
id="new-habit"
>
<SectionHeading
number="A NEW HABIT"
id="new-habit-title"
title={
<>
Make it <em>yours.</em>
</>
}
>
Choose what counts as complete. Your schedule follows your accounts
timezone.
</SectionHeading>
<form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
<Field label="Habit name">
{(id) => (
<input
id={id}
required
maxLength={200}
placeholder="e.g. Read ten pages"
value={name}
onChange={(event) => setName(event.target.value)}
/>
)}
</Field>
<Field label="How will you track it?">
{(id) => (
<select
id={id}
value={method}
onChange={(event) =>
setMethod(event.target.value as typeof method)
}
>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
</select>
)}
</Field>
{method === "count" && (
<div className="ds-form-grid">
<Field label="Daily target">
{(id) => (
<input
id={id}
type="number"
required
min={1}
max={10000}
step={1}
value={Number.isNaN(target) ? "" : target}
onChange={(event) => setTarget(event.target.valueAsNumber)}
/>
)}
</Field>
<Field label="Unit">
{(id) => (
<input
id={id}
required
maxLength={80}
value={unit}
onChange={(event) => setUnit(event.target.value)}
/>
)}
</Field>
</div>
)}
{method === "tasks" && (
<div className="ds-form-section">
{tasks.map((task, index) => (
<Field key={index} label={`Task ${index + 1}`}>
{(id) => (
<div className="ds-input-row">
<input
id={id}
required
maxLength={200}
value={task}
onChange={(event) =>
setTasks((current) =>
current.map((value, i) =>
i === index ? event.target.value : value,
),
)
}
/>
<Button
variant="text"
aria-label={`Remove task ${index + 1}`}
disabled={tasks.length === 1}
onClick={() =>
setTasks((current) =>
current.filter((_, i) => i !== index),
)
}
>
Remove
</Button>
</div>
)}
</Field>
))}
<Button
variant="secondary"
disabled={tasks.length >= 100}
onClick={() => setTasks((current) => [...current, ""])}
>
Add task +
</Button>
<p className="ds-form-feedback">
Tasks repeat on each scheduled day.
</p>
</div>
)}
<ScheduleEditor
value={schedule}
onChange={setSchedule}
anchorDate={date}
/>
<HabitColorPicker value={color} onChange={setColor} mode="create" />
</fieldset>
{error && (
<p className="ds-form-feedback" role="alert">
{error}
</p>
)}
<div className="ds-form-actions">
<Button type="submit" disabled={busy}>
{busy ? "Creating…" : "Create habit"}
</Button>
<Button variant="text" disabled={busy} onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</section>
);
}

View File

@@ -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<CalendarResponse | null>(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 ? (
<div className="home-state">
<div className="ds-form-feedback">
<p role="alert">{error}</p>
<Button
variant="secondary"
@@ -88,7 +102,7 @@ export function HabitHistory({
</Button>
</div>
) : !calendar ? (
<p className="home-state" role="status">
<p className="ds-form-feedback" role="status">
Loading your habit history
</p>
) : (
@@ -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 ? (
<ItemActions name={habit.name ?? "Habit"} kind="habit" disabled={disabled || !calendar}
onEdit={() => onEdit(calendar!.settings.mainColor)} onDelete={onDelete} />
) : undefined}
>
<div
className="home-habit-actions"
role="group"
aria-label={`${habit.name} actions`}
>
<Button
variant="text"
aria-label={`Edit ${habit.name}`}
disabled={disabled || !calendar}
onClick={() => onEdit(calendar!.settings.mainColor)}
{children}
{(onEdit || onDelete) && !(onEdit && onDelete) && (
<div
className="ds-actions"
role="group"
aria-label={`${habit.name} actions`}
>
Edit
</Button>
<Button
variant="text"
aria-label={`Delete ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Delete
</Button>
</div>
{onEdit && (
<Button
variant="text"
aria-label={`Edit ${habit.name}`}
disabled={disabled || !calendar}
onClick={() => onEdit(calendar!.settings.mainColor)}
>
Edit
</Button>
)}
{onDelete && (
<Button
variant="text"
aria-label={`Delete ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Delete
</Button>
)}
</div>
)}
</HabitChart>
);
}

View File

@@ -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<string, unknown>) => Promise<void>;
onDelete: () => Promise<void>;
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 (
<InlineItemForm name={config.name} kind="habit" mode={mode} disabled={disabled} onClose={onClose} onDelete={onDelete}
onSave={async (name) => {
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);
}}>
<Field label="How will you track it?">
{(id) => <select id={id} value={method} onChange={(event) => setMethod(event.target.value as HabitConfig["method"])}>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
</select>}
</Field>
{method !== config.method && <p className="ds-footnote">Changing the tracking method starts todays progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
{method === "count" && (<>
<div className="ds-form-grid">
<Field label="Daily target">{(id) => <input id={id} type="number" min={1} max={10000} step={1} required value={Number.isNaN(target) ? "" : target} onChange={(event) => setTarget(event.target.valueAsNumber)} />}</Field>
<Field label="Unit">{(id) => <input id={id} required maxLength={80} value={unit} onChange={(event) => setUnit(event.target.value)} />}</Field>
</div>
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
<p className="ds-footnote">Completed counts reset. Explicitly recorded counts are kept.</p>
</>)}
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
<HabitColorPicker value={color} onChange={setColor} mode="edit" />
<p className="ds-footnote">Changes start today. Earlier targets and schedules stay as they were.</p>
</InlineItemForm>
);
}

View File

@@ -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<void>;
onDelete: () => Promise<void>;
onClose: () => void;
}) {
const form = useRef<HTMLFormElement>(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<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
return () => { if (previous?.isConnected) previous.focus(); };
}, [mode]);
return (
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : "Delete"} ${kind} ${name}`}
onKeyDown={(event) => {
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" ? (
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
</Field>
{children}
</fieldset>
) : (
<p className="ds-inline-delete-copy">
Delete <strong>{name}</strong>? {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."}
</p>
)}
{error && <p className="ds-form-feedback" role="alert">{error}</p>}
<div className="ds-inline-form-actions">
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
<Button type="submit" disabled={busy || disabled}>
{busy ? "Saving…" : mode === "delete" ? `Delete ${kind}` : submitLabel ?? "Save changes"}
</Button>
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
</div>
</form>
);
}

View File

@@ -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<void>;
onDelete: () => Promise<void>;
onClose: () => void;
}) {
const [schedule, setSchedule] = useState(savedSchedule);
return (
<InlineItemForm name={name} kind="task" mode={mode} disabled={disabled} onDelete={onDelete} onClose={onClose}
formLabel={creating ? "Add task" : undefined} submitLabel={creating ? "Add task" : undefined}
onSave={async (name) => {
const parsed = taskPatch.safeParse({ name, schedule });
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
await onSave(parsed.data);
}}>
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
<p className="ds-footnote">This task is due when both its recurrence and the habits schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
</InlineItemForm>
);
}

View File

@@ -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<CardPrivacy>({ 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<DiscordConnection | null>(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<HTMLHeadingElement>(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<DiscordConnection>("/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<ShareData>("/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<Delivery>("/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 (
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
<header className="ds-share-heading">
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
</header>
<div className="ds-share-layout">
<div className="ds-share-controls">
<fieldset className="ds-share-fieldset" disabled={busy}>
<legend className="type-ui-heading">Choose your habits</legend>
<p className="ds-muted type-small">Up to six per card. Task names are never included.</p>
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
<Field label="Date range">{id => <select id={id} value={range} onChange={e => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="365">Past year</option><option value="custom">Custom dates</option></select>}</Field>
{range === "custom" && <div className="ds-share-dates"><Field label="From">{id => <input id={id} type="date" value={from} max={to || today.date} min="1970-01-01" onChange={e => setFrom(e.target.value)} />}</Field><Field label="To">{id => <input id={id} type="date" value={to} min={from} max={today.date} onChange={e => setTo(e.target.value)} />}</Field></div>}
</fieldset>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="type-ui-heading">Show on the card</legend>
{([ ["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"] ] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
</fieldset>
</div>
<div className="ds-share-preview-column">
<div className="ds-share-preview" aria-busy={!ready && !error}>
{error ? <div role="alert"><p>{error}</p><Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button></div> : ready ? <img src={ready.url} alt={ready.alt} /> : <p role="status">Creating your card</p>}
</div>
<p className="type-small ds-muted">This exact image will be downloaded or sent. Days off are excluded from the completion rate.</p>
{ready?.avatarMissing && <p role="status" className="type-small ds-muted">Your avatar couldnt load. The card uses a neutral profile icon.</p>}
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`minabot-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download PNG</a> : <Button variant="secondary" disabled>Download PNG</Button>}
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}><Send size={17} aria-hidden="true" />{sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
{connectionError ? <div role="alert" className="ds-share-feedback"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry Discord</Button></div> : connection?.connected ? <p className="type-small ds-muted">The bot will post this card to <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a>.</p> : <p className="type-small ds-muted" role="status">{connection?.message ?? "Loading the Discord sharing channel…"}</p>}
{sendError && <p role="alert">{sendError}</p>}
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord </a>}</> : <>Discord didnt confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt wont be resent.</>}</p>}
</div>
</div>
</section>
);
}

View File

@@ -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({
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
>
<div className="ds-calendar-range-toolbar">
<span className="ds-calendar-range-label" aria-live="polite">
@@ -273,20 +274,29 @@ export function CalendarHeatmap({
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<span id={instructionsId} className="ds-muted">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</span>
<details className="ds-calendar-help">
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
<p id={instructionsId}>
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
</details>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<span>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})}
</span>
<span>{describeDay(selected, unit)}</span>
<div className="ds-date-inspector-heading">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
<time dateTime={selected.date}>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})}
</time>
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
</div>
</div>
</div>
</div>
);

View File

@@ -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 (
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-habit-chart-heading">
<div>
<h4 id={headingId}>
<span style={{ backgroundColor: color }} aria-hidden="true" />
{name}
</h4>
<div className="ds-habit-title-row">
<Heading id={headingId}>
<span style={{ backgroundColor: color }} aria-hidden="true" />
{name}
</Heading>
{headingActions}
</div>
<p>
{method} · {schedule}
</p>
</div>
{children}
</header>
{editor}
<p className="ds-habit-chart-progress" aria-live="polite">
<span>
{due
@@ -71,10 +85,13 @@ export function HabitChart({
{tasks && (
<details className="ds-task-accordion">
<summary>
Tasks for today{" "}
<span>
{value} / {target}
<ListChecks size={20} aria-hidden="true" />
<span className="ds-task-accordion-title">{tasksLabel}</span>
<span className="ds-task-accordion-progress">
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} />
<span>{value} / {target}</span>
</span>
<ChevronDown className="ds-task-accordion-chevron" size={18} aria-hidden="true" />
</summary>
<div className="ds-task-accordion-content">{tasks}</div>
</details>

View File

@@ -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 (
<span className="ds-item-actions" role="group" aria-label={`${name} actions`}>
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
<Pencil size={16} aria-hidden="true" />
</Button>
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</span>
);
}

View File

@@ -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 (
<div className="ds-root" id="top">
<a
className="ds-skip-link"
href={`#${mainId}`}
onClick={(event) => {
event.preventDefault();
document.getElementById(mainId)?.focus();
}}
>
Skip to content
</a>
<header className="ds-header">
<Link className="ds-wordmark" to={wordmarkTo}>
minabot<span aria-hidden="true">.</span>
</Link>
{header}
</header>
<main id={mainId} className="ds-main" tabIndex={-1}>
{children}
<footer className="ds-footer">
<span className="ds-wordmark">minabot.</span>
<span>A little, every day.</span>
<Button
variant="text"
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
>
Back to top
</Button>
</footer>
</main>
</div>
);
}

View File

@@ -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<string | null>(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 (
<section className="ds-welcome-panel" aria-labelledby="dashboard-title">
<div className="ds-welcome-content">
<div className="ds-welcome-identity">
<div className="ds-avatar">
{avatarUrl && failedAvatar !== avatarUrl ? (
<img
src={avatarUrl}
alt={`${name}s Discord avatar`}
width={64}
height={64}
onError={() => setFailedAvatar(avatarUrl)}
/>
) : (
<span aria-label={`${name}s avatar`} role="img">
{Array.from(name.trim())[0]?.toUpperCase() || "?"}
</span>
)}
</div>
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
</div>
<h1 id="dashboard-title" className="type-display">
Welcome back, <em>{name}.</em>
</h1>
<p className="ds-welcome-subtitle">Make time for today.</p>
{children}
</div>
<div className="ds-welcome-calendar">
{day && date ? (
<time className="ds-date-sheet" dateTime={date} aria-label={formatTrackingDate(date)}>
<span className="ds-date-month">
{day.toLocaleDateString("en", { month: "long", timeZone: "UTC" })}
<span className="ds-muted">{day.getUTCFullYear()}</span>
</span>
<span className="ds-date-number type-display">{day.getUTCDate()}</span>
<span className="ds-date-weekday">
{day.toLocaleDateString("en", { weekday: "long", timeZone: "UTC" })}
</span>
<span className="ds-date-caption ds-eyebrow">TODAY, AT YOUR OWN PACE</span>
</time>
) : (
<p className="ds-muted" role="status">Your day is loading</p>
)}
<p className="ds-welcome-timezone">
<Globe2 size={16} aria-hidden="true" />
<span>{timezone}</span>
</p>
</div>
</section>
);
}

View File

@@ -1,3 +1,4 @@
import { useId } from "react";
import type {
ButtonHTMLAttributes,
AnchorHTMLAttributes,
@@ -46,7 +47,7 @@ export function SectionHeading({
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
<h2 id={id}>{title}</h2>
<h2 id={id} tabIndex={-1}>{title}</h2>
{children && <p>{children}</p>}
</header>
);
@@ -54,13 +55,23 @@ export function SectionHeading({
export function Checkbox({
label,
description,
className = "",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string }) {
const descriptionId = useId();
return (
<label className={`ds-checkbox ${className}`}>
<input type="checkbox" {...props} />
<span>{label}</span>
<input
type="checkbox"
aria-label={description ? label : undefined}
{...props}
aria-describedby={[props["aria-describedby"], description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined}
/>
<span className="ds-checkbox-copy">
<span className="ds-checkbox-label">{label}</span>
{description && <span id={descriptionId} className="ds-checkbox-description">{description}</span>}
</span>
</label>
);
}

View File

@@ -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),

View File

@@ -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;

View File

@@ -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",

138
src/lib/progress-card.ts Normal file
View File

@@ -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<HTMLImageElement | null> {
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 dont 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<Blob>((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}.` : ""}` };
}

View File

@@ -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 (
<div className="ds-root" id="top">
<a className="ds-skip-link" href="#ds-main" onClick={(event) => {
event.preventDefault();
document.getElementById("ds-main")?.focus();
}}>Skip to content</a>
<header className="ds-header">
<Link className="ds-wordmark" to="/design-system">
minabot<span aria-hidden="true">.</span>
</Link>
<span className="ds-library-label">Interface library / 01</span>
</header>
<main id="ds-main" className="ds-main" tabIndex={-1}>
<PageLayout mainId="ds-main" wordmarkTo="/design-system" header={<span className="ds-library-label">Interface library / 01</span>}>
<header className="ds-library-intro">
<div>
<p className="ds-eyebrow">MINABOT INTERFACE LANGUAGE</p>
@@ -300,14 +291,6 @@ export function DesignSystem() {
</>
) },
]} />
<footer className="ds-footer">
<span className="ds-wordmark">minabot.</span>
<span>A little, every day.</span>
<Button variant="text" onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}>
Back to top
</Button>
</footer>
</main>
</div>
</PageLayout>
);
}

632
src/pages/Home.tsx Normal file
View File

@@ -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 (
<PageLayout
header={
loading ? (
<span className="ds-library-label">Loading account</span>
) : user ? (
<Button variant="text" disabled={busy} onClick={() => void signOut()}>
{busy ? "Signing out…" : "Sign out"}
</Button>
) : (
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} />
)
}
>
{error && (
<div className="ds-form-feedback" role="alert">
{error}
</div>
)}
{loading ? (
<section className="ds-section" aria-busy="true">
<p role="status">Getting things ready</p>
</section>
) : accountError ? (
<section className="ds-section">
<h1 className="type-section">Lets try that again.</h1>
<Card headingLevel={2} heading="Account unavailable">
<p>Your account couldnt be loaded.</p>
<div className="ds-actions">
<Button onClick={retry}>Try again</Button>
</div>
</Card>
</section>
) : user ? (
<Dashboard key={user.id} user={user} onExpired={retry} />
) : (
<Welcome onSignIn={signIn} />
)}
</PageLayout>
);
}
function Welcome({ onSignIn }: { onSignIn: () => void }) {
const [water, setWater] = useState(3);
const [read, setRead] = useState(false);
return (
<>
<section className="ds-section ds-split-section" aria-labelledby="welcome-title">
<div className="ds-section-heading">
<p className="ds-eyebrow">A LITTLE, EVERY DAY</p>
<h1 id="welcome-title" className="type-section">
Small steps.
<br />
<em>Lasting rhythm.</em>
</h1>
</div>
<Card headingLevel={2} heading="Make room for what matters.">
<p>
Check in, count a little more, or work through a few tasks. See your progress grow, one
day at a time.
</p>
<div className="ds-actions">
<DiscordSignInButton onClick={onSignIn} />
<ButtonLink href="#try-it">Try it below </ButtonLink>
</div>
<p className="type-small">Sign in with your Discord account to save your habits.</p>
</Card>
</section>
<section className="ds-section" id="try-it" aria-labelledby="try-it-title">
<div className="ds-section-top">
<SectionHeading
number="TRY A CHECK-IN"
id="try-it-title"
title={
<>
A little progress. <em>Made visible.</em>
</>
}
/>
<span className="ds-demo-note">Example · September 4, 2026 · not saved</span>
</div>
<div className="ds-habit-chart-grid">
<HabitChart
headingLevel={3}
name="Drink water"
method="Count target"
value={water}
target={8}
unit="glasses"
color={HABIT_COLORS.water}
>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart
headingLevel={3}
name="Read a little"
method="Simple check-in"
value={Number(read)}
target={1}
unit="reading session"
color={HABIT_COLORS.reading}
>
<Checkbox
label="Reading done"
checked={read}
onChange={(event) => setRead(event.target.checked)}
/>
</HabitChart>
</div>
</section>
<section className="ds-section" aria-label="Your own rhythm">
<CardGrid>
<Card heading="Your habits. Your pace." eyebrow="MAKE IT FIT">
<p>
Pick a check-in, a count target, or a task list. Repeat daily, on selected weekdays,
or at your own interval.
</p>
</Card>
<Card heading="See the days add up." eyebrow="KEEP PERSPECTIVE" variant="outlined">
<p>
Explore your calendar to see the progress behind each square. Days off stay distinct
from missed days.
</p>
</Card>
</CardGrid>
</section>
</>
);
}
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
const [today, setToday] = useState<TodayResponse | null>(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<TodayResponse>("/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<TodayHabit>(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<string, unknown> | 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<TodayResponse>("/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 (
<>
<WelcomePanel
name={user.displayName || user.username}
avatarUrl={user.avatarUrl}
date={today?.date}
timezone={today?.timezone || user.timezone}
>
{today && (
<>
<div className="ds-welcome-progress" role="status">
{today.due > 0 ? (
<>
<span className="type-title">
{today.completed} / {today.due}
</span>
<span>
habits complete today
{today.completed === today.due && (
<span className="ds-welcome-complete">A little, all done.</span>
)}
</span>
</>
) : (
<p>
{today.habits.length
? "Nothing scheduled today. Enjoy a little breathing room."
: "Start with one habit. Your first small step starts here."}
</p>
)}
</div>
<div className="ds-actions">
<Button
id="add-habit"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
aria-controls={adding ? "new-habit" : undefined}
>
Add a habit +
</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<ButtonLink href="#habits-title">View your habits </ButtonLink>
</>
)}
</div>
</>
)}
</WelcomePanel>
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
{error && (
<div className="ds-form-feedback" role="alert">
<p>{error}</p>
<Button
variant="secondary"
disabled={loading}
onClick={() => setAttempt((value) => value + 1)}
>
Try again
</Button>
</div>
)}
{!today && loading && (
<section className="ds-section">
<p role="status">Loading your habits</p>
</section>
)}
{today && (
<>
{adding && (
<HabitForm
date={today.date}
onCancel={() => setAdding(false)}
onExpired={() => expired.current()}
onCreated={(name) => {
setAdding(false);
setNotice(`${name} created.`);
setAttempt((value) => value + 1);
}}
/>
)}
<p className="ds-form-feedback" role="status">
{notice}
</p>
{today.habits.length > 0 && (
<section
className="ds-section"
aria-labelledby="habits-title"
aria-busy={busy || loading}
>
<div className="ds-section-top">
<SectionHeading
number="YOUR HABITS"
id="habits-title"
title={
<>
One day <em>at a time.</em>
</>
}
/>
</div>
<div className="ds-habit-chart-grid">
{today.habits.map((habit) => (
<SavedHabit
key={habit.habitId}
habit={habit}
date={today.date}
revision={revision}
disabled={busy || loading || needsRefresh}
onUpdate={update}
onManage={manage}
onExpired={() => expired.current()}
/>
))}
</div>
</section>
)}
</>
)}
</>
);
}
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<void>;
onExpired: () => void;
onManage: (
habit: TodayHabit,
patch: Record<string, unknown> | null,
taskId?: string,
createTask?: boolean
) => Promise<void>;
}) {
const [editing, setEditing] = useState<{ mode: ItemMode; color: string } | null>(null);
const [addingTask, setAddingTask] = useState(false);
const blocked = disabled || !habit.due;
return (
<HabitHistory
habit={habit}
date={date}
revision={revision}
disabled={disabled}
onExpired={onExpired}
onEdit={(color) => setEditing({ mode: "edit", color })}
onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
editor={
editing && habit.requirements ? (
<InlineHabitEditor
key={editing.mode}
config={habit.requirements}
date={date}
color={editing.color}
mode={editing.mode}
disabled={disabled}
onClose={() => 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 (
<SavedTask
key={config.id}
task={{ taskId: config.id, name: config.name, done: occurrence?.done ?? false }}
disabled={disabled}
blocked={blocked || !occurrence}
scheduled={!!occurrence}
schedule={config.schedule}
habitSchedule={habit.requirements!.schedule}
date={date}
onCheck={(done) => void onUpdate(habit, { done }, config.id)}
onSave={(patch) => onManage(habit, patch, config.id)}
onDelete={() => onManage(habit, null, config.id)}
/>
);
})}
{addingTask ? (
<InlineTaskEditor
name=""
schedule={{ type: "daily" }}
habitSchedule={habit.requirements.schedule}
date={date}
mode="edit"
creating
disabled={disabled}
onClose={() => setAddingTask(false)}
onSave={(patch) => onManage(habit, patch, undefined, true)}
onDelete={async () => {}}
/>
) : (
<div className="ds-task-add-action">
{!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p>
)}
<Button
variant="text"
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
</Button>
</div>
)}
</>
) : undefined
}
>
{habit.method === "count" ? (
<Counter
label={habit.name ?? "habit count"}
value={habit.value}
target={habit.target ?? 0}
disabled={blocked}
onChange={(count) => void onUpdate(habit, { count })}
/>
) : habit.method === "manual" ? (
<Checkbox
label={`${habit.name} done`}
checked={habit.complete}
disabled={blocked}
onChange={(event) => void onUpdate(habit, { done: event.target.checked })}
/>
) : undefined}
</HabitHistory>
);
}
function SavedTask({
task,
schedule,
habitSchedule,
date,
disabled,
blocked,
scheduled,
onCheck,
onSave,
onDelete,
}: {
task: Pick<TodayHabit["tasks"][number], "taskId" | "name" | "done">;
disabled: boolean;
blocked: boolean;
scheduled: boolean;
schedule: Schedule;
habitSchedule: Schedule;
date: string;
onCheck: (done: boolean) => void;
onSave: (patch: { name?: string; schedule?: Schedule }) => Promise<void>;
onDelete: () => Promise<void>;
}) {
const [mode, setMode] = useState<ItemMode | null>(null);
return (
<div className="ds-editable-task">
<div className="ds-editable-task-row">
<Checkbox
label={task.name}
description={`${scheduleLabel(schedule)}${scheduled ? "" : " · Not scheduled today"}`}
checked={task.done}
disabled={blocked || !!mode}
onChange={(event) => onCheck(event.target.checked)}
/>
<ItemActions
name={task.name}
kind="task"
disabled={disabled || !!mode}
onEdit={() => setMode("edit")}
onDelete={() => setMode("delete")}
/>
</div>
{mode && (
<InlineTaskEditor
name={task.name}
schedule={schedule}
habitSchedule={habitSchedule}
date={date}
mode={mode}
disabled={disabled}
onSave={onSave}
onDelete={onDelete}
onClose={() => setMode(null)}
/>
)}
</div>
);
}

9
src/sharing/config.ts Normal file
View File

@@ -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(),
};
}

21
src/sharing/contracts.ts Normal file
View File

@@ -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 };

105
src/sharing/routes.test.ts Normal file
View File

@@ -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<typeof fixture>[] = [];
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);
});

134
src/sharing/routes.ts Normal file
View File

@@ -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<Response>;
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const snowflake = /^\d{17,20}$/;
export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
const app = new Hono<AuthEnv>();
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<string | null> | null = null;
async function botAvatarUrl(): Promise<string | null> {
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<T extends z.ZodType>(req: Request, schema: T): Promise<z.output<T>> {
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;
}

View File

@@ -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; }
}