Compare commits
64 Commits
892487eb4c
...
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 | ||
|
|
00729096bf | ||
|
|
9e9871c08a | ||
|
|
63e1573dae | ||
|
|
2f67b775bf | ||
|
|
1f017caee5 | ||
|
|
b832b5734e | ||
|
|
3945998fa8 | ||
|
|
7ebf936218 | ||
|
|
0d0aca5604 | ||
|
|
bfba95b1ed | ||
|
|
7ebbf4ed6c | ||
|
|
419bdce36b | ||
|
|
9f8154e412 | ||
|
|
a2f5616b96 |
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=
|
||||
# Optional public HTTPS health URL for the independent monitor.
|
||||
MONITOR_URL=
|
||||
# Discord Developer Portal > General Information > Public Key.
|
||||
# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions
|
||||
DISCORD_PUBLIC_KEY=
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Loaded by bun start and bun run db:migrate:production.
|
||||
# Set your public origin, e.g. https://habits.example.com
|
||||
APP_ORIGIN=
|
||||
# Enable only behind a private proxy that overwrites X-Forwarded-Proto.
|
||||
TRUST_PROXY=false
|
||||
# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1.
|
||||
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
||||
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
||||
|
||||
4
Caddyfile
Normal file
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"]
|
||||
4
PRD.md
4
PRD.md
@@ -1,11 +1,11 @@
|
||||
# Minabot — Habit Tracking PRD
|
||||
# Mina Habits — Habit Tracking PRD
|
||||
|
||||
Status: Initial product requirements
|
||||
Date: 2026-09-04
|
||||
|
||||
## Application intent
|
||||
|
||||
Minabot helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
|
||||
Mina Habits helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
|
||||
|
||||
Individual charts answer, “How much progress did I make on this habit?” Combined charts answer, “How many of the habits due that day did I fully complete?” Partial progress is visible and useful, but does not count as a completed habit in a combined chart.
|
||||
|
||||
|
||||
84
README.md
84
README.md
@@ -1,4 +1,8 @@
|
||||
# Minabot
|
||||
# Mina Habits
|
||||
|
||||
The production app is installable as a PWA, with an offline reconnect screen and
|
||||
user-controlled updates. Habit data and changes require a connection. See
|
||||
[PWA behavior and verification](docs/PWA.md).
|
||||
|
||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
|
||||
client includes a homepage for signed-out visitors and a personal habit dashboard.
|
||||
@@ -67,7 +71,9 @@ its own recurrence. Both habit and task schedules support daily, selected weekda
|
||||
day intervals, and week intervals with an editable start date and weekday.
|
||||
Archive asks for confirmation in a modal and preserves earlier history.
|
||||
**Archived habits** opens the archive modal, with history inspection and restoration
|
||||
starting today. Task deletion uses the same modal confirmation. Tasks
|
||||
starting today. **Delete permanently** removes an archived habit and all its history
|
||||
after confirmation; it also removes the habit from combined charts and deletes
|
||||
charts left empty. Task deletion uses the same modal confirmation. Tasks
|
||||
remain manageable on days off, while their checkboxes stay disabled.
|
||||
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
|
||||
Dialogs keep the background inactive, contain keyboard focus, support Escape and
|
||||
@@ -81,6 +87,28 @@ checkboxes, or task occurrences using that date’s saved requirements. A date p
|
||||
also reaches history outside the displayed year. Future dates and days off are read-only.
|
||||
Combined-chart management is not exposed on this homepage.
|
||||
|
||||
### Daily check-in experience
|
||||
|
||||
The dashboard opens with a compact greeting and a Today filter. All habits includes
|
||||
routines on days off; creating a habit or editing one out of today’s schedule keeps
|
||||
it reachable in All habits. Every habit shows its calendar immediately, starting with three months and offering
|
||||
longer ranges. Historical corrections are available by selecting a day. Selecting a square
|
||||
only changes the inspected day; it preserves the date range and number of weeks,
|
||||
including on untracked, unscheduled, and future dates. An external editor date
|
||||
outside a preset window can still bring that date into view.
|
||||
|
||||
Each saved habit shares a serif heading, type label, color marker, schedule, and
|
||||
explicit completion status. A visible graph sits beside a neutral completion panel:
|
||||
count goals show their target, unit, and editable counter; check-ins show a labeled
|
||||
checkbox; routines show an open task list and progress. On narrow screens, completion
|
||||
controls stack above the graph. Spacing, typography, and neutral surfaces use the
|
||||
shared design-system tokens; habit color marks identity and progress.
|
||||
|
||||
Count totals can be typed directly and saved with **Save** or Enter. Escape restores
|
||||
the recorded total; plus and minus remain available. Counts may exceed the target.
|
||||
New habits offer editable Reading, Get outside, and Evening routine starters, with
|
||||
eight suggested colors and **More colors** for the full palette and custom colors.
|
||||
|
||||
For browser QA without changing application data, run
|
||||
`bun scripts/dashboard-preview.ts` and visit
|
||||
`http://127.0.0.1:3107/__preview/login`. This uses an in-memory database and a
|
||||
@@ -355,29 +383,49 @@ existing delivery does not consume quota. Rejected attempts consume quota too;
|
||||
An empty list permits all signed-in accounts. Restricted users can still preview
|
||||
and download PNGs. Quota rows expire and are pruned after 24 hours.
|
||||
|
||||
### Daily reminders
|
||||
### Reminders
|
||||
|
||||
Open **Settings → Daily reminders** to opt into one private Discord DM when habits
|
||||
remain unfinished. Choose a local time and quiet hours; times follow the account
|
||||
timezone. Quiet-hour start is inclusive, end is exclusive, and equal times disable
|
||||
quiet hours. If the scheduled time falls in quiet hours, delivery waits until they
|
||||
end that same local day. A reminder missed for the whole day is not carried forward.
|
||||
Open **Settings → Reminders** to configure up to 20 reminders, each with an optional
|
||||
name (up to 80 characters), local time, and selected weekdays or a specific date.
|
||||
Select all seven days for a daily reminder. Discord messages include any reminder names that were set, along with the
|
||||
unfinished-habit count and app link. Combined nudges include the applicable names. Reminders
|
||||
only send when habits remain unfinished, and times follow the account timezone.
|
||||
|
||||
Quiet-hour start is inclusive, end is exclusive, and equal times disable quiet
|
||||
hours. Overdue reminders are combined into one nudge when quiet hours end or the
|
||||
server resumes, then later scheduled times continue normally. Overlapping entries
|
||||
at the same time produce one nudge. Missed reminders never carry into another day.
|
||||
Editing a name or removing and re-adding a time does not resend an already delivered
|
||||
slot that day; adding a past time can trigger a nudge on the next worker tick.
|
||||
|
||||
The production server checks once a minute. `DISCORD_BOT_TOKEN` enables delivery;
|
||||
a sharing channel is not required for reminders. Users must be reachable by the
|
||||
bot (normally a shared Discord server and enabled DMs). A message contains only
|
||||
the unfinished-habit count and app link. Settings show the most recent delivery
|
||||
result, including blocked DMs or unconfirmed delivery. Uncheck the option and save
|
||||
to opt out. No reminders are enabled by default.
|
||||
a sharing channel is not required. Users must be reachable by the bot (normally a
|
||||
shared Discord server and enabled DMs). Settings show the most recent delivery
|
||||
result. Uncheck the option and save to opt out. Reminders are disabled by default.
|
||||
|
||||
`GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
|
||||
requires authentication, matching Origin, and `{enabled, time, quietStart, quietEnd}`
|
||||
with `HH:MM` times. Per-user/date reservations persist across restarts and concurrent
|
||||
workers. Discord 429 retry deadlines are persisted; ambiguous message sends are
|
||||
not retried that day. Delivery records expire after 90 days. Restore disables
|
||||
reminders in the recovered database until users opt in again.
|
||||
requires authentication, matching Origin, and `{enabled, reminders, quietStart,
|
||||
quietEnd}`. Times use `HH:MM`. Each reminder is either
|
||||
`{type: "weekly", name?: string, time: "09:00", days: [1, 3, 5]}` (Sunday is 0) or
|
||||
`{type: "date", name?: string, time: "18:00", date: "2026-09-08"}`.
|
||||
The legacy single `time` input remains supported. Existing settings retain their
|
||||
daily schedule; pre-upgrade deliveries prevent another nudge that same day.
|
||||
|
||||
Per-user/date/time reservations persist across restarts and concurrent workers.
|
||||
Discord 429 retry deadlines are persisted; ambiguous sends are not retried for
|
||||
that time slot. Later reminders can still send. Delivery records expire after
|
||||
90 days. Account export includes schedules and names; restore disables reminders
|
||||
until users opt in again.
|
||||
|
||||
The disposable `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` mode also runs a
|
||||
reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed
|
||||
Europe/Belgrade time), leave a habit unfinished, and inspect delivery status in
|
||||
settings or `/__preview/reminder`. No real Discord messages are sent in this mode.
|
||||
|
||||
### Live Discord image
|
||||
|
||||
Open **Share progress → Live image · 16:9** to preview/download a 1920×1080 PNG with every active habit and a rolling 30-day history. **Share live image** posts the attachment through the configured bot; later changes replace the attachment on that same Discord message after a two-second trailing debounce. There is no public live link. Display name, habit names, and progress are shared; task details remain private. Newly created active habits are included automatically.
|
||||
|
||||
Rendering and updates run on the server even when the dashboard is closed. Identical card content skips Discord edits. Message IDs, themes, and retry state persist in SQLite; known messages resume after restart. Rate-limited edits honor Discord's retry delay. When an update confirms the old message was deleted, the bot posts a replacement and saves its new message ID. Missing channels or permissions do not trigger replacement posts. An unconfirmed initial or replacement post is never automatically repeated. **Stop updates** leaves the last image in Discord; **Resume updates** edits the original message again.
|
||||
|
||||
Development uses `bun --watch` so source edits restart the process and recreate the background worker; in-process hot reload can leave its timers stalled. The worker assumes one Bun API process per database. Reverse proxies need no special streaming configuration. Run the normal migrations/build/start workflow to install the live-image table and bundle its font; the server uses `@napi-rs/canvas` for PNG rendering. The previous experimental public-link table is removed by migration 0010.
|
||||
|
||||
17
build.ts
17
build.ts
@@ -1,6 +1,7 @@
|
||||
import tailwind from "bun-plugin-tailwind";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { rm, mkdir, copyFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { buildPwa } from "./scripts/build-pwa";
|
||||
|
||||
const outdir = path.join(process.cwd(), "dist");
|
||||
await rm(outdir, { recursive: true, force: true });
|
||||
@@ -10,7 +11,9 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
plugins: [tailwind],
|
||||
minify: true,
|
||||
splitting: true,
|
||||
target: "bun",
|
||||
external: ["@napi-rs/canvas", "/fonts/browser/*"],
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
@@ -24,3 +27,15 @@ if (!result.success) {
|
||||
for (const output of result.outputs) {
|
||||
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
||||
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
|
||||
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
|
||||
}
|
||||
|
||||
await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
|
||||
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
|
||||
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
|
||||
}
|
||||
|
||||
await buildPwa(outdir, result.outputs);
|
||||
|
||||
4
bun-env.d.ts
vendored
4
bun-env.d.ts
vendored
@@ -9,6 +9,10 @@ declare module "*.svg" {
|
||||
}
|
||||
|
||||
declare module "*.css" {}
|
||||
declare module "*.webmanifest" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
declare module "*.png" {
|
||||
const path: string;
|
||||
export default path;
|
||||
|
||||
32
bun.lock
32
bun.lock
@@ -3,8 +3,9 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"name": "mina-habits",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.8",
|
||||
"@nivo/calendar": "^0.99.0",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"happy-dom": "^20.14.0",
|
||||
"playwright": "^1.63.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
@@ -89,6 +91,30 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.8", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.8", "@napi-rs/canvas-darwin-arm64": "1.0.8", "@napi-rs/canvas-darwin-x64": "1.0.8", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", "@napi-rs/canvas-linux-arm64-musl": "1.0.8", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-musl": "1.0.8", "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", "@napi-rs/canvas-win32-x64-msvc": "1.0.8" } }, "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.8", "", { "os": "android", "cpu": "arm64" }, "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.8", "", { "os": "linux", "cpu": "arm" }, "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.8", "", { "os": "linux", "cpu": "none" }, "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ=="],
|
||||
|
||||
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
|
||||
|
||||
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
|
||||
@@ -265,6 +291,10 @@
|
||||
|
||||
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
|
||||
|
||||
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
env = false
|
||||
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
|
||||
61
compose.yaml
Normal file
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
|
||||
@@ -20,6 +20,7 @@ Run `bun dev` to migrate the local database and serve the API. Run `bun run test
|
||||
| GET | `/habits/:id` | Current habit configuration and revision ID |
|
||||
| PATCH | `/habits/:id` | Change name, method, method settings, schedule or archived flag |
|
||||
| DELETE | `/habits/:id` | Archive from today, preserving history; 204 |
|
||||
| DELETE | `/habits/:id/permanent` | Permanently delete an owned archived habit and all its history; 204. Active habits return 409. Remove it from combined charts and delete charts left empty. |
|
||||
| GET | `/habits/:id/history` | `{revisions: [...]}` newest first, including superseded same-day revisions |
|
||||
| GET | `/habits/:id/tasks` | `{tasks: [...]}` for current task method |
|
||||
| POST | `/habits/:id/tasks` | Add task; 201 |
|
||||
|
||||
109
docs/DEPLOYMENT-PLAN.md
Normal file
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
|
||||
|
||||
See [production deployment](PRODUCTION.md) for the Docker/Caddy server, release,
|
||||
data paths, verification results, and current backup/monitoring limitations.
|
||||
|
||||
## Backups and recovery
|
||||
|
||||
Production creates a verified SQLite snapshot before migrations and automatically
|
||||
|
||||
102
docs/PRODUCTION.md
Normal file
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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Minabot visual studies
|
||||
# Mina Habits visual studies
|
||||
|
||||
Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are visual references, not data-accurate calendar renders. Two independent generations; no input images. The no-card, monochromatic editorial layouts informed the scoped design-system stylesheet and reusable components. Fonts are bundled locally under src/assets/fonts with their OFL license.
|
||||
|
||||
@@ -6,13 +6,13 @@ Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are v
|
||||
|
||||
Saved asset: `src/assets/design/today.png`
|
||||
|
||||
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
|
||||
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
|
||||
|
||||
## Habit detail
|
||||
|
||||
Saved asset: `src/assets/design/habit-detail.png`
|
||||
|
||||
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across Jan–Sep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
|
||||
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across Jan–Sep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
|
||||
|
||||
## Interactive implementation
|
||||
|
||||
@@ -42,4 +42,4 @@ The updated Today preview uses four independent habit charts in a two-column, mu
|
||||
|
||||
New saved asset: `src/assets/design/today-v2.png`. Original `today.png` is preserved; the gallery now uses v2. Built-in imagegen prompt:
|
||||
|
||||
Use case: ui-mockup. Asset type: high fidelity desktop Minabot habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "minabot", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.
|
||||
Use case: ui-mockup. Asset type: high fidelity desktop Mina Habits habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "Mina Habits", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.
|
||||
|
||||
13
docs/design/mina-habits-icon.md
Normal file
13
docs/design/mina-habits-icon.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Mina Habits favicon
|
||||
|
||||
Generated with the built-in imagegen tool. Master: `mina-habits-icon.png`.
|
||||
The application uses 32px and 180px PNG derivatives in `src/assets/`.
|
||||
|
||||
## Prompt
|
||||
|
||||
Use case: logo-brand
|
||||
Asset type: production square favicon for Mina Habits, a minimal editorial habit tracker.
|
||||
Primary request: a single bold, elegant uppercase M monogram, warm white on a solid near-black square background. Match a quiet serif wordmark and minimalist black-and-white interface. Strong thick strokes and subtle sturdy serifs, highly readable at 16x16 pixels.
|
||||
Composition: centered M fills about 75% of square width and height, balanced narrow padding. Flat crisp 2D artwork, square canvas, no mockup.
|
||||
Text (verbatim): "M"
|
||||
Constraints: one icon only; no other letters, no caption, no gradients, shadows, texture, borders or decorative objects.
|
||||
BIN
docs/design/mina-habits-icon.png
Normal file
BIN
docs/design/mina-habits-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 894 KiB |
21
docs/discord-sharing-design.md
Normal file
21
docs/discord-sharing-design.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Discord sharing design
|
||||
|
||||
The shared image uses a separate, vertically stacked GitHub-style calendar for each selected habit. Calendar geometry comes from the dashboard's Sunday-first timeline helper; colors and partial progress come directly from the sharing API. Each total describes the entire selected date range. Days off remain dots and do not enter the denominator.
|
||||
|
||||
Short ranges pair a large period total with the calendar. Long ranges use the full image width for the uninterrupted timeline. Titles, calendars and legends occupy separate vertical regions, including at the tallest seven-row grid size. Text truncates within explicit bounds instead of colliding with neighboring counts. Preview, download and Discord use the same PNG, preserving the server's 1200px export contract.
|
||||
|
||||
The modal puts the image first, shows the Discord destination before sending, exposes quick period choices and keeps privacy details in an expandable section. The footer remains available during scrolling. Its native dialog retains keyboard focus containment, including the privacy disclosure. The thumbnail view uses a 360px image width; actual Discord sizing varies with the client and attachment aspect ratio. Cards containing many stacked calendars will require opening the image to inspect fine daily details.
|
||||
|
||||
## Research
|
||||
|
||||
- [GitHub: Introducing Contributions](https://github.blog/news-insights/product-news/introducing-contributions/): the calendar itself provides the recognizable visual history.
|
||||
- [NN/g: Progressive Disclosure](https://www.nngroup.com/articles/progressive-disclosure/): defer optional settings while keeping the primary task clear.
|
||||
- [Strava: Sharing Your Activities](https://support.strava.com/en-us/articles/15401840-sharing-your-strava-activities): a purpose-built image preview with a focused sharing action.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `bun run typecheck`, `bun test` and `bun run build`.
|
||||
|
||||
For disposable browser testing, run `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` and open http://127.0.0.1:3107/__preview/login. This uses a temporary database and simulated Discord delivery, with no external posts.
|
||||
|
||||
Run `bun scripts/share-card-preview.ts` for the visual fixtures at http://127.0.0.1:3108. These cover long names, six calendars, privacy and no scheduled days at 320px, with a 390px iframe for mobile modal checks. Rendering tests cover date geometry, leap days, original shades, days off and whole-period totals.
|
||||
66
docs/performance.md
Normal file
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,
|
||||
"tag": "0008_reminder_cooldown",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1788596417948,
|
||||
"tag": "0009_purple_wasp",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1788597003524,
|
||||
"tag": "0010_tidy_thena",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1788845281801,
|
||||
"tag": "0011_multiple_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1788847978168,
|
||||
"tag": "0012_discord_commands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1788860000000,
|
||||
"tag": "0013_habit_calendar_revision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1788860000001,
|
||||
"tag": "0014_open_occurrences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "minabot",
|
||||
"name": "mina-habits",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts",
|
||||
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --watch src/index.ts",
|
||||
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
|
||||
"build": "bun run build.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -14,11 +14,13 @@
|
||||
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
|
||||
"test:pwa": "bun run build && bun scripts/pwa-smoke.ts",
|
||||
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
|
||||
"db:restore": "bun scripts/restore.ts",
|
||||
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.8",
|
||||
"@nivo/calendar": "^0.99.0",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -39,6 +41,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"happy-dom": "^20.14.0",
|
||||
"playwright": "^1.63.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Respo
|
||||
const port = probe.port!; probe.stop(true);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
||||
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
||||
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', DISCORD_BOT_TOKEN: '', DISCORD_SHARING_CHANNEL_ID: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
||||
const token = 's'.repeat(43);
|
||||
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
||||
async function boot() {
|
||||
@@ -56,6 +56,10 @@ try {
|
||||
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
||||
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
||||
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
|
||||
const livePreview = await fetch(`${origin}/api/sharing/live/preview?theme=light`, { headers: { Cookie: `minabot_session=${token}` } });
|
||||
check(livePreview.ok, 'Production live PNG rendering failed');
|
||||
const png = Buffer.from(await livePreview.arrayBuffer());
|
||||
check(png.readUInt32BE(16) === 1920 && png.readUInt32BE(20) === 1080, 'Live image is not 16:9');
|
||||
await stop(); await boot();
|
||||
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
||||
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { databasePath } from "../src/db/config";
|
||||
import { backupConfig, createBackup } from "../src/ops/backups";
|
||||
const db = new Database(databasePath, { readonly: true, strict: true });
|
||||
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
|
||||
// Only snapshot SQL runs here; never create a missing source file.
|
||||
const db = new Database(databasePath, { readwrite: true, create: false, strict: true });
|
||||
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }
|
||||
|
||||
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.
|
||||
import { sendDueReminders } from "../src/reminders/worker";
|
||||
import type { DiscordFetch } from "../src/sharing/routes";
|
||||
@@ -6,14 +7,15 @@ import { createApi } from "../src/api";
|
||||
import index from "../src/index.html";
|
||||
|
||||
const f = fixture();
|
||||
const origin = "http://127.0.0.1:3107";
|
||||
const port = Number(process.env.PREVIEW_PORT ?? 3107);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const previewCookie = "minabot_dashboard_preview_session";
|
||||
const sharingPreview = process.env.SHARE_PREVIEW === "1";
|
||||
let sharedImage: Blob | null = null;
|
||||
let reminderMessage: string | null = null;
|
||||
const previewDiscord: DiscordFetch = async (url, init) => {
|
||||
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
|
||||
if (init?.method === "POST") {
|
||||
if (init?.method === "POST" || init?.method === "PATCH") {
|
||||
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
|
||||
else reminderMessage = JSON.parse(String(init.body)).content;
|
||||
return Response.json({ id: "423456789012345678" });
|
||||
@@ -47,13 +49,14 @@ const app = createApi(
|
||||
let reminderRunning = false;
|
||||
if (sharingPreview) setInterval(async () => {
|
||||
if (reminderRunning) return; reminderRunning = true;
|
||||
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
|
||||
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin, commandsAvailable: false }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
|
||||
finally { reminderRunning = false; }
|
||||
}, 1000).unref();
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 3107,
|
||||
port,
|
||||
routes: {
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||
"/__preview/login": () =>
|
||||
|
||||
17
scripts/pwa-icons.ts
Normal file
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(); }
|
||||
}
|
||||
1
scripts/share-card-preview.html
Normal file
1
scripts/share-card-preview.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sharing visual QA</title></head><body><div id="root"></div><script type="module" src="./share-card-preview.tsx"></script></body></html>
|
||||
5
scripts/share-card-preview.ts
Normal file
5
scripts/share-card-preview.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Run with: bun scripts/share-card-preview.ts
|
||||
// Optional mobile iframe: SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts
|
||||
import page from "./share-card-preview.html";
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 3108, routes: { "/*": page }, development: true });
|
||||
console.log(`Sharing visual fixtures: ${server.url}`);
|
||||
30
scripts/share-card-preview.tsx
Normal file
30
scripts/share-card-preview.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// Disposable visual fixtures: no user data and no outbound Discord calls.
|
||||
import { ThemeProvider, ThemeControl, useTheme } from "../src/components/ThemeProvider";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { addDays } from "../src/habits/calendar";
|
||||
import { renderProgressCard } from "../src/lib/progress-card";
|
||||
import type { ShareData } from "../src/sharing/contracts";
|
||||
import "../styles/globals.css";
|
||||
|
||||
const names = ["A few pages, every day", "An afternoon walk", "A very long habit name that should truncate gracefully without hitting the count", "Drink enough water", "练习中文 · 日本語", "Make time to unwind"];
|
||||
const colors = ["#90647e", "#397f76", "#ba793c", "#4b70b4", "#834f9e", "#bd5863"];
|
||||
function Preview({ count, empty = false, privateCard = false }: { count: number; empty?: boolean; privateCard?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const [url, setUrl] = useState("");
|
||||
useEffect(() => {
|
||||
let disposed = false, objectUrl = "";
|
||||
const total = count === 6 ? 365 : 30;
|
||||
const from = addDays("2026-09-04", 1 - total);
|
||||
const data: ShareData = { from, to: "2026-09-04", timezone: "America/Argentina/Buenos_Aires", habits: names.slice(0, count).map((name, i) => {
|
||||
const days = Array.from({length: total}, (_, n) => { const due = !empty && (n + i) % 7 !== 0; const ratio = due ? (n % 4 === 0 ? 0 : n % 3 === 0 ? .5 : 1) : null; return { date: addDays(from, n), due, complete: ratio === 1, ratio, color: ratio === 1 ? colors[i]! : ratio ? "#c7b0bf" : "#e4e7e0" }; });
|
||||
return { name, color: colors[i]!, completed: days.filter(d => d.complete).length, scheduled: days.filter(d => d.due).length, days, legend: { empty: "#e4e7e0", partial: ["#c7b0bf"], complete: colors[i]! } };
|
||||
}) };
|
||||
void renderProgressCard(data, { id: "qa", discordId: "qa", username: "alice", displayName: "Alexandra with a very long display name", avatarUrl: null, timezone: data.timezone }, { name: !privateCard, avatar: !privateCard, habitNames: !privateCard, timezone: privateCard }, theme).then(result => {
|
||||
if (disposed) return; objectUrl = URL.createObjectURL(result.blob); setUrl(objectUrl);
|
||||
});
|
||||
return () => { disposed = true; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [theme]);
|
||||
return <section style={{ background: "#313338", padding: 20, borderRadius: 8, width: 360 }}><h2 style={{ color: "white", marginBottom: 16 }}>{count} {count === 1 ? "habit" : "habits"}{empty ? " · no schedule" : ""}{privateCard ? " · private" : ""}</h2>{url && <img alt="QA progress card at 320 pixels" src={url} style={{ width: 320, display: "block" }} />}</section>;
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<ThemeProvider><main className="ds-root" style={{ padding: 24 }}><ThemeControl /><h1>Card readability at 320px</h1><div style={{ display: "flex", flexWrap: "wrap", alignItems: "start", gap: 24, marginTop: 24 }}><Preview count={1}/><Preview count={6}/><Preview count={6} privateCard/><Preview count={1} empty/></div><h2 style={{ margin: "24px 0" }}>390 × 844 mobile composer</h2><iframe title="Mobile dashboard" src="http://127.0.0.1:3107/__preview/login" style={{ width: 390, height: 844, border: "1px solid #aaa" }}/></main></ThemeProvider>);
|
||||
227
src/App.test.tsx
227
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") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
await settleDeferred();
|
||||
}
|
||||
|
||||
function connectAccount() {
|
||||
@@ -131,6 +138,7 @@ describe("homepage", () => {
|
||||
const opener = buttonNamed("Settings");
|
||||
opener.focus();
|
||||
await act(async () => opener.click());
|
||||
await settleDeferred();
|
||||
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
@@ -158,6 +166,7 @@ describe("homepage", () => {
|
||||
expect(inner.open).toBe(true);
|
||||
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
|
||||
await act(async () => buttonNamed("Finish save").click());
|
||||
await settleDeferred();
|
||||
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||
expect(container.querySelector("#inner")).toBeNull();
|
||||
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
|
||||
@@ -175,31 +184,73 @@ describe("homepage", () => {
|
||||
await f.request(`/habits/${h.id}`, "DELETE");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archived habits").click());
|
||||
await settleDeferred();
|
||||
expect(buttonNamed("Restore Reading")).toBeDefined();
|
||||
await act(async () => buttonNamed("View history").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||
await act(async () => buttonNamed("Restore Reading").click());
|
||||
await settleDeferred();
|
||||
expect(container.textContent).toContain("No archived habits.");
|
||||
expect((await f.json("/today")).habits[0].name).toBe("Reading");
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
|
||||
});
|
||||
|
||||
test("archive permanent deletion supports cancel, failure, retry, and removal", async () => {
|
||||
const f = connectAccount();
|
||||
const habit = await f.json("/habits", "POST", { name: "Discard me", method: "manual" }, 201);
|
||||
await f.request(`/habits/${habit.id}`, "DELETE");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archived habits").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
|
||||
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
|
||||
await act(async () => buttonNamed("Keep habit").click());
|
||||
await settleDeferred();
|
||||
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
|
||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||
await settleDeferred();
|
||||
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
|
||||
await act(async () => confirm().click());
|
||||
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("Temporary failure");
|
||||
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
|
||||
await act(async () => confirm().click());
|
||||
expect(container.querySelector("#delete-archived-habit")).toBeNull();
|
||||
expect(container.textContent).toContain("Discard me permanently deleted.");
|
||||
expect(container.textContent).toContain("No archived habits.");
|
||||
expect((await f.request(`/habits/${habit.id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
f.setTime("2026-01-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
||||
await render("/");
|
||||
expect(container.querySelector(".ds-goal-graph .ds-calendar")?.getAttribute("data-months")).toBe("3");
|
||||
expect(container.querySelector(".ds-goal-graph [hidden]")).toBeNull();
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
|
||||
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
|
||||
await act(async () => buttonNamed("Edit selected check-in").click());
|
||||
await settleDeferred();
|
||||
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
|
||||
expect(past).toBeDefined();
|
||||
await act(async () => past.click());
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
|
||||
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
|
||||
fetchMock.mockClear();
|
||||
await act(async () => {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="date"]')!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "2026-01-03");
|
||||
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
expect(container.querySelector('[aria-label^="2026-01-03:"]')).not.toBeNull();
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("from=2025-11-01") && String(url).includes("compact=true"))).toBe(true);
|
||||
});
|
||||
|
||||
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
||||
@@ -207,7 +258,8 @@ describe("homepage", () => {
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
||||
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
|
||||
await act(async () => buttonNamed("Increase glasses of water").click());
|
||||
expect(container.querySelector(".ds-counter output")?.textContent).toBe("4 / 8");
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -238,6 +290,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).toContain("Account unavailable");
|
||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -247,6 +300,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).toContain("Welcome back, alice.");
|
||||
expect(container.textContent).toContain("Start with one habit.");
|
||||
await act(async () => buttonNamed("Add a habit +").click());
|
||||
await settleDeferred();
|
||||
const input = container.querySelector<HTMLInputElement>("form input")!;
|
||||
expect(document.activeElement).toBe(input);
|
||||
await act(async () => {
|
||||
@@ -256,9 +310,10 @@ describe("homepage", () => {
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Read a little created.");
|
||||
expect(container.textContent).not.toContain("Read a little created.");
|
||||
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
|
||||
await act(async () => buttonNamed("Sign out").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
expect((await f.request("/me")).status).toBe(401);
|
||||
});
|
||||
@@ -272,9 +327,26 @@ describe("homepage", () => {
|
||||
await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201);
|
||||
await render("/");
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3");
|
||||
const countGoal = container.querySelector(".ds-goal--count")!;
|
||||
expect(countGoal.querySelector('.ds-goal-count input[aria-label="Total Water"]')).not.toBeNull();
|
||||
expect(countGoal.querySelector(".ds-goal-unit")?.textContent).toBe("glasses");
|
||||
expect(countGoal.textContent).toContain("Count target");
|
||||
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("In progress");
|
||||
const checkIn = container.querySelector<HTMLInputElement>('.ds-goal--manual input[type="checkbox"]')!;
|
||||
expect(container.querySelector('.ds-goal--manual .ds-goal-check-in > label[for]')?.getAttribute("for")).toBe(checkIn.id);
|
||||
expect(checkIn.getAttribute("aria-label")).toBe("Reading done");
|
||||
expect(container.querySelector('.ds-goal--tasks .ds-goal-tasks details')).toBeNull();
|
||||
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
|
||||
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
|
||||
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
|
||||
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
|
||||
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
|
||||
expect(container.textContent).not.toContain("Sunday walk");
|
||||
await act(async () => buttonNamed("All habits · 4").click());
|
||||
await settleDeferred();
|
||||
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
|
||||
expect(checkbox("Sunday walk done").disabled).toBe(true);
|
||||
await act(async () => checkbox("Reading done").click());
|
||||
@@ -282,9 +354,115 @@ describe("homepage", () => {
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
|
||||
expect((await f.json("/today")).completed).toBe(3);
|
||||
await act(async () => buttonNamed("Decrease Water").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/today")).completed).toBe(2);
|
||||
});
|
||||
|
||||
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(calendarRequests()).toHaveLength(2);
|
||||
expect(calendarRequests()[0]).toContain("from=2026-07-01");
|
||||
expect(calendarRequests()[0]).toContain("compact=true");
|
||||
const parse = spyOn(Date, "parse");
|
||||
await act(async () => buttonNamed("All habits · 2").click());
|
||||
await settleDeferred();
|
||||
expect(parse).not.toHaveBeenCalled();
|
||||
parse.mockRestore();
|
||||
fetchMock.mockClear();
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(0);
|
||||
await f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true });
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(1);
|
||||
expect(calendarRequests()[0]).toContain(reading.id);
|
||||
const history = container.querySelector(`#history-${reading.id}`)!;
|
||||
fetchMock.mockClear();
|
||||
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||
expect(calendarRequests()).toHaveLength(1);
|
||||
expect(calendarRequests()[0]).toContain("from=2025-10-01");
|
||||
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||
fetchMock.mockClear();
|
||||
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
|
||||
expect(calendarRequests()).toHaveLength(0);
|
||||
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
|
||||
expect(history.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||
fetchMock.mockClear();
|
||||
f.setTime("2026-09-05T12:00:00Z");
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("month rollover preserves a twelve-month preset and an explicit custom window", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-30T12:00:00Z");
|
||||
const annual = await f.json("/habits", "POST", { name: "Annual", method: "manual" }, 201);
|
||||
const custom = await f.json("/habits", "POST", { name: "Custom", method: "manual" }, 201);
|
||||
await render("/");
|
||||
const annualHistory = container.querySelector(`#history-${annual.id}`)!;
|
||||
const customHistory = container.querySelector(`#history-${custom.id}`)!;
|
||||
await act(async () => annualHistory.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||
await act(async () => [...customHistory.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Custom")!.click());
|
||||
await act(async () => {
|
||||
for (const [name, value] of [["from", "2026-08-05"], ["to", "2026-09-10"]]) {
|
||||
const input = customHistory.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
}
|
||||
customHistory.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event);
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
f.setTime("2026-10-01T12:00:00Z");
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
const requests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.find(url => url.includes(annual.id))).toContain("from=2025-11-01&to=2026-10-31");
|
||||
expect(requests.find(url => url.includes(custom.id))).toContain("from=2026-08-05&to=2026-09-10");
|
||||
expect(annualHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("12");
|
||||
expect(annualHistory.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||
expect(customHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("custom");
|
||||
expect(customHistory.querySelectorAll(".ds-day")).toHaveLength(37);
|
||||
});
|
||||
|
||||
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
|
||||
const f = connectAccount();
|
||||
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
const calendar = container.querySelector(`#history-${reading.id} .ds-calendar`);
|
||||
const originalFetch = fetchMock.getMockImplementation()!;
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>(resolve => { finish = resolve; });
|
||||
fetchMock.mockImplementation(Object.assign(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
if (init?.method === "PUT" && String(input).includes(`/habits/${water.id}/`)) await pending;
|
||||
return originalFetch(input, init);
|
||||
}, { preconnect: fetch.preconnect }));
|
||||
fetchMock.mockClear();
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(true);
|
||||
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
|
||||
expect(check.disabled).toBe(false);
|
||||
await act(async () => check.click());
|
||||
expect(check.checked).toBe(true);
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(true);
|
||||
expect((await f.json("/today")).completed).toBe(1);
|
||||
fetchMock.mockClear();
|
||||
await act(async () => { finish(); await pending; });
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')!.value).toBe("1");
|
||||
expect(container.querySelector(`#history-${reading.id} .ds-calendar`)).toBe(calendar);
|
||||
const historyRequests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(historyRequests).toHaveLength(1);
|
||||
expect(historyRequests[0]).toContain(`/habits/${water.id}/calendar?`);
|
||||
expect((await f.json("/today")).completed).toBe(1);
|
||||
});
|
||||
|
||||
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
@@ -293,6 +471,7 @@ describe("homepage", () => {
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit habit Water").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
|
||||
expect(document.activeElement).toBe(form.querySelector("input"));
|
||||
await act(async () => {
|
||||
@@ -310,11 +489,15 @@ describe("homepage", () => {
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Archive habit").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(0);
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
@@ -333,6 +516,7 @@ describe("homepage", () => {
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit task Stretch").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -345,7 +529,9 @@ describe("homepage", () => {
|
||||
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
await act(async () => buttonNamed("Delete task Stretch gently").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Delete task").click());
|
||||
await settleDeferred();
|
||||
const today = (await f.json("/today")).habits[0];
|
||||
expect(today.target).toBe(1);
|
||||
expect(today.value).toBe(0);
|
||||
@@ -359,9 +545,13 @@ describe("homepage", () => {
|
||||
const f = connectAccount();
|
||||
const habit = await f.json("/habits", "POST", { name: "Sunday", method: "tasks", schedule: { type: "weekdays", days: [0] }, tasks: [{ name: "Walk" }] }, 201);
|
||||
await render("/");
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
await act(async () => buttonNamed("All habits · 1").click());
|
||||
await settleDeferred();
|
||||
expect(container.textContent).toContain("Not scheduled today");
|
||||
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -401,6 +591,7 @@ describe("homepage", () => {
|
||||
{ type: "daily" },
|
||||
] as const) {
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await selectValue(form.querySelector("select")!, schedule.type);
|
||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||
@@ -410,8 +601,10 @@ describe("homepage", () => {
|
||||
if (schedule.type === "weekly") await selectValue(form.querySelectorAll("select")[1]!, String(schedule.weekday));
|
||||
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect((await f.json(`/habits/${habit.id}/tasks/${id}`)).schedule).toEqual(schedule);
|
||||
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
|
||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||
@@ -419,6 +612,7 @@ describe("homepage", () => {
|
||||
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
|
||||
}
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
await settleDeferred();
|
||||
}
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
expect(previous.tasks[0].done).toBe(true);
|
||||
@@ -438,17 +632,20 @@ describe("homepage", () => {
|
||||
};
|
||||
const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||
await settleDeferred();
|
||||
await selectMethod("count");
|
||||
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
|
||||
await submit();
|
||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
|
||||
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
|
||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
|
||||
await selectMethod("tasks");
|
||||
await submit();
|
||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
|
||||
await act(async () => buttonNamed("Add task +").click());
|
||||
await act(async () => buttonNamed("Add task").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -468,16 +665,19 @@ describe("homepage", () => {
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archive habit Reading").click());
|
||||
await settleDeferred();
|
||||
fetchMock.mockImplementationOnce((async (input, init) => {
|
||||
return f.request(String(input).replace("/api", ""), init?.method);
|
||||
}) as typeof fetch);
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||
await act(async () => buttonNamed("Archive habit").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
||||
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
|
||||
});
|
||||
@@ -488,11 +688,13 @@ describe("homepage", () => {
|
||||
await render("/");
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
|
||||
expect(container.querySelector(".ds-counter output")?.textContent).toBe("0 / 8");
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
expect(container.querySelector(".ds-counter output")?.textContent).toBe("1 / 8");
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -539,11 +741,11 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
|
||||
).toBe("#ebedf0");
|
||||
).toBe("var(--ds-empty)");
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".ds-day--future")!.style
|
||||
.backgroundColor,
|
||||
).toBe("#ebedf0");
|
||||
).toBe("var(--ds-empty)");
|
||||
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
|
||||
await act(async () => restDay.click());
|
||||
expect(restDay.getAttribute("aria-pressed")).toBe("true");
|
||||
@@ -660,7 +862,7 @@ describe("design system tabs", () => {
|
||||
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
|
||||
await clickTab("calendar-states");
|
||||
await clickTab("components");
|
||||
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
|
||||
expect(panel().querySelector<HTMLInputElement>('[aria-label="Total example count"]')?.value).toBe("4");
|
||||
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
|
||||
await act(async () => viewHabit.click());
|
||||
expect(panel().id).toBe("ds-panel-calendar-states");
|
||||
@@ -722,14 +924,15 @@ test("reminder form submits native time values and explicit opt-out", async () =
|
||||
return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
|
||||
}) as typeof fetch);
|
||||
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
|
||||
const time = container.querySelector<HTMLInputElement>('input[name="time"]')!;
|
||||
const time = container.querySelector<HTMLInputElement>('input[name="reminder-time-0"]')!;
|
||||
await act(async () => {
|
||||
time.value = "14:30";
|
||||
time.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
|
||||
});
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect(saved[0]).toEqual({ enabled: true, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
|
||||
expect(saved[0]).toEqual({ enabled: true, reminders: [{ type: "weekly", time: "14:30", days: [0, 1, 2, 3, 4, 5, 6] }], quietStart: "22:00", quietEnd: "08:00" });
|
||||
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect(saved[1]).toEqual({ enabled: false, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
|
||||
expect(saved[1]).toEqual({ enabled: false, reminders: [{ type: "weekly", time: "14:30", days: [0, 1, 2, 3, 4, 5, 6] }], quietStart: "22:00", quietEnd: "08:00" });
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { deferred } from "./components/design-system/deferred";
|
||||
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
|
||||
import { Home } from "./pages/Home";
|
||||
import { AuthProvider } from "./components/AuthProvider";
|
||||
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<ThemeProvider><Routes>
|
||||
<Route path="/design-system" element={<DesignSystem />} />
|
||||
<Route
|
||||
path="/"
|
||||
@@ -16,6 +19,6 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Routes></ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
|
||||
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
app.use("*", auth.requireAuth);
|
||||
app.get("/export", c => {
|
||||
app.get("/export", c => HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||
const user = c.get("user");
|
||||
const service = new HabitService(db, user, now()); service.sync();
|
||||
const data = db.transaction(tx => {
|
||||
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
|
||||
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||
@@ -29,13 +28,14 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
|
||||
};
|
||||
});
|
||||
c.header("Content-Disposition", 'attachment; filename="minabot-export.json"');
|
||||
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
|
||||
return c.json(data);
|
||||
});
|
||||
}));
|
||||
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
|
||||
const userId = c.get("user").id;
|
||||
return HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||
db.transaction(tx => {
|
||||
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
|
||||
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||
@@ -51,5 +51,6 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
auth.clearSession(c);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
});
|
||||
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 { errorDetails } from "./ops/monitor";
|
||||
import { createAccountRoutes } from "./account/routes";
|
||||
@@ -11,26 +13,37 @@ import type { FetchDiscord } from "./auth/discord";
|
||||
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
||||
import type { DiscordSharingConfig } from "./sharing/config";
|
||||
|
||||
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void } = {}) {
|
||||
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void; discord?: CommandConfig } = {}) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
|
||||
// Accounts, calendars, exports and OAuth responses must never enter HTTP caches.
|
||||
c.header("Cache-Control", "no-store");
|
||||
await next();
|
||||
});
|
||||
const auth = createAuth(db, config, request, now);
|
||||
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
const sharing = createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
|
||||
liveSharing.changed(c.get("user").id);
|
||||
});
|
||||
app.get("/api/health", c => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
db.get(sql`SELECT 1`);
|
||||
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed, { progress: { sharing, live: liveSharing } }));
|
||||
app.route("/api/sharing/live", liveSharing.routes);
|
||||
app.route("/api/auth", auth.routes);
|
||||
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
|
||||
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
||||
app.route("/api/sharing", sharing);
|
||||
app.notFound(c => c.json({ error: "Not found" }, 404));
|
||||
app.onError((_error, c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
@@ -41,5 +54,5 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
|
||||
return c.json({ error: "Internal server error", requestId }, 500);
|
||||
});
|
||||
return app;
|
||||
return Object.assign(app, { stopLiveSharing: liveSharing.stop, flushLiveSharing: liveSharing.flush });
|
||||
}
|
||||
|
||||
BIN
src/assets/apple-touch-icon.png
Normal file
BIN
src/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/favicon.png
Normal file
BIN
src/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
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());
|
||||
|
||||
describe("Discord sign-in and sessions", () => {
|
||||
test("trusted TLS proxy supports sign-in and secure callback cookies", async () => {
|
||||
const publicOrigin = "https://habits.example";
|
||||
const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp);
|
||||
const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } });
|
||||
const location = new URL(response.headers.get("Location")!);
|
||||
expect(location.origin).toBe("https://discord.com");
|
||||
expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`);
|
||||
expect(response.headers.get("Set-Cookie")).toContain("Secure");
|
||||
const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, {
|
||||
headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") },
|
||||
});
|
||||
expect(cookie(callback, "__Host-minabot_session")).not.toBe("");
|
||||
expect(callback.headers.get("Set-Cookie")).toContain("Secure");
|
||||
});
|
||||
|
||||
test("forwarded protocol is opt-in and cannot override the actual hostname", async () => {
|
||||
for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) {
|
||||
const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp);
|
||||
const response = await proxyApp.request(`http://${host}/api/auth/discord`, {
|
||||
headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" },
|
||||
});
|
||||
expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example");
|
||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("me requires a valid session and health stays public", async () => {
|
||||
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
||||
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AuthConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
cookieSecret: string;
|
||||
trustProxy?: boolean;
|
||||
};
|
||||
|
||||
export function readAuthConfig(env = process.env): AuthConfig {
|
||||
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
|
||||
clientId: env.DISCORD_CLIENT_ID ?? "",
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
||||
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
||||
trustProxy: env.TRUST_PROXY === "true",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function publicUser(user: typeof users.$inferSelect): PublicUser {
|
||||
export function publicUser(user: typeof users.$inferSelect): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
|
||||
routes.get("/discord", async c => {
|
||||
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
||||
// Use one configured origin for redirects and cookie ownership.
|
||||
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
||||
const requestUrl = new URL(c.req.url);
|
||||
// Enable only behind a private proxy that overwrites X-Forwarded-Proto.
|
||||
// Keep the actual Host; never trust a client-supplied forwarded hostname.
|
||||
if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:";
|
||||
if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
||||
const state = randomToken();
|
||||
await setSignedCookie(c, stateName, JSON.stringify({
|
||||
state,
|
||||
|
||||
14
src/browser-fonts.test.ts
Normal file
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 });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ThemeControl } from "./ThemeProvider";
|
||||
import { ReminderSettings } from "./ReminderSettings";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
@@ -23,8 +24,9 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||
finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
|
||||
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
|
||||
return <WorkspacePanel id="account-settings" title="Account settings" onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
|
||||
<SettingRow title="Appearance"><ThemeControl /></SettingRow>
|
||||
<SettingRow title="Daily timezone" description={<p>Sets day boundaries. Earlier deadlines stay unchanged.</p>}>
|
||||
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
|
||||
<Field label="Timezone">
|
||||
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
|
||||
@@ -34,8 +36,8 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
||||
</form>
|
||||
</SettingRow>
|
||||
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
|
||||
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
|
||||
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
|
||||
<SettingRow title="Export data" description={<p>Download your profile, habits, and history.</p>}>
|
||||
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="mina-habits-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
|
||||
</SettingRow>
|
||||
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
||||
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
|
||||
import { Archive, ChevronDown, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { HabitHistory } from "./HabitHistory";
|
||||
|
||||
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
|
||||
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
|
||||
}) {
|
||||
const [habits, setHabits] = useState<TodayHabit[]>([]);
|
||||
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const archiveFocus = useRef<HTMLDivElement>(null);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
@@ -37,11 +41,26 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
setError(message); if (message.includes("session has expired")) expired.current();
|
||||
} finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||
async function permanentlyDelete(habit: TodayHabit) {
|
||||
if (saving.current) return;
|
||||
saving.current = true; setBusy(true); setDeleteError(""); setNotice("");
|
||||
try {
|
||||
await habitRequest(`/habits/${habit.habitId}/permanent`, { method: "DELETE" });
|
||||
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
|
||||
setSelected(current => current === habit.habitId ? undefined : current);
|
||||
setDeleting(null);
|
||||
setNotice(`${habit.name} permanently deleted.`);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not delete. Try again.";
|
||||
setDeleteError(message); if (message.includes("session has expired")) expired.current();
|
||||
} finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
return <WorkspacePanel id="archived-habits" title="Archived habits" onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
|
||||
<FormMessage error>{error}</FormMessage>
|
||||
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
||||
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
|
||||
<FormMessage>{notice}</FormMessage>
|
||||
<div className="ds-record-list">
|
||||
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
|
||||
<header className="ds-record-heading">
|
||||
@@ -49,10 +68,18 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
<div className="ds-action-row">
|
||||
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
|
||||
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</Button>
|
||||
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
|
||||
</div>
|
||||
</header>
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
</article>)}
|
||||
</div>
|
||||
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
|
||||
<FormMessage error>{deleteError}</FormMessage>
|
||||
<div className="ds-action-row">
|
||||
<Button variant="secondary" data-cancel-delete disabled={busy} onClick={() => setDeleting(null)}>Keep habit</Button>
|
||||
<Button disabled={busy} onClick={() => void permanentlyDelete(deleting)}>{busy ? "Deleting…" : "Delete permanently"}</Button>
|
||||
</div>
|
||||
</Modal>}
|
||||
</WorkspacePanel>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
|
||||
};
|
||||
|
||||
function signIn() {
|
||||
if (navigator.onLine === false) return;
|
||||
let timezone = "UTC";
|
||||
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
||||
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||
@@ -21,6 +22,7 @@ type AuthState = {
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
accountError: string;
|
||||
connectionError: boolean;
|
||||
error: string;
|
||||
signIn: () => void;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [accountError, setAccountError] = useState("");
|
||||
const [connectionError, setConnectionError] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const { search } = useLocation();
|
||||
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
async function loadUser() {
|
||||
let reachedServer = false;
|
||||
try {
|
||||
const response = await fetch("/api/me", { signal: controller.signal });
|
||||
setAccountError("");
|
||||
setConnectionError(false);
|
||||
const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) });
|
||||
reachedServer = true;
|
||||
if (controller.signal.aborted) return;
|
||||
if (response.status === 401) { setUser(null); return; }
|
||||
if (!response.ok) throw new Error("Could not load your account. Please try again.");
|
||||
const account: PublicUser = await response.json();
|
||||
if (!controller.signal.aborted) setUser(account);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
|
||||
if (!controller.signal.aborted) {
|
||||
setConnectionError(!reachedServer);
|
||||
setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account.");
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
|
||||
useEffect(() => {
|
||||
const reconnect = () => { if (accountError) retry(); };
|
||||
const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); };
|
||||
const timer = window.setInterval(check, 15_000);
|
||||
window.addEventListener("online", reconnect);
|
||||
window.addEventListener("focus", check);
|
||||
return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); };
|
||||
}, [accountError, connectionError]);
|
||||
|
||||
function retry() {
|
||||
setAccountError("");
|
||||
setLoading(true);
|
||||
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
|
||||
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
||||
setUser(null);
|
||||
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
|
||||
<AuthContext.Provider value={{ user, loading, busy, accountError, connectionError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
78
src/components/HabitForm.test.tsx
Normal file
78
src/components/HabitForm.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act, useState } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { HabitForm } from "./HabitForm";
|
||||
import { HABIT_COLOR_CHOICES, HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
|
||||
const dom = new Window({ url: "http://localhost:3000/design-system" });
|
||||
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
|
||||
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
({ createRoot } = await import("react-dom/client"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
dom.happyDOM.abort();
|
||||
for (const [key, descriptor] of originalGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
|
||||
function button(text: string) {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
||||
}
|
||||
|
||||
test("blank habit creation exposes tracking-specific fields without templates", async () => {
|
||||
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
|
||||
expect(container.querySelector('[aria-label="Start with an idea"]')).toBeNull();
|
||||
const method = container.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(method.value).toBe("manual");
|
||||
await act(async () => { method.value = "count"; method.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
|
||||
await act(async () => { method.value = "tasks"; method.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(container.querySelector('input[type="number"]')).toBeNull();
|
||||
await act(async () => button("Add task +").click());
|
||||
expect([...container.querySelectorAll("label")].filter(label => /^Task \d+$/.test(label.textContent ?? ""))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("continuous colors disclose custom controls and preserve the selected color", async () => {
|
||||
function Picker() {
|
||||
const [color, setColor] = useState("#123456");
|
||||
return <HabitColorPicker value={color} onChange={setColor} />;
|
||||
}
|
||||
await act(async () => root.render(<Picker />));
|
||||
expect(container.querySelector('[aria-label="Habit colors"]')?.querySelectorAll('button[aria-label^="Color "]').length).toBe(HABIT_COLOR_CHOICES.length);
|
||||
const disclosure = button("Custom");
|
||||
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
|
||||
expect(details.hidden).toBe(true);
|
||||
await act(async () => disclosure.click());
|
||||
expect(details.hidden).toBe(false);
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
|
||||
await act(async () => button("Custom").click());
|
||||
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
|
||||
});
|
||||
@@ -23,11 +23,11 @@ export function HabitForm({
|
||||
const saving = useRef(false);
|
||||
const [name, setName] = useState("");
|
||||
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
|
||||
const [target, setTarget] = useState(8);
|
||||
const [unit, setUnit] = useState("glasses");
|
||||
const [target, setTarget] = useState(10);
|
||||
const [unit, setUnit] = useState("pages");
|
||||
const [tasks, setTasks] = useState([""]);
|
||||
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
|
||||
const [color, setColor] = useState("#58765b");
|
||||
const [color, setColor] = useState("#3e805d");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -75,9 +75,9 @@ export function HabitForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
|
||||
<Modal id="new-habit" title="Create habit" onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
|
||||
<form ref={form} className="ds-edit-content" onSubmit={submit}>
|
||||
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
|
||||
<fieldset className="ds-task-editor-fieldset ds-habit-form-grid" disabled={busy}>
|
||||
<Field label="Habit name">
|
||||
{(id) => (
|
||||
<input
|
||||
@@ -90,7 +90,7 @@ export function HabitForm({
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="How will you track it?">
|
||||
<Field label="Tracking">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
@@ -101,10 +101,11 @@ export function HabitForm({
|
||||
>
|
||||
<option value="manual">A simple check-in</option>
|
||||
<option value="count">A count target</option>
|
||||
<option value="tasks">A list of tasks</option>
|
||||
<option value="tasks">A routine checklist</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
{method === "count" && (
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Daily target">
|
||||
@@ -176,16 +177,8 @@ export function HabitForm({
|
||||
>
|
||||
Add task +
|
||||
</Button>
|
||||
<p className="ds-form-feedback">
|
||||
Tasks repeat on each scheduled day.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ScheduleEditor
|
||||
value={schedule}
|
||||
onChange={setSchedule}
|
||||
anchorDate={date}
|
||||
/>
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="create" />
|
||||
</fieldset>
|
||||
{error && (
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCalendarCache } from "./CalendarCache";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||
import { monthWindow } from "./design-system/calendar-model";
|
||||
import { monthWindow, type MonthView } from "./design-system/calendar-model";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { CheckInEditor } from "./CheckInEditor";
|
||||
import { deferred } from "./design-system/deferred";
|
||||
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
|
||||
import { Field } from "./design-system/Field";
|
||||
import { ItemActions } from "./design-system/ItemActions";
|
||||
|
||||
@@ -40,7 +42,29 @@ export function HabitHistory({
|
||||
onHistorySaved?: () => void;
|
||||
calendarOnly?: boolean;
|
||||
}) {
|
||||
const cache = useCalendarCache();
|
||||
const [rangeSelection, setRangeSelection] = useState(() => ({ date, months: 3 as MonthView | "custom", range: monthWindow(date, 3) }));
|
||||
const range = useMemo(() => {
|
||||
if (rangeSelection.date === date || rangeSelection.months === "custom") return rangeSelection.range;
|
||||
// A preset follows the current month across midnight; an explicitly inspected
|
||||
// older window and custom dates retain the user's chosen historical period.
|
||||
const previousCurrent = monthWindow(rangeSelection.date, rangeSelection.months);
|
||||
const anchor = rangeSelection.range.to < previousCurrent.to ? rangeSelection.range.to : date;
|
||||
return monthWindow(anchor, rangeSelection.months);
|
||||
}, [date, rangeSelection]);
|
||||
function requestRange(next: { from: string; to: string }, months: MonthView | "custom" = rangeSelection.months) {
|
||||
setRangeSelection({ date, months, range: next });
|
||||
}
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<string>();
|
||||
function selectDate(selected: string) {
|
||||
setSelectedDate(selected);
|
||||
const available = monthWindow(date, 12);
|
||||
if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
|
||||
const requested = monthWindow(selected, rangeSelection.months);
|
||||
requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
|
||||
}
|
||||
}
|
||||
const [editingDay, setEditingDay] = useState(false);
|
||||
const [savingDay, setSavingDay] = useState(false);
|
||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||
@@ -49,26 +73,20 @@ export function HabitHistory({
|
||||
const expired = useRef(onExpired);
|
||||
expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
setError("");
|
||||
const range = monthWindow(date, 12);
|
||||
habitRequest<CalendarResponse>(
|
||||
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setCalendar(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (error.message.includes("session has expired") && expired.current)
|
||||
expired.current();
|
||||
setHistoryLoading(true);
|
||||
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
|
||||
.then(result => { if (active) setCalendar(result); })
|
||||
.catch(error => {
|
||||
if (!active) return;
|
||||
if (error.message.includes("session has expired") && expired.current) expired.current();
|
||||
else setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [habit.habitId, date, revision, attempt]);
|
||||
}).finally(() => { if (active) setHistoryLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
||||
|
||||
const days =
|
||||
const days = useMemo(() =>
|
||||
calendar?.days.map((day) => ({
|
||||
date: day.date,
|
||||
color: day.color,
|
||||
@@ -80,14 +98,14 @@ export function HabitHistory({
|
||||
: day.due
|
||||
? ("due" as const)
|
||||
: ("not-due" as const),
|
||||
})) ?? [];
|
||||
})) ?? [], [calendar]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
||||
habit.target ??
|
||||
1,
|
||||
);
|
||||
const shades = calendar
|
||||
const shades = useMemo(() => calendar
|
||||
? [
|
||||
calendar.settings.emptyColor,
|
||||
...Array.from(
|
||||
@@ -101,7 +119,7 @@ export function HabitHistory({
|
||||
).color,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
: [], [calendar, steps]);
|
||||
const chart = error ? (
|
||||
<div className="ds-form-feedback">
|
||||
<p role="alert">{error}</p>
|
||||
@@ -119,16 +137,20 @@ export function HabitHistory({
|
||||
) : (
|
||||
<>
|
||||
<CalendarHeatmap
|
||||
onSelectDate={setSelectedDate}
|
||||
onSelectDate={selectDate}
|
||||
selectedDate={selectedDate}
|
||||
compact
|
||||
initialMonths={3}
|
||||
availableRange={monthWindow(date, 12)}
|
||||
onRangeChange={requestRange}
|
||||
loading={historyLoading}
|
||||
historyLabel="Your recorded progress"
|
||||
label={`${habit.name} progress calendar`}
|
||||
unit={habit.unit}
|
||||
color={calendar.settings.mainColor}
|
||||
emptyColor={calendar.settings.emptyColor}
|
||||
days={days}
|
||||
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { selectDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||
legend={
|
||||
<CalendarLegend
|
||||
label={`${habit.name} progress calendar`}
|
||||
@@ -138,9 +160,9 @@ export function HabitHistory({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||
{editingDay && <Modal id={`check-in-${habit.habitId}`} title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||
<div className="ds-history-editor">
|
||||
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
|
||||
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) selectDate(event.target.value); }} />}</Field></div>
|
||||
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||
</div></Modal>}
|
||||
@@ -150,6 +172,8 @@ export function HabitHistory({
|
||||
return (
|
||||
<HabitChart
|
||||
id={`history-${habit.habitId}`}
|
||||
trackingMethod={habit.requirements?.method ?? habit.method ?? undefined}
|
||||
checkInId={`habit-check-${habit.habitId}`}
|
||||
name={habit.name ?? "Habit"}
|
||||
method={
|
||||
habit.method === "count"
|
||||
|
||||
@@ -30,13 +30,14 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
|
||||
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<Field label="How will you track it?">
|
||||
<Field label="Tracking">
|
||||
{(id) => <select id={id} value={method} onChange={(event) => setMethod(event.target.value as HabitConfig["method"])}>
|
||||
<option value="manual">A simple check-in</option>
|
||||
<option value="count">A count target</option>
|
||||
<option value="tasks">A list of tasks</option>
|
||||
</select>}
|
||||
</Field>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
{method !== config.method && <p className="ds-footnote">Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
|
||||
{method === "count" && (<>
|
||||
<div className="ds-form-grid">
|
||||
@@ -44,11 +45,9 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
|
||||
<Field label="Unit">{(id) => <input id={id} required maxLength={80} value={unit} onChange={(event) => setUnit(event.target.value)} />}</Field>
|
||||
</div>
|
||||
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
|
||||
<p className="ds-footnote">Completed counts reset. Explicitly recorded counts are kept.</p>
|
||||
</>)}
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="edit" />
|
||||
<p className="ds-footnote">Changes start today. Earlier targets and schedules stay as they were.</p>
|
||||
<p className="ds-footnote">Applies from today. Earlier history is kept.</p>
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
||||
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
|
||||
|
||||
return (
|
||||
<Modal id={`item-${id}`} eyebrow={kind === "habit" ? "YOUR HABIT" : "YOUR TASK"} title={formLabel ?? `${action} ${kind}`} description={name || undefined} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
|
||||
<Modal id={`item-${id}`} title={formLabel ?? `${action} ${kind}`} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
|
||||
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -48,7 +48,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
||||
}
|
||||
}}>
|
||||
{mode === "edit" ? (
|
||||
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
|
||||
<fieldset className={`ds-inline-item-fields${kind === "habit" ? " ds-habit-form-grid" : ""}`} disabled={busy || disabled}>
|
||||
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
|
||||
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
|
||||
</Field>
|
||||
@@ -57,7 +57,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
||||
) : (
|
||||
<p className="ds-inline-delete-copy">
|
||||
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
|
||||
? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits."
|
||||
? "History is kept. Restore it anytime from Archived habits."
|
||||
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule,
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<p className="ds-footnote">This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
|
||||
<p className="ds-footnote">Runs when both schedules match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||
{!creating && <p className="ds-footnote">Applies from today. Earlier check-ins stay unchanged.</p>}
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
|
||||
import { allWeekdays, defaultReminder, entries, type ReminderEntry, type ReminderResponse } from "../reminders/contracts";
|
||||
import { Button, Checkbox } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
|
||||
const deliveryLabels: Record<string, string> = {
|
||||
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.",
|
||||
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. This reminder will not be retried.",
|
||||
failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed",
|
||||
};
|
||||
export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
|
||||
const [settings, setSettings] = useState<ReminderResponse>();
|
||||
const [draft, setDraft] = useState(defaultReminder);
|
||||
const [draft, setDraft] = useState({ ...defaultReminder, reminders: entries(defaultReminder) });
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -20,31 +20,42 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); setError("");
|
||||
habitRequest<ReminderResponse>("/reminders", { signal: controller.signal })
|
||||
.then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time, quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
|
||||
.then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time ?? "20:00", reminders: entries(data), quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
|
||||
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
|
||||
const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) }));
|
||||
return <SettingRow title="Reminders" description={<><p>Private Discord reminders for unfinished habits. Reminder names are included; habit names stay private.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
|
||||
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => {
|
||||
event.preventDefault(); if (saving.current || disabled) return;
|
||||
const form = event.currentTarget;
|
||||
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
|
||||
const submitted = { enabled: draft.enabled, reminders: draft.reminders.map((entry, index) => ({ ...entry, time: (form.elements.namedItem(`reminder-time-${index}`) as HTMLInputElement).value, ...(entry.type === "date" ? { date: (form.elements.namedItem(`reminder-date-${index}`) as HTMLInputElement).value } : {}) })), quietStart: draft.quietStart, quietEnd: draft.quietEnd, ...Object.fromEntries((["quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
|
||||
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
|
||||
try {
|
||||
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
|
||||
setDraft(submitted);
|
||||
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
|
||||
setDraft(value => ({ ...value, ...submitted }));
|
||||
setNotice(submitted.enabled ? "Discord reminders saved." : "Reminders turned off.");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
|
||||
finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
|
||||
}}>
|
||||
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
|
||||
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
|
||||
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
||||
<Checkbox className="ds-preference-toggle" label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
||||
{draft.reminders.map((entry, index) => <div key={index} className="ds-form-stack">
|
||||
<Field label={`Reminder ${index + 1} name (optional)`}>{id => <input id={id} value={entry.name ?? ""} placeholder="e.g. Morning check-in" maxLength={80} onChange={event => changeEntry(index, { ...entry, name: event.target.value })} />}</Field>
|
||||
<div className="ds-clock-fields">
|
||||
{([['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>)}
|
||||
</div>
|
||||
<p className="ds-supporting-copy">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
|
||||
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
|
||||
</fieldset>
|
||||
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { LiveDiscordShare } from "./LiveDiscordShare";
|
||||
import { ThemeControl, useTheme } from "./ThemeProvider";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Send } from "lucide-react";
|
||||
import { Check, ChevronDown, Download, Hash, ImageIcon, Send, ShieldCheck } from "lucide-react";
|
||||
import { Button, Checkbox } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
import { habitRequest, type TodayResponse } from "../lib/dashboard";
|
||||
import { addDays } from "../habits/calendar";
|
||||
import { addDays, dayNumber } from "../habits/calendar";
|
||||
import { renderProgressCard, type CardPrivacy } from "../lib/progress-card";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { Delivery, DiscordConnection, ShareData } from "../sharing/contracts";
|
||||
|
||||
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
|
||||
const { theme } = useTheme();
|
||||
const [mode, setMode] = useState<"snapshot" | "live">("live");
|
||||
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
|
||||
const [thumbnail, setThumbnail] = useState(true);
|
||||
const [range, setRange] = useState("30");
|
||||
const [from, setFrom] = useState(addDays(today.date, -29));
|
||||
const [to, setTo] = useState(today.date);
|
||||
@@ -26,7 +31,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
const [sendError, setSendError] = useState("");
|
||||
const lock = useRef(false);
|
||||
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
|
||||
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
|
||||
const key = JSON.stringify({ ids, from, to, privacy, revision, user, theme });
|
||||
const ready = card?.key === key ? card : null;
|
||||
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
||||
const busy = sending;
|
||||
@@ -41,14 +46,16 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
}, [connectionAttempt]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (mode === "live") return () => controller.abort();
|
||||
let objectUrl: string | undefined;
|
||||
setError(""); setSendError("");
|
||||
const timer = setTimeout(async () => {
|
||||
if (!ids.length) { setError("Choose at least one habit for your card."); return; }
|
||||
if (!from || !to || from > to || to > today.date || dayNumber(to) - dayNumber(from) > 365) { setError("Choose a date range of up to 366 days, ending today or earlier."); return; }
|
||||
try {
|
||||
const data = await habitRequest<ShareData>("/sharing/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ habitIds: ids, from, to }), signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
const image = await renderProgressCard(data, user, privacy);
|
||||
const image = await renderProgressCard(data, user, privacy, theme);
|
||||
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await image.blob.arrayBuffer())), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (controller.signal.aborted) return;
|
||||
objectUrl = URL.createObjectURL(image.blob);
|
||||
@@ -58,46 +65,67 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
} catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); }
|
||||
}, 200);
|
||||
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [key, renderAttempt]);
|
||||
}, [key, renderAttempt, mode]);
|
||||
|
||||
async function send() {
|
||||
if (!ready || !connection?.connected || lock.current || sent) return;
|
||||
lock.current = true; setSending(true); setSendError("");
|
||||
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "minabot-progress.png");
|
||||
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "mina-habits-progress.png");
|
||||
try { const result = await habitRequest<Delivery>("/sharing/discord/send", { method: "POST", body: form }); setDelivery({ ...result, id: ready.deliveryId }); }
|
||||
catch (e) { setSendError(e instanceof Error ? e.message : "Could not send. Retrying this card will not post it twice."); }
|
||||
finally { lock.current = false; setSending(false); }
|
||||
}
|
||||
return (
|
||||
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
|
||||
<Modal id="share-progress" title="Share progress" onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
|
||||
<div className="ds-sharing-mode" role="group" aria-label="Sharing format">
|
||||
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image</Button>
|
||||
<Button variant={mode === "snapshot" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "snapshot"} onClick={() => setMode("snapshot")}>Snapshot</Button>
|
||||
</div>
|
||||
{mode === "live" ? <LiveDiscordShare revision={revision} /> : <>
|
||||
|
||||
<div className="ds-share-layout">
|
||||
<section className="ds-share-preview-column" aria-label="Card preview">
|
||||
<div className="ds-share-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
|
||||
<div className="ds-share-size" role="group" aria-label="Preview size"><button type="button" aria-pressed={thumbnail} onClick={() => setThumbnail(true)}>Thumbnail</button><button type="button" aria-pressed={!thumbnail} onClick={() => setThumbnail(false)}>Larger</button></div>
|
||||
</div>
|
||||
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
|
||||
{error ? <div className="ds-share-placeholder" role="alert"><ImageIcon size={28} aria-hidden="true" /><p>{error}</p>{ids.length > 0 && <Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button>}</div> : ready ? <img src={ready.url} alt={ready.alt} /> : <div className="ds-share-placeholder" role="status"><ImageIcon size={28} aria-hidden="true" /><p>Creating your card…</p></div>}
|
||||
</div>
|
||||
<p className="ds-share-caption">Your preview matches the shared image.</p>
|
||||
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldn’t load. A neutral profile icon is used.</p>}
|
||||
</section>
|
||||
<div className="ds-share-controls">
|
||||
<ThemeControl disabled={busy} />
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||
<legend className="type-ui-heading">Choose your habits</legend>
|
||||
<p className="ds-muted type-small">Up to six per card. Task names are never included.</p>
|
||||
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
|
||||
<Field label="Date range">{id => <select id={id} value={range} onChange={e => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="365">Past year</option><option value="custom">Custom dates</option></select>}</Field>
|
||||
<legend>Time period</legend>
|
||||
<div className="ds-share-periods" role="group" aria-label="Date range">{[["7", "7 days"], ["30", "30 days"], ["365", "1 year"], ["custom", "Custom"]].map(([value, label]) => <button key={value} type="button" aria-pressed={range === value} onClick={() => { setRange(value!); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}>{label}</button>)}</div>
|
||||
{range === "custom" && <div className="ds-share-dates"><Field label="From">{id => <input id={id} type="date" value={from} max={to || today.date} min="1970-01-01" onChange={e => setFrom(e.target.value)} />}</Field><Field label="To">{id => <input id={id} type="date" value={to} min={from} max={today.date} onChange={e => setTo(e.target.value)} />}</Field></div>}
|
||||
</fieldset>
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="type-ui-heading">Show on the card</legend>
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
|
||||
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
|
||||
<p className="ds-share-hint">Task names stay private.</p>
|
||||
</fieldset>
|
||||
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">Show on the card</legend>
|
||||
{([["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"]] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
|
||||
</fieldset>
|
||||
</details>
|
||||
<div className="ds-share-destination"><span className="ds-share-destination-icon"><Hash size={20} aria-hidden="true" /></span><div><span className="ds-share-hint">Discord destination</span>
|
||||
{connectionError ? <div role="alert"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry connection</Button></div> : connection?.connected ? <><a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a><p className="ds-share-hint">Posted by the Mina Habits bot.</p></> : <p className="ds-share-hint" role="status">{connection?.message ?? "Loading channel…"}</p>}
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="ds-share-preview-column">
|
||||
<div className="ds-share-preview" aria-busy={!ready && !error}>
|
||||
{error ? <div role="alert"><p>{error}</p><Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button></div> : ready ? <img src={ready.url} alt={ready.alt} /> : <p role="status">Creating your card…</p>}
|
||||
</div>
|
||||
<p className="type-small ds-muted">This exact image will be downloaded or sent. Days off are excluded from the completion rate.</p>
|
||||
{ready?.avatarMissing && <p role="status" className="type-small ds-muted">Your avatar couldn’t load. The card uses a neutral profile icon.</p>}
|
||||
<footer className="ds-share-footer">
|
||||
<div className="ds-share-footer-copy" aria-live="polite">
|
||||
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready to share." : "Preparing preview…"}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
|
||||
</div>
|
||||
<div className="ds-share-actions">
|
||||
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`minabot-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download PNG</a> : <Button variant="secondary" disabled>Download PNG</Button>}
|
||||
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}><Send size={17} aria-hidden="true" />{sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}</Button>
|
||||
</div>
|
||||
{connectionError ? <div role="alert" className="ds-share-feedback"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry Discord</Button></div> : connection?.connected ? <p className="type-small ds-muted">The bot will post this card to <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a>.</p> : <p className="type-small ds-muted" role="status">{connection?.message ?? "Loading the Discord sharing channel…"}</p>}
|
||||
{sendError && <p role="alert">{sendError}</p>}
|
||||
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
||||
</div>
|
||||
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`mina-habits-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download</a> : <Button variant="secondary" disabled><Download size={17} aria-hidden="true" />Download</Button>}
|
||||
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}>{sent?.status === "sent" ? <Check size={17} aria-hidden="true" /> : <Send size={17} aria-hidden="true" />}{sending ? "Sending…" : sent?.status === "sent" ? "Shared" : sent ? "Check Discord" : "Send to Discord"}</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/components/ThemeProvider.tsx
Normal file
48
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Monitor, Sun, Moon } from "lucide-react";
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useId, useState, type ReactNode } from "react";
|
||||
import { resolveTheme, THEME_STORAGE_KEY, themePreference, type Theme, type ThemePreference } from "../lib/theme";
|
||||
const ThemeContext = createContext<{ theme: Theme; preference: ThemePreference; setPreference: (value: ThemePreference) => void }>({ theme: "light", preference: "system", setPreference: () => {} });
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [preference, setValue] = useState<ThemePreference>(() => {
|
||||
try { return themePreference(localStorage.getItem(THEME_STORAGE_KEY)); } catch { return "system"; }
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() => typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const theme = resolveTheme(preference, systemDark);
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
|
||||
}, [theme]);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
const change = () => setSystemDark(media?.matches ?? false);
|
||||
const storage = (event: StorageEvent) => { if (event.key === THEME_STORAGE_KEY || event.key === null) setValue(themePreference(event.newValue)); };
|
||||
change(); media?.addEventListener("change", change); window.addEventListener("storage", storage);
|
||||
return () => { media?.removeEventListener("change", change); window.removeEventListener("storage", storage); };
|
||||
}, []);
|
||||
const setPreference = (value: ThemePreference) => {
|
||||
setValue(value);
|
||||
try { localStorage.setItem(THEME_STORAGE_KEY, value); } catch { /* Theme still works when browser storage is unavailable. */ }
|
||||
};
|
||||
return <ThemeContext value={{ theme, preference, setPreference }}>{children}</ThemeContext>;
|
||||
}
|
||||
export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
|
||||
const { preference, setPreference } = useTheme();
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="ds-theme-control" role="group" aria-label="Theme">
|
||||
{([
|
||||
{ value: "system", label: "System", Icon: Monitor },
|
||||
{ value: "light", label: "Light", Icon: Sun },
|
||||
{ value: "dark", label: "Dark", Icon: Moon },
|
||||
] as const).map(({ value, label, Icon }) => (
|
||||
<label className="ds-theme-option" key={value} title={`${label} theme`}>
|
||||
<input type="radio" name={`theme-${id}`} value={value} checked={preference === value} disabled={disabled} onChange={() => setPreference(value)} />
|
||||
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" /><span className="ds-visually-hidden">{label}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
|
||||
import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import { Field } from "./Field";
|
||||
import { Button } from "./primitives";
|
||||
import { CalendarDays, Keyboard } from "lucide-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import {
|
||||
describeDay,
|
||||
calendarTimeline,
|
||||
progressShade,
|
||||
MONTH_VIEWS,
|
||||
visibleCalendarDays,
|
||||
monthWindow,
|
||||
type CalendarDay,
|
||||
type MonthView,
|
||||
} from "./calendar-model";
|
||||
@@ -18,52 +20,67 @@ export function CalendarHeatmap({
|
||||
unit,
|
||||
label,
|
||||
color = "#111111",
|
||||
emptyColor = "#eeeeee",
|
||||
compact = false,
|
||||
initialMonths = 12,
|
||||
selectedDate: controlledDate,
|
||||
onSelectDate,
|
||||
historyLabel,
|
||||
legend,
|
||||
selectedDayAction,
|
||||
availableRange,
|
||||
onRangeChange,
|
||||
loading = false,
|
||||
}: {
|
||||
availableRange?: { from: string; to: string };
|
||||
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
|
||||
loading?: boolean;
|
||||
days: CalendarDay[];
|
||||
unit: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
emptyColor?: string;
|
||||
compact?: boolean;
|
||||
initialMonths?: MonthView;
|
||||
selectedDate?: string;
|
||||
onSelectDate?: (date: string) => void;
|
||||
historyLabel?: string;
|
||||
legend?: ReactNode;
|
||||
selectedDayAction?: (date: string) => ReactNode;
|
||||
}) {
|
||||
const [months, setMonths] = useState<MonthView | "custom">(12);
|
||||
const { theme } = useTheme();
|
||||
const [months, setMonths] = useState<MonthView | "custom">(initialMonths);
|
||||
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
||||
const [rangeError, setRangeError] = useState("");
|
||||
const latestDate =
|
||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
||||
allDays.at(-1)?.date;
|
||||
// A date selected in the editor must stay visible even outside the current window.
|
||||
const anchor =
|
||||
controlledDate && allDays.some((day) => day.date === controlledDate)
|
||||
? controlledDate
|
||||
: latestDate;
|
||||
// Inspecting a square changes selection, not the window. Only an external
|
||||
// date outside the visible preset needs a new anchor to bring it into view.
|
||||
const [windowAnchor, setWindowAnchor] = useState({ latestDate, date: latestDate });
|
||||
let anchor = windowAnchor.latestDate === latestDate ? windowAnchor.date : latestDate;
|
||||
if (months !== "custom" && anchor && controlledDate &&
|
||||
allDays.some(day => day.date === controlledDate) &&
|
||||
!visibleCalendarDays(allDays, months, anchor).some(day => day.date === controlledDate)) {
|
||||
anchor = controlledDate;
|
||||
}
|
||||
if (windowAnchor.latestDate !== latestDate || windowAnchor.date !== anchor) {
|
||||
setWindowAnchor({ latestDate, date: anchor });
|
||||
}
|
||||
const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
|
||||
const selectedDate = controlledDate ?? localSelectedDate;
|
||||
function setSelectedDate(date: string) {
|
||||
if (onSelectDate) onSelectDate(date);
|
||||
else setLocalSelectedDate(date);
|
||||
}
|
||||
const days = months === "custom" && customRange
|
||||
const days = useMemo(() => months === "custom" && customRange
|
||||
? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to)
|
||||
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [];
|
||||
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]);
|
||||
const selected =
|
||||
days.find((day) => day.date === selectedDate) ??
|
||||
days.find((day) => day.date === anchor) ??
|
||||
days[0];
|
||||
// Date keys and the shared timeline remain stable when the chart resizes.
|
||||
const timeline = calendarTimeline(days);
|
||||
const timeline = useMemo(() => calendarTimeline(days), [days]);
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const inspectorId = useId();
|
||||
const instructionsId = useId();
|
||||
@@ -90,6 +107,7 @@ export function CalendarHeatmap({
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
aria-busy={loading}
|
||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
@@ -100,13 +118,14 @@ export function CalendarHeatmap({
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}{" "}
|
||||
–{" "}
|
||||
{months !== 1 && <>–{" "}
|
||||
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "short",
|
||||
day: months === "custom" ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</>}
|
||||
</span>
|
||||
<div
|
||||
className="ds-month-views"
|
||||
@@ -117,9 +136,9 @@ export function CalendarHeatmap({
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={`${value} months`}
|
||||
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||
aria-pressed={months === value}
|
||||
onClick={() => setMonths(value)}
|
||||
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }}
|
||||
>
|
||||
{value}m
|
||||
</button>
|
||||
@@ -131,6 +150,7 @@ export function CalendarHeatmap({
|
||||
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
||||
setRangeError("");
|
||||
setMonths("custom");
|
||||
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
@@ -149,7 +169,7 @@ export function CalendarHeatmap({
|
||||
const end = form.elements.namedItem("to") as HTMLInputElement;
|
||||
const isAvailable = (input: HTMLInputElement) =>
|
||||
input.validity.valid && input.value !== "" &&
|
||||
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
|
||||
input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date);
|
||||
if (!isAvailable(start) || !isAvailable(end)) {
|
||||
setRangeError("Choose both dates within the available history.");
|
||||
(!isAvailable(start) ? start : end).focus();
|
||||
@@ -160,11 +180,12 @@ export function CalendarHeatmap({
|
||||
end.focus();
|
||||
return;
|
||||
}
|
||||
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||
if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||
setRangeError("There are no calendar dates in this range.");
|
||||
return;
|
||||
}
|
||||
setCustomRange({ from: start.value, to: end.value });
|
||||
onRangeChange?.({ from: start.value, to: end.value }, "custom");
|
||||
setRangeError("");
|
||||
}}
|
||||
>
|
||||
@@ -176,8 +197,8 @@ export function CalendarHeatmap({
|
||||
name={name}
|
||||
type="date"
|
||||
required
|
||||
min={allDays[0]!.date}
|
||||
max={allDays.at(-1)!.date}
|
||||
min={availableRange?.from ?? allDays[0]!.date}
|
||||
max={availableRange?.to ?? allDays.at(-1)!.date}
|
||||
defaultValue={customRange[name]}
|
||||
aria-invalid={rangeError ? true : undefined}
|
||||
aria-describedby={rangeError ? rangeErrorId : undefined}
|
||||
@@ -190,6 +211,7 @@ export function CalendarHeatmap({
|
||||
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
|
||||
<div
|
||||
className="ds-calendar-viewport"
|
||||
role="group"
|
||||
@@ -229,8 +251,8 @@ export function CalendarHeatmap({
|
||||
day.state === "not-due"
|
||||
? "transparent"
|
||||
: day.state === "future"
|
||||
? emptyColor
|
||||
: progressShade(day, color),
|
||||
? "var(--ds-empty)"
|
||||
: day.value === 0 ? "var(--ds-empty)" : day.color ? themedHabitShade(day.color, color, theme) : habitProgressColor(color, (Math.max(1, Math.min(8, Math.round(day.value / day.target * 8))) - 1) / 7, theme),
|
||||
}}
|
||||
aria-label={`${day.date}: ${describeDay(day, unit)}`}
|
||||
title={`${day.date}: ${describeDay(day, unit)}`}
|
||||
@@ -267,27 +289,14 @@ export function CalendarHeatmap({
|
||||
</div>
|
||||
</div>
|
||||
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
|
||||
<div className="ds-calendar-caption">
|
||||
<span>
|
||||
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
|
||||
<span className="ds-muted">
|
||||
/{" "}
|
||||
{historyLabel ??
|
||||
(compact ? "Demo history" : "Illustrative history")}
|
||||
</span>
|
||||
</span>
|
||||
<details className="ds-calendar-help">
|
||||
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
|
||||
<p id={instructionsId}>
|
||||
<p id={instructionsId} className="ds-visually-hidden">
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{!historyLabel && <p className="ds-calendar-demo-label ds-muted">Demo history</p>}
|
||||
<div id={inspectorId} className="ds-date-inspector">
|
||||
<div className="ds-date-inspector-heading" aria-live="polite">
|
||||
<CalendarDays size={22} aria-hidden="true" />
|
||||
<div>
|
||||
<span className="ds-eyebrow">SELECTED DAY</span>
|
||||
<time dateTime={selected.date}>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { themedHabitShade } from "../../lib/habit-palette";
|
||||
import { legendShades, type CalendarDay } from "./calendar-model";
|
||||
|
||||
export function CalendarLegend({
|
||||
@@ -11,7 +13,8 @@ export function CalendarLegend({
|
||||
label: string;
|
||||
shades?: string[];
|
||||
}) {
|
||||
const shades = suppliedShades ?? legendShades(days, color);
|
||||
const { theme } = useTheme();
|
||||
const shades = suppliedShades ?? legendShades(days, color, theme);
|
||||
return (
|
||||
<div
|
||||
className="ds-calendar-legend"
|
||||
@@ -31,11 +34,11 @@ export function CalendarLegend({
|
||||
: "Empty"
|
||||
}
|
||||
>
|
||||
{shades.map((shade) => (
|
||||
{shades.map((shade, index) => (
|
||||
<span
|
||||
key={shade}
|
||||
className="ds-legend-swatch"
|
||||
style={{ backgroundColor: shade }}
|
||||
style={{ backgroundColor: index === 0 ? "var(--ds-empty)" : suppliedShades ? themedHabitShade(shade, color, theme) : shade }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
103
src/components/design-system/Counter.test.tsx
Normal file
103
src/components/design-system/Counter.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act, useState } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { Counter } from "./primitives";
|
||||
|
||||
const dom = new Window({ url: "http://localhost:3000/design-system" });
|
||||
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
|
||||
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
({ createRoot } = await import("react-dom/client"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
dom.happyDOM.abort();
|
||||
for (const [key, descriptor] of originalGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const input = () => container.querySelector<HTMLInputElement>("input")!;
|
||||
const control = (name: string) => container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`)!;
|
||||
async function enter(text: string) {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input(), text);
|
||||
input().dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
}
|
||||
async function key(key: string) {
|
||||
await act(async () => input().dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
|
||||
}
|
||||
|
||||
test("totals save explicitly, support above-target values and retain step controls", async () => {
|
||||
const updates: number[] = [];
|
||||
function Demo() {
|
||||
const [value, setValue] = useState(8);
|
||||
return <Counter label="pages" value={value} target={8} onChange={next => { updates.push(next); setValue(next); }} />;
|
||||
}
|
||||
await act(async () => root.render(<Demo />));
|
||||
await enter("30");
|
||||
expect(updates).toEqual([]);
|
||||
await key("Enter");
|
||||
expect(updates).toEqual([30]);
|
||||
expect(input().value).toBe("30");
|
||||
expect(control("Save total pages")).toBeNull();
|
||||
await act(async () => control("Increase pages").click());
|
||||
expect(input().value).toBe("31");
|
||||
await enter("40");
|
||||
await act(async () => control("Save total pages").click());
|
||||
expect(updates).toEqual([30, 31, 40]);
|
||||
});
|
||||
|
||||
test("invalid totals never submit and Escape restores the saved value", async () => {
|
||||
const updates: number[] = [];
|
||||
await act(async () => root.render(<Counter label="pages" value={3} target={8} onChange={next => updates.push(next)} />));
|
||||
for (const invalid of ["", "-1", "1.5", "abc", "1000000001"]) {
|
||||
await enter(invalid);
|
||||
expect(input().getAttribute("aria-invalid")).toBe("true");
|
||||
expect(control("Save total pages").disabled).toBe(true);
|
||||
await key("Enter");
|
||||
}
|
||||
expect(updates).toEqual([]);
|
||||
await key("Escape");
|
||||
expect(input().value).toBe("3");
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
test("server refresh replaces a draft and API bounds disable increment and decrement", async () => {
|
||||
const render = (value: number, disabled = false) => root.render(<Counter label="pages" value={value} target={8} disabled={disabled} onChange={() => {}} />);
|
||||
await act(async () => render(0));
|
||||
expect(control("Decrease pages").disabled).toBe(true);
|
||||
await enter("12");
|
||||
await act(async () => render(1_000_000_000));
|
||||
expect(input().value).toBe("1000000000");
|
||||
expect(control("Increase pages").disabled).toBe(true);
|
||||
await act(async () => render(2, true));
|
||||
expect(input().disabled).toBe(true);
|
||||
expect(control("Decrease pages").disabled).toBe(true);
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useId, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, ListChecks } from "lucide-react";
|
||||
import { Check, Circle, Hash, ChevronDown, ListChecks } from "lucide-react";
|
||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||
import { demoCalendar } from "./calendar-model";
|
||||
|
||||
/** Open chart section: intentionally no card surface or enclosing border. */
|
||||
/** A shared habit identity, visible history, and goal-specific completion surface. */
|
||||
export function HabitChart({
|
||||
name,
|
||||
method,
|
||||
@@ -21,6 +21,8 @@ export function HabitChart({
|
||||
due = true,
|
||||
id,
|
||||
headingLevel = 4,
|
||||
trackingMethod,
|
||||
checkInId,
|
||||
}: {
|
||||
name: string;
|
||||
method: string;
|
||||
@@ -38,9 +40,43 @@ export function HabitChart({
|
||||
due?: boolean;
|
||||
id?: string;
|
||||
headingLevel?: 3 | 4;
|
||||
trackingMethod?: "manual" | "count" | "tasks";
|
||||
checkInId?: string;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
const Heading = `h${headingLevel}` as const;
|
||||
const complete = due && target > 0 && value >= target;
|
||||
const TypeIcon = trackingMethod === "count" ? Hash : trackingMethod === "tasks" ? ListChecks : Check;
|
||||
if (trackingMethod) return (
|
||||
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
|
||||
<header className="ds-goal-heading">
|
||||
<div className="ds-goal-identity">
|
||||
<div className="ds-goal-meta"><span className="ds-goal-swatch" aria-hidden="true" /><TypeIcon size={16} aria-hidden="true" /><span>{method}</span>{schedule !== "Every day" && <span className="ds-goal-schedule">{schedule}</span>}</div>
|
||||
<Heading id={headingId} className="type-title">{name}</Heading>
|
||||
</div>
|
||||
<div className="ds-goal-heading-actions">
|
||||
<span className="ds-goal-status" role="status">{complete ? <Check size={16} aria-hidden="true" /> : <Circle size={14} aria-hidden="true" />}{!due ? "Day off" : complete ? "Complete" : value > 0 ? "In progress" : "Not started"}</span>
|
||||
{headingActions}
|
||||
</div>
|
||||
</header>
|
||||
{editor}
|
||||
<div className="ds-goal-body">
|
||||
<div className="ds-goal-completion">
|
||||
{trackingMethod === "count" && <div className="ds-goal-count">
|
||||
{children}
|
||||
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
||||
</div>}
|
||||
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
|
||||
{trackingMethod === "tasks" && <>
|
||||
<div className="ds-goal-task-progress"><span>{value} of {target} tasks done</span><progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} /></div>
|
||||
<div className="ds-goal-tasks" role="group" aria-label={`${name}: ${tasksLabel}`}>{tasks}</div>
|
||||
</>}
|
||||
</div>
|
||||
<div className="ds-goal-graph">{calendar ?? <CalendarHeatmap days={demoCalendar(value, target)} label={`${name} progress calendar`} unit={unit} color={color} compact />}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
|
||||
<header className="ds-habit-chart-heading">
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
HABIT_PALETTES,
|
||||
HABIT_COLOR_CHOICES,
|
||||
isHabitColor,
|
||||
previewHabitColors,
|
||||
} from "./HabitColorPicker";
|
||||
import { HABIT_COLORS } from "./calendar-model";
|
||||
|
||||
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
|
||||
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
|
||||
expect(HABIT_PALETTES.length).toBe(8);
|
||||
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
|
||||
expect(new Set(colors).size).toBe(32);
|
||||
expect(colors.every(isHabitColor)).toBe(true);
|
||||
test("the continuous color selection contains distinct, valid colors", () => {
|
||||
expect(new Set(HABIT_COLOR_CHOICES).size).toBe(HABIT_COLOR_CHOICES.length);
|
||||
expect(HABIT_COLOR_CHOICES.every(isHabitColor)).toBe(true);
|
||||
});
|
||||
|
||||
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useId } from "react";
|
||||
import { HABIT_COLORS, progressShade } from "./calendar-model";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { habitProgressColor } from "../../lib/habit-palette";
|
||||
import { useId, useState } from "react";
|
||||
import { HABIT_COLORS } from "./calendar-model";
|
||||
import { Field } from "./Field";
|
||||
|
||||
export type HabitColors = {
|
||||
@@ -22,15 +24,11 @@ export function previewHabitColors(
|
||||
return colors;
|
||||
}
|
||||
|
||||
export const HABIT_PALETTES = [
|
||||
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
|
||||
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
|
||||
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
|
||||
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
|
||||
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
|
||||
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
|
||||
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
|
||||
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
|
||||
export const HABIT_COLOR_CHOICES = [
|
||||
"#b44f57", "#b86643", "#a7792c", "#858337",
|
||||
"#5c813f", "#3e805d", "#337e79", "#387b98",
|
||||
"#466db0", "#6263ad", "#885ba6", "#a35488",
|
||||
"#956976", "#89705a", "#667b82", "#606571",
|
||||
] as const;
|
||||
|
||||
export function isHabitColor(value: string) {
|
||||
@@ -40,48 +38,33 @@ export function isHabitColor(value: string) {
|
||||
export function HabitColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
mode = "demo",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
mode?: "demo" | "create" | "edit";
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const id = useId();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const valid = isHabitColor(value);
|
||||
const showCustom = expanded || !valid;
|
||||
return (
|
||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||
<legend>Habit color</legend>
|
||||
<p id={`${id}-hint`} className="ds-footnote">
|
||||
Choose a suggested shade or enter a custom color for this habit’s calendar.
|
||||
<p id={`${id}-hint`} className="ds-visually-hidden">
|
||||
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
|
||||
</p>
|
||||
<div className="ds-color-palettes">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<div
|
||||
key={palette.name}
|
||||
className="ds-color-palette"
|
||||
role="group"
|
||||
aria-label={`${palette.name} palette`}
|
||||
>
|
||||
<span>{palette.name}</span>
|
||||
<div className="ds-color-swatches">
|
||||
{palette.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
aria-label={`${palette.name} ${color}`}
|
||||
aria-pressed={value.toLowerCase() === color}
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
>
|
||||
<span style={{ backgroundColor: color }}>
|
||||
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
|
||||
{HABIT_COLOR_CHOICES.map((color) => (
|
||||
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
|
||||
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
|
||||
{value.toLowerCase() === color ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="ds-color-custom-toggle" aria-pressed={valid && !HABIT_COLOR_CHOICES.some(color => color === value.toLowerCase())} aria-expanded={showCustom} aria-controls={`${id}-custom`} onClick={() => setExpanded(!showCustom)}>Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div id={`${id}-custom`} hidden={!showCustom}>
|
||||
<div className="ds-custom-color">
|
||||
<Field label="Custom color" id={`${id}-native`}>
|
||||
{(inputId) => (
|
||||
@@ -112,12 +95,12 @@ export function HabitColorPicker({
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{valid && (
|
||||
<div
|
||||
className="ds-color-live-preview"
|
||||
aria-label="Live calendar color preview"
|
||||
>
|
||||
<span className="ds-muted">Calendar preview</span>
|
||||
<div
|
||||
className="ds-color-preview-shades"
|
||||
role="img"
|
||||
@@ -127,24 +110,13 @@ export function HabitColorPicker({
|
||||
<span
|
||||
key={count}
|
||||
style={{
|
||||
backgroundColor: progressShade(
|
||||
{ date: "", value: count, target: 8, state: "due" },
|
||||
value,
|
||||
),
|
||||
backgroundColor: count === 0 ? "var(--ds-empty)" : habitProgressColor(value, (count - 1) / 7, theme),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="ds-muted">0 → complete</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="ds-footnote">
|
||||
{mode === "create"
|
||||
? "Saved with your habit."
|
||||
: mode === "edit"
|
||||
? "Save to apply this color; Cancel restores the saved color."
|
||||
: "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}
|
||||
</p>
|
||||
{!valid && (
|
||||
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
|
||||
Use a six-digit hex color, such as #426582.
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Modal({ id, eyebrow, title, description, children, onClose, clos
|
||||
onKeyDown={event => {
|
||||
if (event.key !== "Tab") return;
|
||||
event.stopPropagation();
|
||||
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
|
||||
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
|
||||
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user