Compare commits

..

64 Commits

Author SHA1 Message Date
syntaxbullet
ccd998ab04 Add service worker implementation and tests for PWA caching strategy
- Implemented a service worker (sw.js) that handles installation, activation, and fetch events.
- Added caching logic for public assets and a fallback for offline navigation.
- Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic.
- Ensured that private API routes and third-party requests are excluded from caching.
2026-09-08 17:19:12 +02:00
syntaxbullet
e081e9acbf Document performance changes and measured results 2026-09-08 16:35:45 +02:00
syntaxbullet
b8df2001ec Merge frontend performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
95f3d9a2c7 Merge worker performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
8f15419703 Merge backend performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
7c7a048b32 Initialize cold WAL bookkeeping for subprocess snapshots 2026-09-08 16:35:37 +02:00
syntaxbullet
27beb01144 Revalidate reminders synchronously after readiness yields 2026-09-08 16:35:36 +02:00
syntaxbullet
149ec36c77 Prepare worker and Discord snapshots through bounded catch-up 2026-09-08 16:35:36 +02:00
syntaxbullet
c3b52e8fe1 Resolve backup subprocess paths with URL decoding 2026-09-08 16:35:36 +02:00
syntaxbullet
ba049efdc8 Bound and deduplicate server image rendering per account 2026-09-08 16:35:36 +02:00
syntaxbullet
e3e07404f8 Cache bounded Discord avatars and coalesce downloads 2026-09-08 16:35:36 +02:00
syntaxbullet
17190219fa Skip reminder habit evaluation for completed delivery slots 2026-09-08 16:35:36 +02:00
syntaxbullet
b0950cb4b4 Run scheduled backups in an isolated subprocess 2026-09-08 16:35:36 +02:00
syntaxbullet
82fe4a1db1 Preserve calendar presets and custom ranges across local midnight 2026-09-08 16:29:20 +02:00
syntaxbullet
c91202bbfc Keep auxiliary previews stable on unchanged background refreshes 2026-09-08 16:27:03 +02:00
syntaxbullet
03eb8d9e7a Serve lossless WOFF2 browser fonts outside blocking stylesheets 2026-09-08 16:25:38 +02:00
syntaxbullet
9f552bf608 Load earlier calendar windows when editing historical dates 2026-09-08 16:25:38 +02:00
syntaxbullet
b22c933fc4 Split optional routes and editing dialogs from initial browser code 2026-09-08 16:25:38 +02:00
syntaxbullet
9a0fc108a9 Preserve unchanged habit renders and memoize calendar geometry 2026-09-08 16:25:38 +02:00
syntaxbullet
b4619dec33 Cache versioned habit history and load visible calendar ranges 2026-09-08 16:25:37 +02:00
syntaxbullet
31e4f3b2ef Yield bounded catch-up batches behind per-user readiness queues 2026-09-08 16:18:09 +02:00
syntaxbullet
30dee55799 Skip unchanged materialization and expire only open occurrences 2026-09-08 16:16:04 +02:00
syntaxbullet
4b8c7dff05 Add compact calendar responses and persisted habit change versions 2026-09-08 16:14:58 +02:00
syntaxbullet
85376a54b3 Bound habit day and calendar reads to requested history 2026-09-08 16:13:48 +02:00
syntaxbullet
833d5673eb Refactor HabitColorPicker to use HABIT_COLOR_CHOICES, update styles and tests
- Replaced HABIT_PALETTES with HABIT_COLOR_CHOICES in HabitColorPicker component and related tests.
- Adjusted the layout and styles of the color picker for improved usability.
- Updated tests to reflect changes in color selection logic.
- Modified WorkspacePanel and SettingRow components to allow optional eyebrow and description props.
- Enhanced accessibility tests for calendar navigation.
- Updated calendar model to include a 1-month view option.
- Refined styles across various components for consistency and improved responsiveness.
2026-09-08 15:42:47 +02:00
syntaxbullet
4d719afd07 fix: move production app to mina.teppelin.com 2026-09-08 11:48:37 +02:00
syntaxbullet
838567e9b8 feat: enhance habit tracking with pending state management and history refresh 2026-09-08 09:25:40 +02:00
syntaxbullet
f79d593a04 feat: add private day, history and reminder command graphics 2026-09-08 08:58:34 +02:00
syntaxbullet
d088179c5e docs: record integrated Discord command suite and activation 2026-09-08 08:46:58 +02:00
syntaxbullet
2c135398d7 feat: render focused 30-day Discord progress cards with Canvas 2026-09-08 08:45:48 +02:00
syntaxbullet
a1ae65634b feat: add private Discord progress and explicit shared delivery controls 2026-09-08 08:45:48 +02:00
syntaxbullet
5dbec04b04 feat: add Discord reminder and timezone settings 2026-09-08 08:45:13 +02:00
syntaxbullet
3487488f7b feat: add Discord habit history and dated progress corrections 2026-09-08 08:44:50 +02:00
syntaxbullet
ab2aa74eba feat: manage routine tasks through Discord forms and recurrence controls 2026-09-08 08:44:32 +02:00
syntaxbullet
07a0f80fc0 feat: manage habits through private Discord controls 2026-09-08 08:44:16 +02:00
syntaxbullet
8b9bd75f24 feat: polish Discord daily check-ins and add reminder buttons 2026-09-08 08:44:01 +02:00
syntaxbullet
2671fe0f34 feat: add Discord help command for servers and DMs 2026-09-08 08:33:34 +02:00
syntaxbullet
0a34142b3e feat: add safe global Discord command registration 2026-09-08 08:17:26 +02:00
syntaxbullet
4f38bf1431 feat: add signed Discord command foundation and daily check-ins 2026-09-08 08:15:22 +02:00
syntaxbullet
8e846fa11b Add named reminders with multiple times and day schedules 2026-09-08 07:36:49 +02:00
syntaxbullet
8ba23961ef docs: clarify safe deployment controller updates 2026-09-07 13:08:56 +02:00
syntaxbullet
6dad98d230 fix: preserve runtime-readable deployment sources 2026-09-07 13:03:52 +02:00
syntaxbullet
2598a5b350 deploy: use reliable HTTPS transport for Gitea sync 2026-09-07 13:01:32 +02:00
syntaxbullet
b20af6d95c deploy: automatically test and release pushed main commits 2026-09-07 12:57:42 +02:00
syntaxbullet
5f36358709 docs: record font deployment and development write protection 2026-09-07 11:58:45 +02:00
syntaxbullet
1721ee469d fix: bundle consistent fonts for share card rendering 2026-09-07 11:57:23 +02:00
syntaxbullet
e6002ad619 fix: block development writes to Discord channels 2026-09-07 11:57:23 +02:00
syntaxbullet
6d1d12953c deploy: add Docker and Caddy production stack 2026-09-07 11:50:22 +02:00
syntaxbullet
50d7838de3 fix: support Discord sign-in behind a trusted TLS proxy 2026-09-07 11:50:22 +02:00
syntaxbullet
c7aa5a1c65 feat: add live Discord sharing functionality with dynamic habit progress images
- Implemented live card image generation for user habits using canvas.
- Created API endpoints for starting and managing live Discord sharing sessions.
- Added tests for live Discord sharing to ensure functionality and reliability.
- Introduced CSS styles for the live Discord sharing interface.
- Enhanced error handling and state management for Discord message updates.
2026-09-07 11:36:47 +02:00
syntaxbullet
00729096bf feat: add theme provider and control components for light/dark mode
- Implemented ThemeProvider to manage theme state and preferences.
- Added ThemeControl component for users to switch between system, light, and dark themes.
- Integrated localStorage for theme preference persistence.
- Updated document styles based on theme changes.

test: add tests for permanent deletion of habits and related records

- Created tests to ensure permanent deletion of habits removes all dependent records.
- Verified that deletion requires ownership, authentication, and checks for archived habits.
- Added tests to confirm rollback behavior on deletion failures.

test: add tests for habit palette and progress card functionality

- Implemented tests for habit color progression and theme-based color shading.
- Added tests for progress card calculations and calendar display logic.

feat: define theme types and preferences

- Introduced Theme and ThemePreference types for better type safety.
- Created utility functions for resolving theme based on user preference and system settings.
- Defined theme palettes for light and dark modes to ensure adequate contrast.
2026-09-05 10:07:28 +02:00
syntaxbullet
9e9871c08a Keep habit tints readable and use shared typography 2026-09-05 08:13:58 +02:00
syntaxbullet
63e1573dae Give each habit completion type a distinct layout 2026-09-05 08:11:00 +02:00
syntaxbullet
2f67b775bf Keep calendar timeline stable when inspecting dates 2026-09-05 08:10:51 +02:00
syntaxbullet
1f017caee5 Remove redundant habit copy and document goal layouts 2026-09-05 08:07:55 +02:00
syntaxbullet
b832b5734e Center each habit layout on its completion action 2026-09-05 08:06:16 +02:00
syntaxbullet
3945998fa8 Keep narrow headings aligned and expanded history contained 2026-09-05 08:01:00 +02:00
syntaxbullet
7ebf936218 Polish dashboard hierarchy and unify daily controls 2026-09-05 07:58:35 +02:00
syntaxbullet
0d0aca5604 Document daily workflow and align starter color with compact palette 2026-09-05 07:54:26 +02:00
syntaxbullet
bfba95b1ed Verify compact Today flow and explicit history access 2026-09-05 07:52:42 +02:00
syntaxbullet
7ebbf4ed6c Bring daily check-ins forward with compact workspace layout 2026-09-05 07:52:28 +02:00
syntaxbullet
419bdce36b Improve count logging with direct editable totals 2026-09-05 07:51:25 +02:00
syntaxbullet
9f8154e412 Simplify habit creation with editable starters and compact colors 2026-09-05 07:51:22 +02:00
syntaxbullet
a2f5616b96 Keep habit history behind an accessible disclosure 2026-09-05 07:50:41 +02:00
184 changed files with 14390 additions and 701 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.env
.env.*
node_modules
dist
data
coverage
*.sqlite*
*.db*
*.log
.DS_Store

View File

@@ -25,3 +25,6 @@ BACKUP_RETAIN=7
BACKUP_REPLICA_DIR=
# Optional public HTTPS health URL for the independent monitor.
MONITOR_URL=
# Discord Developer Portal > General Information > Public Key.
# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions
DISCORD_PUBLIC_KEY=

View File

@@ -1,5 +1,8 @@
# Loaded by bun start and bun run db:migrate:production.
# Set your public origin, e.g. https://habits.example.com
APP_ORIGIN=
# Enable only behind a private proxy that overwrites X-Forwarded-Proto.
TRUST_PROXY=false
# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1.
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.

4
Caddyfile Normal file
View File

@@ -0,0 +1,4 @@
mina.teppelin.com {
encode zstd gzip
reverse_proxy app:3000
}

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
ARG BUN_IMAGE=oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4
FROM ${BUN_IMAGE} AS build
WORKDIR /app
COPY package.json bun.lock bunfig.toml ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM ${BUN_IMAGE} AS dependencies
WORKDIR /app
COPY package.json bun.lock bunfig.toml ./
RUN bun install --frozen-lockfile --production
FROM ${BUN_IMAGE} AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/src ./src
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/drizzle ./drizzle
COPY package.json bunfig.toml ./
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 DATABASE_PATH=/data/minabot.sqlite
USER bun
EXPOSE 3000
CMD ["sh", "-c", "bun scripts/migrate.ts && exec bun scripts/start.ts"]

4
PRD.md
View File

@@ -1,11 +1,11 @@
# Minabot — Habit Tracking PRD
# Mina Habits — Habit Tracking PRD
Status: Initial product requirements
Date: 2026-09-04
## Application intent
Minabot helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
Mina Habits helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
Individual charts answer, “How much progress did I make on this habit?” Combined charts answer, “How many of the habits due that day did I fully complete?” Partial progress is visible and useful, but does not count as a completed habit in a combined chart.

View File

@@ -1,4 +1,8 @@
# Minabot
# Mina Habits
The production app is installable as a PWA, with an offline reconnect screen and
user-controlled updates. Habit data and changes require a connection. See
[PWA behavior and verification](docs/PWA.md).
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.
@@ -67,7 +71,9 @@ its own recurrence. Both habit and task schedules support daily, selected weekda
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
starting today. **Delete permanently** removes an archived habit and all its history
after confirmation; it also removes the habit from combined charts and deletes
charts left empty. 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
@@ -81,6 +87,28 @@ checkboxes, or task occurrences using that date’s saved requirements. A date p
also reaches history outside the displayed year. Future dates and days off are read-only.
Combined-chart management is not exposed on this homepage.
### Daily check-in experience
The dashboard opens with a compact greeting and a Today filter. All habits includes
routines on days off; creating a habit or editing one out of today’s schedule keeps
it reachable in All habits. Every habit shows its calendar immediately, starting with three months and offering
longer ranges. Historical corrections are available by selecting a day. Selecting a square
only changes the inspected day; it preserves the date range and number of weeks,
including on untracked, unscheduled, and future dates. An external editor date
outside a preset window can still bring that date into view.
Each saved habit shares a serif heading, type label, color marker, schedule, and
explicit completion status. A visible graph sits beside a neutral completion panel:
count goals show their target, unit, and editable counter; check-ins show a labeled
checkbox; routines show an open task list and progress. On narrow screens, completion
controls stack above the graph. Spacing, typography, and neutral surfaces use the
shared design-system tokens; habit color marks identity and progress.
Count totals can be typed directly and saved with **Save** or Enter. Escape restores
the recorded total; plus and minus remain available. Counts may exceed the target.
New habits offer editable Reading, Get outside, and Evening routine starters, with
eight suggested colors and **More colors** for the full palette and custom colors.
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
@@ -355,29 +383,49 @@ existing delivery does not consume quota. Rejected attempts consume quota too;
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
### 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.
Open **Settings → Reminders** to configure up to 20 reminders, each with an optional
name (up to 80 characters), local time, and selected weekdays or a specific date.
Select all seven days for a daily reminder. Discord messages include any reminder names that were set, along with the
unfinished-habit count and app link. Combined nudges include the applicable names. Reminders
only send when habits remain unfinished, and times follow the account timezone.
Quiet-hour start is inclusive, end is exclusive, and equal times disable quiet
hours. Overdue reminders are combined into one nudge when quiet hours end or the
server resumes, then later scheduled times continue normally. Overlapping entries
at the same time produce one nudge. Missed reminders never carry into another day.
Editing a name or removing and re-adding a time does not resend an already delivered
slot that day; adding a past time can trigger a nudge on the next worker tick.
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.
a sharing channel is not required. Users must be reachable by the bot (normally a
shared Discord server and enabled DMs). Settings show the most recent delivery
result. Uncheck the option and save to opt out. Reminders are disabled 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.
requires authentication, matching Origin, and `{enabled, reminders, quietStart,
quietEnd}`. Times use `HH:MM`. Each reminder is either
`{type: "weekly", name?: string, time: "09:00", days: [1, 3, 5]}` (Sunday is 0) or
`{type: "date", name?: string, time: "18:00", date: "2026-09-08"}`.
The legacy single `time` input remains supported. Existing settings retain their
daily schedule; pre-upgrade deliveries prevent another nudge that same day.
Per-user/date/time reservations persist across restarts and concurrent workers.
Discord 429 retry deadlines are persisted; ambiguous sends are not retried for
that time slot. Later reminders can still send. Delivery records expire after
90 days. Account export includes schedules and names; restore disables reminders
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.
### Live Discord image
Open **Share progress → Live image · 16:9** to preview/download a 1920×1080 PNG with every active habit and a rolling 30-day history. **Share live image** posts the attachment through the configured bot; later changes replace the attachment on that same Discord message after a two-second trailing debounce. There is no public live link. Display name, habit names, and progress are shared; task details remain private. Newly created active habits are included automatically.
Rendering and updates run on the server even when the dashboard is closed. Identical card content skips Discord edits. Message IDs, themes, and retry state persist in SQLite; known messages resume after restart. Rate-limited edits honor Discord's retry delay. When an update confirms the old message was deleted, the bot posts a replacement and saves its new message ID. Missing channels or permissions do not trigger replacement posts. An unconfirmed initial or replacement post is never automatically repeated. **Stop updates** leaves the last image in Discord; **Resume updates** edits the original message again.
Development uses `bun --watch` so source edits restart the process and recreate the background worker; in-process hot reload can leave its timers stalled. The worker assumes one Bun API process per database. Reverse proxies need no special streaming configuration. Run the normal migrations/build/start workflow to install the live-image table and bundle its font; the server uses `@napi-rs/canvas` for PNG rendering. The previous experimental public-link table is removed by migration 0010.

View File

@@ -1,6 +1,7 @@
import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import { rm, mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { buildPwa } from "./scripts/build-pwa";
const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true });
@@ -10,7 +11,9 @@ const result = await Bun.build({
outdir,
plugins: [tailwind],
minify: true,
splitting: true,
target: "bun",
external: ["@napi-rs/canvas", "/fonts/browser/*"],
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
@@ -24,3 +27,15 @@ if (!result.success) {
for (const output of result.outputs) {
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
}
await mkdir(path.join(outdir, "fonts"), { recursive: true });
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
}
await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
}
await buildPwa(outdir, result.outputs);

4
bun-env.d.ts vendored
View File

@@ -9,6 +9,10 @@ declare module "*.svg" {
}
declare module "*.css" {}
declare module "*.webmanifest" {
const content: string;
export default content;
}
declare module "*.png" {
const path: string;
export default path;

View File

@@ -3,8 +3,9 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"name": "mina-habits",
"dependencies": {
"@napi-rs/canvas": "^1.0.8",
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
@@ -25,6 +26,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2",
},
@@ -89,6 +91,30 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.8", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.8", "@napi-rs/canvas-darwin-arm64": "1.0.8", "@napi-rs/canvas-darwin-x64": "1.0.8", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", "@napi-rs/canvas-linux-arm64-musl": "1.0.8", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-musl": "1.0.8", "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", "@napi-rs/canvas-win32-x64-msvc": "1.0.8" } }, "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.8", "", { "os": "android", "cpu": "arm64" }, "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.8", "", { "os": "linux", "cpu": "arm" }, "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.8", "", { "os": "linux", "cpu": "none" }, "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ=="],
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
@@ -265,6 +291,10 @@
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],

