Compare commits
50 Commits
00729096bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd998ab04 | ||
|
|
e081e9acbf | ||
|
|
b8df2001ec | ||
|
|
95f3d9a2c7 | ||
|
|
8f15419703 | ||
|
|
7c7a048b32 | ||
|
|
27beb01144 | ||
|
|
149ec36c77 | ||
|
|
c3b52e8fe1 | ||
|
|
ba049efdc8 | ||
|
|
e3e07404f8 | ||
|
|
17190219fa | ||
|
|
b0950cb4b4 | ||
|
|
82fe4a1db1 | ||
|
|
c91202bbfc | ||
|
|
03eb8d9e7a | ||
|
|
9f552bf608 | ||
|
|
b22c933fc4 | ||
|
|
9a0fc108a9 | ||
|
|
b4619dec33 | ||
|
|
31e4f3b2ef | ||
|
|
30dee55799 | ||
|
|
4b8c7dff05 | ||
|
|
85376a54b3 | ||
|
|
833d5673eb | ||
|
|
4d719afd07 | ||
|
|
838567e9b8 | ||
|
|
f79d593a04 | ||
|
|
d088179c5e | ||
|
|
2c135398d7 | ||
|
|
a1ae65634b | ||
|
|
5dbec04b04 | ||
|
|
3487488f7b | ||
|
|
ab2aa74eba | ||
|
|
07a0f80fc0 | ||
|
|
8b9bd75f24 | ||
|
|
2671fe0f34 | ||
|
|
0a34142b3e | ||
|
|
4f38bf1431 | ||
|
|
8e846fa11b | ||
|
|
8ba23961ef | ||
|
|
6dad98d230 | ||
|
|
2598a5b350 | ||
|
|
b20af6d95c | ||
|
|
5f36358709 | ||
|
|
1721ee469d | ||
|
|
e6002ad619 | ||
|
|
6d1d12953c | ||
|
|
50d7838de3 | ||
|
|
c7aa5a1c65 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
coverage
|
||||||
|
*.sqlite*
|
||||||
|
*.db*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -25,3 +25,6 @@ BACKUP_RETAIN=7
|
|||||||
BACKUP_REPLICA_DIR=
|
BACKUP_REPLICA_DIR=
|
||||||
# Optional public HTTPS health URL for the independent monitor.
|
# Optional public HTTPS health URL for the independent monitor.
|
||||||
MONITOR_URL=
|
MONITOR_URL=
|
||||||
|
# Discord Developer Portal > General Information > Public Key.
|
||||||
|
# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions
|
||||||
|
DISCORD_PUBLIC_KEY=
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Loaded by bun start and bun run db:migrate:production.
|
# Loaded by bun start and bun run db:migrate:production.
|
||||||
# Set your public origin, e.g. https://habits.example.com
|
# Set your public origin, e.g. https://habits.example.com
|
||||||
APP_ORIGIN=
|
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,
|
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
||||||
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
||||||
|
|||||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mina.teppelin.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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"]
|
||||||
56
README.md
56
README.md
@@ -1,5 +1,9 @@
|
|||||||
# Mina Habits
|
# 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
|
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.
|
client includes a homepage for signed-out visitors and a personal habit dashboard.
|
||||||
|
|
||||||
@@ -379,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
|
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.
|
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
|
Open **Settings → Reminders** to configure up to 20 reminders, each with an optional
|
||||||
remain unfinished. Choose a local time and quiet hours; times follow the account
|
name (up to 80 characters), local time, and selected weekdays or a specific date.
|
||||||
timezone. Quiet-hour start is inclusive, end is exclusive, and equal times disable
|
Select all seven days for a daily reminder. Discord messages include any reminder names that were set, along with the
|
||||||
quiet hours. If the scheduled time falls in quiet hours, delivery waits until they
|
unfinished-habit count and app link. Combined nudges include the applicable names. Reminders
|
||||||
end that same local day. A reminder missed for the whole day is not carried forward.
|
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;
|
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
|
a sharing channel is not required. Users must be reachable by the bot (normally a
|
||||||
bot (normally a shared Discord server and enabled DMs). A message contains only
|
shared Discord server and enabled DMs). Settings show the most recent delivery
|
||||||
the unfinished-habit count and app link. Settings show the most recent delivery
|
result. Uncheck the option and save to opt out. Reminders are disabled by default.
|
||||||
result, including blocked DMs or unconfirmed delivery. Uncheck the option and save
|
|
||||||
to opt out. No reminders are enabled by default.
|
|
||||||
|
|
||||||
`GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
|
`GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
|
||||||
requires authentication, matching Origin, and `{enabled, time, quietStart, quietEnd}`
|
requires authentication, matching Origin, and `{enabled, reminders, quietStart,
|
||||||
with `HH:MM` times. Per-user/date reservations persist across restarts and concurrent
|
quietEnd}`. Times use `HH:MM`. Each reminder is either
|
||||||
workers. Discord 429 retry deadlines are persisted; ambiguous message sends are
|
`{type: "weekly", name?: string, time: "09:00", days: [1, 3, 5]}` (Sunday is 0) or
|
||||||
not retried that day. Delivery records expire after 90 days. Restore disables
|
`{type: "date", name?: string, time: "18:00", date: "2026-09-08"}`.
|
||||||
reminders in the recovered database until users opt in again.
|
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
|
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
|
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
|
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.
|
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.
|
||||||
|
|||||||
17
build.ts
17
build.ts
@@ -1,6 +1,7 @@
|
|||||||
import tailwind from "bun-plugin-tailwind";
|
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 path from "node:path";
|
||||||
|
import { buildPwa } from "./scripts/build-pwa";
|
||||||
|
|
||||||
const outdir = path.join(process.cwd(), "dist");
|
const outdir = path.join(process.cwd(), "dist");
|
||||||
await rm(outdir, { recursive: true, force: true });
|
await rm(outdir, { recursive: true, force: true });
|
||||||
@@ -10,7 +11,9 @@ const result = await Bun.build({
|
|||||||
outdir,
|
outdir,
|
||||||
plugins: [tailwind],
|
plugins: [tailwind],
|
||||||
minify: true,
|
minify: true,
|
||||||
|
splitting: true,
|
||||||
target: "bun",
|
target: "bun",
|
||||||
|
external: ["@napi-rs/canvas", "/fonts/browser/*"],
|
||||||
sourcemap: "linked",
|
sourcemap: "linked",
|
||||||
define: {
|
define: {
|
||||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
@@ -24,3 +27,15 @@ if (!result.success) {
|
|||||||
for (const output of result.outputs) {
|
for (const output of result.outputs) {
|
||||||
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
|
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
4
bun-env.d.ts
vendored
@@ -9,6 +9,10 @@ declare module "*.svg" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.css" {}
|
declare module "*.css" {}
|
||||||
|
declare module "*.webmanifest" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
declare module "*.png" {
|
declare module "*.png" {
|
||||||
const path: string;
|
const path: string;
|
||||||
export default path;
|
export default path;
|
||||||
|
|||||||
30
bun.lock
30
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "mina-habits",
|
"name": "mina-habits",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.8",
|
||||||
"@nivo/calendar": "^0.99.0",
|
"@nivo/calendar": "^0.99.0",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"happy-dom": "^20.14.0",
|
"happy-dom": "^20.14.0",
|
||||||
|
"playwright": "^1.63.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
},
|
},
|
||||||
@@ -89,6 +91,30 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@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/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=="],
|
"@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=="],
|
"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": ["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=="],
|
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
env = false
|
env = false
|
||||||
|
|
||||||
[serve.static]
|
[serve.static]
|
||||||
plugins = ["bun-plugin-tailwind"]
|
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
|
||||||
env = "BUN_PUBLIC_*"
|
env = "BUN_PUBLIC_*"
|
||||||
|
|||||||
61
compose.yaml
Normal file
61
compose.yaml
Normal 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
2
deploy.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
APP_IMAGE=minabot:RELEASE
|
||||||
|
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d
|
||||||
79
deploy/README.md
Normal file
79
deploy/README.md
Normal 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
110
deploy/deploy.sh
Normal 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
93
deploy/deploy.test.ts
Normal 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);
|
||||||
|
});
|
||||||
12
deploy/git-credential-tea.ts
Normal file
12
deploy/git-credential-tea.ts
Normal 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`);
|
||||||
13
deploy/minabot-deploy.service
Normal file
13
deploy/minabot-deploy.service
Normal 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
|
||||||
11
deploy/minabot-deploy.timer
Normal file
11
deploy/minabot-deploy.timer
Normal 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
|
||||||
109
docs/DEPLOYMENT-PLAN.md
Normal file
109
docs/DEPLOYMENT-PLAN.md
Normal 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)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Deployment operations
|
# 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
|
## Backups and recovery
|
||||||
|
|
||||||
Production creates a verified SQLite snapshot before migrations and automatically
|
Production creates a verified SQLite snapshot before migrations and automatically
|
||||||
|
|||||||
102
docs/PRODUCTION.md
Normal file
102
docs/PRODUCTION.md
Normal 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
103
docs/PWA.md
Normal 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.
|
||||||
66
docs/performance.md
Normal file
66
docs/performance.md
Normal 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.
|
||||||
7
drizzle/0009_purple_wasp.sql
Normal file
7
drizzle/0009_purple_wasp.sql
Normal 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`);
|
||||||
17
drizzle/0010_tidy_thena.sql
Normal file
17
drizzle/0010_tidy_thena.sql
Normal 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`;
|
||||||
4
drizzle/0011_multiple_reminders.sql
Normal file
4
drizzle/0011_multiple_reminders.sql
Normal 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;
|
||||||
20
drizzle/0012_discord_commands.sql
Normal file
20
drizzle/0012_discord_commands.sql
Normal 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`);
|
||||||
1
drizzle/0013_habit_calendar_revision.sql
Normal file
1
drizzle/0013_habit_calendar_revision.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;
|
||||||
1
drizzle/0014_open_occurrences.sql
Normal file
1
drizzle/0014_open_occurrences.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;
|
||||||
1003
drizzle/meta/0009_snapshot.json
Normal file
1003
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1066
drizzle/meta/0010_snapshot.json
Normal file
1066
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1082
drizzle/meta/0011_snapshot.json
Normal file
1082
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1217
drizzle/meta/0012_snapshot.json
Normal file
1217
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1225
drizzle/meta/0013_snapshot.json
Normal file
1225
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1233
drizzle/meta/0014_snapshot.json
Normal file
1233
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,48 @@
|
|||||||
"when": 1788539875559,
|
"when": 1788539875559,
|
||||||
"tag": "0008_reminder_cooldown",
|
"tag": "0008_reminder_cooldown",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"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",
|
"build": "bun run build.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
|
"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:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
|
||||||
"db:restore": "bun scripts/restore.ts",
|
"db:restore": "bun scripts/restore.ts",
|
||||||
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
|
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.8",
|
||||||
"@nivo/calendar": "^0.99.0",
|
"@nivo/calendar": "^0.99.0",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"happy-dom": "^20.14.0",
|
"happy-dom": "^20.14.0",
|
||||||
|
"playwright": "^1.63.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 port = probe.port!; probe.stop(true);
|
||||||
const origin = `http://127.0.0.1:${port}`;
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
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);
|
const token = 's'.repeat(43);
|
||||||
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
||||||
async function boot() {
|
async function boot() {
|
||||||
@@ -56,6 +56,10 @@ try {
|
|||||||
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
||||||
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
||||||
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
|
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();
|
await stop(); await boot();
|
||||||
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
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');
|
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { databasePath } from "../src/db/config";
|
import { databasePath } from "../src/db/config";
|
||||||
import { backupConfig, createBackup } from "../src/ops/backups";
|
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(); }
|
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }
|
||||||
|
|||||||
9
scripts/browser-fonts-plugin.ts
Normal file
9
scripts/browser-fonts-plugin.ts
Normal 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
27
scripts/build-pwa.ts
Normal 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`);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { browserFont } from "../src/browser-fonts";
|
||||||
// Isolated browser QA server. Never opens or changes the user's database.
|
// Isolated browser QA server. Never opens or changes the user's database.
|
||||||
import { sendDueReminders } from "../src/reminders/worker";
|
import { sendDueReminders } from "../src/reminders/worker";
|
||||||
import type { DiscordFetch } from "../src/sharing/routes";
|
import type { DiscordFetch } from "../src/sharing/routes";
|
||||||
@@ -14,7 +15,7 @@ let sharedImage: Blob | null = null;
|
|||||||
let reminderMessage: string | null = null;
|
let reminderMessage: string | null = null;
|
||||||
const previewDiscord: DiscordFetch = async (url, init) => {
|
const previewDiscord: DiscordFetch = async (url, init) => {
|
||||||
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
|
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;
|
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
|
||||||
else reminderMessage = JSON.parse(String(init.body)).content;
|
else reminderMessage = JSON.parse(String(init.body)).content;
|
||||||
return Response.json({ id: "423456789012345678" });
|
return Response.json({ id: "423456789012345678" });
|
||||||
@@ -48,13 +49,14 @@ const app = createApi(
|
|||||||
let reminderRunning = false;
|
let reminderRunning = false;
|
||||||
if (sharingPreview) setInterval(async () => {
|
if (sharingPreview) setInterval(async () => {
|
||||||
if (reminderRunning) return; reminderRunning = true;
|
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; }
|
finally { reminderRunning = false; }
|
||||||
}, 1000).unref();
|
}, 1000).unref();
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
port,
|
port,
|
||||||
routes: {
|
routes: {
|
||||||
|
"/fonts/browser/*": browserFont,
|
||||||
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||||
"/__preview/login": () =>
|
"/__preview/login": () =>
|
||||||
|
|||||||
17
scripts/pwa-icons.ts
Normal file
17
scripts/pwa-icons.ts
Normal 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
194
scripts/pwa-smoke.ts
Normal 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 });
|
||||||
|
}
|
||||||
10
scripts/register-discord-commands.ts
Normal file
10
scripts/register-discord-commands.ts
Normal 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.');
|
||||||
|
}
|
||||||
18
scripts/scheduled-backup.ts
Normal file
18
scripts/scheduled-backup.ts
Normal 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(); }
|
||||||
|
}
|
||||||
169
src/App.test.tsx
169
src/App.test.tsx
@@ -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") {
|
async function render(path = "/design-system") {
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
root.render(
|
root.render(
|
||||||
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await settleDeferred();
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectAccount() {
|
function connectAccount() {
|
||||||
@@ -131,6 +138,7 @@ describe("homepage", () => {
|
|||||||
const opener = buttonNamed("Settings");
|
const opener = buttonNamed("Settings");
|
||||||
opener.focus();
|
opener.focus();
|
||||||
await act(async () => opener.click());
|
await act(async () => opener.click());
|
||||||
|
await settleDeferred();
|
||||||
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
|
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
|
||||||
expect(dialog.open).toBe(true);
|
expect(dialog.open).toBe(true);
|
||||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||||
@@ -158,6 +166,7 @@ describe("homepage", () => {
|
|||||||
expect(inner.open).toBe(true);
|
expect(inner.open).toBe(true);
|
||||||
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
|
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
|
||||||
await act(async () => buttonNamed("Finish save").click());
|
await act(async () => buttonNamed("Finish save").click());
|
||||||
|
await settleDeferred();
|
||||||
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||||
expect(container.querySelector("#inner")).toBeNull();
|
expect(container.querySelector("#inner")).toBeNull();
|
||||||
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
|
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
|
||||||
@@ -175,10 +184,13 @@ describe("homepage", () => {
|
|||||||
await f.request(`/habits/${h.id}`, "DELETE");
|
await f.request(`/habits/${h.id}`, "DELETE");
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Archived habits").click());
|
await act(async () => buttonNamed("Archived habits").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(buttonNamed("Restore Reading")).toBeDefined();
|
expect(buttonNamed("Restore Reading")).toBeDefined();
|
||||||
await act(async () => buttonNamed("View history").click());
|
await act(async () => buttonNamed("View history").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||||
await act(async () => buttonNamed("Restore Reading").click());
|
await act(async () => buttonNamed("Restore Reading").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.textContent).toContain("No archived habits.");
|
expect(container.textContent).toContain("No archived habits.");
|
||||||
expect((await f.json("/today")).habits[0].name).toBe("Reading");
|
expect((await f.json("/today")).habits[0].name).toBe("Reading");
|
||||||
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
|
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
|
||||||
@@ -190,12 +202,16 @@ describe("homepage", () => {
|
|||||||
await f.request(`/habits/${habit.id}`, "DELETE");
|
await f.request(`/habits/${habit.id}`, "DELETE");
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Archived habits").click());
|
await act(async () => buttonNamed("Archived habits").click());
|
||||||
|
await settleDeferred();
|
||||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
|
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
|
||||||
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
|
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
|
||||||
await act(async () => buttonNamed("Keep habit").click());
|
await act(async () => buttonNamed("Keep habit").click());
|
||||||
|
await settleDeferred();
|
||||||
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
|
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
|
||||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
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")!;
|
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
|
||||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
|
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
|
||||||
await act(async () => confirm().click());
|
await act(async () => confirm().click());
|
||||||
@@ -210,7 +226,7 @@ describe("homepage", () => {
|
|||||||
|
|
||||||
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
|
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
|
||||||
const f = connectAccount();
|
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);
|
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||||
f.setTime("2026-09-04T12:00:00Z");
|
f.setTime("2026-09-04T12:00:00Z");
|
||||||
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
||||||
@@ -220,12 +236,21 @@ describe("homepage", () => {
|
|||||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
|
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");
|
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 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")!;
|
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
|
||||||
expect(past).toBeDefined();
|
expect(past).toBeDefined();
|
||||||
await act(async () => past.click());
|
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-03`)).complete).toBe(true);
|
||||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
|
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");
|
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 () => {
|
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
||||||
@@ -233,6 +258,7 @@ describe("homepage", () => {
|
|||||||
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
||||||
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
|
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
|
||||||
await act(async () => buttonNamed("Increase glasses of water").click());
|
await act(async () => buttonNamed("Increase glasses of water").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
|
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -264,6 +290,7 @@ describe("homepage", () => {
|
|||||||
expect(container.textContent).toContain("Account unavailable");
|
expect(container.textContent).toContain("Account unavailable");
|
||||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||||
await act(async () => buttonNamed("Try again").click());
|
await act(async () => buttonNamed("Try again").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,6 +300,7 @@ describe("homepage", () => {
|
|||||||
expect(container.textContent).toContain("Welcome back, alice.");
|
expect(container.textContent).toContain("Welcome back, alice.");
|
||||||
expect(container.textContent).toContain("Start with one habit.");
|
expect(container.textContent).toContain("Start with one habit.");
|
||||||
await act(async () => buttonNamed("Add a habit +").click());
|
await act(async () => buttonNamed("Add a habit +").click());
|
||||||
|
await settleDeferred();
|
||||||
const input = container.querySelector<HTMLInputElement>("form input")!;
|
const input = container.querySelector<HTMLInputElement>("form input")!;
|
||||||
expect(document.activeElement).toBe(input);
|
expect(document.activeElement).toBe(input);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -282,9 +310,10 @@ describe("homepage", () => {
|
|||||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||||
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
|
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
|
||||||
expect(container.querySelector("form")).toBeNull();
|
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();
|
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
|
||||||
await act(async () => buttonNamed("Sign out").click());
|
await act(async () => buttonNamed("Sign out").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||||
expect((await f.request("/me")).status).toBe(401);
|
expect((await f.request("/me")).status).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -310,12 +339,14 @@ describe("homepage", () => {
|
|||||||
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
|
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
|
||||||
|
|
||||||
await act(async () => buttonNamed("Increase Water").click());
|
await act(async () => buttonNamed("Increase Water").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
|
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
|
||||||
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
|
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
|
||||||
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
|
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.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
|
||||||
expect(container.textContent).not.toContain("Sunday walk");
|
expect(container.textContent).not.toContain("Sunday walk");
|
||||||
await act(async () => buttonNamed("All habits · 4").click());
|
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)!;
|
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);
|
expect(checkbox("Sunday walk done").disabled).toBe(true);
|
||||||
await act(async () => checkbox("Reading done").click());
|
await act(async () => checkbox("Reading done").click());
|
||||||
@@ -323,9 +354,115 @@ describe("homepage", () => {
|
|||||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
|
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
|
||||||
expect((await f.json("/today")).completed).toBe(3);
|
expect((await f.json("/today")).completed).toBe(3);
|
||||||
await act(async () => buttonNamed("Decrease Water").click());
|
await act(async () => buttonNamed("Decrease Water").click());
|
||||||
|
await settleDeferred();
|
||||||
expect((await f.json("/today")).completed).toBe(2);
|
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 () => {
|
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
|
||||||
const f = connectAccount();
|
const f = connectAccount();
|
||||||
f.setTime("2026-09-03T12:00:00Z");
|
f.setTime("2026-09-03T12:00:00Z");
|
||||||
@@ -334,6 +471,7 @@ describe("homepage", () => {
|
|||||||
f.setTime("2026-09-04T12:00:00Z");
|
f.setTime("2026-09-04T12:00:00Z");
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Edit habit Water").click());
|
await act(async () => buttonNamed("Edit habit Water").click());
|
||||||
|
await settleDeferred();
|
||||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
|
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
|
||||||
expect(document.activeElement).toBe(form.querySelector("input"));
|
expect(document.activeElement).toBe(form.querySelector("input"));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -351,11 +489,15 @@ describe("homepage", () => {
|
|||||||
expect(container.querySelector("form")).toBeNull();
|
expect(container.querySelector("form")).toBeNull();
|
||||||
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
||||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||||
|
await settleDeferred();
|
||||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||||
await act(async () => buttonNamed("Cancel").click());
|
await act(async () => buttonNamed("Cancel").click());
|
||||||
|
await settleDeferred();
|
||||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||||
|
await settleDeferred();
|
||||||
await act(async () => buttonNamed("Archive habit").click());
|
await act(async () => buttonNamed("Archive habit").click());
|
||||||
|
await settleDeferred();
|
||||||
expect((await f.json("/habits")).habits).toHaveLength(0);
|
expect((await f.json("/habits")).habits).toHaveLength(0);
|
||||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||||
@@ -374,6 +516,7 @@ describe("homepage", () => {
|
|||||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
|
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Edit task Stretch").click());
|
await act(async () => buttonNamed("Edit task Stretch").click());
|
||||||
|
await settleDeferred();
|
||||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
|
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const input = form.querySelector("input")!;
|
const input = form.querySelector("input")!;
|
||||||
@@ -386,7 +529,9 @@ describe("homepage", () => {
|
|||||||
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
|
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
|
||||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||||
await act(async () => buttonNamed("Delete task Stretch gently").click());
|
await act(async () => buttonNamed("Delete task Stretch gently").click());
|
||||||
|
await settleDeferred();
|
||||||
await act(async () => buttonNamed("Delete task").click());
|
await act(async () => buttonNamed("Delete task").click());
|
||||||
|
await settleDeferred();
|
||||||
const today = (await f.json("/today")).habits[0];
|
const today = (await f.json("/today")).habits[0];
|
||||||
expect(today.target).toBe(1);
|
expect(today.target).toBe(1);
|
||||||
expect(today.value).toBe(0);
|
expect(today.value).toBe(0);
|
||||||
@@ -402,9 +547,11 @@ describe("homepage", () => {
|
|||||||
await render("/");
|
await render("/");
|
||||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||||
await act(async () => buttonNamed("All habits · 1").click());
|
await act(async () => buttonNamed("All habits · 1").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.textContent).toContain("Not scheduled today");
|
expect(container.textContent).toContain("Not scheduled today");
|
||||||
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
|
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
|
||||||
await act(async () => buttonNamed("Edit task Walk").click());
|
await act(async () => buttonNamed("Edit task Walk").click());
|
||||||
|
await settleDeferred();
|
||||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const input = form.querySelector("input")!;
|
const input = form.querySelector("input")!;
|
||||||
@@ -444,6 +591,7 @@ describe("homepage", () => {
|
|||||||
{ type: "daily" },
|
{ type: "daily" },
|
||||||
] as const) {
|
] as const) {
|
||||||
await act(async () => buttonNamed("Edit task Walk").click());
|
await act(async () => buttonNamed("Edit task Walk").click());
|
||||||
|
await settleDeferred();
|
||||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||||
await selectValue(form.querySelector("select")!, schedule.type);
|
await selectValue(form.querySelector("select")!, schedule.type);
|
||||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||||
@@ -456,6 +604,7 @@ describe("homepage", () => {
|
|||||||
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
|
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
|
||||||
expect(container.querySelector("form")).toBeNull();
|
expect(container.querySelector("form")).toBeNull();
|
||||||
await act(async () => buttonNamed("Edit task Walk").click());
|
await act(async () => buttonNamed("Edit task Walk").click());
|
||||||
|
await settleDeferred();
|
||||||
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||||
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
|
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
|
||||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||||
@@ -463,6 +612,7 @@ describe("homepage", () => {
|
|||||||
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
|
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
|
||||||
}
|
}
|
||||||
await act(async () => buttonNamed("Cancel").click());
|
await act(async () => buttonNamed("Cancel").click());
|
||||||
|
await settleDeferred();
|
||||||
}
|
}
|
||||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||||
expect(previous.tasks[0].done).toBe(true);
|
expect(previous.tasks[0].done).toBe(true);
|
||||||
@@ -482,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));
|
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 act(async () => buttonNamed("Edit habit Reading").click());
|
||||||
|
await settleDeferred();
|
||||||
await selectMethod("count");
|
await selectMethod("count");
|
||||||
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
|
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
|
||||||
await submit();
|
await submit();
|
||||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
|
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
|
||||||
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
|
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
|
||||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
|
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
|
||||||
await selectMethod("tasks");
|
await selectMethod("tasks");
|
||||||
await submit();
|
await submit();
|
||||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
|
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"]')!;
|
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const input = form.querySelector("input")!;
|
const input = form.querySelector("input")!;
|
||||||
@@ -512,16 +665,19 @@ describe("homepage", () => {
|
|||||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||||
await render("/");
|
await render("/");
|
||||||
await act(async () => buttonNamed("Archive habit Reading").click());
|
await act(async () => buttonNamed("Archive habit Reading").click());
|
||||||
|
await settleDeferred();
|
||||||
fetchMock.mockImplementationOnce((async (input, init) => {
|
fetchMock.mockImplementationOnce((async (input, init) => {
|
||||||
return f.request(String(input).replace("/api", ""), init?.method);
|
return f.request(String(input).replace("/api", ""), init?.method);
|
||||||
}) as typeof fetch);
|
}) as typeof fetch);
|
||||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||||
await act(async () => buttonNamed("Archive habit").click());
|
await act(async () => buttonNamed("Archive habit").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector("form")).toBeNull();
|
expect(container.querySelector("form")).toBeNull();
|
||||||
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
||||||
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
|
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
|
||||||
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||||
await act(async () => buttonNamed("Try again").click());
|
await act(async () => buttonNamed("Try again").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||||
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
|
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -532,10 +688,12 @@ describe("homepage", () => {
|
|||||||
await render("/");
|
await render("/");
|
||||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
|
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
|
||||||
await act(async () => buttonNamed("Increase Water").click());
|
await act(async () => buttonNamed("Increase Water").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
|
||||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
|
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
|
||||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||||
await act(async () => buttonNamed("Increase Water").click());
|
await act(async () => buttonNamed("Increase Water").click());
|
||||||
|
await settleDeferred();
|
||||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
|
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
|
||||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -766,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 });
|
return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
|
||||||
}) as typeof fetch);
|
}) as typeof fetch);
|
||||||
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
|
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 () => {
|
await act(async () => {
|
||||||
time.value = "14:30";
|
time.value = "14:30";
|
||||||
|
time.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||||
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
|
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));
|
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<HTMLInputElement>('input[type="checkbox"]')!.click());
|
||||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||||
expect(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" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router";
|
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 { Home } from "./pages/Home";
|
||||||
import { AuthProvider } from "./components/AuthProvider";
|
import { AuthProvider } from "./components/AuthProvider";
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
|
|||||||
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
|
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
|
||||||
const app = new Hono<AuthEnv>();
|
const app = new Hono<AuthEnv>();
|
||||||
app.use("*", auth.requireAuth);
|
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 user = c.get("user");
|
||||||
const service = new HabitService(db, user, now()); service.sync();
|
|
||||||
const data = db.transaction(tx => {
|
const data = db.transaction(tx => {
|
||||||
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
|
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));
|
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||||
@@ -31,11 +30,12 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
|||||||
});
|
});
|
||||||
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
|
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
}));
|
||||||
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
|
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
|
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
|
||||||
const userId = c.get("user").id;
|
const userId = c.get("user").id;
|
||||||
|
return HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||||
db.transaction(tx => {
|
db.transaction(tx => {
|
||||||
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
|
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));
|
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||||
@@ -51,5 +51,6 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
|||||||
auth.clearSession(c);
|
auth.clearSession(c);
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/api.ts
19
src/api.ts
@@ -1,3 +1,5 @@
|
|||||||
|
import { createDiscordRoutes, type CommandConfig } from "./discord/routes";
|
||||||
|
import { createLiveDiscord } from "./sharing/live-discord";
|
||||||
import { createReminderRoutes } from "./reminders/routes";
|
import { createReminderRoutes } from "./reminders/routes";
|
||||||
import { errorDetails } from "./ops/monitor";
|
import { errorDetails } from "./ops/monitor";
|
||||||
import { createAccountRoutes } from "./account/routes";
|
import { createAccountRoutes } from "./account/routes";
|
||||||
@@ -11,26 +13,37 @@ import type { FetchDiscord } from "./auth/discord";
|
|||||||
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
||||||
import type { DiscordSharingConfig } from "./sharing/config";
|
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>();
|
const app = new Hono<AuthEnv>();
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
|
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();
|
await next();
|
||||||
});
|
});
|
||||||
const auth = createAuth(db, config, request, now);
|
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 => {
|
app.get("/api/health", c => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
db.get(sql`SELECT 1`);
|
db.get(sql`SELECT 1`);
|
||||||
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
||||||
return c.json({ status: "ok" });
|
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/auth", auth.routes);
|
||||||
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||||
app.route("/api/account", createAccountRoutes(db, auth, 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.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
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.notFound(c => c.json({ error: "Not found" }, 404));
|
||||||
app.onError((_error, c) => {
|
app.onError((_error, c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
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);
|
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
|
||||||
return c.json({ error: "Internal server error", requestId }, 500);
|
return c.json({ error: "Internal server error", requestId }, 500);
|
||||||
});
|
});
|
||||||
return app;
|
return Object.assign(app, { stopLiveSharing: liveSharing.stop, flushLiveSharing: liveSharing.flush });
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
Binary file not shown.
93
src/assets/fonts/InstrumentSerif-OFL.txt
Normal file
93
src/assets/fonts/InstrumentSerif-OFL.txt
Normal 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.
|
||||||
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
Binary file not shown.
94
src/assets/fonts/NotoSans-LICENSE.txt
Normal file
94
src/assets/fonts/NotoSans-LICENSE.txt
Normal 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.
|
||||||
BIN
src/assets/fonts/NotoSans-Medium.ttf
Normal file
BIN
src/assets/fonts/NotoSans-Medium.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Regular.ttf
Normal file
BIN
src/assets/fonts/NotoSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-SemiBold.ttf
Normal file
BIN
src/assets/fonts/NotoSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
Binary file not shown.
35
src/assets/fonts/README.md
Normal file
35
src/assets/fonts/README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -55,6 +55,32 @@ beforeEach(() => {
|
|||||||
afterEach(() => sqlite.close());
|
afterEach(() => sqlite.close());
|
||||||
|
|
||||||
describe("Discord sign-in and sessions", () => {
|
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 () => {
|
test("me requires a valid session and health stays public", async () => {
|
||||||
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
||||||
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AuthConfig = {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
cookieSecret: string;
|
cookieSecret: string;
|
||||||
|
trustProxy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readAuthConfig(env = process.env): AuthConfig {
|
export function readAuthConfig(env = process.env): AuthConfig {
|
||||||
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
|
|||||||
clientId: env.DISCORD_CLIENT_ID ?? "",
|
clientId: env.DISCORD_CLIENT_ID ?? "",
|
||||||
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
||||||
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
||||||
|
trustProxy: env.TRUST_PROXY === "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
discordId: user.discordId,
|
discordId: user.discordId,
|
||||||
@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
|
|||||||
routes.get("/discord", async c => {
|
routes.get("/discord", async c => {
|
||||||
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
||||||
// Use one configured origin for redirects and cookie ownership.
|
// 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();
|
const state = randomToken();
|
||||||
await setSignedCookie(c, stateName, JSON.stringify({
|
await setSignedCookie(c, stateName, JSON.stringify({
|
||||||
state,
|
state,
|
||||||
|
|||||||
14
src/browser-fonts.test.ts
Normal file
14
src/browser-fonts.test.ts
Normal 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
18
src/browser-fonts.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
|||||||
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||||
finally { saving.current = false; setBusy(false); }
|
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">
|
return <WorkspacePanel id="account-settings" title="Account settings" onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
|
||||||
<SettingRow title="Appearance" description={<p>Follow your device or choose a theme for this browser.</p>}><ThemeControl /></SettingRow>
|
<SettingRow title="Appearance"><ThemeControl /></SettingRow>
|
||||||
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
|
<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 }) })); }}>
|
<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">
|
<Field label="Timezone">
|
||||||
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
|
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
|
||||||
@@ -36,7 +36,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
|||||||
</form>
|
</form>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
|
<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>}>
|
<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>
|
<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>
|
||||||
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
|||||||
setDeleteError(message); if (message.includes("session has expired")) expired.current();
|
setDeleteError(message); if (message.includes("session has expired")) expired.current();
|
||||||
} finally { saving.current = false; setBusy(false); }
|
} 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">
|
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>
|
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
|
||||||
<FormMessage error>{error}</FormMessage>
|
<FormMessage error>{error}</FormMessage>
|
||||||
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
||||||
@@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</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>)}
|
</article>)}
|
||||||
</div>
|
</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)}>
|
{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)}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function signIn() {
|
function signIn() {
|
||||||
|
if (navigator.onLine === false) return;
|
||||||
let timezone = "UTC";
|
let timezone = "UTC";
|
||||||
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
||||||
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||||
@@ -21,6 +22,7 @@ type AuthState = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
accountError: string;
|
accountError: string;
|
||||||
|
connectionError: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
signIn: () => void;
|
signIn: () => void;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [accountError, setAccountError] = useState("");
|
const [accountError, setAccountError] = useState("");
|
||||||
|
const [connectionError, setConnectionError] = useState(false);
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
|
let reachedServer = false;
|
||||||
try {
|
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 (controller.signal.aborted) return;
|
||||||
if (response.status === 401) { setUser(null); return; }
|
if (response.status === 401) { setUser(null); return; }
|
||||||
if (!response.ok) throw new Error("Could not load your account. Please try again.");
|
if (!response.ok) throw new Error("Could not load your account. Please try again.");
|
||||||
const account: PublicUser = await response.json();
|
const account: PublicUser = await response.json();
|
||||||
if (!controller.signal.aborted) setUser(account);
|
if (!controller.signal.aborted) setUser(account);
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [attempt]);
|
}, [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() {
|
function retry() {
|
||||||
setAccountError("");
|
setAccountError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
|
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
|
||||||
const response = await fetch("/api/auth/logout", { method: "POST" });
|
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||||
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
27
src/components/CalendarCache.test.ts
Normal file
27
src/components/CalendarCache.test.ts
Normal 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);
|
||||||
|
});
|
||||||
34
src/components/CalendarCache.tsx
Normal file
34
src/components/CalendarCache.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Window } from "happy-dom";
|
|||||||
import { act, useState } from "react";
|
import { act, useState } from "react";
|
||||||
import type { Root } from "react-dom/client";
|
import type { Root } from "react-dom/client";
|
||||||
import { HabitForm } from "./HabitForm";
|
import { HabitForm } from "./HabitForm";
|
||||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
import { HABIT_COLOR_CHOICES, HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||||
|
|
||||||
const dom = new Window({ url: "http://localhost:3000/design-system" });
|
const dom = new Window({ url: "http://localhost:3000/design-system" });
|
||||||
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||||
@@ -46,33 +46,33 @@ function button(text: string) {
|
|||||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("habit starters remain editable and custom creation resets the draft", async () => {
|
test("blank habit creation exposes tracking-specific fields without templates", async () => {
|
||||||
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
|
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
|
||||||
await act(async () => button("Reading").click());
|
|
||||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read");
|
|
||||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("count");
|
|
||||||
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
|
|
||||||
await act(async () => button("Evening routine").click());
|
|
||||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("tasks");
|
|
||||||
expect([...container.querySelectorAll("input")].map(input => input.value)).toContain("Prepare for tomorrow");
|
|
||||||
await act(async () => button("Start from scratch").click());
|
|
||||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
|
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
|
||||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("manual");
|
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("compact palette discloses full controls and preserves custom color", async () => {
|
test("continuous colors disclose custom controls and preserve the selected color", async () => {
|
||||||
function Picker() {
|
function Picker() {
|
||||||
const [color, setColor] = useState("#123456");
|
const [color, setColor] = useState("#123456");
|
||||||
return <HabitColorPicker value={color} onChange={setColor} compact />;
|
return <HabitColorPicker value={color} onChange={setColor} />;
|
||||||
}
|
}
|
||||||
await act(async () => root.render(<Picker />));
|
await act(async () => root.render(<Picker />));
|
||||||
expect(container.querySelector('[aria-label="Suggested colors"]')?.querySelectorAll("button").length).toBe(8);
|
expect(container.querySelector('[aria-label="Habit colors"]')?.querySelectorAll('button[aria-label^="Color "]').length).toBe(HABIT_COLOR_CHOICES.length);
|
||||||
const disclosure = button("More colors");
|
const disclosure = button("Custom");
|
||||||
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
|
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
|
||||||
expect(details.hidden).toBe(true);
|
expect(details.hidden).toBe(true);
|
||||||
await act(async () => disclosure.click());
|
await act(async () => disclosure.click());
|
||||||
expect(details.hidden).toBe(false);
|
expect(details.hidden).toBe(false);
|
||||||
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
|
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
|
||||||
await act(async () => button("Fewer colors").click());
|
await act(async () => button("Custom").click());
|
||||||
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
|
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
|||||||
import { habitInput, type Schedule } from "../habits/contracts";
|
import { habitInput, type Schedule } from "../habits/contracts";
|
||||||
import { habitRequest } from "../lib/dashboard";
|
import { habitRequest } from "../lib/dashboard";
|
||||||
|
|
||||||
const STARTERS = [
|
|
||||||
{ label: "Reading", name: "Read", method: "count", target: 10, unit: "pages", tasks: [""], color: "#426582" },
|
|
||||||
{ label: "Get outside", name: "Get outside", method: "manual", target: 1, unit: "times", tasks: [""], color: "#58765b" },
|
|
||||||
{ label: "Evening routine", name: "Wind down", method: "tasks", target: 1, unit: "times", tasks: ["Put things away", "Prepare for tomorrow"], color: "#79618d" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Account behavior composed from the design system's existing form components. */
|
/** Account behavior composed from the design system's existing form components. */
|
||||||
export function HabitForm({
|
export function HabitForm({
|
||||||
date,
|
date,
|
||||||
@@ -33,22 +27,10 @@ export function HabitForm({
|
|||||||
const [unit, setUnit] = useState("pages");
|
const [unit, setUnit] = useState("pages");
|
||||||
const [tasks, setTasks] = useState([""]);
|
const [tasks, setTasks] = useState([""]);
|
||||||
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
|
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
|
||||||
const [color, setColor] = useState("#58765b");
|
const [color, setColor] = useState("#3e805d");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
function applyStarter(starter: (typeof STARTERS)[number] | null) {
|
|
||||||
setName(starter?.name ?? "");
|
|
||||||
setMethod(starter?.method ?? "manual");
|
|
||||||
setTarget(starter?.target ?? 10);
|
|
||||||
setUnit(starter?.unit ?? "pages");
|
|
||||||
setTasks(starter ? [...starter.tasks] : [""]);
|
|
||||||
setColor(starter?.color ?? "#58765b");
|
|
||||||
setSchedule({ type: "daily" });
|
|
||||||
setError("");
|
|
||||||
form.current?.querySelector<HTMLInputElement>("input")?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current) return;
|
if (saving.current) return;
|
||||||
@@ -93,17 +75,9 @@ export function HabitForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Schedules follow 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}>
|
<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}>
|
||||||
<div className="ds-habit-starters" role="group" aria-label="Start with an idea">
|
|
||||||
<div className="ds-habit-starter-options">
|
|
||||||
{STARTERS.map((starter) => (
|
|
||||||
<Button key={starter.label} variant="secondary" onClick={() => applyStarter(starter)}>{starter.label}</Button>
|
|
||||||
))}
|
|
||||||
<Button variant="text" onClick={() => applyStarter(null)}>Start from scratch</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Field label="Habit name">
|
<Field label="Habit name">
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<input
|
<input
|
||||||
@@ -116,7 +90,7 @@ export function HabitForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="How will you track it?">
|
<Field label="Tracking">
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<select
|
<select
|
||||||
id={id}
|
id={id}
|
||||||
@@ -131,9 +105,10 @@ export function HabitForm({
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||||
{method === "count" && (
|
{method === "count" && (
|
||||||
<div className="ds-form-grid">
|
<div className="ds-form-grid">
|
||||||
<Field label="Target per scheduled day">
|
<Field label="Daily target">
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -202,17 +177,9 @@ export function HabitForm({
|
|||||||
>
|
>
|
||||||
Add task +
|
Add task +
|
||||||
</Button>
|
</Button>
|
||||||
<p className="ds-form-feedback">
|
|
||||||
Tasks repeat on each scheduled day.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ScheduleEditor
|
<HabitColorPicker value={color} onChange={setColor} mode="create" />
|
||||||
value={schedule}
|
|
||||||
onChange={setSchedule}
|
|
||||||
anchorDate={date}
|
|
||||||
/>
|
|
||||||
<HabitColorPicker value={color} onChange={setColor} mode="create" compact />
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="ds-form-feedback" role="alert">
|
<p className="ds-form-feedback" role="alert">
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { useCalendarCache } from "./CalendarCache";
|
||||||
import { Modal } from "./design-system/Modal";
|
import { Modal } from "./design-system/Modal";
|
||||||
import { Pencil } from "lucide-react";
|
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 type { CalendarResponse } from "../shared/calendar";
|
||||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
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 { Button } from "./design-system/primitives";
|
||||||
import { HabitChart } from "./design-system/HabitChart";
|
import { HabitChart } from "./design-system/HabitChart";
|
||||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||||
import { shade } from "../habits/calendar";
|
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 { Field } from "./design-system/Field";
|
||||||
import { ItemActions } from "./design-system/ItemActions";
|
import { ItemActions } from "./design-system/ItemActions";
|
||||||
|
|
||||||
@@ -40,7 +42,29 @@ export function HabitHistory({
|
|||||||
onHistorySaved?: () => void;
|
onHistorySaved?: () => void;
|
||||||
calendarOnly?: boolean;
|
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>();
|
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 [editingDay, setEditingDay] = useState(false);
|
||||||
const [savingDay, setSavingDay] = useState(false);
|
const [savingDay, setSavingDay] = useState(false);
|
||||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||||
@@ -49,26 +73,20 @@ export function HabitHistory({
|
|||||||
const expired = useRef(onExpired);
|
const expired = useRef(onExpired);
|
||||||
expired.current = onExpired;
|
expired.current = onExpired;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
let active = true;
|
||||||
setError("");
|
setError("");
|
||||||
const range = monthWindow(date, 12);
|
setHistoryLoading(true);
|
||||||
habitRequest<CalendarResponse>(
|
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
|
||||||
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
|
.then(result => { if (active) setCalendar(result); })
|
||||||
{ signal: controller.signal },
|
.catch(error => {
|
||||||
)
|
if (!active) return;
|
||||||
.then((result) => {
|
if (error.message.includes("session has expired") && expired.current) expired.current();
|
||||||
if (!controller.signal.aborted) setCalendar(result);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
if (error.message.includes("session has expired") && expired.current)
|
|
||||||
expired.current();
|
|
||||||
else setError(error.message);
|
else setError(error.message);
|
||||||
});
|
}).finally(() => { if (active) setHistoryLoading(false); });
|
||||||
return () => controller.abort();
|
return () => { active = false; };
|
||||||
}, [habit.habitId, date, revision, attempt]);
|
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
||||||
|
|
||||||
const days =
|
const days = useMemo(() =>
|
||||||
calendar?.days.map((day) => ({
|
calendar?.days.map((day) => ({
|
||||||
date: day.date,
|
date: day.date,
|
||||||
color: day.color,
|
color: day.color,
|
||||||
@@ -80,14 +98,14 @@ export function HabitHistory({
|
|||||||
: day.due
|
: day.due
|
||||||
? ("due" as const)
|
? ("due" as const)
|
||||||
: ("not-due" as const),
|
: ("not-due" as const),
|
||||||
})) ?? [];
|
})) ?? [], [calendar]);
|
||||||
const steps = Math.max(
|
const steps = Math.max(
|
||||||
1,
|
1,
|
||||||
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
||||||
habit.target ??
|
habit.target ??
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
const shades = calendar
|
const shades = useMemo(() => calendar
|
||||||
? [
|
? [
|
||||||
calendar.settings.emptyColor,
|
calendar.settings.emptyColor,
|
||||||
...Array.from(
|
...Array.from(
|
||||||
@@ -101,7 +119,7 @@ export function HabitHistory({
|
|||||||
).color,
|
).color,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [];
|
: [], [calendar, steps]);
|
||||||
const chart = error ? (
|
const chart = error ? (
|
||||||
<div className="ds-form-feedback">
|
<div className="ds-form-feedback">
|
||||||
<p role="alert">{error}</p>
|
<p role="alert">{error}</p>
|
||||||
@@ -119,17 +137,20 @@ export function HabitHistory({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CalendarHeatmap
|
<CalendarHeatmap
|
||||||
onSelectDate={setSelectedDate}
|
onSelectDate={selectDate}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
compact
|
compact
|
||||||
initialMonths={3}
|
initialMonths={3}
|
||||||
|
availableRange={monthWindow(date, 12)}
|
||||||
|
onRangeChange={requestRange}
|
||||||
|
loading={historyLoading}
|
||||||
historyLabel="Your recorded progress"
|
historyLabel="Your recorded progress"
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
unit={habit.unit}
|
unit={habit.unit}
|
||||||
color={calendar.settings.mainColor}
|
color={calendar.settings.mainColor}
|
||||||
emptyColor={calendar.settings.emptyColor}
|
emptyColor={calendar.settings.emptyColor}
|
||||||
days={days}
|
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={
|
legend={
|
||||||
<CalendarLegend
|
<CalendarLegend
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
@@ -139,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-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></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}
|
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||||
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||||
</div></Modal>}
|
</div></Modal>}
|
||||||
|
|||||||
@@ -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(" "));
|
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||||
await onSave(parsed.data);
|
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"])}>
|
{(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="manual">A simple check-in</option>
|
||||||
<option value="count">A count target</option>
|
<option value="count">A count target</option>
|
||||||
<option value="tasks">A list of tasks</option>
|
<option value="tasks">A list of tasks</option>
|
||||||
</select>}
|
</select>}
|
||||||
</Field>
|
</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 !== 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" && (<>
|
{method === "count" && (<>
|
||||||
<div className="ds-form-grid">
|
<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>
|
<Field label="Unit">{(id) => <input id={id} required maxLength={80} value={unit} onChange={(event) => setUnit(event.target.value)} />}</Field>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
|
<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" />
|
<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>
|
</InlineItemForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
|
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
|
||||||
|
|
||||||
return (
|
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}`}
|
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -48,7 +48,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{mode === "edit" ? (
|
{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"}>
|
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
|
||||||
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
|
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -57,7 +57,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
|||||||
) : (
|
) : (
|
||||||
<p className="ds-inline-delete-copy">
|
<p className="ds-inline-delete-copy">
|
||||||
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
|
{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."}
|
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule,
|
|||||||
await onSave(parsed.data);
|
await onSave(parsed.data);
|
||||||
}}>
|
}}>
|
||||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
<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>
|
<p className="ds-footnote">Runs when both schedules match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||||
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
|
{!creating && <p className="ds-footnote">Applies from today. Earlier check-ins stay unchanged.</p>}
|
||||||
</InlineItemForm>
|
</InlineItemForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/components/LiveDiscordShare.tsx
Normal file
65
src/components/LiveDiscordShare.tsx
Normal 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>;
|
||||||
|
}
|
||||||
43
src/components/PwaControls.tsx
Normal file
43
src/components/PwaControls.tsx
Normal 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>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
|
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { habitRequest } from "../lib/dashboard";
|
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 { Button, Checkbox } from "./design-system/primitives";
|
||||||
import { Field } from "./design-system/Field";
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
const deliveryLabels: Record<string, string> = {
|
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",
|
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 }) {
|
export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
|
||||||
const [settings, setSettings] = useState<ReminderResponse>();
|
const [settings, setSettings] = useState<ReminderResponse>();
|
||||||
const [draft, setDraft] = useState(defaultReminder);
|
const [draft, setDraft] = useState({ ...defaultReminder, reminders: entries(defaultReminder) });
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -20,31 +20,42 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController(); setError("");
|
const controller = new AbortController(); setError("");
|
||||||
habitRequest<ReminderResponse>("/reminders", { signal: controller.signal })
|
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); });
|
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [attempt]);
|
}, [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 => {
|
{!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;
|
event.preventDefault(); if (saving.current || disabled) return;
|
||||||
const form = event.currentTarget;
|
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("");
|
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
|
||||||
try {
|
try {
|
||||||
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
|
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
|
||||||
setDraft(submitted);
|
setDraft(value => ({ ...value, ...submitted }));
|
||||||
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
|
setNotice(submitted.enabled ? "Discord reminders saved." : "Reminders turned off.");
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
||||||
finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
|
finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
|
||||||
}}>
|
}}>
|
||||||
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
|
{!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}>
|
<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">
|
<div className="ds-clock-fields">
|
||||||
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
|
<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">
|
||||||
|
{([['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>)}
|
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
|
||||||
</div>
|
</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>
|
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
|
||||||
</fieldset>
|
</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>}
|
{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>}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LiveDiscordShare } from "./LiveDiscordShare";
|
||||||
import { ThemeControl, useTheme } from "./ThemeProvider";
|
import { ThemeControl, useTheme } from "./ThemeProvider";
|
||||||
import { Modal } from "./design-system/Modal";
|
import { Modal } from "./design-system/Modal";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -12,6 +13,7 @@ import type { Delivery, DiscordConnection, ShareData } from "../sharing/contract
|
|||||||
|
|
||||||
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
|
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [mode, setMode] = useState<"snapshot" | "live">("live");
|
||||||
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
|
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
|
||||||
const [thumbnail, setThumbnail] = useState(true);
|
const [thumbnail, setThumbnail] = useState(true);
|
||||||
const [range, setRange] = useState("30");
|
const [range, setRange] = useState("30");
|
||||||
@@ -44,6 +46,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
}, [connectionAttempt]);
|
}, [connectionAttempt]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
if (mode === "live") return () => controller.abort();
|
||||||
let objectUrl: string | undefined;
|
let objectUrl: string | undefined;
|
||||||
setError(""); setSendError("");
|
setError(""); setSendError("");
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
@@ -62,7 +65,7 @@ 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."); }
|
} catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); }
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||||
}, [key, renderAttempt]);
|
}, [key, renderAttempt, mode]);
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (!ready || !connection?.connected || lock.current || sent) return;
|
if (!ready || !connection?.connected || lock.current || sent) return;
|
||||||
@@ -73,7 +76,13 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
finally { lock.current = false; setSending(false); }
|
finally { lock.current = false; setSending(false); }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what you’ll share." 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">
|
<div className="ds-share-layout">
|
||||||
<section className="ds-share-preview-column" aria-label="Card preview">
|
<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-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
|
||||||
@@ -82,7 +91,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
|
<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>}
|
{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>
|
</div>
|
||||||
<p className="ds-share-caption">{thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.</p>
|
<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>}
|
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldn’t load. A neutral profile icon is used.</p>}
|
||||||
</section>
|
</section>
|
||||||
<div className="ds-share-controls">
|
<div className="ds-share-controls">
|
||||||
@@ -95,7 +104,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||||
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
|
<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>
|
<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">Choose up to six. Task names stay private.</p>
|
<p className="ds-share-hint">Task names stay private.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
|
<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>
|
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">Show on the card</legend>
|
||||||
@@ -109,13 +118,14 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
|||||||
</div>
|
</div>
|
||||||
<footer className="ds-share-footer">
|
<footer className="ds-share-footer">
|
||||||
<div className="ds-share-footer-copy" aria-live="polite">
|
<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 when you are." : "Your card is taking shape."}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
|
{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>
|
||||||
<div className="ds-share-actions">
|
<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>}
|
{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>
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</>}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
|
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
@@ -37,9 +38,9 @@ export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
|
|||||||
{ value: "light", label: "Light", Icon: Sun },
|
{ value: "light", label: "Light", Icon: Sun },
|
||||||
{ value: "dark", label: "Dark", Icon: Moon },
|
{ value: "dark", label: "Dark", Icon: Moon },
|
||||||
] as const).map(({ value, label, Icon }) => (
|
] as const).map(({ value, label, Icon }) => (
|
||||||
<label className="ds-theme-option" key={value}>
|
<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)} />
|
<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" />{label}</span>
|
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" /><span className="ds-visually-hidden">{label}</span></span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTheme } from "../ThemeProvider";
|
import { useTheme } from "../ThemeProvider";
|
||||||
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
|
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
|
||||||
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import { CalendarLegend } from "./CalendarLegend";
|
import { CalendarLegend } from "./CalendarLegend";
|
||||||
import { Field } from "./Field";
|
import { Field } from "./Field";
|
||||||
import { Button } from "./primitives";
|
import { Button } from "./primitives";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
calendarTimeline,
|
calendarTimeline,
|
||||||
MONTH_VIEWS,
|
MONTH_VIEWS,
|
||||||
visibleCalendarDays,
|
visibleCalendarDays,
|
||||||
|
monthWindow,
|
||||||
type CalendarDay,
|
type CalendarDay,
|
||||||
type MonthView,
|
type MonthView,
|
||||||
} from "./calendar-model";
|
} from "./calendar-model";
|
||||||
@@ -26,7 +27,13 @@ export function CalendarHeatmap({
|
|||||||
historyLabel,
|
historyLabel,
|
||||||
legend,
|
legend,
|
||||||
selectedDayAction,
|
selectedDayAction,
|
||||||
|
availableRange,
|
||||||
|
onRangeChange,
|
||||||
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
|
availableRange?: { from: string; to: string };
|
||||||
|
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
|
||||||
|
loading?: boolean;
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
unit: string;
|
unit: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -65,15 +72,15 @@ export function CalendarHeatmap({
|
|||||||
if (onSelectDate) onSelectDate(date);
|
if (onSelectDate) onSelectDate(date);
|
||||||
else setLocalSelectedDate(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)
|
? 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 =
|
const selected =
|
||||||
days.find((day) => day.date === selectedDate) ??
|
days.find((day) => day.date === selectedDate) ??
|
||||||
days.find((day) => day.date === anchor) ??
|
days.find((day) => day.date === anchor) ??
|
||||||
days[0];
|
days[0];
|
||||||
// Date keys and the shared timeline remain stable when the chart resizes.
|
// 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 buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
const inspectorId = useId();
|
const inspectorId = useId();
|
||||||
const instructionsId = useId();
|
const instructionsId = useId();
|
||||||
@@ -100,6 +107,7 @@ export function CalendarHeatmap({
|
|||||||
<div
|
<div
|
||||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||||
data-months={months}
|
data-months={months}
|
||||||
|
aria-busy={loading}
|
||||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="ds-calendar-range-toolbar">
|
<div className="ds-calendar-range-toolbar">
|
||||||
@@ -110,13 +118,14 @@ export function CalendarHeatmap({
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
})}{" "}
|
})}{" "}
|
||||||
–{" "}
|
{months !== 1 && <>–{" "}
|
||||||
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: months === "custom" ? "numeric" : undefined,
|
day: months === "custom" ? "numeric" : undefined,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
})}
|
})}
|
||||||
|
</>}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="ds-month-views"
|
className="ds-month-views"
|
||||||
@@ -127,9 +136,9 @@ export function CalendarHeatmap({
|
|||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${value} months`}
|
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||||
aria-pressed={months === value}
|
aria-pressed={months === value}
|
||||||
onClick={() => setMonths(value)}
|
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }}
|
||||||
>
|
>
|
||||||
{value}m
|
{value}m
|
||||||
</button>
|
</button>
|
||||||
@@ -141,6 +150,7 @@ export function CalendarHeatmap({
|
|||||||
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
||||||
setRangeError("");
|
setRangeError("");
|
||||||
setMonths("custom");
|
setMonths("custom");
|
||||||
|
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Custom
|
Custom
|
||||||
@@ -159,7 +169,7 @@ export function CalendarHeatmap({
|
|||||||
const end = form.elements.namedItem("to") as HTMLInputElement;
|
const end = form.elements.namedItem("to") as HTMLInputElement;
|
||||||
const isAvailable = (input: HTMLInputElement) =>
|
const isAvailable = (input: HTMLInputElement) =>
|
||||||
input.validity.valid && input.value !== "" &&
|
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)) {
|
if (!isAvailable(start) || !isAvailable(end)) {
|
||||||
setRangeError("Choose both dates within the available history.");
|
setRangeError("Choose both dates within the available history.");
|
||||||
(!isAvailable(start) ? start : end).focus();
|
(!isAvailable(start) ? start : end).focus();
|
||||||
@@ -170,11 +180,12 @@ export function CalendarHeatmap({
|
|||||||
end.focus();
|
end.focus();
|
||||||
return;
|
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.");
|
setRangeError("There are no calendar dates in this range.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCustomRange({ from: start.value, to: end.value });
|
setCustomRange({ from: start.value, to: end.value });
|
||||||
|
onRangeChange?.({ from: start.value, to: end.value }, "custom");
|
||||||
setRangeError("");
|
setRangeError("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -186,8 +197,8 @@ export function CalendarHeatmap({
|
|||||||
name={name}
|
name={name}
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
min={allDays[0]!.date}
|
min={availableRange?.from ?? allDays[0]!.date}
|
||||||
max={allDays.at(-1)!.date}
|
max={availableRange?.to ?? allDays.at(-1)!.date}
|
||||||
defaultValue={customRange[name]}
|
defaultValue={customRange[name]}
|
||||||
aria-invalid={rangeError ? true : undefined}
|
aria-invalid={rangeError ? true : undefined}
|
||||||
aria-describedby={rangeError ? rangeErrorId : undefined}
|
aria-describedby={rangeError ? rangeErrorId : undefined}
|
||||||
@@ -200,6 +211,7 @@ export function CalendarHeatmap({
|
|||||||
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
|
||||||
<div
|
<div
|
||||||
className="ds-calendar-viewport"
|
className="ds-calendar-viewport"
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function HabitChart({
|
|||||||
<div className="ds-goal-body">
|
<div className="ds-goal-body">
|
||||||
<div className="ds-goal-completion">
|
<div className="ds-goal-completion">
|
||||||
{trackingMethod === "count" && <div className="ds-goal-count">
|
{trackingMethod === "count" && <div className="ds-goal-count">
|
||||||
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
|
||||||
{children}
|
{children}
|
||||||
|
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
||||||
</div>}
|
</div>}
|
||||||
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
|
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
|
||||||
{trackingMethod === "tasks" && <>
|
{trackingMethod === "tasks" && <>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
HABIT_PALETTES,
|
HABIT_COLOR_CHOICES,
|
||||||
isHabitColor,
|
isHabitColor,
|
||||||
previewHabitColors,
|
previewHabitColors,
|
||||||
} from "./HabitColorPicker";
|
} from "./HabitColorPicker";
|
||||||
import { HABIT_COLORS } from "./calendar-model";
|
import { HABIT_COLORS } from "./calendar-model";
|
||||||
|
|
||||||
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
|
test("the continuous color selection contains distinct, valid colors", () => {
|
||||||
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
|
expect(new Set(HABIT_COLOR_CHOICES).size).toBe(HABIT_COLOR_CHOICES.length);
|
||||||
expect(HABIT_PALETTES.length).toBe(8);
|
expect(HABIT_COLOR_CHOICES.every(isHabitColor)).toBe(true);
|
||||||
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("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
|
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
|
||||||
|
|||||||
@@ -24,15 +24,11 @@ export function previewHabitColors(
|
|||||||
return colors;
|
return colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HABIT_PALETTES = [
|
export const HABIT_COLOR_CHOICES = [
|
||||||
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
|
"#b44f57", "#b86643", "#a7792c", "#858337",
|
||||||
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
|
"#5c813f", "#3e805d", "#337e79", "#387b98",
|
||||||
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
|
"#466db0", "#6263ad", "#885ba6", "#a35488",
|
||||||
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
|
"#956976", "#89705a", "#667b82", "#606571",
|
||||||
{ 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"] },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isHabitColor(value: string) {
|
export function isHabitColor(value: string) {
|
||||||
@@ -42,65 +38,33 @@ export function isHabitColor(value: string) {
|
|||||||
export function HabitColorPicker({
|
export function HabitColorPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
compact = false,
|
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
mode?: "demo" | "create" | "edit";
|
mode?: "demo" | "create" | "edit";
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const showDetails = !compact || expanded;
|
|
||||||
const valid = isHabitColor(value);
|
const valid = isHabitColor(value);
|
||||||
|
const showCustom = expanded || !valid;
|
||||||
return (
|
return (
|
||||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||||
<legend>Habit color</legend>
|
<legend>Habit color</legend>
|
||||||
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
|
<p id={`${id}-hint`} className="ds-visually-hidden">
|
||||||
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
|
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
|
||||||
</p>
|
</p>
|
||||||
{compact && (
|
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
|
||||||
<>
|
{HABIT_COLOR_CHOICES.map((color) => (
|
||||||
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
|
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
|
||||||
{HABIT_PALETTES.map((palette) => (
|
|
||||||
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
|
|
||||||
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="ds-button ds-button--text" aria-expanded={expanded} aria-controls={`${id}-details`} onClick={() => setExpanded(!expanded)}>{expanded ? "Fewer colors" : "More colors"}</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div id={`${id}-details`} hidden={!showDetails}>
|
|
||||||
<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: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
|
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
|
||||||
{value.toLowerCase() === color ? "✓" : ""}
|
{value.toLowerCase() === color ? "✓" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
<div id={`${id}-custom`} hidden={!showCustom}>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="ds-custom-color">
|
<div className="ds-custom-color">
|
||||||
<Field label="Custom color" id={`${id}-native`}>
|
<Field label="Custom color" id={`${id}-native`}>
|
||||||
{(inputId) => (
|
{(inputId) => (
|
||||||
@@ -137,7 +101,6 @@ export function HabitColorPicker({
|
|||||||
className="ds-color-live-preview"
|
className="ds-color-live-preview"
|
||||||
aria-label="Live calendar color preview"
|
aria-label="Live calendar color preview"
|
||||||
>
|
>
|
||||||
<span className="ds-muted">Calendar preview</span>
|
|
||||||
<div
|
<div
|
||||||
className="ds-color-preview-shades"
|
className="ds-color-preview-shades"
|
||||||
role="img"
|
role="img"
|
||||||
@@ -152,7 +115,6 @@ export function HabitColorPicker({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="ds-muted">0 → complete</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!valid && (
|
{!valid && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { ThemeControl } from "../ThemeProvider";
|
import { ThemeControl } from "../ThemeProvider";
|
||||||
import { Button } from "./primitives";
|
import { Button } from "./primitives";
|
||||||
|
import { InstallApp, PwaStatus } from "../PwaControls";
|
||||||
|
|
||||||
/** The existing design-system page shell, shared without page-specific styling. */
|
/** The existing design-system page shell, shared without page-specific styling. */
|
||||||
export function PageLayout({
|
export function PageLayout({
|
||||||
@@ -34,10 +35,12 @@ export function PageLayout({
|
|||||||
<div className="ds-header-actions">{header}<ThemeControl /></div>
|
<div className="ds-header-actions">{header}<ThemeControl /></div>
|
||||||
</header>
|
</header>
|
||||||
<main id={mainId} className="ds-main" tabIndex={-1}>
|
<main id={mainId} className="ds-main" tabIndex={-1}>
|
||||||
|
<PwaStatus />
|
||||||
{children}
|
{children}
|
||||||
<footer className="ds-footer">
|
<footer className="ds-footer">
|
||||||
<span className="ds-wordmark">Mina Habits.</span>
|
<span className="ds-wordmark">Mina Habits.</span>
|
||||||
<span>A little, every day.</span>
|
<span>A little, every day.</span>
|
||||||
|
<InstallApp />
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
|
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { Modal } from "./Modal";
|
|||||||
|
|
||||||
/** Secondary workspace flows share one modal presentation. */
|
/** Secondary workspace flows share one modal presentation. */
|
||||||
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
|
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
|
||||||
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
id: string; eyebrow?: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
|
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Consistent label, explanation and control columns for preference forms. */
|
/** Consistent label, explanation and control columns for preference forms. */
|
||||||
export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) {
|
export function SettingRow({ title, description, children }: { title: string; description?: ReactNode; children: ReactNode }) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return <section className="ds-setting-row" aria-labelledby={id}>
|
return <section className="ds-setting-row" aria-labelledby={id}>
|
||||||
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>
|
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>
|
||||||
|
|||||||
@@ -190,13 +190,13 @@ test("invalid custom colors associate both guidance and corrective feedback", as
|
|||||||
|
|
||||||
test("unified calendar retains every date and navigates across months in every range", async () => {
|
test("unified calendar retains every date and navigates across months in every range", async () => {
|
||||||
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
|
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
|
||||||
for (const months of [3, 4, 6, 12]) {
|
for (const months of [1, 3, 4, 6, 12]) {
|
||||||
await act(async () => button(`${months}m`).click());
|
await act(async () => button(`${months}m`).click());
|
||||||
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
|
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
|
||||||
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
|
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
|
||||||
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
|
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
|
||||||
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
|
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
|
||||||
expect(dates).toHaveLength({ 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
|
expect(dates).toHaveLength({ 1: 30, 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
|
||||||
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
|
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
|
||||||
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
|
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
|
||||||
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);
|
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);
|
||||||
|
|||||||
@@ -104,11 +104,14 @@ test("month presets include exact whole months and cross year and leap-year boun
|
|||||||
expect(
|
expect(
|
||||||
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
|
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
{ from: "2026-09-01", to: "2026-09-30" },
|
||||||
{ from: "2026-07-01", to: "2026-09-30" },
|
{ from: "2026-07-01", to: "2026-09-30" },
|
||||||
{ from: "2026-06-01", to: "2026-09-30" },
|
{ from: "2026-06-01", to: "2026-09-30" },
|
||||||
{ from: "2026-04-01", to: "2026-09-30" },
|
{ from: "2026-04-01", to: "2026-09-30" },
|
||||||
{ from: "2025-10-01", to: "2026-09-30" },
|
{ from: "2025-10-01", to: "2026-09-30" },
|
||||||
]);
|
]);
|
||||||
|
expect(monthWindow("2024-02-29", 1)).toEqual({ from: "2024-02-01", to: "2024-02-29" });
|
||||||
|
expect(monthWindow("2026-01-04", 1)).toEqual({ from: "2026-01-01", to: "2026-01-31" });
|
||||||
expect(monthWindow("2024-02-29", 3)).toEqual({
|
expect(monthWindow("2024-02-29", 3)).toEqual({
|
||||||
from: "2023-12-01",
|
from: "2023-12-01",
|
||||||
to: "2024-02-29",
|
to: "2024-02-29",
|
||||||
@@ -121,7 +124,7 @@ test("switching month views preserves dated values, complete coverage and future
|
|||||||
MONTH_VIEWS.map(
|
MONTH_VIEWS.map(
|
||||||
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
|
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
|
||||||
),
|
),
|
||||||
).toEqual([92, 122, 183, 365]);
|
).toEqual([30, 92, 122, 183, 365]);
|
||||||
for (const months of MONTH_VIEWS) {
|
for (const months of MONTH_VIEWS) {
|
||||||
const visible = visibleCalendarDays(days, months, "2026-09-04");
|
const visible = visibleCalendarDays(days, months, "2026-09-04");
|
||||||
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
|
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type CalendarDay = {
|
|||||||
state: "due" | "not-due" | "future";
|
state: "due" | "not-due" | "future";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
|
export const MONTH_VIEWS = [1, 3, 4, 6, 12] as const;
|
||||||
export type MonthView = (typeof MONTH_VIEWS)[number];
|
export type MonthView = (typeof MONTH_VIEWS)[number];
|
||||||
|
|
||||||
/** Whole calendar months, including the month containing the anchor date. */
|
/** Whole calendar months, including the month containing the anchor date. */
|
||||||
|
|||||||
9
src/components/design-system/deferred.tsx
Normal file
9
src/components/design-system/deferred.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { lazy, Suspense, type ComponentProps, type ComponentType } from "react";
|
||||||
|
|
||||||
|
/** Keep the surrounding workspace usable while an optional surface loads. */
|
||||||
|
export function deferred<Component extends ComponentType<any>>(load: () => Promise<Component>) {
|
||||||
|
const Surface = lazy(async () => ({ default: await load() })) as ComponentType<ComponentProps<Component>>;
|
||||||
|
return function Deferred(props: ComponentProps<Component>) {
|
||||||
|
return <Suspense fallback={<p className="ds-supporting-copy" role="status">Loading…</p>}><Surface {...props} /></Suspense>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -117,6 +117,7 @@ export function Counter({
|
|||||||
>
|
>
|
||||||
−
|
−
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="ds-counter-value">
|
||||||
<input
|
<input
|
||||||
className="ds-counter-input"
|
className="ds-counter-input"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -133,7 +134,8 @@ export function Counter({
|
|||||||
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
|
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ds-muted"> / {target}</span>
|
<span className="ds-counter-target ds-muted"> / {target}</span>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
aria-label={`Increase ${label}`}
|
aria-label={`Increase ${label}`}
|
||||||
|
|||||||
@@ -51,3 +51,24 @@ test('deadline migration repairs historical expiry using the original state befo
|
|||||||
]);
|
]);
|
||||||
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); }
|
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('multiple reminder migration preserves existing schedules and delivery history', () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'minabot-reminders-upgrade-'));
|
||||||
|
const sqlite = new Database(':memory:'); sqlite.exec('PRAGMA foreign_keys = ON'); const db = drizzle(sqlite);
|
||||||
|
try {
|
||||||
|
mkdirSync(join(directory, 'meta'));
|
||||||
|
const journal = JSON.parse(readFileSync('drizzle/meta/_journal.json', 'utf8'));
|
||||||
|
journal.entries = journal.entries.slice(0, 11);
|
||||||
|
writeFileSync(join(directory, 'meta/_journal.json'), JSON.stringify(journal));
|
||||||
|
for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(directory, `${entry.tag}.sql`));
|
||||||
|
migrate(db, { migrationsFolder: directory });
|
||||||
|
sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('u', 'u', 'u', 0, 0)");
|
||||||
|
sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('u', 1, '17:30', '23:00', '07:00', 0)");
|
||||||
|
sqlite.exec("INSERT INTO reminder_deliveries (id, user_id, date, status, updated_at) VALUES ('d', 'u', '2026-09-08', 'sent', 0)");
|
||||||
|
migrate(db, { migrationsFolder: './drizzle' });
|
||||||
|
expect(sqlite.query('SELECT enabled, time, quiet_start, quiet_end, reminders FROM reminder_settings').get()).toEqual({ enabled: 1, time: '17:30', quiet_start: '23:00', quiet_end: '07:00', reminders: null });
|
||||||
|
expect(sqlite.query('SELECT slot, status FROM reminder_deliveries').get()).toEqual({ slot: '*', status: 'sent' });
|
||||||
|
sqlite.exec("INSERT INTO reminder_deliveries (id, user_id, date, slot, status, updated_at) VALUES ('d2', 'u', '2026-09-09', '09:00', 'sent', 0), ('d3', 'u', '2026-09-09', '18:00', 'sent', 0)");
|
||||||
|
expect(() => sqlite.exec("INSERT INTO reminder_deliveries (id, user_id, date, slot, status, updated_at) VALUES ('d4', 'u', '2026-09-09', '18:00', 'sent', 0)")).toThrow();
|
||||||
|
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable("users", {
|
||||||
@@ -33,6 +34,7 @@ export const discordDeliveries = sqliteTable("discord_deliveries", {
|
|||||||
// Revisions are append-only, including multiple edits on the same local date.
|
// Revisions are append-only, including multiple edits on the same local date.
|
||||||
export const habits = sqliteTable('habits', {
|
export const habits = sqliteTable('habits', {
|
||||||
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
|
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
calendarRevision: integer('calendar_revision').notNull().default(0),
|
||||||
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
|
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
|
||||||
}, t => [index('habits_user_idx').on(t.userId)]);
|
}, t => [index('habits_user_idx').on(t.userId)]);
|
||||||
export const habitRevisions = sqliteTable('habit_revisions', {
|
export const habitRevisions = sqliteTable('habit_revisions', {
|
||||||
@@ -47,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
|
|||||||
export const taskOccurrences = sqliteTable('task_occurrences', {
|
export const taskOccurrences = sqliteTable('task_occurrences', {
|
||||||
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
|
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
|
||||||
done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(),
|
done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(),
|
||||||
}, t => [index('task_occurrences_day_idx').on(t.dayId)]);
|
}, t => [index('task_occurrences_day_idx').on(t.dayId), index('task_occurrences_open_idx').on(t.dayId).where(sql`${t.closedAt} IS NULL`)]);
|
||||||
export const progressEvents = sqliteTable('progress_events', {
|
export const progressEvents = sqliteTable('progress_events', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
|
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
|
||||||
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),
|
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),
|
||||||
@@ -71,6 +73,7 @@ export const reminderSettings = sqliteTable('reminder_settings', {
|
|||||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
time: text('time').notNull().default('20:00'),
|
time: text('time').notNull().default('20:00'),
|
||||||
|
reminders: text('reminders', { mode: 'json' }).$type<import('../reminders/contracts').ReminderEntry[]>(),
|
||||||
quietStart: text('quiet_start').notNull().default('22:00'),
|
quietStart: text('quiet_start').notNull().default('22:00'),
|
||||||
quietEnd: text('quiet_end').notNull().default('08:00'),
|
quietEnd: text('quiet_end').notNull().default('08:00'),
|
||||||
updatedAt: integer('updated_at').notNull(),
|
updatedAt: integer('updated_at').notNull(),
|
||||||
@@ -78,9 +81,37 @@ export const reminderSettings = sqliteTable('reminder_settings', {
|
|||||||
export const reminderDeliveries = sqliteTable('reminder_deliveries', {
|
export const reminderDeliveries = sqliteTable('reminder_deliveries', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
slot: text('slot').notNull().default('*'),
|
||||||
date: text('date').notNull(), status: text('status').notNull(),
|
date: text('date').notNull(), status: text('status').notNull(),
|
||||||
retryAt: integer('retry_at'), updatedAt: integer('updated_at').notNull(),
|
retryAt: integer('retry_at'), updatedAt: integer('updated_at').notNull(),
|
||||||
}, t => [uniqueIndex('reminder_deliveries_user_date_idx').on(t.userId, t.date)]);
|
}, t => [uniqueIndex('reminder_deliveries_user_date_slot_idx').on(t.userId, t.date, t.slot)]);
|
||||||
export const reminderWorkerState = sqliteTable('reminder_worker_state', {
|
export const reminderWorkerState = sqliteTable('reminder_worker_state', {
|
||||||
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
|
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const liveDiscordCards = sqliteTable('live_discord_cards', {
|
||||||
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
channelId: text('channel_id').notNull(),
|
||||||
|
messageId: text('message_id'),
|
||||||
|
messageUrl: text('message_url'),
|
||||||
|
nonce: text('nonce').notNull(),
|
||||||
|
theme: text('theme').$type<import('../lib/theme').Theme>().notNull(),
|
||||||
|
status: text('status').$type<'active' | 'paused' | 'sending' | 'uncertain' | 'error'>().notNull(),
|
||||||
|
imageHash: text('image_hash'),
|
||||||
|
dirty: integer('dirty', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
error: text('error'),
|
||||||
|
retryAt: integer('retry_at'),
|
||||||
|
updatedAt: integer('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short-lived UI state and replay receipts; no Discord interaction tokens are persisted.
|
||||||
|
export const discordControls = sqliteTable('discord_controls', {
|
||||||
|
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
action: text('action').notNull(), payload: text('payload', { mode: 'json' }).$type<import('../discord/types').ControlData>().notNull(),
|
||||||
|
expiresAt: integer('expires_at').notNull(), used: integer('used', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
}, t => [index('discord_controls_expiry_idx').on(t.expiresAt)]);
|
||||||
|
export const discordReceipts = sqliteTable('discord_receipts', {
|
||||||
|
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
response: text('response', { mode: 'json' }).$type<import('../discord/types').Reply>().notNull(),
|
||||||
|
expiresAt: integer('expires_at').notNull(),
|
||||||
|
}, t => [index('discord_receipts_expiry_idx').on(t.expiresAt)]);
|
||||||
|
|||||||
131
src/discord/README.md
Normal file
131
src/discord/README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Discord interface
|
||||||
|
|
||||||
|
Signed HTTP interactions enter `/api/discord/interactions`. The raw request body
|
||||||
|
is verified with the application's Ed25519 public key and a five-minute timestamp
|
||||||
|
window. `DISCORD_PUBLIC_KEY` enables the endpoint; `DISCORD_CLIENT_ID` identifies
|
||||||
|
the application. Commands are global with contexts `[0, 1]` (server and bot DM).
|
||||||
|
|
||||||
|
`HabitService` remains the shared domain implementation. Verified Discord user IDs
|
||||||
|
resolve the same account as website OAuth; new accounts start in UTC. Server
|
||||||
|
responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state,
|
||||||
|
expire after 15 minutes, and mutation controls are consumed after successful use.
|
||||||
|
Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens.
|
||||||
|
|
||||||
|
`daily.ts` implements the `/today` workflow. `types.ts`, `ui.ts`, and
|
||||||
|
`state.ts` provide the shared feature building blocks. Feature tasks extend command
|
||||||
|
registration and route dispatch while keeping their behavior in separate modules.
|
||||||
|
|
||||||
|
Tests use real Ed25519 signatures and disposable migrated SQLite databases. The
|
||||||
|
foundation tests cover server/DM privacy, replay after restart, stale progress,
|
||||||
|
cross-account controls, and matching website state. All feature modules are now
|
||||||
|
composed in the shared API. End-to-end user interaction in Discord remains a
|
||||||
|
manual acceptance check after deployment and registration.
|
||||||
|
|
||||||
|
`/help` lists the current command definitions automatically, explains check-in
|
||||||
|
controls and privacy, and links to the website. It works before account setup and
|
||||||
|
does not create an account or synchronize habits. Re-run the registration script
|
||||||
|
with `--apply` after deployment to expose newly added commands in Discord.
|
||||||
|
Reminder delivery accepts `commandsAvailable`; production enables it when
|
||||||
|
`DISCORD_PUBLIC_KEY` is a 64-digit hexadecimal key and `DISCORD_CLIENT_ID` is set.
|
||||||
|
The existing bot token still controls delivery. Configured reminders include an
|
||||||
|
owner-bound `today` control with `mode: 'reminder'`: it opens a separate private
|
||||||
|
flow, leaving the reminder unchanged, and expires at the sending account's next
|
||||||
|
local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules
|
||||||
|
are unchanged. The dashboard preview explicitly disables buttons because it has
|
||||||
|
no signed interaction endpoint configured. Global command registration and the
|
||||||
|
Discord interaction endpoint must be configured separately before production use.
|
||||||
|
## Preferences
|
||||||
|
|
||||||
|
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus
|
||||||
|
paginate ten named schedules at a time; entries with matching name and schedule
|
||||||
|
are edited together as multiple comma-separated 24-hour times. Forms accept
|
||||||
|
weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for
|
||||||
|
all schedules. Removing the last schedule disables delivery and stores an empty
|
||||||
|
list; the shared contract permits empty lists only while disabled. Invalid forms
|
||||||
|
provide a prefilled retry button. UTC accounts receive a timezone setup hint.
|
||||||
|
|
||||||
|
`ReminderService` is shared with the website routes. Every save advances its
|
||||||
|
`updatedAt` monotonically; Discord controls compare the saved preference snapshot
|
||||||
|
and the user's timezone/update version before editing. `HabitService.setTimezone`
|
||||||
|
also advances the user timestamp monotonically, including repeated writes in the
|
||||||
|
same millisecond. This rejects old Discord forms after edits from either interface,
|
||||||
|
including change-and-revert sequences. Website PUT retains its existing request
|
||||||
|
contract. Delivery receipts do not invalidate preference controls.
|
||||||
|
|
||||||
|
Integration: retain the `preferences` command dispatch and `preferencesAction`
|
||||||
|
component dispatch in `routes.ts`, the two definitions in `commands.ts`, and the
|
||||||
|
preference fields in `ControlData`. `CommandConfig.remindersAvailable` supplies
|
||||||
|
availability; `createApi` derives it from the existing sharing bot token, matching
|
||||||
|
the website and reminder worker. Direct route fixtures default it to false.
|
||||||
|
No new environment variables, migrations, outbound requests, or remote command
|
||||||
|
registration are needed by this module. Global command registration remains an
|
||||||
|
integration step. Successful mutations invoke the supplied live-card dirty hook.
|
||||||
|
`progress.ts` implements `/progress`: six-habit text pages, focused private
|
||||||
|
previews, explicit destination confirmation for snapshots, and live-image
|
||||||
|
start/status/pause/resume. API composition injects the same `sharing.send` and
|
||||||
|
`live.start/pause/state` operations used by website routes. Keep these shared
|
||||||
|
instances when reconciling dispatch; do not construct a second live worker.
|
||||||
|
|
||||||
|
Rendering and network actions return ephemeral type-5 acknowledgements and edit
|
||||||
|
the original interaction response once. `DeferredDispatch.complete` and
|
||||||
|
`schedule` are injectable for tests. Interaction tokens live only in memory,
|
||||||
|
never receipts or controls. A process interruption after acknowledgement can
|
||||||
|
leave the private response unfinished; restarting never replays the public send.
|
||||||
|
Run `/progress` again to inspect status. Public snapshot and live deliveries
|
||||||
|
retain their existing persistent quotas, nonce and uncertain-delivery guards.
|
||||||
|
|
||||||
|
`progress-image.ts` renders the focused 30-day snapshot at 1200×860 with bundled
|
||||||
|
Mina Sans / Instrument Serif, shared theme colors, and existing habit shades.
|
||||||
|
The 30 cells retain fixed readable sizes; symbols are Canvas paths. Text summaries
|
||||||
|
and buttons accompany the PNG. To produce review images without sending messages:
|
||||||
|
`PROGRESS_IMAGE_REVIEW_DIR=/tmp/minabot-progress-review bun test src/discord/progress-image.test.ts`.
|
||||||
|
|
||||||
|
|
||||||
|
## Integrated commands
|
||||||
|
|
||||||
|
All commands use global registration with server and bot-DM contexts. `/help`
|
||||||
|
reads this same registration metadata, so its command list stays in sync.
|
||||||
|
|
||||||
|
| Command | Function |
|
||||||
|
| --- | --- |
|
||||||
|
| `/help` | Command guide and website link |
|
||||||
|
| `/today` | Daily manual/count/task check-ins |
|
||||||
|
| `/habits` | Create, edit, schedule, archive, restore, permanently delete |
|
||||||
|
| `/tasks` | Add, rename, schedule, delete routine tasks |
|
||||||
|
| `/history` | Inspect and correct dated progress, including archived habits |
|
||||||
|
| `/reminders` | Named schedules, multiple times, quiet hours, enable/disable |
|
||||||
|
| `/settings` | Timezone and reminder delivery status |
|
||||||
|
| `/progress` | Private graphics, confirmed snapshot sharing, live-image controls |
|
||||||
|
|
||||||
|
After the deployment controller installs a release, register its definitions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/minabot
|
||||||
|
sudo docker compose exec -T app bun scripts/register-discord-commands.ts --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration upserts individual global commands and preserves unrelated commands.
|
||||||
|
The public key and interactions URL are one-time setup; adding commands requires
|
||||||
|
rerunning registration after their handlers have deployed.
|
||||||
|
|
||||||
|
## Additional command graphics
|
||||||
|
|
||||||
|
`/today` includes **View day card**, summarizing all due habits in pages of six.
|
||||||
|
The selected daily check-in and `/history` editor include **View check-in card**,
|
||||||
|
showing the selected date's requirements, task completion, carryover where
|
||||||
|
applicable, and a 14-day progress strip ending on that date. `/reminders` includes
|
||||||
|
**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
|
||||||
|
conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
|
||||||
|
Management forms and `/help` remain text-based because their controls already
|
||||||
|
communicate the relevant choices.
|
||||||
|
|
||||||
|
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
|
||||||
|
request a fresh owned snapshot. Regular check-ins keep their existing immediate
|
||||||
|
text responses. Graphic actions acknowledge before rendering and attach a PNG to
|
||||||
|
a new private reply; they never publish to the sharing channel or write progress.
|
||||||
|
Each image has a text equivalent and attachment description. A render failure
|
||||||
|
retains the text and navigation. No new command registration or configuration is
|
||||||
|
needed after deploying this change.
|
||||||
|
|
||||||
|
Generate local visual review artifacts with
|
||||||
|
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.
|
||||||
98
src/discord/command-graphics.test.ts
Normal file
98
src/discord/command-graphics.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { users, reminderSettings, progressEvents } from '../db/schema';
|
||||||
|
import { publicUser } from '../auth';
|
||||||
|
import { HabitService } from '../habits/service';
|
||||||
|
import { ControlStore } from './state';
|
||||||
|
import type { Context } from './types';
|
||||||
|
import type { DeferredMessage } from './progress';
|
||||||
|
import { commandCard } from './command-graphics';
|
||||||
|
import { renderCommandCard, commandCardSize } from './command-image';
|
||||||
|
import { discordFixture, control, submit } from './test-fixture';
|
||||||
|
let f: ReturnType<typeof discordFixture>;
|
||||||
|
afterEach(() => f?.close());
|
||||||
|
function setup() {
|
||||||
|
const jobs: (() => Promise<void>)[] = [], outputs: DeferredMessage[] = [];
|
||||||
|
f = discordFixture(() => ({ schedule: job => jobs.push(job), complete: async (_i, result) => { outputs.push(result); } }));
|
||||||
|
return { jobs, outputs, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: outputs.at(-1)!.message }; } };
|
||||||
|
}
|
||||||
|
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, values } });
|
||||||
|
function context(): Context {
|
||||||
|
const now = Date.parse('2026-09-04T12:00:00Z'), user = publicUser(f.db.select().from(users).where(eq(users.id, 'alice')).get()!);
|
||||||
|
const service = new HabitService(f.db, user, now); service.sync();
|
||||||
|
return { db: f.db, service, now, controls: new ControlStore(f.db, user.id, now), interaction: f.payload({}), origin: f.origin, changed: () => {}, remindersAvailable: true };
|
||||||
|
}
|
||||||
|
for (const guild of [false, true]) test(`day graphics retain private context and refresh without progress writes (${guild ? 'server' : 'DM'})`, async () => {
|
||||||
|
const e = setup();
|
||||||
|
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', unit: 'pages', target: 10 }, 201);
|
||||||
|
let t = await f.run({ data: { name: 'today' } }, guild);
|
||||||
|
const payload = f.payload(click(control(t, 'View day card')), guild);
|
||||||
|
expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} });
|
||||||
|
await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1);
|
||||||
|
let card = await e.drain(); expect(card.data.content).toContain('0 of 1 habits complete');
|
||||||
|
expect(e.outputs[0]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71]));
|
||||||
|
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
|
||||||
|
expect(f.db.select().from(progressEvents).all()).toEqual([]);
|
||||||
|
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 });
|
||||||
|
await f.run(click(control(card, 'Refresh card')), guild); card = await e.drain();
|
||||||
|
expect(card.data.content).toContain('12/10 pages'); expect(card.data.content).toContain('1 of 1');
|
||||||
|
t = await f.run({ data: { name: 'today' } }, guild);
|
||||||
|
const d = await f.run(click(control(t, 'Choose a habit'), [h.id]), guild);
|
||||||
|
await f.run(click(control(d, 'View check-in card')), guild); card = await e.drain();
|
||||||
|
expect(card.data.content).toContain('12/10 pages');
|
||||||
|
expect(f.changed).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
|
||||||
|
const e = setup(); const h = await f.json('/habits', 'POST', { name: 'Old target', method: 'count', target: 4 }, 201);
|
||||||
|
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 3 });
|
||||||
|
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||||
|
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 });
|
||||||
|
const history = await f.run({ data: { name: 'history' } }, true);
|
||||||
|
const form = await f.run(click(control(history, 'Choose a habit'), [h.id]), true);
|
||||||
|
const day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true);
|
||||||
|
const id = control(day, 'View check-in card');
|
||||||
|
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
|
||||||
|
await f.run(click(id), true); const card = await e.drain();
|
||||||
|
expect(card.data.content).toContain('Old target'); expect(card.data.content).toContain('3/4');
|
||||||
|
f.setDiscordTime('2026-09-06T12:00:00Z');
|
||||||
|
expect((await f.run(click(id), true)).data.content).toContain('expired');
|
||||||
|
});
|
||||||
|
test('six-row cards paginate complete habit and reminder collections', async () => {
|
||||||
|
const e = setup();
|
||||||
|
for (let i = 0; i < 8; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||||
|
const lastName = (await f.json('/habits')).habits.at(-1).name;
|
||||||
|
const today = await f.run({ data: { name: 'today' } });
|
||||||
|
await f.run(click(control(today, 'View day card'))); let card = await e.drain();
|
||||||
|
expect(card.data.content).toContain('Page 1/2'); expect(card.data.content).not.toContain(lastName);
|
||||||
|
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain(lastName);
|
||||||
|
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: false, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1,
|
||||||
|
reminders: Array.from({ length: 8 }, (_, i) => ({ type: 'weekly' as const, days: [1, 3, 5], name: `Reminder ${i}`, time: `${String(i + 1).padStart(2, '0')}:00` })) }).run();
|
||||||
|
const panel = await f.run({ data: { name: 'reminders' } });
|
||||||
|
await f.run(click(control(panel, 'View schedule card'))); card = await e.drain();
|
||||||
|
expect(card.data.content).toContain('During quiet hours'); expect(card.data.content).toContain('Mon, Wed, Fri');
|
||||||
|
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain('Reminder 7');
|
||||||
|
});
|
||||||
|
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
|
||||||
|
setup();
|
||||||
|
const h = await f.json('/habits', 'POST', { name: 'Evening routine', method: 'tasks', tasks: [{ name: 'Read a chapter' }, { name: 'Stretch' }, { name: 'Prepare for tomorrow' }] }, 201);
|
||||||
|
const tasks = (await f.json(`/habits/${h.id}/tasks`)).tasks;
|
||||||
|
await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${tasks[0].id}`, 'PUT', { done: true });
|
||||||
|
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: true, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1, reminders: [
|
||||||
|
{ type: 'weekly', name: 'Morning reading', time: '09:00', days: [1,2,3,4,5] },
|
||||||
|
{ type: 'weekly', name: 'Evening wind-down', time: '21:00', days: [0,1,2,3,4,5,6] },
|
||||||
|
{ type: 'date', name: 'A little extra encouragement', time: '23:00', date: '2026-09-10' },
|
||||||
|
] }).run();
|
||||||
|
for (const [action, data] of [['graphic.today', {}], ['graphic.day', { habitId: h.id, date: '2026-09-04' }], ['graphic.reminders', {}]] as const) {
|
||||||
|
const model = commandCard(context(), action, data);
|
||||||
|
for (const theme of ['light','dark'] as const) {
|
||||||
|
const png = await renderCommandCard(model, theme), b = Buffer.from(png);
|
||||||
|
expect(b.readUInt32BE(16)).toBe(1200); expect(b.readUInt32BE(20)).toBe(commandCardSize(model).height); expect(b.length).toBeLessThan(4 * 1024 * 1024);
|
||||||
|
if (process.env.COMMAND_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.COMMAND_IMAGE_REVIEW_DIR}/${action}-${theme}.png`, png);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const future = commandCard(context(), 'graphic.day', { habitId: h.id, date: '2026-09-05' });
|
||||||
|
expect(future.summary).toContain('Future date'); expect(future.history!.at(-1)!.future).toBe(true);
|
||||||
|
expect(commandCard(context(), 'graphic.day', { habitId: h.id, date: '1970-01-01' }).history).toHaveLength(1);
|
||||||
|
const long = commandCard(context(), 'graphic.today', {}); long.title = '🌸Very long habit name '.repeat(50); long.rows[0]!.label = '漢字'.repeat(200);
|
||||||
|
expect((await renderCommandCard(long)).length).toBeLessThan(4 * 1024 * 1024);
|
||||||
|
});
|
||||||
62
src/discord/command-graphics.ts
Normal file
62
src/discord/command-graphics.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { addDays } from '../habits/calendar';
|
||||||
|
import { dateSchema } from '../habits/contracts';
|
||||||
|
import { ApiError } from '../habits/service';
|
||||||
|
import { ReminderService } from '../reminders/service';
|
||||||
|
import { inQuietHours } from '../reminders/contracts';
|
||||||
|
import type { Context, ControlData, Reply } from './types';
|
||||||
|
import { button, message, row, text } from './ui';
|
||||||
|
import { renderCommandCard, type CommandCard, type CardRow } from './command-image';
|
||||||
|
|
||||||
|
const size = 6;
|
||||||
|
export function commandCard(c: Context, action: string, data: ControlData): CommandCard {
|
||||||
|
const base = { owner: c.service.user.displayName, subtitle: c.service.user.timezone };
|
||||||
|
let rows: CardRow[], title: string, summary: string, note: string, history: CommandCard['history'];
|
||||||
|
if (action === 'graphic.today') {
|
||||||
|
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.');
|
||||||
|
const days = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
|
||||||
|
title = 'Today, at a glance'; base.subtitle = `${c.service.today} · ${base.subtitle}`;
|
||||||
|
summary = `${days.filter(d => d.complete).length} of ${days.length} habits complete`;
|
||||||
|
note = 'Snapshot · Use /today to update progress';
|
||||||
|
rows = days.map(d => ({ label: d.name ?? 'Habit', detail: `${d.complete ? 'Complete' : d.value > 0 ? 'In progress' : 'Not started'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}`, value: `${d.value}/${d.target} ${d.unit}`, ratio: d.ratio ?? 0, color: c.service.settings(d.habitId).mainColor }));
|
||||||
|
} else if (action === 'graphic.day') {
|
||||||
|
const date = dateSchema.parse(data.date ?? c.service.today);
|
||||||
|
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.');
|
||||||
|
const d = c.service.day(data.habitId!, date), color = c.service.settings(data.habitId!).mainColor;
|
||||||
|
title = d.name ?? 'Not tracked yet'; base.subtitle = `${date} · ${d.timezone}`;
|
||||||
|
summary = d.future ? 'Future date · read-only' : !d.requirements ? 'Not tracked on this date' : d.requirements.archived ? 'Archived on this date' : !d.due ? 'Day off · not scheduled' : `${d.value}/${d.target} ${d.unit} · ${d.complete ? 'Complete' : 'Incomplete'}`;
|
||||||
|
note = 'Snapshot · Historical requirements for the selected date';
|
||||||
|
rows = d.method === 'tasks' ? d.tasks.map(t => ({ label: t.name, detail: d.future ? 'Not yet due' : t.done ? 'Complete' : 'Incomplete', ratio: Number(t.done), color })) : [{ label: d.method === 'count' ? 'Recorded amount' : 'Check-in', detail: d.carriedFrom ? `Carried from ${d.carriedFrom}` : d.due ? 'Requirements saved for this date' : 'Read-only date', value: d.due && !d.future ? `${d.value}/${d.target ?? 0}` : '—', ...(!d.future && d.due ? { ratio: d.ratio ?? 0 } : {}), color }];
|
||||||
|
history = Array.from({ length: 14 }, (_, i) => addDays(date, i - 13)).filter(day => day >= '1970-01-01').map(day => { const value = c.service.day(data.habitId!, day); return { date: day, ratio: value.ratio, future: value.future, color }; });
|
||||||
|
} else if (action === 'graphic.reminders') {
|
||||||
|
const prefs = new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now).read();
|
||||||
|
title = 'Your reminder schedule';
|
||||||
|
summary = !prefs.available ? 'Delivery not configured' : !prefs.enabled ? 'Delivery disabled' : `${prefs.reminders.length} reminder time slots`;
|
||||||
|
note = 'DMs send only while habits remain unfinished';
|
||||||
|
base.subtitle = `${base.subtitle} · Quiet hours ${prefs.quietStart === prefs.quietEnd ? 'off' : `${prefs.quietStart}–${prefs.quietEnd}`}`;
|
||||||
|
rows = [...prefs.reminders].sort((a, b) => a.time.localeCompare(b.time)).map(e => ({ label: `${e.time} · ${e.name || 'Unnamed reminder'}`, detail: `${e.type === 'date' ? e.date : 'Weekly · Mon to Sun'}${inQuietHours(e.time, prefs.quietStart, prefs.quietEnd) ? ' · During quiet hours' : ''}`, ...(e.type === 'weekly' ? { weekdays: e.days } : {}) }));
|
||||||
|
} else throw new ApiError(404, 'Unknown graphic.');
|
||||||
|
const pages = Math.max(1, Math.ceil(rows.length / size)), page = Math.max(0, Math.min(Math.trunc(data.page ?? 0), pages - 1));
|
||||||
|
return { ...base, title, summary, note, rows: rows.slice(page * size, (page + 1) * size), page, pages, history };
|
||||||
|
}
|
||||||
|
export function commandGraphicAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||||
|
if (!action.startsWith('graphic.')) return;
|
||||||
|
if (!c.defer) throw new ApiError(409, 'Run the command again to open a fresh card.');
|
||||||
|
// Read one owned, dated snapshot before yielding; these cards never change progress.
|
||||||
|
const model = commandCard(c, action, data), theme = data.theme ?? 'light';
|
||||||
|
const content = [ `**${text(model.title)}** · ${text(model.subtitle)}`, model.summary,
|
||||||
|
...model.rows.map(r => `${text(r.label)}${r.value ? ` · ${text(r.value)}` : ''} — ${text(r.detail)}${r.weekdays ? ` (${r.weekdays.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')})` : ''}`),
|
||||||
|
...(model.history ? [`Recent days: ${model.history.map(d => `${d.date}: ${d.future ? 'future' : d.ratio === null ? 'off' : `${Math.round(d.ratio * 100)}%`}`).join('; ')}`] : []),
|
||||||
|
`Page ${model.page + 1}/${model.pages} · ${model.note}` ].join('\n');
|
||||||
|
const controls = [
|
||||||
|
...(model.page > 0 ? [button(c, 'Previous card', action, { ...data, page: model.page - 1 })] : []),
|
||||||
|
...(model.page + 1 < model.pages ? [button(c, 'Next card', action, { ...data, page: model.page + 1 })] : []),
|
||||||
|
button(c, 'Refresh card', action, { ...data, page: model.page }),
|
||||||
|
button(c, 'Toggle theme', action, { ...data, page: model.page, theme: theme === 'light' ? 'dark' : 'light' }),
|
||||||
|
];
|
||||||
|
const reply = message(c, content, [row(...controls)], false);
|
||||||
|
c.defer(async () => {
|
||||||
|
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme, c.service.user.id), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
|
||||||
|
catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; }
|
||||||
|
});
|
||||||
|
return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };
|
||||||
|
}
|
||||||
70
src/discord/command-image.ts
Normal file
70
src/discord/command-image.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { renderImage } from '../rendering/queue';
|
||||||
|
import { createCanvas } from '@napi-rs/canvas';
|
||||||
|
import '../sharing/live-card-image';
|
||||||
|
import { themePalette, type Theme } from '../lib/theme';
|
||||||
|
import { habitProgressColor } from '../lib/habit-palette';
|
||||||
|
|
||||||
|
export type CardRow = { label: string; detail: string; value?: string; ratio?: number; color?: string; weekdays?: number[] };
|
||||||
|
export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] };
|
||||||
|
export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 });
|
||||||
|
/** Fixed-size rows and text keep every page readable, including very large accounts. */
|
||||||
|
export function renderCommandCard(data: CommandCard, theme: Theme = 'light', userId = 'unscoped') {
|
||||||
|
return renderImage('command', userId, data, theme, () => draw(data, theme));
|
||||||
|
}
|
||||||
|
async function draw(data: CommandCard, theme: Theme) {
|
||||||
|
if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.');
|
||||||
|
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
|
||||||
|
const p = themePalette[theme];
|
||||||
|
const rect = (x: number, y: number, w: number, h: number, color: string, r = 0) => { ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); };
|
||||||
|
const text = (value: string, x: number, y: number, size: number, maxWidth: number, color: string = p.ink, serif = false) => {
|
||||||
|
ctx.font = `${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
|
||||||
|
const characters = Array.from(value.replace(/[\r\n\t]/g, ' ')); let fitted = characters.join('');
|
||||||
|
while (characters.length && ctx.measureText(fitted).width > maxWidth) { characters.pop(); fitted = characters.join('') + '…'; }
|
||||||
|
ctx.fillText(fitted, x, y);
|
||||||
|
};
|
||||||
|
rect(0, 0, width, height, p.paper); rect(0, 0, width, 8, '#285d49');
|
||||||
|
text(data.owner, 64, 72, 26, 760); text('Mina Habits.', 938, 76, 38, 205, p.ink, true);
|
||||||
|
text(data.title, 64, 150, 60, 1072, p.ink, true);
|
||||||
|
text(data.subtitle, 64, 200, 25, 1072, p.muted);
|
||||||
|
text(data.summary, 64, 270, 38, 1072);
|
||||||
|
rect(64, 312, 1072, 1, p.rule);
|
||||||
|
if (!data.rows.length) text('Nothing scheduled here.', 64, 392, 30, 1072, p.muted);
|
||||||
|
data.rows.forEach((r, index) => {
|
||||||
|
const y = 346 + index * 116, color = r.color ?? '#285d49';
|
||||||
|
rect(64, y, 6, 88, color, 3);
|
||||||
|
const valueWidth = r.weekdays ? 0 : 270;
|
||||||
|
text(r.label, 92, y + 30, 30, 1016 - valueWidth);
|
||||||
|
if (r.value) text(r.value, 860, y + 30, 25, 270);
|
||||||
|
text(r.detail, 92, y + 68, 22, r.weekdays ? 580 : 1016, p.muted);
|
||||||
|
if (r.weekdays) {
|
||||||
|
['M', 'T', 'W', 'T', 'F', 'S', 'S'].forEach((label, i) => {
|
||||||
|
const active = r.weekdays!.includes((i + 1) % 7), x = 732 + i * 58;
|
||||||
|
rect(x, y + 45, 46, 35, active ? p.avatar : p.empty, 6);
|
||||||
|
text(label, x + 12, y + 71, 22, 28, active ? p.ink : p.muted);
|
||||||
|
if (active) rect(x + 12, y + 83, 22, 3, habitProgressColor(color, 1, theme), 1);
|
||||||
|
});
|
||||||
|
} else if (r.ratio !== undefined) {
|
||||||
|
rect(92, y + 87, 1044, 6, p.empty, 3);
|
||||||
|
if (r.ratio > 0) rect(92, y + 87, 1044 * Math.max(0, Math.min(1, r.ratio)), 6, habitProgressColor(color, 1, theme), 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const bottom = 350 + Math.max(1, data.rows.length) * 116;
|
||||||
|
if (data.history) {
|
||||||
|
text(`Recent days · ${data.history[0]?.date ?? ''} to ${data.history.at(-1)?.date ?? ''}`, 64, bottom + 26, 23, 1072, p.muted);
|
||||||
|
data.history.forEach((day, i) => {
|
||||||
|
const x = 64 + i * 77;
|
||||||
|
const fill = day.future || day.ratio === null ? p.avatar : !day.ratio ? p.empty : habitProgressColor(day.color, day.ratio, theme);
|
||||||
|
rect(x, bottom + 47, 62, 42, fill, 6);
|
||||||
|
// State symbols retain meaning without color; draw them without font dependence.
|
||||||
|
ctx.strokeStyle = p.ink; ctx.lineWidth = 2;
|
||||||
|
if (day.future || day.ratio === null) { ctx.beginPath(); ctx.moveTo(x + 24, bottom + 68); ctx.lineTo(x + 38, bottom + 68); ctx.stroke(); }
|
||||||
|
text(day.date.slice(8), x + 15, bottom + 119, 20, 45, p.muted);
|
||||||
|
if (i === data.history!.length - 1) rect(x, bottom + 127, 62, 3, p.ink, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rect(64, height - 92, 1072, 1, p.rule);
|
||||||
|
text(data.note, 64, height - 48, 21, 860, p.muted);
|
||||||
|
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted);
|
||||||
|
try { return new Uint8Array(await canvas.encode('png')); }
|
||||||
|
finally { canvas.width = 1; canvas.height = 1; }
|
||||||
|
}
|
||||||
11
src/discord/commands.ts
Normal file
11
src/discord/commands.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Global commands are required for bot DMs; guild-only registrations cannot run there.
|
||||||
|
export const commands = [
|
||||||
|
{ name: 'help', description: 'Show commands and how to use Minabot' },
|
||||||
|
{ name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' },
|
||||||
|
{ name: 'habits', description: 'Create, edit, archive and restore your habits' },
|
||||||
|
{ name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' },
|
||||||
|
{ name: 'settings', description: 'Set your timezone and view delivery settings' },
|
||||||
|
{ name: 'progress', description: 'Preview progress and manage public snapshot or live sharing' },
|
||||||
|
{ name: 'today', description: 'View and complete today’s habits' },
|
||||||
|
{ name: 'history', description: 'View and correct habit progress for a calendar date' },
|
||||||
|
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user