384 lines
21 KiB
Markdown
384 lines
21 KiB
Markdown
# Minabot
|
||
|
||
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 [deployment operations](docs/OPERATIONS.md) for backup, recovery, and monitoring.
|
||
|
||
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
|
||
combined calendars, historical corrections, daily resets, and optional count carryover.
|
||
|
||
```sh
|
||
bun install
|
||
bun dev
|
||
```
|
||
|
||
Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
||
|
||
- Homepage: `/`. Signed-out visitors see an introduction and interactive examples;
|
||
signed-in users see their saved habits and today’s progress.
|
||
- Component library: `/design-system`, independent of account loading.
|
||
- Unknown client URLs redirect to `/`.
|
||
- Hono endpoint: `GET /api/health`.
|
||
- Unknown API routes return JSON with a 404 status.
|
||
|
||
### Design system preview
|
||
|
||
The library uses four tabs: Foundations, Components, Layout, and Calendars.
|
||
Foundations is the default. Arrow keys, Home, and End navigate the tabs; URL
|
||
hashes and browser back/forward restore the section. Switching tabs preserves
|
||
local example state. Old `#playground` links open `#calendar-states`; removed
|
||
`#editing` and `#views` links resolve to `#foundations`.
|
||
|
||
Open `/design-system#calendar-states` for progress states and three working
|
||
calendar examples: a count target, a manual checkbox, and combined completion.
|
||
Changing water or reading progress updates today's combined score. Each calendar
|
||
supports 3/4/6/12-month ranges, a Custom start/end date range within available
|
||
history, date inspection, and keyboard navigation. Custom dates are inclusive;
|
||
Apply range updates the chart. Dates
|
||
form one continuous weekly chart with shared month and weekday labels and a fixed
|
||
4px gap between dates across every timeframe. Landscape
|
||
views fit the available width without chart scrollbars; narrow portrait views
|
||
retain 24px date targets in a local horizontal scroller. Primary controls use
|
||
44px targets, and custom checkboxes retain native semantics in forced-color mode.
|
||
These examples use a fixed September 4, 2026 demo date and do not call the API;
|
||
reload or Reset examples restores their initial progress.
|
||
|
||
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 homepage is composed from the existing design-system components and layouts:
|
||
shared page shell, WelcomePanel, Card/CardGrid, SectionHeading, HabitChart, CalendarHeatmap,
|
||
Counter, Checkbox, Field, ScheduleEditor, HabitColorPicker, and DiscordSignInButton.
|
||
It uses no page-specific styles. The shared page shell also renders the component
|
||
library. The design system does not load account data or call the API.
|
||
|
||
The dashboard's welcome panel groups the Discord avatar, greeting, progress, and
|
||
habit actions on the left, with the account's current date and timezone on the
|
||
right. It stacks on narrow screens and shows an initial if the avatar is unavailable.
|
||
|
||
Signed-in users can create manual, count, or task habits, choose a recurrence and
|
||
color, log today’s progress, and inspect their real calendars. Completed-versus-due
|
||
counts exclude days off. Controls beside habit headings open a modal to edit the name,
|
||
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
|
||
Each task can be renamed, rescheduled, or deleted in a modal; 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.
|
||
Archive asks for confirmation in a modal and preserves earlier history.
|
||
**Archived habits** opens the archive modal, with history inspection and restoration
|
||
starting today. Task deletion uses the same modal confirmation. Tasks
|
||
remain manageable on days off, while their checkboxes stay disabled.
|
||
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
|
||
Dialogs keep the background inactive, contain keyboard focus, support Escape and
|
||
backdrop dismissal, and return focus to their opener. Long content scrolls beneath
|
||
a fixed header; closing is disabled during saves.
|
||
Mutations are saved through the authenticated API; failed
|
||
saves retain the recorded values and allow retry. Account and calendar failures
|
||
have retry states. The dashboard refreshes on window focus and every minute while
|
||
visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts,
|
||
checkboxes, or task occurrences using that date’s saved requirements. A date picker
|
||
also reaches history outside the displayed year. Future dates and days off are read-only.
|
||
Combined-chart management is 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
|
||
override them predictably. Tailwind Preflight remains omitted.
|
||
|
||
`styles/typography.css` defines reusable Tailwind classes: `type-display` (72/81),
|
||
`type-section` (48/54), `type-title` (32/40), `type-lead` (20/30), `type-body`
|
||
(16/24), `type-small` and `type-label` (14/21), and `type-control` (24/30, symbols).
|
||
Values are font-size/line-height in pixels at the default 16px root, implemented
|
||
in rem. At 640px and below, display steps to 48/54 and section to 36/45; body and
|
||
supporting text never shrink. Use these classes directly or with `@apply`, not
|
||
bespoke font sizes or viewport-based type. See `/design-system#foundations` for
|
||
live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared
|
||
`DiscordSignInButton` for headings and calls to action. All default button variants,
|
||
including text/ghost buttons and links, share 10px vertical and 19px horizontal
|
||
padding. Ghost changes only colors and border visibility; counter buttons retain
|
||
their explicit 44px square sizing.
|
||
Use `type-ui-heading` (18/27, medium system sans) for interface headings, field
|
||
labels, and form legends; it stays visibly larger than 14/21 supporting text at
|
||
every breakpoint. Preserve serif `type-title` and `type-section` for editorial
|
||
headings. Use `type-eyebrow` for 12/18 section markers. `Field` supplies a control ID and hint ID through its
|
||
render callback; apply both to the native control. `Card.headingLevel` defaults
|
||
to 3 and can be set to match its surrounding document hierarchy without changing
|
||
its appearance.
|
||
|
||
Interface colors use the existing neutral palette through `--ds-ink`,
|
||
`--ds-secondary`, `--ds-control-border`, `--ds-rule`, `--ds-paper`,
|
||
`--ds-surface` (the existing #F5F5F5 fill), and `--ds-empty` (the existing #EEEEEE
|
||
calendar fill). Use secondary for muted text and primary hover, surface for
|
||
neutral hover/grouping, and control-border for actionable boundaries. Keep the
|
||
calendar progress ramp and habit identity colors separate. Foundations lists the
|
||
values and roles. Supporting copy should explain a consequence, constraint, or
|
||
interaction; omit prose that merely restates a heading or visible control.
|
||
|
||
```sh
|
||
bun run typecheck
|
||
bun run build
|
||
bun start
|
||
```
|
||
|
||
The build includes the backend and frontend in `dist/`. `bun start` serves that
|
||
production build. Client routes support direct loading and browser history.
|
||
|
||
## Database
|
||
|
||
The backend uses Drizzle ORM with Bun's built-in SQLite driver. The database is
|
||
created at `data/minabot.sqlite`, with WAL mode and foreign keys enabled. Database
|
||
files are ignored by Git. Development and production use the same file.
|
||
|
||
Optionally copy `.env.example` to `.env` and set `DATABASE_PATH` to another local
|
||
file. Environment-specific database paths can be set in `.env.development` and
|
||
`.env.production`. Relative paths are resolved from the project root by the package scripts.
|
||
|
||
Define tables in `src/db/schema.ts`, then generate and apply migrations:
|
||
|
||
```sh
|
||
bun run db:generate --name describe_change
|
||
bun run db:migrate
|
||
```
|
||
|
||
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
||
`bun start` apply pending migrations before starting the server. Development also
|
||
checks for pending migrations when the server entrypoint is hot-reloaded, so new
|
||
routes do not run against an older schema. After adding only migration files,
|
||
run `bun run db:migrate` or restart `bun dev`. The initial migration creates the
|
||
users and sessions tables.
|
||
|
||
Import `db` from `src/db/index.ts` in backend code to query the database.
|
||
`GET /api/health` checks the database connection before returning success.
|
||
|
||
## Discord sign-in
|
||
|
||
1. Copy `.env.example` to `.env` for shared defaults, and copy
|
||
`.env.development.example` / `.env.production.example` to `.env.development` /
|
||
`.env.production` if those files do not exist. These local files are Git-ignored.
|
||
2. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
|
||
Under OAuth2, copy the client ID and client secret into `DISCORD_CLIENT_ID`
|
||
and `DISCORD_CLIENT_SECRET` in `.env`.
|
||
3. Set `AUTH_COOKIE_SECRET` to a random secret of at least 32 characters, generated
|
||
with `openssl rand -hex 32`. Keep it server-side along with the client secret.
|
||
4. Set `APP_ORIGIN` in `.env.development` to the development browser origin,
|
||
initially `http://127.0.0.1:3000`, and set the same variable in `.env.production`
|
||
to the production origin, such as `https://habits.example.com`. Register **both**
|
||
origins with `/api/auth/discord/callback` appended as redirect URIs in Discord.
|
||
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, 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
|
||
any env file. Use HTTPS for non-local deployments; HTTP is only allowed on loopback
|
||
origins. When using a reverse proxy, preserve the public request host and serve
|
||
both React and `/api` from the selected origin.
|
||
|
||
The package scripts explicitly select the environment before migrations or server
|
||
startup:
|
||
|
||
| Command | Environment file | Mode |
|
||
| --- | --- | --- |
|
||
| `bun dev` | `.env.development` | development |
|
||
| `bun start` | `.env.production` | production |
|
||
| `bun run db:generate` / `bun run db:migrate` | `.env.development` | development |
|
||
| `bun run db:migrate:production` | `.env.production` | production |
|
||
|
||
Each command loads optional `.env` shared defaults, then the selected file.
|
||
Variables exported by the shell or deployment platform take precedence over files.
|
||
The selected `APP_ORIGIN` controls OAuth callbacks, cookie security, and logout
|
||
Origin validation. Production requires an explicit `APP_ORIGIN`; development can
|
||
fall back to `http://127.0.0.1:$PORT` (port 3000 if unset). To test `bun start`
|
||
locally, set `APP_ORIGIN=http://127.0.0.1:3000` in `.env.production`.
|
||
|
||
Automatic env-file loading is disabled in `bunfig.toml` so the outer `bun start`
|
||
process cannot preload development settings into production. Package scripts load
|
||
the files explicitly using Bun's `--env-file` option; `.env.local` files are not
|
||
loaded by these commands. Build, test, and typecheck do not load application env
|
||
files. For custom Bun commands, pass the desired `--env-file` flags explicitly.
|
||
See [Bun environment variables](https://bun.sh/docs/runtime/environment-variables).
|
||
|
||
Use `APP_ORIGIN` in each file; `APP_ORIGIN_DEV` and `APP_ORIGIN_PROD` are no longer
|
||
used. Credentials can stay in `.env` or be overridden separately in each file.
|
||
For deployments sharing a machine, set distinct `DATABASE_PATH` values to keep
|
||
production habit data separate from development data.
|
||
|
||
### Schema and session behavior
|
||
|
||
- `users`: app UUID, unique Discord ID stored as text, username, nullable display
|
||
name and avatar hash, IANA timezone, and creation/update timestamps.
|
||
- `sessions`: SHA-256 token hash, user foreign key with cascading deletion,
|
||
creation timestamp, and expiry timestamp. User and expiry indexes support
|
||
session management and cleanup.
|
||
- Timestamps are Unix milliseconds. Each session expires 30 days after sign-in;
|
||
activity does not extend it. A new sign-in replaces the current browser's session.
|
||
Other browsers retain independent sessions. Expired sessions are removed during
|
||
sign-in or when presented to an authenticated route.
|
||
- Cookies are HttpOnly, SameSite=Lax, and host-only. HTTPS enables Secure and
|
||
`__Host-` cookie names. OAuth state uses a signed cookie with a ten-minute
|
||
lifetime and is cleared on callback. Token exchange and profile retrieval happen
|
||
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
||
- React detects the browser timezone during initial registration. The server
|
||
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
||
update the Discord profile without changing the saved timezone. **Settings** lets users change their timezone, download a complete JSON export,
|
||
and permanently delete their account with typed confirmation. `PATCH /api/me`
|
||
updates the timezone. Habit timestamps
|
||
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 |
|
||
| --- | --- | --- |
|
||
| GET | `/api/health` | Public database health check |
|
||
| GET | `/api/auth/discord?timezone=Europe%2FBelgrade` | Start sign-in |
|
||
| GET | `/api/auth/discord/callback` | Validate OAuth response and create a session |
|
||
| POST | `/api/auth/logout` | Revoke current session; requires matching Origin |
|
||
| GET | `/api/me` | Public user fields for the current session, or 401 |
|
||
|
||
`/api/me` returns the object directly:
|
||
|
||
```json
|
||
{
|
||
"id": "app-generated-uuid",
|
||
"discordId": "123456789012345678",
|
||
"username": "mina",
|
||
"displayName": "Mina",
|
||
"avatarUrl": null,
|
||
"timezone": "Europe/Belgrade"
|
||
}
|
||
```
|
||
|
||
Only the fields declared in `src/shared/user.ts` are returned. Missing/invalid or
|
||
expired sessions receive `401 {"error":"Unauthorized"}`. Authentication endpoints
|
||
and `/api/me` use `Cache-Control: no-store`.
|
||
|
||
In `src/api.ts`, protect another route with the same middleware:
|
||
|
||
```ts
|
||
app.get("/api/example", auth.requireAuth, c => c.json({ userId: c.get("user").id }));
|
||
app.post("/api/example", auth.requireSameOrigin, auth.requireAuth, c => {
|
||
// Validate input and enforce ownership using c.get("user").id.
|
||
return c.json({ ok: true });
|
||
});
|
||
```
|
||
|
||
Use `requireSameOrigin` for all cookie-authenticated mutations. Same-origin browser
|
||
fetches send the cookie automatically; tokens should not be placed in localStorage.
|
||
|
||
```sh
|
||
bun test
|
||
bun run typecheck
|
||
bun run build
|
||
```
|
||
|
||
Tests use an isolated in-memory SQLite database with the real migrations and a
|
||
stubbed Discord HTTP client. They cover registration, profile refresh, timezone
|
||
preservation, cookie security, invalid/expired state, provider failures, session
|
||
expiry/rotation, logout CSRF checks, and the exact `/api/me` response. A real Discord
|
||
consent flow additionally requires your application credentials and browser login.
|
||
|
||
|
||
## Habit API verification
|
||
|
||
```sh
|
||
bun run test:coverage
|
||
bun run typecheck
|
||
bun run test:smoke
|
||
```
|
||
|
||
The smoke command builds and starts the production server against a disposable
|
||
SQLite database, exercises authenticated HTTP requests, then restarts it to verify
|
||
persistence. It does not use a live Discord provider or modify application data.
|
||
Habit tests cover every route, all PRD acceptance behaviors, ownership and Origin
|
||
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
|
||
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
||
separate the database foundation, REST implementation, carryover, and verification.
|
||
|
||
### Account data
|
||
|
||
`GET /api/account/export` downloads versioned JSON containing all owned profile,
|
||
habit, revision, dated progress, task, audit, calendar, chart, and delivery records.
|
||
It excludes session tokens and credentials. `DELETE /api/account` requires matching
|
||
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
|
||
revokes every session. Discord posts and retained operator backups are not erased
|
||
by this action.
|
||
|
||
Discord sharing reserves persistent quotas before posting: one new delivery per
|
||
Discord user per 60 seconds, and ten per channel per 60 seconds. Rechecking an
|
||
existing delivery does not consume quota. Rejected attempts consume quota too;
|
||
429 responses include `Retry-After`. Optional `DISCORD_SHARING_ALLOWED_USER_IDS`
|
||
(comma-separated Discord IDs) restricts posting to community members you approve.
|
||
An empty list permits all signed-in accounts. Restricted users can still preview
|
||
and download PNGs. Quota rows expire and are pruned after 24 hours.
|
||
|
||
### Daily reminders
|
||
|
||
Open **Settings → Daily reminders** to opt into one private Discord DM when habits
|
||
remain unfinished. Choose a local time and quiet hours; times follow the account
|
||
timezone. Quiet-hour start is inclusive, end is exclusive, and equal times disable
|
||
quiet hours. If the scheduled time falls in quiet hours, delivery waits until they
|
||
end that same local day. A reminder missed for the whole day is not carried forward.
|
||
|
||
The production server checks once a minute. `DISCORD_BOT_TOKEN` enables delivery;
|
||
a sharing channel is not required for reminders. Users must be reachable by the
|
||
bot (normally a shared Discord server and enabled DMs). A message contains only
|
||
the unfinished-habit count and app link. Settings show the most recent delivery
|
||
result, including blocked DMs or unconfirmed delivery. Uncheck the option and save
|
||
to opt out. No reminders are enabled by default.
|
||
|
||
`GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
|
||
requires authentication, matching Origin, and `{enabled, time, quietStart, quietEnd}`
|
||
with `HH:MM` times. Per-user/date reservations persist across restarts and concurrent
|
||
workers. Discord 429 retry deadlines are persisted; ambiguous message sends are
|
||
not retried that day. Delivery records expire after 90 days. Restore disables
|
||
reminders in the recovered database until users opt in again.
|
||
|
||
The disposable `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` mode also runs a
|
||
reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed
|
||
Europe/Belgrade time), leave a habit unfinished, and inspect delivery status in
|
||
settings or `/__preview/reminder`. No real Discord messages are sent in this mode.
|