View File

@@ -3,5 +3,5 @@
env = false
[serve.static]
plugins = ["bun-plugin-tailwind"]
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
env = "BUN_PUBLIC_*"

61
compose.yaml Normal file
View File

@@ -0,0 +1,61 @@
name: minabot
services:
app:
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
env_file:
- path: .env.production
format: raw
volumes:
- /srv/minabot/data:/data
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
healthcheck:
test: [CMD, bun, -e, "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
logging:
driver: local
options:
max-size: 10m
max-file: "3"
caddy:
image: ${CADDY_IMAGE:?Set CADDY_IMAGE to a pinned image digest}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
app:
condition: service_healthy
logging:
driver: local
options:
max-size: 10m
max-file: "3"
monitor:
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
command: [bun, scripts/monitor.ts]
environment:
MONITOR_URL: https://mina.teppelin.com/api/health
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
read_only: true
logging:
driver: local
options:
max-size: 10m
max-file: "3"
volumes:
caddy_data:
caddy_config:

2
deploy.env.example Normal file
View File

@@ -0,0 +1,2 @@
APP_IMAGE=minabot:RELEASE
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d

79
deploy/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Automatic production deployment
The server checks `https://git.ayau.me/syntaxbullet/minabot.git`, branch `main`, every
minute after the last check finishes. A local commit is deployed after it is
pushed to Gitea. Other branches are not deployed. If several commits arrive
between checks, the latest fetched main commit is built.
```sh
git push origin main
```
The repository is public, so the server fetches over HTTPS without credentials.
Gitea's SSH transport disconnected during transfer, so this checkout also pushes
over HTTPS. Its repository-local credential helper reads the existing `ayau` Tea
login only for this exact HTTPS host/repository; it stores no additional token copy.
The personal Mac SSH key and Tea token are not copied to the server. If the repository
becomes private, provision repository-scoped read credentials before the change.
The pipeline fetches into a bare repository and archives an exact commit into
`/opt/minabot/releases/<sha>`. It builds a test image, runs typecheck and the full
test suite, builds the runtime image, runs a disposable production smoke test,
and validates Compose/Caddy. Secrets and production SQLite stay on the server;
they are never synced from Git or development during a release.
After checks pass, Caddy and the old app stop briefly, a verified database snapshot
is taken, and the new app starts before Caddy reopens public access. Discord workers
never overlap. A lock prevents concurrent manual/timer deployments. Successful
commits are recorded in `/opt/minabot/deployed-sha` and skipped on later checks.
A build/test failure leaves the old service running. Failed revisions are recorded
in `failed-sha` and skipped until a new commit or an explicit retry. On startup
failure, the old configuration/image is restored only if the schema and migration
fingerprint is unchanged. Otherwise services remain stopped for operator recovery;
the script never overwrites the production database with an old snapshot.
Changes to `.env.production` or Caddy's pinned image remain explicit server operations.
## Server operations
```sh
# Check status and recent deployment output
systemctl status minabot-deploy.timer minabot-deploy.service
journalctl -u minabot-deploy.service -n 100 --no-pager
cat /opt/minabot/deployed-sha
# Run a check now / retry a previously failed revision
sudo systemctl start minabot-deploy.service
/usr/local/bin/minabot-deploy --retry
# Pause / resume automatic deployment
sudo systemctl stop minabot-deploy.timer
sudo systemctl start minabot-deploy.timer
```
Build/test logs are inside each release directory. Retained release images and
Docker build cache need occasional housekeeping; the script refuses a build when
less than 2 GiB is free. Keep the current and previous known-good image and backups.
Failures are logged locally; external alerts are not configured.
To install or update the controller from a reviewed checkout on the server:
```sh
sudo systemctl stop minabot-deploy.timer
# Let any running deployment finish before replacing its controller.
sudo systemctl start minabot-deploy.service
sudo install -m 755 deploy/deploy.sh /usr/local/bin/minabot-deploy
sudo install -m 644 deploy/minabot-deploy.service deploy/minabot-deploy.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now minabot-deploy.timer
```
Controller/service changes require this explicit installation; normal app,
Dockerfile, Compose, and Caddyfile changes deploy automatically. Do not force-reset
`main` to roll back: prefer a revert commit so the pipeline tests the result.
For first-time installation, omit the first two commands if the units do not exist.
`bun test deploy/deploy.test.ts` verifies success, idempotency, failure before
cutover, safe rollback, and refusal to replace data after a schema change using
isolated fake Git/Docker commands. It never contacts production.

110
deploy/deploy.sh Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Installed on the server as /usr/local/bin/minabot-deploy.
set -Eeuo pipefail
umask 077
root=${MINABOT_DEPLOY_ROOT:-/opt/minabot}
cd "$root"
exec 9>deploy.lock
flock -n 9 || exit 0
# Preserve journal stderr even when a failing build function redirects its logs.
exec 4>&2
export GIT_TERMINAL_PROMPT=0
repo="$root/git-sync.git"
target=''
switching=false
schema_before=''
previous_image=''
docker_cmd() { sudo -n docker "$@" </dev/null; }
compose() { docker_cmd compose --project-directory "$root" -f "$root/compose.yaml" --env-file "$root/.env" "$@"; }
schema_fingerprint() {
docker_cmd run --rm --network none -v /srv/minabot/data:/data:ro "$1" bun -e '
import {Database} from "bun:sqlite";
const db=new Database("/data/minabot.sqlite",{readonly:true});
const schema=db.query("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type,name").all();
const migrations=db.query("SELECT * FROM __drizzle_migrations ORDER BY id").all();
console.log(new Bun.CryptoHasher("sha256").update(JSON.stringify({schema,migrations})).digest("hex"));
db.close();'
}
failed() {
local result=$?
trap - ERR
set +e
printf 'Deployment failed: %s (exit %s)\n' "${target:-fetch}" "$result" >&4
printf 'Build and test logs: %s\n' "${release:-$root}" >&4
if [[ -n "$target" ]]; then printf '%s\n' "$target" > failed-sha; fi
if $switching; then
compose stop caddy app monitor
local schema_after
schema_after=$(schema_fingerprint "$previous_image")
if [[ -n "$schema_before" && "$schema_before" == "$schema_after" ]]; then
cp "$rollback/.env" .env
cp "$rollback/compose.yaml" compose.yaml
cp "$rollback/Caddyfile" Caddyfile
if compose up -d --wait --wait-timeout 120; then
echo 'Previous release restored; database was not replaced.' >&2
else
echo 'Rollback failed; inspect the journal and saved release configuration.' >&2
fi
else
echo "Database migrations changed or could not be verified. Services remain stopped; inspect $rollback and /srv/minabot/data/backups before recovery. No database was overwritten." >&2
fi
fi
exit "$result"
}
trap failed ERR
if [[ ! -d "$repo" ]]; then
git init --bare "$repo"
git --git-dir="$repo" remote add origin https://git.ayau.me/syntaxbullet/minabot.git
fi
timeout 90 git --git-dir="$repo" fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
target=$(git --git-dir="$repo" rev-parse refs/remotes/origin/main)
[[ "$target" =~ ^[0-9a-f]{40}$ ]]
[[ "$target" != "$(cat deployed-sha 2>/dev/null || true)" ]] || exit 0
if [[ "${1:-}" != '--retry' && "$target" == "$(cat failed-sha 2>/dev/null || true)" ]]; then exit 0; fi
echo "Testing main at $target"
available_kb=$(df -Pk "$root" | awk 'NR==2 {print $4}')
if (( available_kb < 2097152 )); then echo 'Less than 2 GiB free; refusing to build.' >&2; false; fi
release="$root/releases/$target"
mkdir -p "$release"
# Source files must remain readable by the non-root runtime after Docker COPY.
# Keep secrets/state private via the outer umask; apply 022 only to Git sources.
git --git-dir="$repo" archive "$target" | (umask 022; tar -x -C "$release")
image="minabot:git-${target:0:12}"
checks="$image-checks"
docker_cmd build --target build -t "$checks" "$release" > "$release/build-checks.log" 2>&1
docker_cmd run --rm "$checks" sh -c 'bun run typecheck && bun test' > "$release/tests.log" 2>&1
docker_cmd build -t "$image" "$release" > "$release/build-runtime.log" 2>&1
docker_cmd run --rm "$image" bun scripts/api-smoke.ts > "$release/smoke.log" 2>&1
docker_cmd image rm "$checks" > /dev/null
previous_image=$(sed -n 's/^APP_IMAGE=//p' .env)
[[ -n "$previous_image" ]]
awk -v image="$image" '/^APP_IMAGE=/ {$0="APP_IMAGE=" image} {print}' .env > next.env
docker_cmd compose --project-directory "$root" -f "$release/compose.yaml" --env-file "$root/next.env" config --quiet
caddy_image=$(sed -n 's/^CADDY_IMAGE=//p' .env)
docker_cmd run --rm -v "$release/Caddyfile:/etc/caddy/Caddyfile:ro" "$caddy_image" caddy validate --config /etc/caddy/Caddyfile > "$release/caddy-check.log" 2>&1
rollback="$release/previous"
mkdir -p "$rollback"
cp .env compose.yaml Caddyfile "$rollback/"
echo "Switching to $target"
# Prevent user writes during migration and never overlap Discord workers.
# Obtain the fingerprint before stopping so failure here leaves production running.
schema_before=$(schema_fingerprint "$previous_image")
switching=true
compose stop caddy app
compose run --rm --no-deps --interactive=false --entrypoint bun app scripts/backup.ts > "$rollback/backup-path.txt"
cp "$release/compose.yaml" compose.yaml
cp "$release/Caddyfile" Caddyfile
mv next.env .env
compose up -d --wait --wait-timeout 120 app monitor
compose up -d --wait --wait-timeout 120 caddy
curl --fail --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 --max-time 15 https://mina.teppelin.com/api/health > "$release/public-health.json"
printf '%s\n' "$target" > deployed-sha.tmp
mv deployed-sha.tmp deployed-sha
rm -f failed-sha
docker_cmd image inspect "$image" --format '{{.Id}}' > release-image-id
switching=false
echo "Deployed $target"

93
deploy/deploy.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import { expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
const revision = 'b'.repeat(40);
const stub = `#!${process.execPath}
import {basename} from 'node:path';
import {appendFileSync,mkdirSync,existsSync,writeFileSync,statSync} from 'node:fs';
const tool=basename(process.argv[1]), args=process.argv.slice(2), root=process.env.STUB_ROOT, mode=process.env.STUB_MODE;
appendFileSync(root+'/calls.jsonl',JSON.stringify({tool,args})+'\\n');
if(tool==='flock') process.exit(0);
if(tool==='timeout') { const r=Bun.spawnSync(args.slice(1),{env:process.env});process.stdout.write(r.stdout);process.stderr.write(r.stderr);process.exit(r.exitCode); }
if(tool==='git') {
if(args.includes('init')) mkdirSync(args.at(-1),{recursive:true});
if(args.includes('rev-parse')) console.log('${revision}');
if(args.includes('archive')) { const r=Bun.spawnSync(['tar','-cf','-','-C',process.env.STUB_SOURCE,'.']);process.stdout.write(r.stdout);process.exit(r.exitCode); }
process.exit(0);
}
if(tool==='curl') { console.log('{"status":"ok"}');process.exit(0); }
if(tool==='sudo') {
if(args.includes('build') && !(statSync(args.at(-1)+'/Caddyfile').mode & 4)) {console.error('Source unreadable to runtime user');process.exit(2);}
if(args.includes('build') && mode==='build-failure') process.exit(1);
if(args.some(a=>a.includes('SELECT type,name,sql'))) { console.log(mode==='migration-failure' && existsSync(root+'/switched')?'schema-new':'schema-old');process.exit(0); }
if(args.includes('compose') && args.includes('up') && args.includes('app')) {
writeFileSync(root+'/switched','yes');
if(['switch-failure','migration-failure'].includes(mode)) process.exit(1);
}
if(args.includes('scripts/backup.ts')) console.log('/data/backups/safety.sqlite');
if(args.includes('inspect')) console.log('sha256:test-image');
process.exit(0);
}
process.exit(2);
`;
async function scenario(mode: string, initial?: 'unchanged' | 'failed') {
const root = mkdtempSync(join(tmpdir(), 'minabot-deploy-test-'));
const bin = join(root, 'bin'), source = join(root, 'source');
mkdirSync(bin); mkdirSync(source);
for (const name of ['git', 'sudo', 'timeout', 'flock', 'curl']) writeFileSync(join(bin, name), stub, { mode: 0o755 });
writeFileSync(join(root, '.env'), 'APP_IMAGE=minabot:previous\nCADDY_IMAGE=caddy:pinned\n');
writeFileSync(join(root, '.env.production'), 'SECRET=must-stay-server-side\n');
writeFileSync(join(root, 'compose.yaml'), 'old-compose');
writeFileSync(join(root, 'Caddyfile'), 'old-caddy');
writeFileSync(join(root, 'deployed-sha'), initial === 'unchanged' ? revision : 'a'.repeat(40));
if (initial === 'failed') writeFileSync(join(root, 'failed-sha'), revision);
writeFileSync(join(source, 'compose.yaml'), 'new-compose');
writeFileSync(join(source, 'Caddyfile'), 'new-caddy');
try {
const child = Bun.spawn(['bash', new URL('./deploy.sh', import.meta.url).pathname], {
env: { ...process.env, PATH: `${bin}:${dirname(process.execPath)}:${process.env.PATH}`, MINABOT_DEPLOY_ROOT: root, STUB_ROOT: root, STUB_SOURCE: source, STUB_MODE: mode },
stdout: 'pipe', stderr: 'pipe',
});
const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
const calls = readFileSync(join(root, 'calls.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
return { code, stdout, stderr, calls, env: readFileSync(join(root, '.env'), 'utf8'), secret: readFileSync(join(root, '.env.production'), 'utf8'), deployed: readFileSync(join(root, 'deployed-sha'), 'utf8').trim(), failed: existsSync(join(root, 'failed-sha')) };
} finally { rmSync(root, { recursive: true, force: true }); }
}
test('deploys tested main, preserves secrets, and stops old workers before starting new ones', async () => {
const r = await scenario('success');
expect(r.code).toBe(0); expect(r.deployed).toBe(revision);
expect(r.env).toContain('APP_IMAGE=minabot:git-bbbbbbbbbbbb');
expect(r.secret).toBe('SECRET=must-stay-server-side\n');
const stop = r.calls.findIndex(c => c.tool === 'sudo' && c.args.includes('stop'));
const up = r.calls.findIndex(c => c.tool === 'sudo' && c.args.includes('up'));
expect(stop).toBeGreaterThan(0); expect(up).toBeGreaterThan(stop);
});
test('unchanged and previously failed revisions do no Docker work', async () => {
for (const initial of ['unchanged', 'failed'] as const) {
const r = await scenario('success', initial);
expect(r.code).toBe(0); expect(r.calls.some(c => c.tool === 'sudo')).toBe(false);
}
});
test('build failure leaves the current deployment running', async () => {
const r = await scenario('build-failure');
expect(r.code).not.toBe(0); expect(r.failed).toBe(true);
expect(r.env).toContain('APP_IMAGE=minabot:previous');
expect(r.calls.some(c => c.args.includes('stop'))).toBe(false);
});
test('startup failure rolls back configuration without restoring the database', async () => {
const r = await scenario('switch-failure');
expect(r.code).not.toBe(0); expect(r.env).toContain('APP_IMAGE=minabot:previous');
expect(r.stderr).toContain('database was not replaced');
expect(r.deployed).toBe('a'.repeat(40));
});
test('a changed migration fingerprint stops instead of overwriting production data', async () => {
const r = await scenario('migration-failure');
expect(r.code).not.toBe(0); expect(r.failed).toBe(true);
expect(r.stderr).toContain('No database was overwritten');
expect(r.deployed).toBe('a'.repeat(40));
expect(r.calls.filter(c => c.tool === 'sudo' && c.args.includes('up'))).toHaveLength(1);
});

View File

@@ -0,0 +1,12 @@
// Repository-local HTTPS credential helper. Git consumes stdout; never log it.
import { join } from 'node:path';
if (process.argv[2] !== 'get') process.exit(0);
const fields = Object.fromEntries((await Bun.stdin.text()).trim().split('\n').map(line => {
const index = line.indexOf('='); return [line.slice(0, index), line.slice(index + 1)];
}));
if (fields.protocol !== 'https' || fields.host !== 'git.ayau.me' || fields.path !== 'syntaxbullet/minabot.git') process.exit(0);
const base = process.env.XDG_CONFIG_HOME || join(process.env.HOME!, 'Library', 'Application Support');
const config = Bun.YAML.parse(await Bun.file(join(base, 'tea', 'config.yml')).text()) as { logins?: { name: string; url: string; user: string; token: string }[] };
const login = config.logins?.find(login => login.name === 'ayau' && login.url.replace(/\/$/, '') === 'https://git.ayau.me');
if (!login?.token || !login.user || /[\r\n]/.test(login.token + login.user)) process.exit(1);
process.stdout.write(`username=${login.user}\npassword=${login.token}\n\n`);

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Test and deploy Minabot main from Gitea
Wants=network-online.target
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=oneshot
User=syntaxbullet
WorkingDirectory=/opt/minabot
ExecStart=/usr/local/bin/minabot-deploy
TimeoutStartSec=30min
UMask=0077

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Check Gitea main for Minabot updates every minute
[Timer]
OnBootSec=60s
OnUnitInactiveSec=60s
AccuracySec=5s
Unit=minabot-deploy.service
[Install]
WantedBy=timers.target

View File

@@ -20,6 +20,7 @@ Run `bun dev` to migrate the local database and serve the API. Run `bun run test
| GET | `/habits/:id` | Current habit configuration and revision ID |
| PATCH | `/habits/:id` | Change name, method, method settings, schedule or archived flag |
| DELETE | `/habits/:id` | Archive from today, preserving history; 204 |
| DELETE | `/habits/:id/permanent` | Permanently delete an owned archived habit and all its history; 204. Active habits return 409. Remove it from combined charts and delete charts left empty. |
| GET | `/habits/:id/history` | `{revisions: [...]}` newest first, including superseded same-day revisions |
| GET | `/habits/:id/tasks` | `{tasks: [...]}` for current task method |
| POST | `/habits/:id/tasks` | Add task; 201 |

109
docs/DEPLOYMENT-PLAN.md Normal file
View File

@@ -0,0 +1,109 @@
# Minabot deployment plan
Target: `https://mina.teppelin.com` on `syntaxbullet@46.62.156.93`.
Prepared September 7, 2026. Executed the same day; see
[production operations](PRODUCTION.md) for the deployed configuration and results.
## Verified baseline
- Ubuntu 26.04.1, x86_64, 3.7 GiB RAM, approximately 34 GiB free disk.
- DNS A resolves to this server; no AAAA answer was returned.
- Docker and Caddy are not installed. Only SSH is publicly listening.
- SSH keys work, password authentication is disabled, UFW and Fail2ban are active.
- Passwordless sudo is installed in `/etc/sudoers.d/90-syntaxbullet` and verified
with `sudo -k` followed by `sudo -n id` in a fresh SSH connection.
- The checkout has uncommitted live Discord image changes and migrations 0009/0010.
Include and validate the intended complete release before tagging it.
## Architecture
Internet → Caddy container on TCP 80/443 → app container on port 3000 → SQLite.
Use Docker Compose with one app process and one Caddy container on a shared bridge
network. The app needs outbound access to Discord. Publish only Caddy's ports;
do not publish app port 3000 or mount the Docker socket into either container.
Keep reminders, backups, and live Discord updates in the existing app process.
Multiple app replicas are unsuitable for the current live-image worker.
Use `/opt/minabot` for deployment configuration and tagged releases,
`/srv/minabot/data` mounted as `/data` for SQLite and snapshots, and persistent
Docker volumes for Caddy `/data` and `/config`. Match data ownership to the app's
non-root container UID. Use `restart: unless-stopped`, bounded container logs,
a health check, and a sufficient startup grace period for migrations/backups.
An unhealthy health check alone does not restart a container; monitor it separately.
## Implementation sequence
1. Add `HOST` configuration to `src/index.ts`, retaining `127.0.0.1` locally and
using `HOST=0.0.0.0` inside Docker. The current hard-coded loopback binding
prevents Caddy from reaching the app across containers.
2. Add a multi-stage Dockerfile, `.dockerignore`, Compose file, Caddyfile, and
deployment environment example. Pin tested Bun/Caddy versions and image digests.
Build Linux amd64 dependencies in the image using the frozen lockfile; never
copy macOS `node_modules`. Exclude real dotenv files, databases, backups, and Git.
3. Preserve the existing startup contract: migrations must succeed before
`scripts/start.ts` loads `dist/index.js`. Include migrations and required script
imports, production dependencies, built assets, and the bundled Instrument Serif
font. Use a glibc-based Bun runtime with a sans-serif font installed, and verify
the native `@napi-rs/canvas` renderer inside that exact image.
4. Install Docker Engine and Compose from Docker's official Ubuntu repository.
Ubuntu 26.04 is supported. Keep Docker's firewall management enabled; published
ports can bypass UFW. Publish only 80/443 and verify exposure externally. Align
the existing Fail2ban nftables action with the chosen Docker firewall backend
(prefer an iptables-compatible SSH action for the standard Docker backend),
then recheck SSH protection. Open TCP 80/443 in UFW and any Hetzner firewall.
5. Put runtime secrets in `/opt/minabot/.env.production` with mode 0600 and inject
through Compose `env_file`. Set `NODE_ENV=production`, `HOST=0.0.0.0`, `PORT=3000`,
`APP_ORIGIN=https://mina.teppelin.com`, and `DATABASE_PATH=/data/minabot.sqlite`.
Supply `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, a stable random
`AUTH_COOKIE_SECRET`, and the bot/channel settings. Do not bake secrets into
images or build arguments. Register this Discord OAuth redirect exactly:
`https://mina.teppelin.com/api/auth/discord/callback`.
6. Default to a fresh production database. If existing local data should be moved,
use a verified SQLite snapshot, not a live database-file copy. Avoid running
two workers against copied live Discord message state. Resolve the data choice
before first production startup.
7. Build and tag the release, start it on the private Docker network, verify its
health, then start Caddy. Caddy obtains and renews TLS certificates and redirects
HTTP to HTTPS. Persist its certificate storage across container replacement.
Proposed Caddyfile:
```caddyfile
mina.teppelin.com {
encode zstd gzip
reverse_proxy app:3000
}
```
## Verification and operations
- Before release: typecheck, tests, build, and disposable production API smoke test.
Validate Compose and Caddy configuration; run migrations, health, static assets,
native PNG rendering, and a restart/persistence test inside the Linux image.
- After launch: verify public HTTPS, HTTP redirect, `/api/health`, Discord sign-in,
secure cookies, habit persistence, and port 3000 being unreachable externally.
Exercise bot workers with stubbed transports; a live Discord post or DM needs
an explicitly selected test destination and authorization to send it.
- Keep daily verified snapshots and seven retained copies using the existing app
backup system. Configure an off-server destination separately; none is provisioned
yet. Test restore to a new database with the matching image before relying on it.
- Configure an independent HTTPS monitor and alert destination. Existing monitor
scripts provide checks but do not provision an external monitoring service.
- Updates: build/test the next immutable image, snapshot the database, stop the
old app, and start the new release. Accept brief downtime; do not overlap workers.
Keep the previous image. If schema compatibility prevents image-only rollback,
stop the app and restore its matching pre-migration snapshot to a new file.
The restore tool revokes sessions and disables reminders; audit restored live
Discord state before starting workers. Never use `docker compose down -v`.
Remaining launch inputs: fresh database versus migration, Discord redirect setup
and production credentials, off-server backup destination, and monitoring alerts.
## References
- [Docker Ubuntu installation](https://docs.docker.com/engine/install/ubuntu/)
- [Docker firewall behavior](https://docs.docker.com/engine/network/packet-filtering-firewalls/)
- [Bun Docker guide](https://bun.sh/guides/ecosystem/docker)
- [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https)
- [Existing backup and recovery workflow](OPERATIONS.md)

View File

@@ -1,5 +1,8 @@
# Deployment operations
See [production deployment](PRODUCTION.md) for the Docker/Caddy server, release,
data paths, verification results, and current backup/monitoring limitations.
## Backups and recovery
Production creates a verified SQLite snapshot before migrations and automatically

102
docs/PRODUCTION.md Normal file
View File

@@ -0,0 +1,102 @@
# Minabot production
Live: https://mina.teppelin.com
SSH: `ssh syntaxbullet@46.62.156.93` (key authentication; passwordless sudo).
Deployment root: `/opt/minabot`. Compose manages `app`, `caddy`, and `monitor`.
Docker starts at boot; containers use `restart: unless-stopped`.
New pushes to Gitea `main` are now checked and deployed by a systemd timer.
See [automatic deployment](../deploy/README.md) for checks, rollback, and controls.
The current deployed commit is `/opt/minabot/deployed-sha`; the initial image below
is the baseline before automatic deployment was enabled.
## Release and configuration
Current image: `minabot:20260907-fonts-1`, built on the Linux amd64 server from
commit `1721ee4`, including development write protection and bundled share fonts.
Its immutable image ID is recorded by `docker image inspect`; source is retained
under `/opt/minabot/releases/20260907-fonts-1`, with `source.tar.gz` and build
logs in `/opt/minabot`. Bun 1.3.14 and Caddy images are pinned by digest.
`/opt/minabot/.env` selects release images. `/opt/minabot/.env.production` contains
runtime secrets (0600). Discord client and bot credentials were copied from the
effective development environment; production has a separate generated cookie
signing secret. Runtime uses `APP_ORIGIN=https://mina.teppelin.com`,
`TRUST_PROXY=true`, `HOST=0.0.0.0`, and `DATABASE_PATH=/data/minabot.sqlite`.
`TRUST_PROXY` only accepts HTTPS from `X-Forwarded-Proto`, retains the actual
request Host, and must only be enabled behind the private Caddy proxy. Port 3000
is not published. Caddy exposes TCP 80/443 and persists certificate state in
`minabot_caddy_data` and `minabot_caddy_config`. SSH remains on 22. Fail2ban uses
the iptables-compatible SSH action alongside Docker/UFW.
The registered Discord callback is
`https://mina.teppelin.com/api/auth/discord/callback`; localhost remains registered.
## Data and backups
`/srv/minabot/data` is bind-mounted at `/data`, owned by UID 1000. A verified
`VACUUM INTO` snapshot copied the development database, including its one account,
four habits, history, reminders, and live Discord state. The transfer hash and
counts are recorded in `/opt/minabot/migration-manifest.json`. Local development
was stopped before the snapshot so only production manages the existing live cards.
Development now blocks channel writes at both snapshot-send and live-card entry
points. It also skips copied live-card recovery and change scheduling. Preview and
download remain available. Stubbed tests inject their own transport/configuration;
real writes through the environment loader are enabled only in production.
Daily verified snapshots and pre-migration snapshots are stored under
`/srv/minabot/data/backups`, retaining seven snapshots. The restore rehearsal
passed using a separate file; production data was not replaced by the rehearsal.
Restore revokes sessions and disables reminder opt-ins; see [OPERATIONS.md](OPERATIONS.md).
Off-server backup replication and delivered uptime alerts are **not configured**:
the operator has no destinations set up yet. The monitor checks public HTTPS every
minute in a separate container on this server and writes failure/recovery events
to its logs. It cannot report a complete server outage to an external destination.
## Common commands
Run after SSH login:
```sh
cd /opt/minabot
sudo docker compose ps
sudo docker compose logs --tail=100 app caddy monitor
sudo docker compose exec -T app bun scripts/backup.ts
sudo docker compose exec -T monitor bun scripts/monitor.ts --once
```
When sending several shell commands over SSH stdin, redirect noninteractive
`docker compose exec` calls from `/dev/null` so they do not consume later commands.
For updates, build a new release tag, run the container smoke test, take a backup,
update `APP_IMAGE` in `.env`, stop the old app, then run
`sudo docker compose up -d --wait --wait-timeout 120`. Never run overlapping app
workers or `docker compose down -v`. Keep the last known-good image and a matching
pre-migration snapshot. The initial `-1` image predates the proxy fix and is not
a suitable rollback target for working production sign-in.
## Verified September 7, 2026
- Typecheck, build, 197 tests, and production HTTP smoke tests passed.
- The Linux runtime passed migrations, native 1920×1080 PNG rendering, and
persistence across a process restart using a disposable database.
- Public HTTPS health returns `{"status":"ok"}`; HTTP redirects to HTTPS.
- Browser Discord sign-in completed and displayed the migrated account and habits.
- Read-only authenticated production API/PNG checks passed; unauthenticated access
and cross-origin writes were rejected. Temporary verification sessions were removed.
- Snapshot integrity, restore rehearsal, and transferred database hash passed.
- Application port 3000 is unpublished and an external HTTP probe timed out.
- Docker and Fail2ban are active; runtime secrets and the SQLite file are mode 0600.
Live Discord test posts/DMs were not sent. Existing configured workers continue
their normal production behavior.
## Share fonts
Instrument Serif and Noto Sans (400/500/600) are bundled for both browser and native
canvas share renderers. Mac/Linux fixture text metrics match exactly; pixel-channel
differences are at most 2/255 from native rasterization. Missing native font files
fail startup. Renderer version 3 refreshes existing live cards with the new fonts.

103
docs/PWA.md Normal file
View File

@@ -0,0 +1,103 @@
# Progressive web app
Mina Habits has an installable production build with a public offline app shell.
Its manifest supplies a stable app identity and root scope, standalone display,
192px and 512px icons, a separate maskable icon, app colors and descriptions.
The HTML includes an Apple touch icon, mobile metadata, theme color and safe-area
support. Browser chrome follows the selected light or dark theme.
Install from **Install app** in the footer. Supporting browsers show their native
install prompt. Other browsers receive instructions for Add to Home Screen or
Add to Dock. Installed windows hide the install action.
## Offline behavior
After one successful online visit and service-worker installation, the application
shell, fonts, icons and lazy browser chunks are available offline. Opening or
reloading the app offline shows a reconnect screen instead of the browser error
page. Nested routes also resolve correctly. Accounts retry on reconnection and
periodically while a failed connection is visible, including when the browser
incorrectly reports that it is online. An account request has a 10-second timeout;
page navigations fall back to the cached shell after four seconds.
**Account access, habit changes, sign-in, exports and Discord actions require a
connection.** Existing dashboard content can remain visible in an open window,
but no account responses are persisted for offline use and no mutations are
queued or replayed. Failed saves must be retried by the user. This is an offline
fallback, not offline habit editing or background synchronization.
All `/api` traffic, including OAuth callback navigations, bypasses the service
worker. API responses have `Cache-Control: no-store`. Only explicitly public
build assets enter Cache Storage; server JavaScript, source maps, account data,
exports and third-party responses are excluded. Clearing site data removes the
public offline files; it does not remove account records from the server.
## Build and updates
`bun run build` runs `scripts/build-pwa.ts` after Bun emits the browser and server
bundles. It derives a release identifier from the worker source and public asset
contents, copies a version-specific public HTML shell, and emits `dist/pwa/sw.js`
with an explicit precache list. A failed precache download prevents installation
of that release. The stable `/sw.js` route is served as JavaScript with
`Cache-Control: no-cache` and root service-worker scope.
Updates wait until existing windows close or the user chooses **Update app**,
then **Reload and update**. The confirmation tells the user to save unfinished
changes. Only the accepting window reloads; other windows retain their drafts
and receive their own update action. Old static caches remain available for their
lazy chunks. A subsequent activation with no open app windows removes obsolete
Mina Habits caches, leaving unrelated caches untouched. Checking for updates on
return to the app, reconnection and hourly supports long-lived installed windows.
The source manifest is `src/pwa/manifest.webmanifest`. Bun fingerprints its HTML
link; an explicit `/manifest.webmanifest` endpoint serves the same document. The
manifest's stable `id` keeps app identity independent of its asset filename.
Install icons reuse `docs/design/mina-habits-icon.png`; regenerate their exact
sizes with `bun scripts/pwa-icons.ts`. The maskable derivative adds opaque padding
to keep the monogram inside the central safe zone. Existing favicon and Apple
icons are preserved.
## Serving requirements
Use the normal production build/start or Docker workflow at the root of an HTTPS
origin. Local testing may use `http://localhost` or `http://127.0.0.1`. The current
production configuration already requires an HTTPS origin outside loopback and
uses Caddy for TLS. No new deployment environment variables are required.
Service-worker registration is deliberately disabled in development/HMR mode;
run the production smoke check to verify caching. Deploy the entire build
atomically, including `dist/pwa`, rather than uploading individual browser files.
Do not apply immutable HTTP caching to `/sw.js` or add a proxy cache for `/api`.
PWA installation support and its interface vary by browser. Push notifications,
background sync and app-store packaging are optional capabilities, not
installation requirements. This implementation follows the current
[MDN installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)
and the documented
[service-worker lifecycle](https://web.dev/articles/service-worker-lifecycle).
## Verification
```sh
bun run typecheck
bun test
bun x playwright install chromium
bun run test:pwa
bun run test:smoke
```
`test:pwa` copies the production build into a temporary directory, starts it with
a disposable SQLite database and no Discord credentials, then runs Chromium.
It verifies manifest parsing and Chrome installability errors, icon dimensions,
MIME/cache headers, first installation, offline startup and nested/lazy routes,
reconnection, responsive layouts, theme color, failed-update recovery, a two-window
update with an unsaved draft, API-cache exclusion, sign-out and mobile install
instructions. Screenshots are written to `.cache/pwa/`. Temporary data and the
server are removed when the check finishes. Worker unit tests cover routing,
installation failure and cache cleanup.
Before a public release, also exercise installation and standalone relaunch on a
physical iPhone/iPad and Android device over the production HTTPS origin. Desktop
Chromium checks and mobile emulation do not establish native OS installation or
Safari behavior. The implementation does not claim a universal PWA certification.

View File

@@ -1,4 +1,4 @@
# Minabot visual studies
# Mina Habits visual studies
Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are visual references, not data-accurate calendar renders. Two independent generations; no input images. The no-card, monochromatic editorial layouts informed the scoped design-system stylesheet and reusable components. Fonts are bundled locally under src/assets/fonts with their OFL license.
@@ -6,13 +6,13 @@ Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are v
Saved asset: `src/assets/design/today.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
## Habit detail
Saved asset: `src/assets/design/habit-detail.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across Jan–Sep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across Jan–Sep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
## Interactive implementation
@@ -42,4 +42,4 @@ The updated Today preview uses four independent habit charts in a two-column, mu
New saved asset: `src/assets/design/today-v2.png`. Original `today.png` is preserved; the gallery now uses v2. Built-in imagegen prompt:
Use case: ui-mockup. Asset type: high fidelity desktop Minabot habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "minabot", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.
Use case: ui-mockup. Asset type: high fidelity desktop Mina Habits habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "Mina Habits", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.

View File

@@ -0,0 +1,13 @@
# Mina Habits favicon
Generated with the built-in imagegen tool. Master: `mina-habits-icon.png`.
The application uses 32px and 180px PNG derivatives in `src/assets/`.
## Prompt
Use case: logo-brand
Asset type: production square favicon for Mina Habits, a minimal editorial habit tracker.
Primary request: a single bold, elegant uppercase M monogram, warm white on a solid near-black square background. Match a quiet serif wordmark and minimalist black-and-white interface. Strong thick strokes and subtle sturdy serifs, highly readable at 16x16 pixels.
Composition: centered M fills about 75% of square width and height, balanced narrow padding. Flat crisp 2D artwork, square canvas, no mockup.
Text (verbatim): "M"
Constraints: one icon only; no other letters, no caption, no gradients, shadows, texture, borders or decorative objects.

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

View File

@@ -0,0 +1,21 @@
# Discord sharing design
The shared image uses a separate, vertically stacked GitHub-style calendar for each selected habit. Calendar geometry comes from the dashboard's Sunday-first timeline helper; colors and partial progress come directly from the sharing API. Each total describes the entire selected date range. Days off remain dots and do not enter the denominator.
Short ranges pair a large period total with the calendar. Long ranges use the full image width for the uninterrupted timeline. Titles, calendars and legends occupy separate vertical regions, including at the tallest seven-row grid size. Text truncates within explicit bounds instead of colliding with neighboring counts. Preview, download and Discord use the same PNG, preserving the server's 1200px export contract.
The modal puts the image first, shows the Discord destination before sending, exposes quick period choices and keeps privacy details in an expandable section. The footer remains available during scrolling. Its native dialog retains keyboard focus containment, including the privacy disclosure. The thumbnail view uses a 360px image width; actual Discord sizing varies with the client and attachment aspect ratio. Cards containing many stacked calendars will require opening the image to inspect fine daily details.
## Research
- [GitHub: Introducing Contributions](https://github.blog/news-insights/product-news/introducing-contributions/): the calendar itself provides the recognizable visual history.
- [NN/g: Progressive Disclosure](https://www.nngroup.com/articles/progressive-disclosure/): defer optional settings while keeping the primary task clear.
- [Strava: Sharing Your Activities](https://support.strava.com/en-us/articles/15401840-sharing-your-strava-activities): a purpose-built image preview with a focused sharing action.
## Verification
Run `bun run typecheck`, `bun test` and `bun run build`.
For disposable browser testing, run `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` and open http://127.0.0.1:3107/__preview/login. This uses a temporary database and simulated Discord delivery, with no external posts.
Run `bun scripts/share-card-preview.ts` for the visual fixtures at http://127.0.0.1:3108. These cover long names, six calendars, privacy and no scheduled days at 320px, with a 390px iframe for mobile modal checks. Rendering tests cover date geometry, leap days, original shades, days off and whole-period totals.

66
docs/performance.md Normal file
View File

@@ -0,0 +1,66 @@
# Performance improvements
The September 2026 audit produced twelve changes, implemented in separate backend,
frontend, and worker worktrees and verified together before release.
## Changes
| Area | Improvement |
| --- | --- |
| Database reads | Fetch the latest day snapshots, tasks, and effective revisions for the requested dates. Carry-enabled counts look back to the necessary prior reset or log. |
| Synchronization | Skip already-materialized habits and unchanged marker writes; expire tasks using an index of open occurrences. |
| Catch-up | Materialize bounded batches with bulk task inserts, yield between batches, and serialize same-account operations until their data is ready. |
| Calendar payloads | `compact=true` omits task lists and repeated requirements. Dated editing endpoints retain full detail. |
| Calendar caching | Cache account-scoped ranges using persisted per-habit change versions; fetch visible months first and extend on demand. Historical edits and local midnight invalidate the appropriate data. |
| React rendering | Preserve unchanged habit objects and callbacks; memoize calendar data and geometry. |
| Initial JavaScript | Split the showcase and optional forms, editors, settings, and sharing interfaces into lazy-loaded modules. |
| Browser fonts | Serve complete WOFF2 fonts separately with conditional caching; retain the original TTF files for native image rendering. |
| Image rendering | Share a bounded queue across server renderers, deduplicate identical in-flight requests within their owner, and release completed canvas surfaces. |
| Backups | Run SQLite snapshots, verification, durability, and replication in a subprocess, preserving startup health and overlap prevention. |
| Reminders | Check delivery-slot eligibility before evaluating habits; revalidate preferences and progress immediately before sending. |
| Avatars | Bound cached images/downloads, reuse concurrent downloads, and briefly cache failures. |
## Local measurements
These are synthetic measurements on a development Mac, not production capacity
estimates. Absolute timings vary with hardware and load.
| Scenario | Before | After |
| --- | ---: | ---: |
| Dashboard service work, ten task habits with five years of history | 169.81 ms | 2.76 ms |
| Unchanged synchronization, ten habits | 34 queries / 10 row changes | 4 queries / 0 row changes |
| Combined year calendar, ten habits with ten tasks each | 10,693,864 JSON bytes | 1,267,884 compact JSON bytes |
| Catching up a year, ten habits with ten tasks each | ~1.17 s without yielding | ~1.11 s total; 112 timer ticks, largest gap 11.75 ms |
| Initial calendars, ten new manual habits | 1,947,301 JSON characters | 476,954 JSON characters |
| Unchanged focus refresh, same ten habits | 11 requests / 10 calendar requests | 1 request / 0 calendar requests |
| Switching identical habit filters | 920 `Date.parse` calls | 0 |
| Initial JavaScript, including static dependencies | 716,094 bytes | 286,641 bytes |
| Initial JavaScript, locally gzip-compressed | 199,622 bytes | 99,192 bytes |
| Main stylesheet | 273,268 bytes | 85,733 bytes |
| Main stylesheet, locally gzip-compressed | 106,410 bytes | 13,636 bytes |
| Fifty already-delivered reminder checks | 210 ms / 1,000 day evaluations | 14 ms / 0 day evaluations |
| Three renders with the same avatar URL | 3 downloads | 1 download |
| Sixteen identical concurrent image renders: timer delay | 68 ms | 1.1 ms |
| ~98 MiB replicated backup: main-thread timer delay | 421 ms | 1.9 ms |
Calendar byte/character counts describe serialized JSON, not compressed wire
traffic. JavaScript/CSS gzip sizes are local compression estimates. Image RSS
measurements concern transient native allocations, not memory leaks.
## Operation and verification
The application remains one Bun process with SQLite/WAL and one set of Discord
delivery workers. The backup subprocess owns a separate connection. Render
admission defaults to two active jobs, sixteen total pending jobs, and two jobs
per account; an overloaded renderer returns a retryable busy response.
Migrations add a persisted calendar revision and a partial index for open task
occurrences. Existing historical requirements, carryover rules, privacy boundaries,
rate-limit handling, and confirmed-deleted-message replacement remain covered by
regression tests.
Run `bun run typecheck`, `bun test`, and `bun run test:smoke`. The smoke test uses
a disposable production database. Browser verification should cover lazy route
and dialog loading, count/task saves, historical corrections, calendar ranges,
both themes, narrow layouts, and separately served fonts. Timing tests should
measure event-loop availability as well as total job duration.

View File

@@ -0,0 +1,7 @@
CREATE TABLE `live_cards` (
`user_id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `live_cards_token_unique` ON `live_cards` (`token`);

View File

@@ -0,0 +1,17 @@
CREATE TABLE `live_discord_cards` (
`user_id` text PRIMARY KEY NOT NULL,
`channel_id` text NOT NULL,
`message_id` text,
`message_url` text,
`nonce` text NOT NULL,
`theme` text NOT NULL,
`status` text NOT NULL,
`image_hash` text,
`dirty` integer DEFAULT true NOT NULL,
`error` text,
`retry_at` integer,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
DROP TABLE `live_cards`;

View File

@@ -0,0 +1,4 @@
DROP INDEX `reminder_deliveries_user_date_idx`;--> statement-breakpoint
ALTER TABLE `reminder_deliveries` ADD `slot` text DEFAULT '*' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `reminder_deliveries_user_date_slot_idx` ON `reminder_deliveries` (`user_id`,`date`,`slot`);--> statement-breakpoint
ALTER TABLE `reminder_settings` ADD `reminders` text;

View File

@@ -0,0 +1,20 @@
CREATE TABLE `discord_controls` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`action` text NOT NULL,
`payload` text NOT NULL,
`expires_at` integer NOT NULL,
`used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `discord_controls_expiry_idx` ON `discord_controls` (`expires_at`);--> statement-breakpoint
CREATE TABLE `discord_receipts` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`response` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `discord_receipts_expiry_idx` ON `discord_receipts` (`expires_at`);

View File

@@ -0,0 +1 @@
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,48 @@
"when": 1788539875559,
"tag": "0008_reminder_cooldown",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1788596417948,
"tag": "0009_purple_wasp",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1788597003524,
"tag": "0010_tidy_thena",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1788845281801,
"tag": "0011_multiple_reminders",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1788847978168,
"tag": "0012_discord_commands",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1788860000000,
"tag": "0013_habit_calendar_revision",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1788860000001,
"tag": "0014_open_occurrences",
"breakpoints": true
}
]
}
}

View File

@@ -1,10 +1,10 @@
{
"name": "minabot",
"name": "mina-habits",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts",
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --watch src/index.ts",
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
"build": "bun run build.ts",
"typecheck": "tsc --noEmit",
@@ -14,11 +14,13 @@
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
"test:coverage": "bun test --coverage",
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
"test:pwa": "bun run build && bun scripts/pwa-smoke.ts",
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
"db:restore": "bun scripts/restore.ts",
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
},
"dependencies": {
"@napi-rs/canvas": "^1.0.8",
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
@@ -39,6 +41,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2"
}

View File

@@ -10,7 +10,7 @@ const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Respo
const port = probe.port!; probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', DISCORD_BOT_TOKEN: '', DISCORD_SHARING_CHANNEL_ID: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
const token = 's'.repeat(43);
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
async function boot() {
@@ -56,6 +56,10 @@ try {
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
const livePreview = await fetch(`${origin}/api/sharing/live/preview?theme=light`, { headers: { Cookie: `minabot_session=${token}` } });
check(livePreview.ok, 'Production live PNG rendering failed');
const png = Buffer.from(await livePreview.arrayBuffer());
check(png.readUInt32BE(16) === 1920 && png.readUInt32BE(20) === 1080, 'Live image is not 16:9');
await stop(); await boot();
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');

View File

@@ -1,5 +1,7 @@
import { Database } from "bun:sqlite";
import { databasePath } from "../src/db/config";
import { backupConfig, createBackup } from "../src/ops/backups";
const db = new Database(databasePath, { readonly: true, strict: true });
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
// Only snapshot SQL runs here; never create a missing source file.
const db = new Database(databasePath, { readwrite: true, create: false, strict: true });
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }

View File

@@ -0,0 +1,9 @@
import type { BunPlugin } from "bun";
/** Match the production bundler's external font URLs in the HTML dev server. */
export default {
name: "external-browser-fonts",
setup(build) {
build.onResolve({ filter: /^\/fonts\/browser\// }, args => ({ path: args.path, external: true }));
},
} satisfies BunPlugin;

27
scripts/build-pwa.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { manifest } from "../src/pwa/manifest";
export async function buildPwa(outdir: string, outputs: readonly { path: string }[]) {
const directory = path.join(outdir, "pwa");
await mkdir(directory, { recursive: true });
for (const icon of manifest.icons) await copyFile(`src/pwa/public/${path.basename(icon.src)}`, path.join(directory, path.basename(icon.src)));
await Bun.write(path.join(outdir, "manifest.webmanifest"), JSON.stringify(manifest, null, 2));
// Only browser assets emitted by Bun; index.js is the private server bundle.
const files = outputs.map(output => path.relative(outdir, output.path)).filter(file =>
file !== "index.js" && /\.(js|css|png|svg|woff2|webmanifest)$/.test(file));
files.push(...manifest.icons.map(icon => icon.src.slice(1)), "manifest.webmanifest");
for await (const file of new Bun.Glob("fonts/browser/*.woff2").scan(outdir)) files.push(file);
files.sort();
const template = await Bun.file("src/pwa/sw.js").text();
const html = await Bun.file(path.join(outdir, "index.html")).text();
const hash = createHash("sha256").update(template).update(html);
for (const file of files) hash.update(file).update(await Bun.file(path.join(outdir, file)).arrayBuffer().then(Buffer.from));
const version = hash.digest("hex").slice(0, 16);
const shell = `/pwa/shell-${version}.html`;
await Bun.write(path.join(outdir, shell.slice(1)), html);
const config = { version, shell, assets: [...files.map(file => `/${file}`), shell] };
await Bun.write(path.join(directory, "sw.js"), template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))));
console.log(` PWA ${version}: ${config.assets.length} public assets`);
}

View File

@@ -1,3 +1,4 @@
import { browserFont } from "../src/browser-fonts";
// Isolated browser QA server. Never opens or changes the user's database.
import { sendDueReminders } from "../src/reminders/worker";
import type { DiscordFetch } from "../src/sharing/routes";
@@ -6,14 +7,15 @@ import { createApi } from "../src/api";
import index from "../src/index.html";
const f = fixture();
const origin = "http://127.0.0.1:3107";
const port = Number(process.env.PREVIEW_PORT ?? 3107);
const origin = `http://127.0.0.1:${port}`;
const previewCookie = "minabot_dashboard_preview_session";
const sharingPreview = process.env.SHARE_PREVIEW === "1";
let sharedImage: Blob | null = null;
let reminderMessage: string | null = null;
const previewDiscord: DiscordFetch = async (url, init) => {
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
if (init?.method === "POST") {
if (init?.method === "POST" || init?.method === "PATCH") {
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
else reminderMessage = JSON.parse(String(init.body)).content;
return Response.json({ id: "423456789012345678" });
@@ -47,13 +49,14 @@ const app = createApi(
let reminderRunning = false;
if (sharingPreview) setInterval(async () => {
if (reminderRunning) return; reminderRunning = true;
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin, commandsAvailable: false }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
finally { reminderRunning = false; }
}, 1000).unref();
const server = Bun.serve({
hostname: "127.0.0.1",
port: 3107,
port,
routes: {
"/fonts/browser/*": browserFont,
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
"/__preview/login": () =>

17
scripts/pwa-icons.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { mkdir } from "node:fs/promises";
// Package the approved master at exact platform sizes; do not regenerate the artwork.
const master = await loadImage("docs/design/mina-habits-icon.png");
await mkdir("src/pwa/public", { recursive: true });
for (const [name, size, scale] of [
["icon-192", 192, 1], ["icon-512", 512, 1], ["icon-maskable-512", 512, 0.7],
] as const) {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, size, size);
const inset = size * (1 - scale) / 2;
ctx.drawImage(master, inset, inset, size * scale, size * scale);
await Bun.write(`src/pwa/public/${name}.png`, canvas.toBuffer("image/png"));
}

194
scripts/pwa-smoke.ts Normal file
View File

@@ -0,0 +1,194 @@
import assert from "node:assert/strict";
import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { chromium } from "playwright";
import { Database } from "bun:sqlite";
import { hashToken } from "../src/auth";
// Exercise a copied production bundle and disposable account. No live data or Discord calls.
const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-"));
const project = process.cwd();
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
const port = probe.port!;
probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const databasePath = path.join(directory, "test.sqlite");
const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin,
DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "",
DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" };
let server: ReturnType<typeof Bun.spawn> | undefined;
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
const screenshots = path.resolve(".cache/pwa");
try {
await cp("dist", path.join(directory, "dist"), { recursive: true });
await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir");
const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" });
assert.equal(await migration.exited, 0, "Production migrations failed");
const sqlite = new Database(databasePath);
const now = Date.now();
const token = "p".repeat(43);
sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now);
sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000);
sqlite.close();
server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" });
let healthy = false;
for (let attempt = 0; attempt < 100; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ }
if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text());
await Bun.sleep(50);
}
assert(healthy, "Production server failed to start");
const swResponse = await fetch(`${origin}/sw.js`);
assert.equal(swResponse.headers.get("service-worker-allowed"), "/");
assert.equal(swResponse.headers.get("cache-control"), "no-cache");
assert.match(swResponse.headers.get("content-type")!, /javascript/);
assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405);
assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store");
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
const pageErrors: string[] = [];
page.on("pageerror", error => pageErrors.push(error.message));
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update");
const cdp = await context.newCDPSession(page);
const manifestResult = await cdp.send("Page.getAppManifest");
assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors));
const manifest = JSON.parse(manifestResult.data!);
assert.equal(manifest.id, "/");
assert.equal(manifest.display, "standalone");
assert.equal(manifest.scope, "/");
for (const icon of manifest.icons) {
const response = await fetch(new URL(icon.src, origin));
assert.match(response.headers.get("content-type")!, /image\/png/);
const png = Buffer.from(await response.arrayBuffer());
assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes);
}
const installability = await cdp.send("Page.getInstallabilityErrors");
assert.deepEqual(installability.installabilityErrors, []);
console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy");
await mkdir(screenshots, { recursive: true });
await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true });
await page.getByRole("radio", { name: "Dark", exact: true }).check();
assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b");
await page.getByRole("radio", { name: "Light", exact: true }).check();
await context.setOffline(true);
await page.goto(`${origin}/a/deep/link`);
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible());
await page.setViewportSize({ width: 375, height: 812 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true });
await page.goto(`${origin}/design-system`);
await page.getByRole("heading", { name: /design system/i }).first().waitFor();
await context.setOffline(false);
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true });
console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection");
// Keep an unsaved draft in another open window while applying an update here.
const other = await context.newPage();
await other.goto(origin);
const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true });
await draft.fill("6");
const workerPath = path.join(directory, "dist/pwa/sw.js");
const original = await Bun.file(workerPath).text();
const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!;
const config = JSON.parse(JSON.parse(configMatch[1]!));
// A broken release must leave the active worker and existing offline assets usable.
const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] };
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`));
await page.evaluate(async () => {
const registration = (await navigator.serviceWorker.getRegistration())!;
await new Promise<void>((resolve, reject) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing!;
worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); });
}, { once: true });
void registration.update().catch(reject);
});
});
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0);
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("button", { name: "Try again", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
config.version += "-qa";
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`));
await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); });
await page.getByRole("button", { name: "Update app", exact: true }).waitFor();
await page.getByRole("button", { name: "Update app", exact: true }).click();
await page.getByRole("button", { name: "Later", exact: true }).click();
assert.equal(await draft.inputValue(), "6");
await page.getByRole("button", { name: "Update app", exact: true }).click();
await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft");
await other.getByRole("button", { name: "Update app", exact: true }).waitFor();
const keys = await page.evaluate(() => caches.keys());
assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained");
console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft");
await context.addCookies([{ name: "minabot_session", value: token, url: origin }]);
await page.reload();
await page.getByRole("button", { name: "Sign out", exact: true }).waitFor();
const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status }));
assert.equal(privateResponse.status, 200);
await context.setOffline(true);
assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached");
await context.setOffline(false);
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
const cacheUrls = await page.evaluate(async () => {
const urls: string[] = [];
for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname);
return urls;
});
assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage");
assert.deepEqual(pageErrors, []);
console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors");
const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
await manualContext.addInitScript(() => {
window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); });
});
const manual = await manualContext.newPage();
await manual.goto(origin);
await manual.getByRole("button", { name: "Install app", exact: true }).click();
await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor();
assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible());
assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows");
await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true });
await manual.getByRole("button", { name: "Got it", exact: true }).click();
await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0);
await manualContext.close();
console.log("PASS mobile installation guidance and installed-state controls");
console.log(`Screenshots: ${screenshots}`);
} catch (error) {
const page = browser?.contexts()[0]?.pages()[0];
if (page) {
console.error("Browser at failure:", await page.locator("body").innerText());
await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true });
}
throw error;
} finally {
await browser?.close();
if (server) { server.kill(); await server.exited; }
await rm(directory, { recursive: true, force: true });
}

View File

@@ -0,0 +1,10 @@
import { commands } from '../src/discord/commands';
import { registerCommands } from '../src/discord/registration';
if (process.argv.includes('--apply')) {
const registered = await registerCommands(process.env.DISCORD_CLIENT_ID ?? '', process.env.DISCORD_BOT_TOKEN ?? '', commands);
console.log(`Registered global commands for servers and bot DMs: ${registered.map(name => `/${name}`).join(', ')}`);
} else {
console.log(JSON.stringify(commands, null, 2));
console.log('Preview only. Add --apply to register these commands with Discord.');
}

View File

@@ -0,0 +1,18 @@
import { Database } from 'bun:sqlite';
import { createBackup, latestBackupTime, type BackupConfig } from '../src/ops/backups';
const path = process.argv[2];
if (!path || !process.argv[3]) throw new Error('Expected database path and backup configuration');
const config = JSON.parse(process.argv[3]) as BackupConfig;
const latest = latestBackupTime(config);
if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false }));
else {
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
// Only snapshot SQL runs here; never create a missing source file.
const db = new Database(path, { readwrite: true, create: false, strict: true });
try {
db.exec('PRAGMA busy_timeout=5000');
createBackup(db, config);
console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true }));
} finally { db.close(); }
}

View File

@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sharing visual QA</title></head><body><div id="root"></div><script type="module" src="./share-card-preview.tsx"></script></body></html>

View File

@@ -0,0 +1,5 @@
// Run with: bun scripts/share-card-preview.ts
// Optional mobile iframe: SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts
import page from "./share-card-preview.html";
const server = Bun.serve({ hostname: "127.0.0.1", port: 3108, routes: { "/*": page }, development: true });
console.log(`Sharing visual fixtures: ${server.url}`);

View File

@@ -0,0 +1,30 @@
// Disposable visual fixtures: no user data and no outbound Discord calls.
import { ThemeProvider, ThemeControl, useTheme } from "../src/components/ThemeProvider";
import { createRoot } from "react-dom/client";
import { useEffect, useState } from "react";
import { addDays } from "../src/habits/calendar";
import { renderProgressCard } from "../src/lib/progress-card";
import type { ShareData } from "../src/sharing/contracts";
import "../styles/globals.css";
const names = ["A few pages, every day", "An afternoon walk", "A very long habit name that should truncate gracefully without hitting the count", "Drink enough water", "练习中文 · 日本語", "Make time to unwind"];
const colors = ["#90647e", "#397f76", "#ba793c", "#4b70b4", "#834f9e", "#bd5863"];
function Preview({ count, empty = false, privateCard = false }: { count: number; empty?: boolean; privateCard?: boolean }) {
const { theme } = useTheme();
const [url, setUrl] = useState("");
useEffect(() => {
let disposed = false, objectUrl = "";
const total = count === 6 ? 365 : 30;
const from = addDays("2026-09-04", 1 - total);
const data: ShareData = { from, to: "2026-09-04", timezone: "America/Argentina/Buenos_Aires", habits: names.slice(0, count).map((name, i) => {
const days = Array.from({length: total}, (_, n) => { const due = !empty && (n + i) % 7 !== 0; const ratio = due ? (n % 4 === 0 ? 0 : n % 3 === 0 ? .5 : 1) : null; return { date: addDays(from, n), due, complete: ratio === 1, ratio, color: ratio === 1 ? colors[i]! : ratio ? "#c7b0bf" : "#e4e7e0" }; });
return { name, color: colors[i]!, completed: days.filter(d => d.complete).length, scheduled: days.filter(d => d.due).length, days, legend: { empty: "#e4e7e0", partial: ["#c7b0bf"], complete: colors[i]! } };
}) };
void renderProgressCard(data, { id: "qa", discordId: "qa", username: "alice", displayName: "Alexandra with a very long display name", avatarUrl: null, timezone: data.timezone }, { name: !privateCard, avatar: !privateCard, habitNames: !privateCard, timezone: privateCard }, theme).then(result => {
if (disposed) return; objectUrl = URL.createObjectURL(result.blob); setUrl(objectUrl);
});
return () => { disposed = true; if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [theme]);
return <section style={{ background: "#313338", padding: 20, borderRadius: 8, width: 360 }}><h2 style={{ color: "white", marginBottom: 16 }}>{count} {count === 1 ? "habit" : "habits"}{empty ? " · no schedule" : ""}{privateCard ? " · private" : ""}</h2>{url && <img alt="QA progress card at 320 pixels" src={url} style={{ width: 320, display: "block" }} />}</section>;
}
createRoot(document.getElementById("root")!).render(<ThemeProvider><main className="ds-root" style={{ padding: 24 }}><ThemeControl /><h1>Card readability at 320px</h1><div style={{ display: "flex", flexWrap: "wrap", alignItems: "start", gap: 24, marginTop: 24 }}><Preview count={1}/><Preview count={6}/><Preview count={6} privateCard/><Preview count={1} empty/></div><h2 style={{ margin: "24px 0" }}>390 × 844 mobile composer</h2><iframe title="Mobile dashboard" src="http://127.0.0.1:3107/__preview/login" style={{ width: 390, height: 844, border: "1px solid #aaa" }}/></main></ThemeProvider>);

View File

@@ -91,6 +91,12 @@ function LocationProbe() {
</>;
}
async function settleDeferred() {
for (let attempt = 0; attempt < 100 && [...container.querySelectorAll('[role="status"]')].some(node => node.textContent === "Loading…"); attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 1)); });
}
}
async function render(path = "/design-system") {
await act(async () =>
root.render(
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
</MemoryRouter>,
),
);
await settleDeferred();
}
function connectAccount() {
@@ -131,6 +138,7 @@ describe("homepage", () => {
const opener = buttonNamed("Settings");
opener.focus();
await act(async () => opener.click());
await settleDeferred();
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true);
@@ -158,6 +166,7 @@ describe("homepage", () => {
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click());
await settleDeferred();
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(container.querySelector("#inner")).toBeNull();
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
@@ -175,31 +184,73 @@ describe("homepage", () => {
await f.request(`/habits/${h.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
expect(buttonNamed("Restore Reading")).toBeDefined();
await act(async () => buttonNamed("View history").click());
await settleDeferred();
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
await act(async () => buttonNamed("Restore Reading").click());
await settleDeferred();
expect(container.textContent).toContain("No archived habits.");
expect((await f.json("/today")).habits[0].name).toBe("Reading");
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
});
test("archive permanent deletion supports cancel, failure, retry, and removal", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Discard me", method: "manual" }, 201);
await f.request(`/habits/${habit.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click());
await settleDeferred();
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("Temporary failure");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")).toBeNull();
expect(container.textContent).toContain("Discard me permanently deleted.");
expect(container.textContent).toContain("No archived habits.");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(404);
});
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
f.setTime("2026-01-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
expect(container.querySelector(".ds-goal-graph .ds-calendar")?.getAttribute("data-months")).toBe("3");
expect(container.querySelector(".ds-goal-graph [hidden]")).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
await settleDeferred();
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();
await act(async () => past.click());
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
fetchMock.mockClear();
await act(async () => {
const input = container.querySelector<HTMLInputElement>('input[type="date"]')!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "2026-01-03");
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
});
expect(container.querySelector('[aria-label^="2026-01-03:"]')).not.toBeNull();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("from=2025-11-01") && String(url).includes("compact=true"))).toBe(true);
});
test("signed-out visitors can try progress without calling private habit APIs", async () => {
@@ -207,7 +258,8 @@ describe("homepage", () => {
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");
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -238,6 +290,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Account unavailable");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
@@ -247,6 +300,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Welcome back, alice.");
expect(container.textContent).toContain("Start with one habit.");
await act(async () => buttonNamed("Add a habit +").click());
await settleDeferred();
const input = container.querySelector<HTMLInputElement>("form input")!;
expect(document.activeElement).toBe(input);
await act(async () => {
@@ -256,9 +310,10 @@ describe("homepage", () => {
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.textContent).not.toContain("Read a little created.");
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull();
expect((await f.request("/me")).status).toBe(401);
});
@@ -272,9 +327,26 @@ describe("homepage", () => {
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");
const countGoal = container.querySelector(".ds-goal--count")!;
expect(countGoal.querySelector('.ds-goal-count input[aria-label="Total Water"]')).not.toBeNull();
expect(countGoal.querySelector(".ds-goal-unit")?.textContent).toBe("glasses");
expect(countGoal.textContent).toContain("Count target");
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("In progress");
const checkIn = container.querySelector<HTMLInputElement>('.ds-goal--manual input[type="checkbox"]')!;
expect(container.querySelector('.ds-goal--manual .ds-goal-check-in > label[for]')?.getAttribute("for")).toBe(checkIn.id);
expect(checkIn.getAttribute("aria-label")).toBe("Reading done");
expect(container.querySelector('.ds-goal--tasks .ds-goal-tasks details')).toBeNull();
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click());
await settleDeferred();
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());
@@ -282,9 +354,115 @@ describe("homepage", () => {
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());
await settleDeferred();
expect((await f.json("/today")).completed).toBe(2);
});
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await render("/");
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(calendarRequests()).toHaveLength(2);
expect(calendarRequests()[0]).toContain("from=2026-07-01");
expect(calendarRequests()[0]).toContain("compact=true");
const parse = spyOn(Date, "parse");
await act(async () => buttonNamed("All habits · 2").click());
await settleDeferred();
expect(parse).not.toHaveBeenCalled();
parse.mockRestore();
fetchMock.mockClear();
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(0);
await f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true });
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain(reading.id);
const history = container.querySelector(`#history-${reading.id}`)!;
fetchMock.mockClear();
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain("from=2025-10-01");
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
fetchMock.mockClear();
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
expect(calendarRequests()).toHaveLength(0);
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
expect(history.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
fetchMock.mockClear();
f.setTime("2026-09-05T12:00:00Z");
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(2);
});
test("month rollover preserves a twelve-month preset and an explicit custom window", async () => {
const f = connectAccount();
f.setTime("2026-09-30T12:00:00Z");
const annual = await f.json("/habits", "POST", { name: "Annual", method: "manual" }, 201);
const custom = await f.json("/habits", "POST", { name: "Custom", method: "manual" }, 201);
await render("/");
const annualHistory = container.querySelector(`#history-${annual.id}`)!;
const customHistory = container.querySelector(`#history-${custom.id}`)!;
await act(async () => annualHistory.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
await act(async () => [...customHistory.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Custom")!.click());
await act(async () => {
for (const [name, value] of [["from", "2026-08-05"], ["to", "2026-09-10"]]) {
const input = customHistory.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
}
customHistory.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event);
});
fetchMock.mockClear();
f.setTime("2026-10-01T12:00:00Z");
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
const requests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(requests).toHaveLength(2);
expect(requests.find(url => url.includes(annual.id))).toContain("from=2025-11-01&to=2026-10-31");
expect(requests.find(url => url.includes(custom.id))).toContain("from=2026-08-05&to=2026-09-10");
expect(annualHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("12");
expect(annualHistory.querySelectorAll(".ds-day")).toHaveLength(365);
expect(customHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("custom");
expect(customHistory.querySelectorAll(".ds-day")).toHaveLength(37);
});
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
const f = connectAccount();
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
const calendar = container.querySelector(`#history-${reading.id} .ds-calendar`);
const originalFetch = fetchMock.getMockImplementation()!;
let finish!: () => void;
const pending = new Promise<void>(resolve => { finish = resolve; });
fetchMock.mockImplementation(Object.assign(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
if (init?.method === "PUT" && String(input).includes(`/habits/${water.id}/`)) await pending;
return originalFetch(input, init);
}, { preconnect: fetch.preconnect }));
fetchMock.mockClear();
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(buttonNamed("Increase Water").disabled).toBe(true);
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
expect(check.disabled).toBe(false);
await act(async () => check.click());
expect(check.checked).toBe(true);
expect(buttonNamed("Increase Water").disabled).toBe(true);
expect((await f.json("/today")).completed).toBe(1);
fetchMock.mockClear();
await act(async () => { finish(); await pending; });
expect(buttonNamed("Increase Water").disabled).toBe(false);
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')!.value).toBe("1");
expect(container.querySelector(`#history-${reading.id} .ds-calendar`)).toBe(calendar);
const historyRequests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(historyRequests).toHaveLength(1);
expect(historyRequests[0]).toContain(`/habits/${water.id}/calendar?`);
expect((await f.json("/today")).completed).toBe(1);
});
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
@@ -293,6 +471,7 @@ describe("homepage", () => {
f.setTime("2026-09-04T12:00:00Z");
await render("/");
await act(async () => buttonNamed("Edit habit Water").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => {
@@ -310,11 +489,15 @@ describe("homepage", () => {
expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
await act(async () => buttonNamed("Archive habit").click());
await settleDeferred();
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`);
@@ -333,6 +516,7 @@ describe("homepage", () => {
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());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -345,7 +529,9 @@ describe("homepage", () => {
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 settleDeferred();
await act(async () => buttonNamed("Delete task").click());
await settleDeferred();
const today = (await f.json("/today")).habits[0];
expect(today.target).toBe(1);
expect(today.value).toBe(0);
@@ -359,9 +545,13 @@ describe("homepage", () => {
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.querySelector(".ds-habit-chart")).toBeNull();
await act(async () => buttonNamed("All habits · 1").click());
await settleDeferred();
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());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -401,6 +591,7 @@ describe("homepage", () => {
{ type: "daily" },
] as const) {
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
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") {
@@ -410,8 +601,10 @@ describe("homepage", () => {
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);
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
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") {
@@ -419,6 +612,7 @@ describe("homepage", () => {
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
}
await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
}
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks[0].done).toBe(true);
@@ -438,17 +632,20 @@ describe("homepage", () => {
};
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 settleDeferred();
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());
await settleDeferred();
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());
await act(async () => buttonNamed("Add task").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -468,16 +665,19 @@ describe("homepage", () => {
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
await act(async () => buttonNamed("Archive habit Reading").click());
await settleDeferred();
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("Archive habit").click());
await settleDeferred();
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector(".ds-habit-chart")).toBeNull();
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
});
@@ -488,11 +688,13 @@ describe("homepage", () => {
await render("/");
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector(".ds-counter output")?.textContent).toBe("0 / 8");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-counter output")?.textContent).toBe("1 / 8");
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
expect((await f.json("/today")).habits[0].value).toBe(1);
});
});
@@ -539,11 +741,11 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");
@@ -660,7 +862,7 @@ describe("design system tabs", () => {
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
await clickTab("calendar-states");
await clickTab("components");
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
expect(panel().querySelector<HTMLInputElement>('[aria-label="Total example count"]')?.value).toBe("4");
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
await act(async () => viewHabit.click());
expect(panel().id).toBe("ds-panel-calendar-states");
@@ -722,14 +924,15 @@ test("reminder form submits native time values and explicit opt-out", async () =
return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
}) as typeof fetch);
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
const time = container.querySelector<HTMLInputElement>('input[name="time"]')!;
const time = container.querySelector<HTMLInputElement>('input[name="reminder-time-0"]')!;
await act(async () => {
time.value = "14:30";
time.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
});
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(saved[0]).toEqual({ enabled: true, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
expect(saved[0]).toEqual({ enabled: true, reminders: [{ type: "weekly", time: "14:30", days: [0, 1, 2, 3, 4, 5, 6] }], quietStart: "22:00", quietEnd: "08:00" });
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(saved[1]).toEqual({ enabled: false, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
expect(saved[1]).toEqual({ enabled: false, reminders: [{ type: "weekly", time: "14:30", days: [0, 1, 2, 3, 4, 5, 6] }], quietStart: "22:00", quietEnd: "08:00" });
});

View File

@@ -1,11 +1,14 @@
import { Navigate, Route, Routes } from "react-router";
import { DesignSystem } from "./pages/DesignSystem";
import { deferred } from "./components/design-system/deferred";
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
import { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider";
import { ThemeProvider } from "./components/ThemeProvider";
export function App() {
return (
<Routes>
<ThemeProvider><Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route
path="/"
@@ -16,6 +19,6 @@ export function App() {
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Routes></ThemeProvider>
);
}

View File

@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
const app = new Hono<AuthEnv>();
app.use("*", auth.requireAuth);
app.get("/export", c => {
app.get("/export", c => HabitService.withPrepared(db, c.get("user"), now, () => {
const user = c.get("user");
const service = new HabitService(db, user, now()); service.sync();
const data = db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
@@ -29,13 +28,14 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
};
});
c.header("Content-Disposition", 'attachment; filename="minabot-export.json"');
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
return c.json(data);
});
}));
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
const body = await c.req.json().catch(() => null);
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
const userId = c.get("user").id;
return HabitService.withPrepared(db, c.get("user"), now, () => {
db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
@@ -50,6 +50,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
});
auth.clearSession(c);
return c.body(null, 204);
});
});
return app;
}

View File

@@ -1,3 +1,5 @@
import { createDiscordRoutes, type CommandConfig } from "./discord/routes";
import { createLiveDiscord } from "./sharing/live-discord";
import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor";
import { createAccountRoutes } from "./account/routes";
@@ -11,26 +13,37 @@ 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, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void } = {}) {
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void; discord?: CommandConfig } = {}) {
const app = new Hono<AuthEnv>();
app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
// Accounts, calendars, exports and OAuth responses must never enter HTTP caches.
c.header("Cache-Control", "no-store");
await next();
});
const auth = createAuth(db, config, request, now);
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
const sharing = createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest);
app.use("*", async (c, next) => {
await next();
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
liveSharing.changed(c.get("user").id);
});
app.get("/api/health", c => {
c.header("Cache-Control", "no-store");
db.get(sql`SELECT 1`);
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
return c.json({ status: "ok" });
});
app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed, { progress: { sharing, live: liveSharing } }));
app.route("/api/sharing/live", liveSharing.routes);
app.route("/api/auth", auth.routes);
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
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.route("/api/sharing", sharing);
app.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => {
c.header("Cache-Control", "no-store");
@@ -41,5 +54,5 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
return c.json({ error: "Internal server error", requestId }, 500);
});
return app;
return Object.assign(app, { stopLiveSharing: liveSharing.stop, flushLiveSharing: liveSharing.flush });
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@@ -0,0 +1,94 @@
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,35 @@
# Bundled share-card fonts
Instrument Serif Regular/Italic: downloaded from the
[Google Fonts source](https://github.com/google/fonts/tree/main/ofl/instrumentserif).
License: `InstrumentSerif-OFL.txt` (also retained in the original `OFL.txt`).
Noto Sans Regular (400), Medium (500), and SemiBold (600): downloaded from the
[Noto source](https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSans).
License: `NotoSans-LICENSE.txt`.
Downloaded September 7, 2026. The files are versioned in this repository and copied
into the production image. Browser and native canvas renderers use the alias
`Mina Sans`; no system sans-serif font is selected for card text. The native font
loader fails startup if a required font cannot be registered.
Browser WOFF2 copies are lossless conversions of these exact TTF files using
FontTools with Brotli; no subsetting is applied. All glyphs and Unicode mappings
are retained (3,748 glyphs for each Noto Sans weight, 377 for each Instrument
Serif style). Native canvas keeps using the original TTFs. CSS uses separately
cacheable `/fonts/browser/*.woff2` URLs so font bytes do not block CSS parsing.
Regenerate with `uv run --with fonttools --with brotli python`:
```python
from pathlib import Path
from fontTools.ttLib import TTFont
for path in Path("src/assets/fonts").glob("*.ttf"):
font = TTFont(path)
cmap, glyphs = font.getBestCmap(), font.getGlyphOrder()
font.flavor = "woff2"
font.save(path.with_suffix(".woff2"))
restored = TTFont(path.with_suffix(".woff2"))
assert restored.getBestCmap() == cmap
assert restored.getGlyphOrder() == glyphs
```

View File

@@ -55,6 +55,32 @@ beforeEach(() => {
afterEach(() => sqlite.close());
describe("Discord sign-in and sessions", () => {
test("trusted TLS proxy supports sign-in and secure callback cookies", async () => {
const publicOrigin = "https://habits.example";
const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp);
const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } });
const location = new URL(response.headers.get("Location")!);
expect(location.origin).toBe("https://discord.com");
expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`);
expect(response.headers.get("Set-Cookie")).toContain("Secure");
const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, {
headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") },
});
expect(cookie(callback, "__Host-minabot_session")).not.toBe("");
expect(callback.headers.get("Set-Cookie")).toContain("Secure");
});
test("forwarded protocol is opt-in and cannot override the actual hostname", async () => {
for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) {
const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp);
const response = await proxyApp.request(`http://${host}/api/auth/discord`, {
headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" },
});
expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example");
expect(response.headers.get("Set-Cookie")).toBeNull();
}
});
test("me requires a valid session and health stays public", async () => {
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });

View File

@@ -3,6 +3,7 @@ export type AuthConfig = {
clientId: string;
clientSecret: string;
cookieSecret: string;
trustProxy?: boolean;
};
export function readAuthConfig(env = process.env): AuthConfig {
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
clientId: env.DISCORD_CLIENT_ID ?? "",
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
trustProxy: env.TRUST_PROXY === "true",
};
}

View File

@@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string {
}
}
function publicUser(user: typeof users.$inferSelect): PublicUser {
export function publicUser(user: typeof users.$inferSelect): PublicUser {
return {
id: user.id,
discordId: user.discordId,
@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
routes.get("/discord", async c => {
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
// Use one configured origin for redirects and cookie ownership.
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
const requestUrl = new URL(c.req.url);
// Enable only behind a private proxy that overwrites X-Forwarded-Proto.
// Keep the actual Host; never trust a client-supplied forwarded hostname.
if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:";
if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
const state = randomToken();
await setSignedCookie(c, stateName, JSON.stringify({
state,

14
src/browser-fonts.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import { expect, test } from "bun:test";
import { browserFont } from "./browser-fonts";
test("browser fonts are external WOFF2 files with conditional caching and a fixed allowlist", async () => {
const url = "http://localhost/fonts/browser/InstrumentSerif-Regular.woff2";
const response = browserFont(new Request(url));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("font/woff2");
const bytes = new Uint8Array(await response.arrayBuffer());
expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("wOF2");
expect(browserFont(new Request(url, { headers: { "If-None-Match": response.headers.get("ETag")! } })).status).toBe(304);
expect(browserFont(new Request("http://localhost/fonts/browser/.env")).status).toBe(404);
expect(browserFont(new Request("http://localhost/fonts/browser/%2e%2e%2f.env")).status).toBe(404);
expect(await browserFont(new Request(url, { method: "HEAD" })).text()).toBe("");
});

18
src/browser-fonts.ts Normal file
View File

@@ -0,0 +1,18 @@
import path from "node:path";
const names = ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"];
const directory = process.env.NODE_ENV === "production" ? "fonts/browser" : "src/assets/fonts";
const fonts: Map<string, { file: ReturnType<typeof Bun.file>; etag: string }> = new Map(names.map(name => {
const file = Bun.file(path.resolve(directory, `${name}.woff2`));
return [`${name}.woff2`, { file, etag: `"${file.size.toString(16)}-${file.lastModified.toString(16)}"` }] as const;
}));
/** Explicit allowlist: font URLs never grant access to other filesystem paths. */
export function browserFont(request: Request): Response {
const name = new URL(request.url).pathname.slice("/fonts/browser/".length);
const font = fonts.get(name);
if (!font || !["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 404 });
const headers = { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=3600", ETag: font.etag };
if (request.headers.get("If-None-Match") === font.etag) return new Response(null, { status: 304, headers });
return new Response(request.method === "HEAD" ? null : font.file, { headers });
}

View File

@@ -1,3 +1,4 @@
import { ThemeControl } from "./ThemeProvider";
import { ReminderSettings } from "./ReminderSettings";
import { useId, useRef, useState } from "react";
import type { PublicUser } from "../shared/user";
@@ -23,8 +24,9 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
return <WorkspacePanel id="account-settings" title="Account settings" onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Appearance"><ThemeControl /></SettingRow>
<SettingRow title="Daily timezone" description={<p>Sets day boundaries. Earlier deadlines stay unchanged.</p>}>
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
<Field label="Timezone">
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
@@ -34,8 +36,8 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
</form>
</SettingRow>
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
<SettingRow title="Export data" description={<p>Download your profile, habits, and history.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="mina-habits-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
</SettingRow>
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :

View File

@@ -1,14 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { Button } from "./design-system/primitives";
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
import { Archive, ChevronDown, RotateCcw, Trash2 } from "lucide-react";
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
import { Modal } from "./design-system/Modal";
import { HabitHistory } from "./HabitHistory";
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
}) {
const [habits, setHabits] = useState<TodayHabit[]>([]);
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
const [deleteError, setDeleteError] = useState("");
const archiveFocus = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState<string>();
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
@@ -37,11 +41,26 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
setError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
async function permanentlyDelete(habit: TodayHabit) {
if (saving.current) return;
saving.current = true; setBusy(true); setDeleteError(""); setNotice("");
try {
await habitRequest(`/habits/${habit.habitId}/permanent`, { method: "DELETE" });
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
setSelected(current => current === habit.habitId ? undefined : current);
setDeleting(null);
setNotice(`${habit.name} permanently deleted.`);
onChanged();
} catch (error) {
const message = error instanceof Error ? error.message : "Could not delete. Try again.";
setDeleteError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="archived-habits" title="Archived habits" onClose={onClose} busy={busy} closeLabel="Close archive">
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
<FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
<FormMessage>{notice}</FormMessage>
<div className="ds-record-list">
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
<header className="ds-record-heading">
@@ -49,10 +68,18 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
<div className="ds-action-row">
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</Button>
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
</div>
</header>
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
</article>)}
</div>
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
<FormMessage error>{deleteError}</FormMessage>
<div className="ds-action-row">
<Button variant="secondary" data-cancel-delete disabled={busy} onClick={() => setDeleting(null)}>Keep habit</Button>
<Button disabled={busy} onClick={() => void permanentlyDelete(deleting)}>{busy ? "Deleting…" : "Delete permanently"}</Button>
</div>
</Modal>}
</WorkspacePanel>;
}

View File

@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
};
function signIn() {
if (navigator.onLine === false) return;
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
@@ -21,6 +22,7 @@ type AuthState = {
loading: boolean;
busy: boolean;
accountError: string;
connectionError: boolean;
error: string;
signIn: () => void;
signOut: () => Promise<void>;
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState("");
const [connectionError, setConnectionError] = useState(false);
const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0);
const { search } = useLocation();
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
let reachedServer = false;
try {
const response = await fetch("/api/me", { signal: controller.signal });
setAccountError("");
setConnectionError(false);
const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) });
reachedServer = true;
if (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account);
} catch (error) {
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
if (!controller.signal.aborted) {
setConnectionError(!reachedServer);
setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account.");
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => controller.abort();
}, [attempt]);
useEffect(() => {
const reconnect = () => { if (accountError) retry(); };
const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); };
const timer = window.setInterval(check, 15_000);
window.addEventListener("online", reconnect);
window.addEventListener("focus", check);
return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); };
}, [accountError, connectionError]);
function retry() {
setAccountError("");
setLoading(true);
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setBusy(true);
setActionError("");
try {
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return (
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
<AuthContext.Provider value={{ user, loading, busy, accountError, connectionError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
{children}
</AuthContext.Provider>
);

View File

@@ -0,0 +1,27 @@
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
import { CalendarCache } from "./CalendarCache";
const fetchSpy = spyOn(globalThis, "fetch");
afterEach(() => fetchSpy.mockReset());
afterAll(() => fetchSpy.mockRestore());
const range = { from: "2026-07-01", to: "2026-09-30" };
test("calendar cache shares in-flight reads, retains wider history and invalidates changed versions", async () => {
fetchSpy.mockImplementation(Object.assign(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }), { preconnect: fetch.preconnect }));
const cache = new CalendarCache();
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0]![0])).toContain("compact=true");
const subset = await cache.load("habit", "v1", { from: "2026-09-01", to: range.to });
expect(subset.days).toHaveLength(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
await cache.load("habit", "v2", range);
expect(fetchSpy).toHaveBeenCalledTimes(2);
await new CalendarCache().load("habit", "v2", range);
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
test("a failed history request can be retried without retaining a rejected cache entry", async () => {
fetchSpy.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(Response.json({ ...range, days: [] }));
const cache = new CalendarCache();
await expect(cache.load("habit", "v1", range)).rejects.toThrow("offline");
await expect(cache.load("habit", "v1", range)).resolves.toMatchObject({ days: [] });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

View File

@@ -0,0 +1,34 @@
import { createContext, useContext, useRef, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest } from "../lib/dashboard";
type Range = { from: string; to: string };
type Entry = Range & { version: string; value: Promise<CalendarResponse> };
/** Account-scoped, bounded cache; never survives signing out or changing accounts. */
export class CalendarCache {
private entries = new Map<string, Entry[]>();
load(habitId: string, version: string, range: Range) {
const entries = (this.entries.get(habitId) ?? []).filter(entry => entry.version === version);
const cached = entries.find(entry => entry.from <= range.from && entry.to >= range.to);
if (cached) return cached.value.then(value => ({ ...value, ...range, days: value.days.filter(day => day.date >= range.from && day.date <= range.to) }));
const entry: Entry = { ...range, version, value: habitRequest<CalendarResponse>(`/habits/${habitId}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`) };
entries.push(entry);
this.entries.delete(habitId);
this.entries.set(habitId, entries.slice(-8));
if (this.entries.size > 100) this.entries.delete(this.entries.keys().next().value!);
entry.value.catch(() => { const current = this.entries.get(habitId); if (current) this.entries.set(habitId, current.filter(item => item !== entry)); });
return entry.value;
}
}
const Context = createContext<CalendarCache | null>(null);
export function CalendarCacheProvider({ children }: { children: ReactNode }) {
const cache = useRef<CalendarCache | null>(null);
cache.current ??= new CalendarCache();
return <Context.Provider value={cache.current}>{children}</Context.Provider>;
}
export function useCalendarCache() {
const shared = useContext(Context);
const local = useRef<CalendarCache | null>(null);
local.current ??= new CalendarCache();
return shared ?? local.current;
}

View File

@@ -0,0 +1,78 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { HabitForm } from "./HabitForm";
import { HABIT_COLOR_CHOICES, HabitColorPicker } from "./design-system/HabitColorPicker";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
}
test("blank habit creation exposes tracking-specific fields without templates", async () => {
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
expect(container.querySelector('[aria-label="Start with an idea"]')).toBeNull();
const method = container.querySelector<HTMLSelectElement>("select")!;
expect(method.value).toBe("manual");
await act(async () => { method.value = "count"; method.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
await act(async () => { method.value = "tasks"; method.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.querySelector('input[type="number"]')).toBeNull();
await act(async () => button("Add task +").click());
expect([...container.querySelectorAll("label")].filter(label => /^Task \d+$/.test(label.textContent ?? ""))).toHaveLength(2);
});
test("continuous colors disclose custom controls and preserve the selected color", async () => {
function Picker() {
const [color, setColor] = useState("#123456");
return <HabitColorPicker value={color} onChange={setColor} />;
}
await act(async () => root.render(<Picker />));
expect(container.querySelector('[aria-label="Habit colors"]')?.querySelectorAll('button[aria-label^="Color "]').length).toBe(HABIT_COLOR_CHOICES.length);
const disclosure = button("Custom");
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
expect(details.hidden).toBe(true);
await act(async () => disclosure.click());
expect(details.hidden).toBe(false);
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
await act(async () => button("Custom").click());
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
});

View File

@@ -23,11 +23,11 @@ export function HabitForm({
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 [target, setTarget] = useState(10);
const [unit, setUnit] = useState("pages");
const [tasks, setTasks] = useState([""]);
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
const [color, setColor] = useState("#58765b");
const [color, setColor] = useState("#3e805d");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -75,9 +75,9 @@ export function HabitForm({
}
return (
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
<Modal id="new-habit" title="Create habit" onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
<form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
<fieldset className="ds-task-editor-fieldset ds-habit-form-grid" disabled={busy}>
<Field label="Habit name">
{(id) => (
<input
@@ -90,7 +90,7 @@ export function HabitForm({
/>
)}
</Field>
<Field label="How will you track it?">
<Field label="Tracking">
{(id) => (
<select
id={id}
@@ -101,10 +101,11 @@ export function HabitForm({
>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
<option value="tasks">A routine checklist</option>
</select>
)}
</Field>
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
{method === "count" && (
<div className="ds-form-grid">
<Field label="Daily target">
@@ -176,16 +177,8 @@ export function HabitForm({
>
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 && (

View File

@@ -1,15 +1,17 @@
import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, 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";
import { monthWindow } from "./design-system/calendar-model";
import { monthWindow, type MonthView } from "./design-system/calendar-model";
import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar";
import { CheckInEditor } from "./CheckInEditor";
import { deferred } from "./design-system/deferred";
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
import { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions";
@@ -40,7 +42,29 @@ export function HabitHistory({
onHistorySaved?: () => void;
calendarOnly?: boolean;
}) {
const cache = useCalendarCache();
const [rangeSelection, setRangeSelection] = useState(() => ({ date, months: 3 as MonthView | "custom", range: monthWindow(date, 3) }));
const range = useMemo(() => {
if (rangeSelection.date === date || rangeSelection.months === "custom") return rangeSelection.range;
// A preset follows the current month across midnight; an explicitly inspected
// older window and custom dates retain the user's chosen historical period.
const previousCurrent = monthWindow(rangeSelection.date, rangeSelection.months);
const anchor = rangeSelection.range.to < previousCurrent.to ? rangeSelection.range.to : date;
return monthWindow(anchor, rangeSelection.months);
}, [date, rangeSelection]);
function requestRange(next: { from: string; to: string }, months: MonthView | "custom" = rangeSelection.months) {
setRangeSelection({ date, months, range: next });
}
const [historyLoading, setHistoryLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState<string>();
function selectDate(selected: string) {
setSelectedDate(selected);
const available = monthWindow(date, 12);
if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
const requested = monthWindow(selected, rangeSelection.months);
requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
}
}
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
@@ -49,26 +73,20 @@ export function HabitHistory({
const expired = useRef(onExpired);
expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
let active = true;
setError("");
const range = monthWindow(date, 12);
habitRequest<CalendarResponse>(
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
{ signal: controller.signal },
)
.then((result) => {
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (controller.signal.aborted) return;
if (error.message.includes("session has expired") && expired.current)
expired.current();
setHistoryLoading(true);
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
.then(result => { if (active) setCalendar(result); })
.catch(error => {
if (!active) return;
if (error.message.includes("session has expired") && expired.current) expired.current();
else setError(error.message);
});
return () => controller.abort();
}, [habit.habitId, date, revision, attempt]);
}).finally(() => { if (active) setHistoryLoading(false); });
return () => { active = false; };
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days =
const days = useMemo(() =>
calendar?.days.map((day) => ({
date: day.date,
color: day.color,
@@ -80,14 +98,14 @@ export function HabitHistory({
: day.due
? ("due" as const)
: ("not-due" as const),
})) ?? [];
})) ?? [], [calendar]);
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ??
1,
);
const shades = calendar
const shades = useMemo(() => calendar
? [
calendar.settings.emptyColor,
...Array.from(
@@ -101,7 +119,7 @@ export function HabitHistory({
).color,
),
]
: [];
: [], [calendar, steps]);
const chart = error ? (
<div className="ds-form-feedback">
<p role="alert">{error}</p>
@@ -119,16 +137,20 @@ export function HabitHistory({
) : (
<>
<CalendarHeatmap
onSelectDate={setSelectedDate}
onSelectDate={selectDate}
selectedDate={selectedDate}
compact
initialMonths={3}
availableRange={monthWindow(date, 12)}
onRangeChange={requestRange}
loading={historyLoading}
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { selectDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
@@ -138,9 +160,9 @@ export function HabitHistory({
/>
}
/>
{editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
{editingDay && <Modal id={`check-in-${habit.habitId}`} title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
<div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) selectDate(event.target.value); }} />}</Field></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>}
@@ -150,6 +172,8 @@ export function HabitHistory({
return (
<HabitChart
id={`history-${habit.habitId}`}
trackingMethod={habit.requirements?.method ?? habit.method ?? undefined}
checkInId={`habit-check-${habit.habitId}`}
name={habit.name ?? "Habit"}
method={
habit.method === "count"

View File

@@ -30,13 +30,14 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
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?">
<Field label="Tracking">
{(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>
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
{method !== config.method && <p className="ds-footnote">Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
{method === "count" && (<>
<div className="ds-form-grid">
@@ -44,11 +45,9 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
<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>
<p className="ds-footnote">Applies from today. Earlier history is kept.</p>
</InlineItemForm>
);
}

View File

@@ -27,7 +27,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
return (
<Modal id={`item-${id}`} eyebrow={kind === "habit" ? "YOUR HABIT" : "YOUR TASK"} title={formLabel ?? `${action} ${kind}`} description={name || undefined} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
<Modal id={`item-${id}`} title={formLabel ?? `${action} ${kind}`} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
onSubmit={async (event) => {
event.preventDefault();
@@ -48,7 +48,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
}
}}>
{mode === "edit" ? (
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
<fieldset className={`ds-inline-item-fields${kind === "habit" ? " ds-habit-form-grid" : ""}`} 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>
@@ -57,7 +57,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
) : (
<p className="ds-inline-delete-copy">
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits."
? "History is kept. Restore it anytime from Archived habits."
: "This removes the task from today and future check-ins. Earlier history is kept."}
</p>
)}

View File

@@ -26,8 +26,8 @@ export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule,
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 habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
<p className="ds-footnote">Runs when both schedules match. Habit: {scheduleLabel(habitSchedule)}.</p>
{!creating && <p className="ds-footnote">Applies from today. Earlier check-ins stay unchanged.</p>}
</InlineItemForm>
);
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { Download, Send, Pause } from "lucide-react";
import { useTheme, ThemeControl } from "./ThemeProvider";
import { Button } from "./design-system/primitives";
import { habitRequest } from "../lib/dashboard";
import type { LiveDiscordState } from "../sharing/live-discord";
import type { DiscordConnection } from "../sharing/contracts";
import "../../styles/live-discord.css";
export function LiveDiscordShare({ revision }: { revision: number }) {
const { theme } = useTheme();
const [state, setState] = useState<LiveDiscordState | null>(null);
const [connection, setConnection] = useState<DiscordConnection | null>(null);
const [preview, setPreview] = useState<{ url: string; theme: string; revision: number } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [previewError, setPreviewError] = useState('');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
setError('');
Promise.all([habitRequest<LiveDiscordState>('/sharing/live', { signal: controller.signal }), habitRequest<DiscordConnection>('/sharing/discord', { signal: controller.signal })])
.then(([next, destination]) => { if (!controller.signal.aborted) { setState(next); setConnection(destination); } })
.catch(e => { if (!controller.signal.aborted) setError(e.message); });
return () => controller.abort();
}, [attempt]);
useEffect(() => {
const controller = new AbortController(); let url: string | undefined;
setPreviewError('');
fetch(`/api/sharing/live/preview?theme=${theme}`, { signal: controller.signal, cache: 'no-store' })
.then(async response => { if (!response.ok) throw new Error('Could not render your card.'); return response.blob(); })
.then(blob => { if (!controller.signal.aborted) { url = URL.createObjectURL(blob); setPreview({ url, theme, revision }); } })
.catch(e => { if (!controller.signal.aborted) setPreviewError(e.message); });
return () => { controller.abort(); if (url) URL.revokeObjectURL(url); };
}, [theme, revision, attempt]);
async function change(method: 'POST' | 'DELETE') {
setBusy(true); setError('');
try { setState(await habitRequest<LiveDiscordState>('/sharing/live', { method, ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme }) } : {}) })); }
catch (e) { setError(e instanceof Error ? e.message : 'Could not update sharing.'); }
finally { setBusy(false); }
}
const ready = preview?.theme === theme && preview.revision === revision ? preview : null;
const active = state?.status === 'active' || state?.status === 'sending';
return <section className="ds-live-discord" aria-label="Live Discord image">
<div className="ds-live-discord-heading"><div><h3>All habits</h3><p>Last 30 days</p></div><ThemeControl disabled={busy} /></div>
<div className="ds-live-discord-preview" aria-busy={!ready && !previewError}>
{previewError ? <p role="alert">{previewError}</p> : ready ? <img src={ready.url} alt="16:9 Discord card containing all active habit names and their daily progress for the last 30 days." /> : <p role="status">Creating your image…</p>}
</div>
<p className="ds-live-discord-description">Automatically updates in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}. Updates continue with this page closed.</p>
<p className="ds-live-discord-privacy">Shares your display name, avatar, all active habit names, and progress—including new habits. Task details stay private. Stopping updates leaves the last image in Discord.</p>
<div className="ds-live-discord-footer">
<div aria-live="polite">{state?.messageUrl && <a href={state.messageUrl} target="_blank" rel="noreferrer">View message in Discord ↗</a>}
<p>{state?.error || (active ? 'Live sharing is on.' : state?.status === 'paused' ? 'Updates stopped.' : 'Preview your image before sharing.')}</p>
{error && <p role="alert">{error}</p>}
{connection && !connection.connected && <p>{connection.message}</p>}
</div>
<div className="ds-share-actions">
<Button variant="text" disabled={busy} onClick={() => setAttempt(n => n + 1)}>Refresh preview</Button>
{ready && <a className="ds-button ds-button--secondary" download="minabot-live-progress.png" href={ready.url}><Download size={17} />Download PNG</a>}
{active && <Button variant="secondary" disabled={busy} onClick={() => void change('DELETE')}><Pause size={17} />Stop updates</Button>}
<Button disabled={busy || !ready || !state || !connection?.connected || state.status === 'uncertain' || (active && state.theme === theme)} onClick={() => void change('POST')}><Send size={17} />{busy ? 'Saving…' : active ? 'Apply theme' : state?.messageUrl ? 'Resume updates' : 'Share live image'}</Button>
</div>
</div>
</section>;
}

View File

@@ -0,0 +1,43 @@
import { useState } from "react";
import { applyUpdate, installApp, useOnline, usePwa } from "../pwa/client";
import { Button } from "./design-system/primitives";
import { Modal } from "./design-system/Modal";
export function InstallApp() {
const { enabled, installed, installPrompt } = usePwa();
const [help, setHelp] = useState(false);
if (!enabled || installed) return null;
return <>
<Button variant="text" onClick={() => { if (installPrompt) void installApp(); else setHelp(true); }}>Install app</Button>
{help && <Modal id="install-app" size="compact" title="Keep Mina Habits close." onClose={() => setHelp(false)}>
<div className="ds-form-stack">
<p>Open Mina Habits from your home screen or dock.</p>
<p><strong>iPhone or iPad:</strong> open the Share menu, then choose Add to Home Screen.</p>
<p><strong>Mac Safari:</strong> choose File → Add to Dock.</p>
<p><strong>Chrome or Edge:</strong> use the install icon in the address bar, or Install app in the browser menu.</p>
<p className="ds-supporting-copy">Installation options depend on your browser. Your habits still need a connection to load and save.</p>
<div className="ds-actions"><Button onClick={() => setHelp(false)}>Got it</Button></div>
</div>
</Modal>}
</>;
}
export function PwaStatus() {
const online = useOnline();
const { updateReady, error } = usePwa();
const [confirmUpdate, setConfirmUpdate] = useState(false);
return <>
{!online && <div className="ds-form-feedback" role="status">You’re offline. Reconnect to load or save your habits. Changes are not queued.</div>}
{error && <div className="ds-form-feedback" role="status">{error}</div>}
{updateReady && <div className="ds-action-row" role="status">
<span>A new version of Mina Habits is ready.</span>
<Button variant="secondary" onClick={() => setConfirmUpdate(true)}>Update app</Button>
</div>}
{confirmUpdate && <Modal id="update-app" size="compact" title="Ready to update?" onClose={() => setConfirmUpdate(false)}>
<div className="ds-form-stack">
<p>This window will reload. Save any unfinished changes first.</p>
<div className="ds-actions"><Button onClick={applyUpdate}>Reload and update</Button><Button variant="text" onClick={() => setConfirmUpdate(false)}>Later</Button></div>
</div>
</Modal>}
</>;
}

View File

@@ -1,17 +1,17 @@
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
import { useEffect, useRef, useState } from "react";
import { habitRequest } from "../lib/dashboard";
import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
import { allWeekdays, defaultReminder, entries, type ReminderEntry, type ReminderResponse } from "../reminders/contracts";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
const deliveryLabels: Record<string, string> = {
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.",
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. This reminder will not be retried.",
failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed",
};
export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
const [settings, setSettings] = useState<ReminderResponse>();
const [draft, setDraft] = useState(defaultReminder);
const [draft, setDraft] = useState({ ...defaultReminder, reminders: entries(defaultReminder) });
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
@@ -20,31 +20,42 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
useEffect(() => {
const controller = new AbortController(); setError("");
habitRequest<ReminderResponse>("/reminders", { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time, quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
.then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time ?? "20:00", reminders: entries(data), quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
return () => controller.abort();
}, [attempt]);
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) }));
return <SettingRow title="Reminders" description={<><p>Private Discord reminders for unfinished habits. Reminder names are included; habit names stay private.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => {
event.preventDefault(); if (saving.current || disabled) return;
const form = event.currentTarget;
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
const submitted = { enabled: draft.enabled, reminders: draft.reminders.map((entry, index) => ({ ...entry, time: (form.elements.namedItem(`reminder-time-${index}`) as HTMLInputElement).value, ...(entry.type === "date" ? { date: (form.elements.namedItem(`reminder-date-${index}`) as HTMLInputElement).value } : {}) })), quietStart: draft.quietStart, quietEnd: draft.quietEnd, ...Object.fromEntries((["quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try {
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
setDraft(submitted);
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
setDraft(value => ({ ...value, ...submitted }));
setNotice(submitted.enabled ? "Discord reminders saved." : "Reminders turned off.");
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}}>
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
<Checkbox className="ds-preference-toggle" label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
{draft.reminders.map((entry, index) => <div key={index} className="ds-form-stack">
<Field label={`Reminder ${index + 1} name (optional)`}>{id => <input id={id} value={entry.name ?? ""} placeholder="e.g. Morning check-in" maxLength={80} onChange={event => changeEntry(index, { ...entry, name: event.target.value })} />}</Field>
<div className="ds-clock-fields">
<Field label={`Reminder ${index + 1} time`}>{id => <input id={id} name={`reminder-time-${index}`} type="time" required value={entry.time} onInput={event => changeEntry(index, { ...entry, time: event.currentTarget.value })} />}</Field>
<Field label="Repeat">{id => <select id={id} value={entry.type} onChange={event => changeEntry(index, event.target.value === "date" ? { type: "date", name: entry.name, time: entry.time, date: "" } : { type: "weekly", name: entry.name, time: entry.time, days: allWeekdays })}><option value="weekly">Weekdays</option><option value="date">Specific date</option></select>}</Field>
</div>
{entry.type === "date" ? <Field label="Date">{id => <input id={id} name={`reminder-date-${index}`} type="date" required value={entry.date} onInput={event => changeEntry(index, { ...entry, date: event.currentTarget.value })} />}</Field> :
<div className="ds-action-row" role="group" aria-label={`Reminder ${index + 1} weekdays`}>{[[1, "Mon"], [2, "Tue"], [3, "Wed"], [4, "Thu"], [5, "Fri"], [6, "Sat"], [0, "Sun"]].map(([day, label]) => <Checkbox key={day} label={String(label)} checked={entry.days.includes(Number(day))} onChange={event => changeEntry(index, { ...entry, days: event.target.checked ? [...entry.days, Number(day)] : entry.days.filter(value => value !== day) })} />)}</div>}
<Button variant="text" disabled={draft.reminders.length === 1} onClick={() => setDraft(value => ({ ...value, reminders: value.reminders.filter((_, position) => position !== index) }))}>Remove reminder {index + 1}</Button>
</div>)}
<Button variant="secondary" disabled={draft.reminders.length >= 20} onClick={() => setDraft(value => ({ ...value, reminders: [...value.reminders, { type: "weekly", time: "20:00", days: allWeekdays }] }))}>Add reminder</Button>
<div className="ds-clock-fields">
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
{([['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
</div>
<p className="ds-supporting-copy">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
</fieldset>
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}

View File

@@ -1,16 +1,21 @@
import { LiveDiscordShare } from "./LiveDiscordShare";
import { ThemeControl, useTheme } from "./ThemeProvider";
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react";
import { Download, Send } from "lucide-react";
import { Check, ChevronDown, Download, Hash, ImageIcon, Send, ShieldCheck } 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 { addDays, dayNumber } 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 { theme } = useTheme();
const [mode, setMode] = useState<"snapshot" | "live">("live");
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
const [thumbnail, setThumbnail] = useState(true);
const [range, setRange] = useState("30");
const [from, setFrom] = useState(addDays(today.date, -29));
const [to, setTo] = useState(today.date);
@@ -26,7 +31,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
const [sendError, setSendError] = useState("");
const lock = useRef(false);
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
const key = JSON.stringify({ ids, from, to, privacy, revision, user, theme });
const ready = card?.key === key ? card : null;
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
const busy = sending;
@@ -41,14 +46,16 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
}, [connectionAttempt]);
useEffect(() => {
const controller = new AbortController();
if (mode === "live") return () => controller.abort();
let objectUrl: string | undefined;
setError(""); setSendError("");
const timer = setTimeout(async () => {
if (!ids.length) { setError("Choose at least one habit for your card."); return; }
if (!from || !to || from > to || to > today.date || dayNumber(to) - dayNumber(from) > 365) { setError("Choose a date range of up to 366 days, ending today or earlier."); 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 image = await renderProgressCard(data, user, privacy, theme);
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);
@@ -58,46 +65,67 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
} 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]);
}, [key, renderAttempt, mode]);
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");
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "mina-habits-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 (
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<Modal id="share-progress" title="Share progress" onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-sharing-mode" role="group" aria-label="Sharing format">
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image</Button>
<Button variant={mode === "snapshot" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "snapshot"} onClick={() => setMode("snapshot")}>Snapshot</Button>
</div>
{mode === "live" ? <LiveDiscordShare revision={revision} /> : <>
<div className="ds-share-layout">
<section className="ds-share-preview-column" aria-label="Card preview">
<div className="ds-share-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
<div className="ds-share-size" role="group" aria-label="Preview size"><button type="button" aria-pressed={thumbnail} onClick={() => setThumbnail(true)}>Thumbnail</button><button type="button" aria-pressed={!thumbnail} onClick={() => setThumbnail(false)}>Larger</button></div>
</div>
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
{error ? <div className="ds-share-placeholder" role="alert"><ImageIcon size={28} aria-hidden="true" /><p>{error}</p>{ids.length > 0 && <Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button>}</div> : ready ? <img src={ready.url} alt={ready.alt} /> : <div className="ds-share-placeholder" role="status"><ImageIcon size={28} aria-hidden="true" /><p>Creating your card…</p></div>}
</div>
<p className="ds-share-caption">Your preview matches the shared image.</p>
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldn’t load. A neutral profile icon is used.</p>}
</section>
<div className="ds-share-controls">
<ThemeControl disabled={busy} />
<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>
<legend>Time period</legend>
<div className="ds-share-periods" role="group" aria-label="Date range">{[["7", "7 days"], ["30", "30 days"], ["365", "1 year"], ["custom", "Custom"]].map(([value, label]) => <button key={value} type="button" aria-pressed={range === value} onClick={() => { setRange(value!); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}>{label}</button>)}</div>
{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 className="ds-share-fieldset" disabled={busy}>
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
<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>
<p className="ds-share-hint">Task names stay private.</p>
</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 couldn’t 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 didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">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>
</details>
<div className="ds-share-destination"><span className="ds-share-destination-icon"><Hash size={20} aria-hidden="true" /></span><div><span className="ds-share-hint">Discord destination</span>
{connectionError ? <div role="alert"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry connection</Button></div> : connection?.connected ? <><a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a><p className="ds-share-hint">Posted by the Mina Habits bot.</p></> : <p className="ds-share-hint" role="status">{connection?.message ?? "Loading channel…"}</p>}
</div></div>
</div>
</div>
<footer className="ds-share-footer">
<div className="ds-share-footer-copy" aria-live="polite">
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready to share." : "Preparing preview…"}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
</div>
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`mina-habits-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download</a> : <Button variant="secondary" disabled><Download size={17} aria-hidden="true" />Download</Button>}
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}>{sent?.status === "sent" ? <Check size={17} aria-hidden="true" /> : <Send size={17} aria-hidden="true" />}{sending ? "Sending…" : sent?.status === "sent" ? "Shared" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
</footer>
</>}
</Modal>
);
}

View File

@@ -0,0 +1,48 @@
import { Monitor, Sun, Moon } from "lucide-react";
import { createContext, useContext, useEffect, useLayoutEffect, useId, useState, type ReactNode } from "react";
import { resolveTheme, THEME_STORAGE_KEY, themePreference, type Theme, type ThemePreference } from "../lib/theme";
const ThemeContext = createContext<{ theme: Theme; preference: ThemePreference; setPreference: (value: ThemePreference) => void }>({ theme: "light", preference: "system", setPreference: () => {} });
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setValue] = useState<ThemePreference>(() => {
try { return themePreference(localStorage.getItem(THEME_STORAGE_KEY)); } catch { return "system"; }
});
const [systemDark, setSystemDark] = useState(() => typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches);
const theme = resolveTheme(preference, systemDark);
useLayoutEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
}, [theme]);
useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
const change = () => setSystemDark(media?.matches ?? false);
const storage = (event: StorageEvent) => { if (event.key === THEME_STORAGE_KEY || event.key === null) setValue(themePreference(event.newValue)); };
change(); media?.addEventListener("change", change); window.addEventListener("storage", storage);
return () => { media?.removeEventListener("change", change); window.removeEventListener("storage", storage); };
}, []);
const setPreference = (value: ThemePreference) => {
setValue(value);
try { localStorage.setItem(THEME_STORAGE_KEY, value); } catch { /* Theme still works when browser storage is unavailable. */ }
};
return <ThemeContext value={{ theme, preference, setPreference }}>{children}</ThemeContext>;
}
export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
const { preference, setPreference } = useTheme();
const id = useId();
return (
<div className="ds-theme-control" role="group" aria-label="Theme">
{([
{ value: "system", label: "System", Icon: Monitor },
{ value: "light", label: "Light", Icon: Sun },
{ value: "dark", label: "Dark", Icon: Moon },
] as const).map(({ value, label, Icon }) => (
<label className="ds-theme-option" key={value} title={`${label} theme`}>
<input type="radio" name={`theme-${id}`} value={value} checked={preference === value} disabled={disabled} onChange={() => setPreference(value)} />
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" /><span className="ds-visually-hidden">{label}</span></span>
</label>
))}
</div>
);
}

View File

@@ -1,14 +1,16 @@
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field";
import { Button } from "./primitives";
import { CalendarDays, Keyboard } from "lucide-react";
import { CalendarDays } from "lucide-react";
import {
describeDay,
calendarTimeline,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
monthWindow,
type CalendarDay,
type MonthView,
} from "./calendar-model";
@@ -18,52 +20,67 @@ export function CalendarHeatmap({
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
initialMonths = 12,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
legend,
selectedDayAction,
availableRange,
onRangeChange,
loading = false,
}: {
availableRange?: { from: string; to: string };
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
loading?: boolean;
days: CalendarDay[];
unit: string;
label: string;
color?: string;
emptyColor?: string;
compact?: boolean;
initialMonths?: MonthView;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const { theme } = useTheme();
const [months, setMonths] = useState<MonthView | "custom">(initialMonths);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.at(-1)?.date;
// A date selected in the editor must stay visible even outside the current window.
const anchor =
controlledDate && allDays.some((day) => day.date === controlledDate)
? controlledDate
: latestDate;
// Inspecting a square changes selection, not the window. Only an external
// date outside the visible preset needs a new anchor to bring it into view.
const [windowAnchor, setWindowAnchor] = useState({ latestDate, date: latestDate });
let anchor = windowAnchor.latestDate === latestDate ? windowAnchor.date : latestDate;
if (months !== "custom" && anchor && controlledDate &&
allDays.some(day => day.date === controlledDate) &&
!visibleCalendarDays(allDays, months, anchor).some(day => day.date === controlledDate)) {
anchor = controlledDate;
}
if (windowAnchor.latestDate !== latestDate || windowAnchor.date !== anchor) {
setWindowAnchor({ latestDate, date: anchor });
}
const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
const selectedDate = controlledDate ?? localSelectedDate;
function setSelectedDate(date: string) {
if (onSelectDate) onSelectDate(date);
else setLocalSelectedDate(date);
}
const days = months === "custom" && customRange
const days = useMemo(() => months === "custom" && customRange
? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to)
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [];
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]);
const selected =
days.find((day) => day.date === selectedDate) ??
days.find((day) => day.date === anchor) ??
days[0];
// Date keys and the shared timeline remain stable when the chart resizes.
const timeline = calendarTimeline(days);
const timeline = useMemo(() => calendarTimeline(days), [days]);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const inspectorId = useId();
const instructionsId = useId();
@@ -90,6 +107,7 @@ export function CalendarHeatmap({
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
aria-busy={loading}
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
>
<div className="ds-calendar-range-toolbar">
@@ -100,13 +118,14 @@ export function CalendarHeatmap({
year: "numeric",
timeZone: "UTC",
})}{" "}
–{" "}
{months !== 1 && <>–{" "}
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
day: months === "custom" ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
})}
</>}
</span>
<div
className="ds-month-views"
@@ -117,9 +136,9 @@ export function CalendarHeatmap({
<button
key={value}
type="button"
aria-label={`${value} months`}
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
aria-pressed={months === value}
onClick={() => setMonths(value)}
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }}
>
{value}m
</button>
@@ -131,6 +150,7 @@ export function CalendarHeatmap({
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
setRangeError("");
setMonths("custom");
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
}}
>
Custom
@@ -149,7 +169,7 @@ export function CalendarHeatmap({
const end = form.elements.namedItem("to") as HTMLInputElement;
const isAvailable = (input: HTMLInputElement) =>
input.validity.valid && input.value !== "" &&
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date);
if (!isAvailable(start) || !isAvailable(end)) {
setRangeError("Choose both dates within the available history.");
(!isAvailable(start) ? start : end).focus();
@@ -160,11 +180,12 @@ export function CalendarHeatmap({
end.focus();
return;
}
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
setRangeError("There are no calendar dates in this range.");
return;
}
setCustomRange({ from: start.value, to: end.value });
onRangeChange?.({ from: start.value, to: end.value }, "custom");
setRangeError("");
}}
>
@@ -176,8 +197,8 @@ export function CalendarHeatmap({
name={name}
type="date"
required
min={allDays[0]!.date}
max={allDays.at(-1)!.date}
min={availableRange?.from ?? allDays[0]!.date}
max={availableRange?.to ?? allDays.at(-1)!.date}
defaultValue={customRange[name]}
aria-invalid={rangeError ? true : undefined}
aria-describedby={rangeError ? rangeErrorId : undefined}
@@ -190,6 +211,7 @@ export function CalendarHeatmap({
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
</form>
)}
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
<div
className="ds-calendar-viewport"
role="group"
@@ -229,8 +251,8 @@ export function CalendarHeatmap({
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
? "var(--ds-empty)"
: day.value === 0 ? "var(--ds-empty)" : day.color ? themedHabitShade(day.color, color, theme) : habitProgressColor(color, (Math.max(1, Math.min(8, Math.round(day.value / day.target * 8))) - 1) / 7, theme),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
@@ -267,27 +289,14 @@ export function CalendarHeatmap({
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</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>
<p id={instructionsId} className="ds-visually-hidden">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
{!historyLabel && <p className="ds-calendar-demo-label ds-muted">Demo history</p>}
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<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",

View File

@@ -1,3 +1,5 @@
import { useTheme } from "../ThemeProvider";
import { themedHabitShade } from "../../lib/habit-palette";
import { legendShades, type CalendarDay } from "./calendar-model";
export function CalendarLegend({
@@ -11,7 +13,8 @@ export function CalendarLegend({
label: string;
shades?: string[];
}) {
const shades = suppliedShades ?? legendShades(days, color);
const { theme } = useTheme();
const shades = suppliedShades ?? legendShades(days, color, theme);
return (
<div
className="ds-calendar-legend"
@@ -31,11 +34,11 @@ export function CalendarLegend({
: "Empty"
}
>
{shades.map((shade) => (
{shades.map((shade, index) => (
<span
key={shade}
className="ds-legend-swatch"
style={{ backgroundColor: shade }}
style={{ backgroundColor: index === 0 ? "var(--ds-empty)" : suppliedShades ? themedHabitShade(shade, color, theme) : shade }}
/>
))}
</span>

View File

@@ -0,0 +1,103 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { Counter } from "./primitives";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const input = () => container.querySelector<HTMLInputElement>("input")!;
const control = (name: string) => container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`)!;
async function enter(text: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input(), text);
input().dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
}
async function key(key: string) {
await act(async () => input().dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
}
test("totals save explicitly, support above-target values and retain step controls", async () => {
const updates: number[] = [];
function Demo() {
const [value, setValue] = useState(8);
return <Counter label="pages" value={value} target={8} onChange={next => { updates.push(next); setValue(next); }} />;
}
await act(async () => root.render(<Demo />));
await enter("30");
expect(updates).toEqual([]);
await key("Enter");
expect(updates).toEqual([30]);
expect(input().value).toBe("30");
expect(control("Save total pages")).toBeNull();
await act(async () => control("Increase pages").click());
expect(input().value).toBe("31");
await enter("40");
await act(async () => control("Save total pages").click());
expect(updates).toEqual([30, 31, 40]);
});
test("invalid totals never submit and Escape restores the saved value", async () => {
const updates: number[] = [];
await act(async () => root.render(<Counter label="pages" value={3} target={8} onChange={next => updates.push(next)} />));
for (const invalid of ["", "-1", "1.5", "abc", "1000000001"]) {
await enter(invalid);
expect(input().getAttribute("aria-invalid")).toBe("true");
expect(control("Save total pages").disabled).toBe(true);
await key("Enter");
}
expect(updates).toEqual([]);
await key("Escape");
expect(input().value).toBe("3");
expect(container.querySelector('[role="alert"]')).toBeNull();
});
test("server refresh replaces a draft and API bounds disable increment and decrement", async () => {
const render = (value: number, disabled = false) => root.render(<Counter label="pages" value={value} target={8} disabled={disabled} onChange={() => {}} />);
await act(async () => render(0));
expect(control("Decrease pages").disabled).toBe(true);
await enter("12");
await act(async () => render(1_000_000_000));
expect(input().value).toBe("1000000000");
expect(control("Increase pages").disabled).toBe(true);
await act(async () => render(2, true));
expect(input().disabled).toBe(true);
expect(control("Decrease pages").disabled).toBe(true);
});

View File

@@ -1,9 +1,9 @@
import { useId, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ListChecks } from "lucide-react";
import { Check, Circle, Hash, ChevronDown, ListChecks } from "lucide-react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Open chart section: intentionally no card surface or enclosing border. */
/** A shared habit identity, visible history, and goal-specific completion surface. */
export function HabitChart({
name,
method,
@@ -21,6 +21,8 @@ export function HabitChart({
due = true,
id,
headingLevel = 4,
trackingMethod,
checkInId,
}: {
name: string;
method: string;
@@ -38,9 +40,43 @@ export function HabitChart({
due?: boolean;
id?: string;
headingLevel?: 3 | 4;
trackingMethod?: "manual" | "count" | "tasks";
checkInId?: string;
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
const complete = due && target > 0 && value >= target;
const TypeIcon = trackingMethod === "count" ? Hash : trackingMethod === "tasks" ? ListChecks : Check;
if (trackingMethod) return (
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-identity">
<div className="ds-goal-meta"><span className="ds-goal-swatch" aria-hidden="true" /><TypeIcon size={16} aria-hidden="true" /><span>{method}</span>{schedule !== "Every day" && <span className="ds-goal-schedule">{schedule}</span>}</div>
<Heading id={headingId} className="type-title">{name}</Heading>
</div>
<div className="ds-goal-heading-actions">
<span className="ds-goal-status" role="status">{complete ? <Check size={16} aria-hidden="true" /> : <Circle size={14} aria-hidden="true" />}{!due ? "Day off" : complete ? "Complete" : value > 0 ? "In progress" : "Not started"}</span>
{headingActions}
</div>
</header>
{editor}
<div className="ds-goal-body">
<div className="ds-goal-completion">
{trackingMethod === "count" && <div className="ds-goal-count">
{children}
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
</div>}
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
{trackingMethod === "tasks" && <>
<div className="ds-goal-task-progress"><span>{value} of {target} tasks done</span><progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} /></div>
<div className="ds-goal-tasks" role="group" aria-label={`${name}: ${tasksLabel}`}>{tasks}</div>
</>}
</div>
<div className="ds-goal-graph">{calendar ?? <CalendarHeatmap days={demoCalendar(value, target)} label={`${name} progress calendar`} unit={unit} color={color} compact />}</div>
</div>
</section>
);
return (
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-habit-chart-heading">

View File

@@ -1,17 +1,14 @@
import { expect, test } from "bun:test";
import {
HABIT_PALETTES,
HABIT_COLOR_CHOICES,
isHabitColor,
previewHabitColors,
} from "./HabitColorPicker";
import { HABIT_COLORS } from "./calendar-model";
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
expect(HABIT_PALETTES.length).toBe(8);
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
expect(new Set(colors).size).toBe(32);
expect(colors.every(isHabitColor)).toBe(true);
test("the continuous color selection contains distinct, valid colors", () => {
expect(new Set(HABIT_COLOR_CHOICES).size).toBe(HABIT_COLOR_CHOICES.length);
expect(HABIT_COLOR_CHOICES.every(isHabitColor)).toBe(true);
});
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {

View File

@@ -1,5 +1,7 @@
import { useId } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
import { useTheme } from "../ThemeProvider";
import { habitProgressColor } from "../../lib/habit-palette";
import { useId, useState } from "react";
import { HABIT_COLORS } from "./calendar-model";
import { Field } from "./Field";
export type HabitColors = {
@@ -22,15 +24,11 @@ export function previewHabitColors(
return colors;
}
export const HABIT_PALETTES = [
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
export const HABIT_COLOR_CHOICES = [
"#b44f57", "#b86643", "#a7792c", "#858337",
"#5c813f", "#3e805d", "#337e79", "#387b98",
"#466db0", "#6263ad", "#885ba6", "#a35488",
"#956976", "#89705a", "#667b82", "#606571",
] as const;
export function isHabitColor(value: string) {
@@ -40,48 +38,33 @@ export function isHabitColor(value: string) {
export function HabitColorPicker({
value,
onChange,
mode = "demo",
}: {
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
}) {
const { theme } = useTheme();
const id = useId();
const [expanded, setExpanded] = useState(false);
const valid = isHabitColor(value);
const showCustom = expanded || !valid;
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote">
Choose a suggested shade or enter a custom color for this habit’s calendar.
<p id={`${id}-hint`} className="ds-visually-hidden">
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
<div
key={palette.name}
className="ds-color-palette"
role="group"
aria-label={`${palette.name} palette`}
>
<span>{palette.name}</span>
<div className="ds-color-swatches">
{palette.colors.map((color) => (
<button
key={color}
type="button"
aria-label={`${palette.name} ${color}`}
aria-pressed={value.toLowerCase() === color}
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
))}
</div>
</div>
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
{HABIT_COLOR_CHOICES.map((color) => (
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
))}
<button type="button" className="ds-color-custom-toggle" aria-pressed={valid && !HABIT_COLOR_CHOICES.some(color => color === value.toLowerCase())} aria-expanded={showCustom} aria-controls={`${id}-custom`} onClick={() => setExpanded(!showCustom)}>Custom</button>
</div>
<div id={`${id}-custom`} hidden={!showCustom}>
<div className="ds-custom-color">
<Field label="Custom color" id={`${id}-native`}>
{(inputId) => (
@@ -112,12 +95,12 @@ export function HabitColorPicker({
)}
</Field>
</div>
</div>
{valid && (
<div
className="ds-color-live-preview"
aria-label="Live calendar color preview"
>
<span className="ds-muted">Calendar preview</span>
<div
className="ds-color-preview-shades"
role="img"
@@ -127,24 +110,13 @@ export function HabitColorPicker({
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
backgroundColor: count === 0 ? "var(--ds-empty)" : habitProgressColor(value, (count - 1) / 7, theme),
}}
/>
))}
</div>
<span className="ds-muted">0 → complete</span>
</div>
)}
<p className="ds-footnote">
{mode === "create"
? "Saved with your habit."
: mode === "edit"
? "Save to apply this color; Cancel restores the saved color."
: "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}
</p>
{!valid && (
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
Use a six-digit hex color, such as #426582.

View File

@@ -53,7 +53,7 @@ export function Modal({ id, eyebrow, title, description, children, onClose, clos
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);

Some files were not shown because too many files have changed in this diff Show More