Compare commits
78 Commits
084603bb6e
...
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 | ||
|
|
892487eb4c | ||
|
|
392deb9cbe | ||
|
|
865bdd1328 | ||
|
|
1208e93953 | ||
|
|
16d92f171c | ||
|
|
19a9aa6862 | ||
|
|
9c8a96ba03 | ||
|
|
661bc6c346 | ||
|
|
32002151e0 | ||
|
|
d624ee6c3c | ||
|
|
e88ffceb22 | ||
|
|
ecc2620a4c | ||
|
|
dde5e77317 | ||
|
|
bfa54851c2 |
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
|
||||||
18
.env.example
18
.env.example
@@ -10,3 +10,21 @@ DISCORD_CLIENT_ID=
|
|||||||
DISCORD_CLIENT_SECRET=
|
DISCORD_CLIENT_SECRET=
|
||||||
# Generate once: openssl rand -hex 32 (at least 32 characters required)
|
# Generate once: openssl rand -hex 32 (at least 32 characters required)
|
||||||
AUTH_COOKIE_SECRET=
|
AUTH_COOKIE_SECRET=
|
||||||
|
# Bot-powered progress sharing. Invite the bot to the server and grant it
|
||||||
|
# View Channel, Send Messages, and Attach Files in this channel.
|
||||||
|
DISCORD_BOT_TOKEN=
|
||||||
|
DISCORD_SHARING_CHANNEL_ID=
|
||||||
|
# Optional comma-separated Discord user IDs permitted to post. Empty permits all accounts.
|
||||||
|
DISCORD_SHARING_ALLOWED_USER_IDS=
|
||||||
|
# Production snapshots, plus a snapshot before migrations. Paths resolve beside DATABASE_PATH.
|
||||||
|
BACKUP_ENABLED=true
|
||||||
|
BACKUP_DIR=backups
|
||||||
|
BACKUP_INTERVAL_HOURS=24
|
||||||
|
BACKUP_RETAIN=7
|
||||||
|
# Existing mounted off-machine backup directory (absolute path recommended).
|
||||||
|
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.
|
# Loaded by bun start and bun run db:migrate:production.
|
||||||
# Set your public origin, e.g. https://habits.example.com
|
# Set your public origin, e.g. https://habits.example.com
|
||||||
APP_ORIGIN=
|
APP_ORIGIN=
|
||||||
|
# Enable only behind a private proxy that overwrites X-Forwarded-Proto.
|
||||||
|
TRUST_PROXY=false
|
||||||
|
# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1.
|
||||||
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
||||||
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
||||||
|
|||||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mina.teppelin.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
ARG BUN_IMAGE=oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4
|
||||||
|
FROM ${BUN_IMAGE} AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock bunfig.toml ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM ${BUN_IMAGE} AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock bunfig.toml ./
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
FROM ${BUN_IMAGE} AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/src ./src
|
||||||
|
COPY --from=build /app/scripts ./scripts
|
||||||
|
COPY --from=build /app/drizzle ./drizzle
|
||||||
|
COPY package.json bunfig.toml ./
|
||||||
|
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 DATABASE_PATH=/data/minabot.sqlite
|
||||||
|
USER bun
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "-c", "bun scripts/migrate.ts && exec bun scripts/start.ts"]
|
||||||
4
PRD.md
4
PRD.md
@@ -1,11 +1,11 @@
|
|||||||
# Minabot — Habit Tracking PRD
|
# Mina Habits — Habit Tracking PRD
|
||||||
|
|
||||||
Status: Initial product requirements
|
Status: Initial product requirements
|
||||||
Date: 2026-09-04
|
Date: 2026-09-04
|
||||||
|
|
||||||
## Application intent
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
287
README.md
287
README.md
@@ -1,7 +1,13 @@
|
|||||||
# Minabot
|
# Mina Habits
|
||||||
|
|
||||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The existing
|
The production app is installable as a PWA, with an offline reconnect screen and
|
||||||
React Router client has a public landing page and a connected signed-in habit dashboard.
|
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.
|
||||||
|
|
||||||
|
See [deployment operations](docs/OPERATIONS.md) for backup, recovery, and monitoring.
|
||||||
|
|
||||||
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
|
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
|
||||||
combined calendars, historical corrections, daily resets, and optional count carryover.
|
combined calendars, historical corrections, daily resets, and optional count carryover.
|
||||||
@@ -13,56 +19,134 @@ bun dev
|
|||||||
|
|
||||||
Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
||||||
|
|
||||||
- Client pages: `/`, `/about`, `/settings`.
|
- Homepage: `/`. Signed-out visitors see an introduction and interactive examples;
|
||||||
|
signed-in users see their saved habits and today’s progress.
|
||||||
|
- Component library: `/design-system`, independent of account loading.
|
||||||
|
- Unknown client URLs redirect to `/`.
|
||||||
- Hono endpoint: `GET /api/health`.
|
- Hono endpoint: `GET /api/health`.
|
||||||
- Unknown API routes return JSON with a 404 status.
|
- Unknown API routes return JSON with a 404 status.
|
||||||
|
|
||||||
### Design system preview
|
### Design system preview
|
||||||
|
|
||||||
Open `/design-system#editing` for interactive habit settings, task recurrence,
|
The library uses four tabs: Foundations, Components, Layout, and Calendars.
|
||||||
and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest,
|
Foundations is the default. Arrow keys, Home, and End navigate the tabs; URL
|
||||||
Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel,
|
hashes and browser back/forward restore the section. Switching tabs preserves
|
||||||
archive/restore, and an in-session correction history. The backfill calendar
|
local example state. Old `#playground` links open `#calendar-states`; removed
|
||||||
uses frozen historical requirements, independent of edits to today's settings.
|
`#editing` and `#views` links resolve to `#foundations`.
|
||||||
These are unsaved, local UI examples with a fixed September 4, 2026 demo clock;
|
|
||||||
they do not call the habit API or change account data. Reload or Reset editor
|
|
||||||
clears the preview. Habit colors preview immediately across the editor and the
|
|
||||||
daily/detail charts; Save keeps the color in the demo and Cancel restores it.
|
|
||||||
Other settings and progress remain independent of the daily tracking playground.
|
|
||||||
|
|
||||||
Signed-out visitors at `/` see the landing page, with interactive habit calendars
|
Open `/design-system#calendar-states` for progress states and three working
|
||||||
reused from the design system, a how-it-works introduction, and Discord sign-in.
|
calendar examples: a count target, a manual checkbox, and combined completion.
|
||||||
The demo is local, unsaved state and never writes to the habit API. Account loading
|
Changing water or reading progress updates today's combined score. Each calendar
|
||||||
and retryable errors are resolved before choosing the public or signed-in home.
|
supports 3/4/6/12-month ranges, a Custom start/end date range within available
|
||||||
Signing out updates the shared account state and reveals the landing page immediately.
|
history, date inspection, and keyboard navigation. Custom dates are inclusive;
|
||||||
Discord integration currently covers identity only, not bot commands or reminders.
|
Apply range updates the chart. Dates
|
||||||
|
form one continuous weekly chart with shared month and weekday labels and a fixed
|
||||||
|
4px gap between dates across every timeframe. Landscape
|
||||||
|
views fit the available width without chart scrollbars; narrow portrait views
|
||||||
|
retain 24px date targets in a local horizontal scroller. Primary controls use
|
||||||
|
44px targets, and custom checkboxes retain native semantics in forced-color mode.
|
||||||
|
These examples use a fixed September 4, 2026 demo date and do not call the API;
|
||||||
|
reload or Reset examples restores their initial progress.
|
||||||
|
|
||||||
Signed-in users at `/` get a personalized home with their Discord avatar, tracking
|
The Playground, Editing, and References showcase tabs have been removed.
|
||||||
date/timezone, daily completion summary, manual/count/task check-ins, a remaining
|
Reusable calendar, editing, color-picker components and reference assets remain
|
||||||
filter, and per-habit calendar history in the design system's two-column chart grid.
|
available for future pages.
|
||||||
Each habit has its own heading, schedule, color key, and compact calendar. Daily
|
|
||||||
check-ins use compact rows with expandable tasks. New accounts get editable starter ideas and
|
|
||||||
a create-habit dialog supporting all three methods, all schedule types, and the shared
|
|
||||||
palette/custom color picker. The habit and its color are saved atomically. Progress
|
|
||||||
is saved through the habit API; history uses server-provided progress colors and dated units,
|
|
||||||
never the landing page's demo data. Visible dashboards refresh every minute and on
|
|
||||||
returning to the tab. Loading, retry, expired-session, no-schedule, and all-complete
|
|
||||||
states are handled separately. Each habit has Edit and Delete controls: edit its name,
|
|
||||||
count target/unit, schedule, and color, or confirm removal from the dashboard using
|
|
||||||
the existing archive API, which retains recorded history. Editing keeps the tracking
|
|
||||||
method and existing tasks intact. Unscheduled dates use neutral dots; upcoming dates
|
|
||||||
share the zero-progress fill while retaining their distinct inspection labels.
|
|
||||||
Archive management, task-definition editing, historical corrections, and saved
|
|
||||||
combined-chart management remain outside this home view.
|
|
||||||
|
|
||||||
For isolated browser QA, run `bun scripts/dashboard-preview.ts` and open the printed
|
The homepage is composed from the existing design-system components and layouts:
|
||||||
preview login URL. It serves the real UI and API against an in-memory test database,
|
shared page shell, WelcomePanel, Card/CardGrid, SectionHeading, HabitChart, CalendarHeatmap,
|
||||||
uses a separate test cookie, and discards its data on exit.
|
Counter, Checkbox, Field, ScheduleEditor, HabitColorPicker, and DiscordSignInButton.
|
||||||
It is a local test harness, not a production sign-in flow.
|
It uses no page-specific styles. The shared page shell also renders the component
|
||||||
|
library. The design system does not load account data or call the API.
|
||||||
|
|
||||||
Secondary pages retain a heading, native navigation buttons, and account controls. Tailwind utilities
|
The dashboard's welcome panel groups the Discord avatar, greeting, progress, and
|
||||||
and shadcn configuration are available; Tailwind Preflight is omitted to preserve
|
habit actions on the left, with the account's current date and timezone on the
|
||||||
native browser styling.
|
right. It stacks on narrow screens and shows an initial if the avatar is unavailable.
|
||||||
|
|
||||||
|
Signed-in users can create manual, count, or task habits, choose a recurrence and
|
||||||
|
color, log today’s progress, and inspect their real calendars. Completed-versus-due
|
||||||
|
counts exclude days off. Controls beside habit headings open a modal to edit the name,
|
||||||
|
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
|
||||||
|
Each task can be renamed, rescheduled, or deleted in a modal; Add task also supports
|
||||||
|
its own recurrence. Both habit and task schedules support daily, selected weekdays,
|
||||||
|
day intervals, and week intervals with an editable start date and weekday.
|
||||||
|
Archive asks for confirmation in a modal and preserves earlier history.
|
||||||
|
**Archived habits** opens the archive modal, with history inspection and restoration
|
||||||
|
starting today. **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
|
||||||
|
backdrop dismissal, and return focus to their opener. Long content scrolls beneath
|
||||||
|
a fixed header; closing is disabled during saves.
|
||||||
|
Mutations are saved through the authenticated API; failed
|
||||||
|
saves retain the recorded values and allow retry. Account and calendar failures
|
||||||
|
have retry states. The dashboard refreshes on window focus and every minute while
|
||||||
|
visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts,
|
||||||
|
checkboxes, or task occurrences using that date’s saved requirements. A date picker
|
||||||
|
also reaches history outside the displayed year. Future dates and days off are read-only.
|
||||||
|
Combined-chart management is not exposed on this homepage.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
separate preview cookie. The signed-out examples use a fixed September 4, 2026
|
||||||
|
demo date; progress in those examples is never saved.
|
||||||
|
|
||||||
|
`styles/globals.css` is the only stylesheet
|
||||||
|
entry point; component styles are in Tailwind's components layer so utilities can
|
||||||
|
override them predictably. Tailwind Preflight remains omitted.
|
||||||
|
|
||||||
|
`styles/typography.css` defines reusable Tailwind classes: `type-display` (72/81),
|
||||||
|
`type-section` (48/54), `type-title` (32/40), `type-lead` (20/30), `type-body`
|
||||||
|
(16/24), `type-small` and `type-label` (14/21), and `type-control` (24/30, symbols).
|
||||||
|
Values are font-size/line-height in pixels at the default 16px root, implemented
|
||||||
|
in rem. At 640px and below, display steps to 48/54 and section to 36/45; body and
|
||||||
|
supporting text never shrink. Use these classes directly or with `@apply`, not
|
||||||
|
bespoke font sizes or viewport-based type. See `/design-system#foundations` for
|
||||||
|
live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared
|
||||||
|
`DiscordSignInButton` for headings and calls to action. All default button variants,
|
||||||
|
including text/ghost buttons and links, share 10px vertical and 19px horizontal
|
||||||
|
padding. Ghost changes only colors and border visibility; counter buttons retain
|
||||||
|
their explicit 44px square sizing.
|
||||||
|
Use `type-ui-heading` (18/27, medium system sans) for interface headings, field
|
||||||
|
labels, and form legends; it stays visibly larger than 14/21 supporting text at
|
||||||
|
every breakpoint. Preserve serif `type-title` and `type-section` for editorial
|
||||||
|
headings. Use `type-eyebrow` for 12/18 section markers. `Field` supplies a control ID and hint ID through its
|
||||||
|
render callback; apply both to the native control. `Card.headingLevel` defaults
|
||||||
|
to 3 and can be set to match its surrounding document hierarchy without changing
|
||||||
|
its appearance.
|
||||||
|
|
||||||
|
Interface colors use the existing neutral palette through `--ds-ink`,
|
||||||
|
`--ds-secondary`, `--ds-control-border`, `--ds-rule`, `--ds-paper`,
|
||||||
|
`--ds-surface` (the existing #F5F5F5 fill), and `--ds-empty` (the existing #EEEEEE
|
||||||
|
calendar fill). Use secondary for muted text and primary hover, surface for
|
||||||
|
neutral hover/grouping, and control-border for actionable boundaries. Keep the
|
||||||
|
calendar progress ramp and habit identity colors separate. Foundations lists the
|
||||||
|
values and roles. Supporting copy should explain a consequence, constraint, or
|
||||||
|
interaction; omit prose that merely restates a heading or visible control.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
@@ -91,8 +175,11 @@ bun run db:migrate
|
|||||||
```
|
```
|
||||||
|
|
||||||
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
|
||||||
`bun start` apply pending migrations before starting the server. The initial
|
`bun start` apply pending migrations before starting the server. Development also
|
||||||
migration creates the users and sessions tables.
|
checks for pending migrations when the server entrypoint is hot-reloaded, so new
|
||||||
|
routes do not run against an older schema. After adding only migration files,
|
||||||
|
run `bun run db:migrate` or restart `bun dev`. The initial migration creates the
|
||||||
|
users and sessions tables.
|
||||||
|
|
||||||
Import `db` from `src/db/index.ts` in backend code to query the database.
|
Import `db` from `src/db/index.ts` in backend code to query the database.
|
||||||
`GET /api/health` checks the database connection before returning success.
|
`GET /api/health` checks the database connection before returning success.
|
||||||
@@ -114,8 +201,8 @@ Import `db` from `src/db/index.ts` in backend code to query the database.
|
|||||||
Use the matching hostname throughout each sign-in; `localhost` and `127.0.0.1`
|
Use the matching hostname throughout each sign-in; `localhost` and `127.0.0.1`
|
||||||
have different cookie storage.
|
have different cookie storage.
|
||||||
5. Run `bun dev`, click **Sign in with Discord**, and authorize the `identify` scope.
|
5. Run `bun dev`, click **Sign in with Discord**, and authorize the `identify` scope.
|
||||||
No bot token or email scope is needed. After returning, **View my profile** opens
|
No bot token or email scope is needed. After returning, the homepage opens your
|
||||||
`/api/me`. **Sign out** revokes the current browser's session.
|
habit dashboard. **Sign out** revokes the current browser's session.
|
||||||
|
|
||||||
Without the OAuth settings, public pages and `/api/health` remain usable, `/api/me`
|
Without the OAuth settings, public pages and `/api/health` remain usable, `/api/me`
|
||||||
returns 401, and sign-in displays a configuration message. Restart after editing
|
returns 401, and sign-in displays a configuration message. Restart after editing
|
||||||
@@ -169,11 +256,47 @@ production habit data separate from development data.
|
|||||||
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
||||||
- React detects the browser timezone during initial registration. The server
|
- React detects the browser timezone during initial registration. The server
|
||||||
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
||||||
update the Discord profile without changing the saved timezone. A timezone
|
update the Discord profile without changing the saved timezone. **Settings** lets users change their timezone, download a complete JSON export,
|
||||||
settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
|
and permanently delete their account with typed confirmation. `PATCH /api/me`
|
||||||
|
updates the timezone. Habit timestamps
|
||||||
remain UTC and daily boundaries follow the saved timezone with preserved historical
|
remain UTC and daily boundaries follow the saved timezone with preserved historical
|
||||||
deadlines. See the API reference for travel and DST behavior.
|
deadlines. See the API reference for travel and DST behavior.
|
||||||
|
|
||||||
|
### Progress sharing
|
||||||
|
|
||||||
|
Use **Share progress** on the dashboard to select up to six habits and a 7-day,
|
||||||
|
30-day, yearly, or custom range (at most 366 days, ending no later than today).
|
||||||
|
The preview, PNG download, and Discord attachment use the same 1200px-wide image.
|
||||||
|
Each habit has a color legend; completion rates exclude unscheduled days. Display
|
||||||
|
name, avatar, habit names, and timezone can be hidden separately. Task names are
|
||||||
|
never included. PNGs are rendered locally in the browser and are not stored by
|
||||||
|
the server.
|
||||||
|
|
||||||
|
Set `DISCORD_BOT_TOKEN` and `DISCORD_SHARING_CHANNEL_ID` in `.env` (or the selected
|
||||||
|
environment file), then restart the server. Invite the bot to the Discord server
|
||||||
|
and grant it View Channel, Send Messages, and Attach Files in the sharing channel.
|
||||||
|
Threads additionally require Send Messages in Threads. The app displays the
|
||||||
|
configured destination automatically; all signed-in users share to that channel.
|
||||||
|
**Send to Discord** posts only the previewed PNG as the bot, with mentions disabled.
|
||||||
|
The bot token stays in the server environment and is never stored in SQLite or
|
||||||
|
returned to the browser. No Gateway connection or message-content intent is needed.
|
||||||
|
Delivery IDs and Discord message nonces protect against duplicate posts; ambiguous
|
||||||
|
network failures ask the user to check the channel. Webhook setup has been retired,
|
||||||
|
and the migration removes saved webhook connections while retaining delivery history.
|
||||||
|
|
||||||
|
The authenticated `/api/sharing` routes require the configured Origin for mutations:
|
||||||
|
|
||||||
|
| Method | Route | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/preview` | `{habitIds, from, to}` → owned calendar data, totals, and legends |
|
||||||
|
| GET | `/discord` | Bot sharing status, channel name, and channel link; no credentials |
|
||||||
|
| POST | `/discord/send` | Multipart `image` PNG (up to 4 MB) and UUID `deliveryId` |
|
||||||
|
|
||||||
|
For isolated browser QA, `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` creates
|
||||||
|
sample history and a local Discord transport stub on port 3107. Its login route
|
||||||
|
is `/__preview/login`; `/__preview/shared-image` returns the last attachment for
|
||||||
|
inspection. This mode never contacts Discord or opens the application database.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
|
|
||||||
| Method | Route | Behavior |
|
| Method | Route | Behavior |
|
||||||
@@ -242,3 +365,67 @@ Habit tests cover every route, all PRD acceptance behaviors, ownership and Origi
|
|||||||
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
|
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
|
||||||
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
||||||
separate the database foundation, REST implementation, carryover, and verification.
|
separate the database foundation, REST implementation, carryover, and verification.
|
||||||
|
|
||||||
|
### Account data
|
||||||
|
|
||||||
|
`GET /api/account/export` downloads versioned JSON containing all owned profile,
|
||||||
|
habit, revision, dated progress, task, audit, calendar, chart, and delivery records.
|
||||||
|
It excludes session tokens and credentials. `DELETE /api/account` requires matching
|
||||||
|
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
|
||||||
|
revokes every session. Discord posts and retained operator backups are not erased
|
||||||
|
by this action.
|
||||||
|
|
||||||
|
Discord sharing reserves persistent quotas before posting: one new delivery per
|
||||||
|
Discord user per 60 seconds, and ten per channel per 60 seconds. Rechecking an
|
||||||
|
existing delivery does not consume quota. Rejected attempts consume quota too;
|
||||||
|
429 responses include `Retry-After`. Optional `DISCORD_SHARING_ALLOWED_USER_IDS`
|
||||||
|
(comma-separated Discord IDs) restricts posting to community members you approve.
|
||||||
|
An empty list permits all signed-in accounts. Restricted users can still preview
|
||||||
|
and download PNGs. Quota rows expire and are pruned after 24 hours.
|
||||||
|
|
||||||
|
### Reminders
|
||||||
|
|
||||||
|
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. 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, 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 tailwind from "bun-plugin-tailwind";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm, mkdir, copyFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { buildPwa } from "./scripts/build-pwa";
|
||||||
|
|
||||||
const outdir = path.join(process.cwd(), "dist");
|
const outdir = path.join(process.cwd(), "dist");
|
||||||
await rm(outdir, { recursive: true, force: true });
|
await rm(outdir, { recursive: true, force: true });
|
||||||
@@ -10,7 +11,9 @@ const result = await Bun.build({
|
|||||||
outdir,
|
outdir,
|
||||||
plugins: [tailwind],
|
plugins: [tailwind],
|
||||||
minify: true,
|
minify: true,
|
||||||
|
splitting: true,
|
||||||
target: "bun",
|
target: "bun",
|
||||||
|
external: ["@napi-rs/canvas", "/fonts/browser/*"],
|
||||||
sourcemap: "linked",
|
sourcemap: "linked",
|
||||||
define: {
|
define: {
|
||||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
@@ -24,3 +27,15 @@ if (!result.success) {
|
|||||||
for (const output of result.outputs) {
|
for (const output of result.outputs) {
|
||||||
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
|
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
||||||
|
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
|
||||||
|
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
|
||||||
|
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
|
||||||
|
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await buildPwa(outdir, result.outputs);
|
||||||
|
|||||||
4
bun-env.d.ts
vendored
4
bun-env.d.ts
vendored
@@ -9,6 +9,10 @@ declare module "*.svg" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.css" {}
|
declare module "*.css" {}
|
||||||
|
declare module "*.webmanifest" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
declare module "*.png" {
|
declare module "*.png" {
|
||||||
const path: string;
|
const path: string;
|
||||||
export default path;
|
export default path;
|
||||||
|
|||||||
32
bun.lock
32
bun.lock
@@ -3,8 +3,9 @@
|
|||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bun-react-template",
|
"name": "mina-habits",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.8",
|
||||||
"@nivo/calendar": "^0.99.0",
|
"@nivo/calendar": "^0.99.0",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"happy-dom": "^20.14.0",
|
"happy-dom": "^20.14.0",
|
||||||
|
"playwright": "^1.63.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
},
|
},
|
||||||
@@ -89,6 +91,30 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.8", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.8", "@napi-rs/canvas-darwin-arm64": "1.0.8", "@napi-rs/canvas-darwin-x64": "1.0.8", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", "@napi-rs/canvas-linux-arm64-musl": "1.0.8", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-musl": "1.0.8", "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", "@napi-rs/canvas-win32-x64-msvc": "1.0.8" } }, "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.8", "", { "os": "android", "cpu": "arm64" }, "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.8", "", { "os": "linux", "cpu": "arm" }, "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.8", "", { "os": "linux", "cpu": "none" }, "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ=="],
|
||||||
|
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ=="],
|
||||||
|
|
||||||
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
|
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
|
||||||
|
|
||||||
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
|
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
|
||||||
@@ -265,6 +291,10 @@
|
|||||||
|
|
||||||
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
|
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||||
|
|
||||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
env = false
|
env = false
|
||||||
|
|
||||||
[serve.static]
|
[serve.static]
|
||||||
plugins = ["bun-plugin-tailwind"]
|
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
|
||||||
env = "BUN_PUBLIC_*"
|
env = "BUN_PUBLIC_*"
|
||||||
|
|||||||
61
compose.yaml
Normal file
61
compose.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: minabot
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
|
||||||
|
env_file:
|
||||||
|
- path: .env.production
|
||||||
|
format: raw
|
||||||
|
volumes:
|
||||||
|
- /srv/minabot/data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, bun, -e, "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 60s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
caddy:
|
||||||
|
image: ${CADDY_IMAGE:?Set CADDY_IMAGE to a pinned image digest}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
monitor:
|
||||||
|
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
|
||||||
|
command: [bun, scripts/monitor.ts]
|
||||||
|
environment:
|
||||||
|
MONITOR_URL: https://mina.teppelin.com/api/health
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
read_only: true
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
2
deploy.env.example
Normal file
2
deploy.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
APP_IMAGE=minabot:RELEASE
|
||||||
|
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d
|
||||||
79
deploy/README.md
Normal file
79
deploy/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Automatic production deployment
|
||||||
|
|
||||||
|
The server checks `https://git.ayau.me/syntaxbullet/minabot.git`, branch `main`, every
|
||||||
|
minute after the last check finishes. A local commit is deployed after it is
|
||||||
|
pushed to Gitea. Other branches are not deployed. If several commits arrive
|
||||||
|
between checks, the latest fetched main commit is built.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
The repository is public, so the server fetches over HTTPS without credentials.
|
||||||
|
Gitea's SSH transport disconnected during transfer, so this checkout also pushes
|
||||||
|
over HTTPS. Its repository-local credential helper reads the existing `ayau` Tea
|
||||||
|
login only for this exact HTTPS host/repository; it stores no additional token copy.
|
||||||
|
The personal Mac SSH key and Tea token are not copied to the server. If the repository
|
||||||
|
becomes private, provision repository-scoped read credentials before the change.
|
||||||
|
|
||||||
|
The pipeline fetches into a bare repository and archives an exact commit into
|
||||||
|
`/opt/minabot/releases/<sha>`. It builds a test image, runs typecheck and the full
|
||||||
|
test suite, builds the runtime image, runs a disposable production smoke test,
|
||||||
|
and validates Compose/Caddy. Secrets and production SQLite stay on the server;
|
||||||
|
they are never synced from Git or development during a release.
|
||||||
|
|
||||||
|
After checks pass, Caddy and the old app stop briefly, a verified database snapshot
|
||||||
|
is taken, and the new app starts before Caddy reopens public access. Discord workers
|
||||||
|
never overlap. A lock prevents concurrent manual/timer deployments. Successful
|
||||||
|
commits are recorded in `/opt/minabot/deployed-sha` and skipped on later checks.
|
||||||
|
|
||||||
|
A build/test failure leaves the old service running. Failed revisions are recorded
|
||||||
|
in `failed-sha` and skipped until a new commit or an explicit retry. On startup
|
||||||
|
failure, the old configuration/image is restored only if the schema and migration
|
||||||
|
fingerprint is unchanged. Otherwise services remain stopped for operator recovery;
|
||||||
|
the script never overwrites the production database with an old snapshot.
|
||||||
|
Changes to `.env.production` or Caddy's pinned image remain explicit server operations.
|
||||||
|
|
||||||
|
## Server operations
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Check status and recent deployment output
|
||||||
|
systemctl status minabot-deploy.timer minabot-deploy.service
|
||||||
|
journalctl -u minabot-deploy.service -n 100 --no-pager
|
||||||
|
cat /opt/minabot/deployed-sha
|
||||||
|
|
||||||
|
# Run a check now / retry a previously failed revision
|
||||||
|
sudo systemctl start minabot-deploy.service
|
||||||
|
/usr/local/bin/minabot-deploy --retry
|
||||||
|
|
||||||
|
# Pause / resume automatic deployment
|
||||||
|
sudo systemctl stop minabot-deploy.timer
|
||||||
|
sudo systemctl start minabot-deploy.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Build/test logs are inside each release directory. Retained release images and
|
||||||
|
Docker build cache need occasional housekeeping; the script refuses a build when
|
||||||
|
less than 2 GiB is free. Keep the current and previous known-good image and backups.
|
||||||
|
Failures are logged locally; external alerts are not configured.
|
||||||
|
|
||||||
|
To install or update the controller from a reviewed checkout on the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl stop minabot-deploy.timer
|
||||||
|
# Let any running deployment finish before replacing its controller.
|
||||||
|
sudo systemctl start minabot-deploy.service
|
||||||
|
sudo install -m 755 deploy/deploy.sh /usr/local/bin/minabot-deploy
|
||||||
|
sudo install -m 644 deploy/minabot-deploy.service deploy/minabot-deploy.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now minabot-deploy.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Controller/service changes require this explicit installation; normal app,
|
||||||
|
Dockerfile, Compose, and Caddyfile changes deploy automatically. Do not force-reset
|
||||||
|
`main` to roll back: prefer a revert commit so the pipeline tests the result.
|
||||||
|
|
||||||
|
For first-time installation, omit the first two commands if the units do not exist.
|
||||||
|
|
||||||
|
`bun test deploy/deploy.test.ts` verifies success, idempotency, failure before
|
||||||
|
cutover, safe rollback, and refusal to replace data after a schema change using
|
||||||
|
isolated fake Git/Docker commands. It never contacts production.
|
||||||
110
deploy/deploy.sh
Normal file
110
deploy/deploy.sh
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installed on the server as /usr/local/bin/minabot-deploy.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
root=${MINABOT_DEPLOY_ROOT:-/opt/minabot}
|
||||||
|
cd "$root"
|
||||||
|
exec 9>deploy.lock
|
||||||
|
flock -n 9 || exit 0
|
||||||
|
# Preserve journal stderr even when a failing build function redirects its logs.
|
||||||
|
exec 4>&2
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
repo="$root/git-sync.git"
|
||||||
|
target=''
|
||||||
|
switching=false
|
||||||
|
schema_before=''
|
||||||
|
previous_image=''
|
||||||
|
docker_cmd() { sudo -n docker "$@" </dev/null; }
|
||||||
|
compose() { docker_cmd compose --project-directory "$root" -f "$root/compose.yaml" --env-file "$root/.env" "$@"; }
|
||||||
|
schema_fingerprint() {
|
||||||
|
docker_cmd run --rm --network none -v /srv/minabot/data:/data:ro "$1" bun -e '
|
||||||
|
import {Database} from "bun:sqlite";
|
||||||
|
const db=new Database("/data/minabot.sqlite",{readonly:true});
|
||||||
|
const schema=db.query("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type,name").all();
|
||||||
|
const migrations=db.query("SELECT * FROM __drizzle_migrations ORDER BY id").all();
|
||||||
|
console.log(new Bun.CryptoHasher("sha256").update(JSON.stringify({schema,migrations})).digest("hex"));
|
||||||
|
db.close();'
|
||||||
|
}
|
||||||
|
failed() {
|
||||||
|
local result=$?
|
||||||
|
trap - ERR
|
||||||
|
set +e
|
||||||
|
printf 'Deployment failed: %s (exit %s)\n' "${target:-fetch}" "$result" >&4
|
||||||
|
printf 'Build and test logs: %s\n' "${release:-$root}" >&4
|
||||||
|
if [[ -n "$target" ]]; then printf '%s\n' "$target" > failed-sha; fi
|
||||||
|
if $switching; then
|
||||||
|
compose stop caddy app monitor
|
||||||
|
local schema_after
|
||||||
|
schema_after=$(schema_fingerprint "$previous_image")
|
||||||
|
if [[ -n "$schema_before" && "$schema_before" == "$schema_after" ]]; then
|
||||||
|
cp "$rollback/.env" .env
|
||||||
|
cp "$rollback/compose.yaml" compose.yaml
|
||||||
|
cp "$rollback/Caddyfile" Caddyfile
|
||||||
|
if compose up -d --wait --wait-timeout 120; then
|
||||||
|
echo 'Previous release restored; database was not replaced.' >&2
|
||||||
|
else
|
||||||
|
echo 'Rollback failed; inspect the journal and saved release configuration.' >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Database migrations changed or could not be verified. Services remain stopped; inspect $rollback and /srv/minabot/data/backups before recovery. No database was overwritten." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit "$result"
|
||||||
|
}
|
||||||
|
trap failed ERR
|
||||||
|
|
||||||
|
if [[ ! -d "$repo" ]]; then
|
||||||
|
git init --bare "$repo"
|
||||||
|
git --git-dir="$repo" remote add origin https://git.ayau.me/syntaxbullet/minabot.git
|
||||||
|
fi
|
||||||
|
timeout 90 git --git-dir="$repo" fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
|
||||||
|
target=$(git --git-dir="$repo" rev-parse refs/remotes/origin/main)
|
||||||
|
[[ "$target" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
[[ "$target" != "$(cat deployed-sha 2>/dev/null || true)" ]] || exit 0
|
||||||
|
if [[ "${1:-}" != '--retry' && "$target" == "$(cat failed-sha 2>/dev/null || true)" ]]; then exit 0; fi
|
||||||
|
|
||||||
|
echo "Testing main at $target"
|
||||||
|
available_kb=$(df -Pk "$root" | awk 'NR==2 {print $4}')
|
||||||
|
if (( available_kb < 2097152 )); then echo 'Less than 2 GiB free; refusing to build.' >&2; false; fi
|
||||||
|
release="$root/releases/$target"
|
||||||
|
mkdir -p "$release"
|
||||||
|
# Source files must remain readable by the non-root runtime after Docker COPY.
|
||||||
|
# Keep secrets/state private via the outer umask; apply 022 only to Git sources.
|
||||||
|
git --git-dir="$repo" archive "$target" | (umask 022; tar -x -C "$release")
|
||||||
|
image="minabot:git-${target:0:12}"
|
||||||
|
checks="$image-checks"
|
||||||
|
docker_cmd build --target build -t "$checks" "$release" > "$release/build-checks.log" 2>&1
|
||||||
|
docker_cmd run --rm "$checks" sh -c 'bun run typecheck && bun test' > "$release/tests.log" 2>&1
|
||||||
|
docker_cmd build -t "$image" "$release" > "$release/build-runtime.log" 2>&1
|
||||||
|
docker_cmd run --rm "$image" bun scripts/api-smoke.ts > "$release/smoke.log" 2>&1
|
||||||
|
docker_cmd image rm "$checks" > /dev/null
|
||||||
|
|
||||||
|
previous_image=$(sed -n 's/^APP_IMAGE=//p' .env)
|
||||||
|
[[ -n "$previous_image" ]]
|
||||||
|
awk -v image="$image" '/^APP_IMAGE=/ {$0="APP_IMAGE=" image} {print}' .env > next.env
|
||||||
|
docker_cmd compose --project-directory "$root" -f "$release/compose.yaml" --env-file "$root/next.env" config --quiet
|
||||||
|
caddy_image=$(sed -n 's/^CADDY_IMAGE=//p' .env)
|
||||||
|
docker_cmd run --rm -v "$release/Caddyfile:/etc/caddy/Caddyfile:ro" "$caddy_image" caddy validate --config /etc/caddy/Caddyfile > "$release/caddy-check.log" 2>&1
|
||||||
|
|
||||||
|
rollback="$release/previous"
|
||||||
|
mkdir -p "$rollback"
|
||||||
|
cp .env compose.yaml Caddyfile "$rollback/"
|
||||||
|
echo "Switching to $target"
|
||||||
|
# Prevent user writes during migration and never overlap Discord workers.
|
||||||
|
# Obtain the fingerprint before stopping so failure here leaves production running.
|
||||||
|
schema_before=$(schema_fingerprint "$previous_image")
|
||||||
|
switching=true
|
||||||
|
compose stop caddy app
|
||||||
|
compose run --rm --no-deps --interactive=false --entrypoint bun app scripts/backup.ts > "$rollback/backup-path.txt"
|
||||||
|
cp "$release/compose.yaml" compose.yaml
|
||||||
|
cp "$release/Caddyfile" Caddyfile
|
||||||
|
mv next.env .env
|
||||||
|
compose up -d --wait --wait-timeout 120 app monitor
|
||||||
|
compose up -d --wait --wait-timeout 120 caddy
|
||||||
|
curl --fail --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 --max-time 15 https://mina.teppelin.com/api/health > "$release/public-health.json"
|
||||||
|
printf '%s\n' "$target" > deployed-sha.tmp
|
||||||
|
mv deployed-sha.tmp deployed-sha
|
||||||
|
rm -f failed-sha
|
||||||
|
docker_cmd image inspect "$image" --format '{{.Id}}' > release-image-id
|
||||||
|
switching=false
|
||||||
|
echo "Deployed $target"
|
||||||
93
deploy/deploy.test.ts
Normal file
93
deploy/deploy.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
|
||||||
|
const revision = 'b'.repeat(40);
|
||||||
|
const stub = `#!${process.execPath}
|
||||||
|
import {basename} from 'node:path';
|
||||||
|
import {appendFileSync,mkdirSync,existsSync,writeFileSync,statSync} from 'node:fs';
|
||||||
|
const tool=basename(process.argv[1]), args=process.argv.slice(2), root=process.env.STUB_ROOT, mode=process.env.STUB_MODE;
|
||||||
|
appendFileSync(root+'/calls.jsonl',JSON.stringify({tool,args})+'\\n');
|
||||||
|
if(tool==='flock') process.exit(0);
|
||||||
|
if(tool==='timeout') { const r=Bun.spawnSync(args.slice(1),{env:process.env});process.stdout.write(r.stdout);process.stderr.write(r.stderr);process.exit(r.exitCode); }
|
||||||
|
if(tool==='git') {
|
||||||
|
if(args.includes('init')) mkdirSync(args.at(-1),{recursive:true});
|
||||||
|
if(args.includes('rev-parse')) console.log('${revision}');
|
||||||
|
if(args.includes('archive')) { const r=Bun.spawnSync(['tar','-cf','-','-C',process.env.STUB_SOURCE,'.']);process.stdout.write(r.stdout);process.exit(r.exitCode); }
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if(tool==='curl') { console.log('{"status":"ok"}');process.exit(0); }
|
||||||
|
if(tool==='sudo') {
|
||||||
|
if(args.includes('build') && !(statSync(args.at(-1)+'/Caddyfile').mode & 4)) {console.error('Source unreadable to runtime user');process.exit(2);}
|
||||||
|
if(args.includes('build') && mode==='build-failure') process.exit(1);
|
||||||
|
if(args.some(a=>a.includes('SELECT type,name,sql'))) { console.log(mode==='migration-failure' && existsSync(root+'/switched')?'schema-new':'schema-old');process.exit(0); }
|
||||||
|
if(args.includes('compose') && args.includes('up') && args.includes('app')) {
|
||||||
|
writeFileSync(root+'/switched','yes');
|
||||||
|
if(['switch-failure','migration-failure'].includes(mode)) process.exit(1);
|
||||||
|
}
|
||||||
|
if(args.includes('scripts/backup.ts')) console.log('/data/backups/safety.sqlite');
|
||||||
|
if(args.includes('inspect')) console.log('sha256:test-image');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.exit(2);
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function scenario(mode: string, initial?: 'unchanged' | 'failed') {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'minabot-deploy-test-'));
|
||||||
|
const bin = join(root, 'bin'), source = join(root, 'source');
|
||||||
|
mkdirSync(bin); mkdirSync(source);
|
||||||
|
for (const name of ['git', 'sudo', 'timeout', 'flock', 'curl']) writeFileSync(join(bin, name), stub, { mode: 0o755 });
|
||||||
|
writeFileSync(join(root, '.env'), 'APP_IMAGE=minabot:previous\nCADDY_IMAGE=caddy:pinned\n');
|
||||||
|
writeFileSync(join(root, '.env.production'), 'SECRET=must-stay-server-side\n');
|
||||||
|
writeFileSync(join(root, 'compose.yaml'), 'old-compose');
|
||||||
|
writeFileSync(join(root, 'Caddyfile'), 'old-caddy');
|
||||||
|
writeFileSync(join(root, 'deployed-sha'), initial === 'unchanged' ? revision : 'a'.repeat(40));
|
||||||
|
if (initial === 'failed') writeFileSync(join(root, 'failed-sha'), revision);
|
||||||
|
writeFileSync(join(source, 'compose.yaml'), 'new-compose');
|
||||||
|
writeFileSync(join(source, 'Caddyfile'), 'new-caddy');
|
||||||
|
try {
|
||||||
|
const child = Bun.spawn(['bash', new URL('./deploy.sh', import.meta.url).pathname], {
|
||||||
|
env: { ...process.env, PATH: `${bin}:${dirname(process.execPath)}:${process.env.PATH}`, MINABOT_DEPLOY_ROOT: root, STUB_ROOT: root, STUB_SOURCE: source, STUB_MODE: mode },
|
||||||
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
|
});
|
||||||
|
const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||||
|
const calls = readFileSync(join(root, 'calls.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||||||
|
return { code, stdout, stderr, calls, env: readFileSync(join(root, '.env'), 'utf8'), secret: readFileSync(join(root, '.env.production'), 'utf8'), deployed: readFileSync(join(root, 'deployed-sha'), 'utf8').trim(), failed: existsSync(join(root, 'failed-sha')) };
|
||||||
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deploys tested main, preserves secrets, and stops old workers before starting new ones', async () => {
|
||||||
|
const r = await scenario('success');
|
||||||
|
expect(r.code).toBe(0); expect(r.deployed).toBe(revision);
|
||||||
|
expect(r.env).toContain('APP_IMAGE=minabot:git-bbbbbbbbbbbb');
|
||||||
|
expect(r.secret).toBe('SECRET=must-stay-server-side\n');
|
||||||
|
const stop = r.calls.findIndex(c => c.tool === 'sudo' && c.args.includes('stop'));
|
||||||
|
const up = r.calls.findIndex(c => c.tool === 'sudo' && c.args.includes('up'));
|
||||||
|
expect(stop).toBeGreaterThan(0); expect(up).toBeGreaterThan(stop);
|
||||||
|
});
|
||||||
|
test('unchanged and previously failed revisions do no Docker work', async () => {
|
||||||
|
for (const initial of ['unchanged', 'failed'] as const) {
|
||||||
|
const r = await scenario('success', initial);
|
||||||
|
expect(r.code).toBe(0); expect(r.calls.some(c => c.tool === 'sudo')).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('build failure leaves the current deployment running', async () => {
|
||||||
|
const r = await scenario('build-failure');
|
||||||
|
expect(r.code).not.toBe(0); expect(r.failed).toBe(true);
|
||||||
|
expect(r.env).toContain('APP_IMAGE=minabot:previous');
|
||||||
|
expect(r.calls.some(c => c.args.includes('stop'))).toBe(false);
|
||||||
|
});
|
||||||
|
test('startup failure rolls back configuration without restoring the database', async () => {
|
||||||
|
const r = await scenario('switch-failure');
|
||||||
|
expect(r.code).not.toBe(0); expect(r.env).toContain('APP_IMAGE=minabot:previous');
|
||||||
|
expect(r.stderr).toContain('database was not replaced');
|
||||||
|
expect(r.deployed).toBe('a'.repeat(40));
|
||||||
|
});
|
||||||
|
test('a changed migration fingerprint stops instead of overwriting production data', async () => {
|
||||||
|
const r = await scenario('migration-failure');
|
||||||
|
expect(r.code).not.toBe(0); expect(r.failed).toBe(true);
|
||||||
|
expect(r.stderr).toContain('No database was overwritten');
|
||||||
|
expect(r.deployed).toBe('a'.repeat(40));
|
||||||
|
expect(r.calls.filter(c => c.tool === 'sudo' && c.args.includes('up'))).toHaveLength(1);
|
||||||
|
});
|
||||||
12
deploy/git-credential-tea.ts
Normal file
12
deploy/git-credential-tea.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Repository-local HTTPS credential helper. Git consumes stdout; never log it.
|
||||||
|
import { join } from 'node:path';
|
||||||
|
if (process.argv[2] !== 'get') process.exit(0);
|
||||||
|
const fields = Object.fromEntries((await Bun.stdin.text()).trim().split('\n').map(line => {
|
||||||
|
const index = line.indexOf('='); return [line.slice(0, index), line.slice(index + 1)];
|
||||||
|
}));
|
||||||
|
if (fields.protocol !== 'https' || fields.host !== 'git.ayau.me' || fields.path !== 'syntaxbullet/minabot.git') process.exit(0);
|
||||||
|
const base = process.env.XDG_CONFIG_HOME || join(process.env.HOME!, 'Library', 'Application Support');
|
||||||
|
const config = Bun.YAML.parse(await Bun.file(join(base, 'tea', 'config.yml')).text()) as { logins?: { name: string; url: string; user: string; token: string }[] };
|
||||||
|
const login = config.logins?.find(login => login.name === 'ayau' && login.url.replace(/\/$/, '') === 'https://git.ayau.me');
|
||||||
|
if (!login?.token || !login.user || /[\r\n]/.test(login.token + login.user)) process.exit(1);
|
||||||
|
process.stdout.write(`username=${login.user}\npassword=${login.token}\n\n`);
|
||||||
13
deploy/minabot-deploy.service
Normal file
13
deploy/minabot-deploy.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Test and deploy Minabot main from Gitea
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=syntaxbullet
|
||||||
|
WorkingDirectory=/opt/minabot
|
||||||
|
ExecStart=/usr/local/bin/minabot-deploy
|
||||||
|
TimeoutStartSec=30min
|
||||||
|
UMask=0077
|
||||||
11
deploy/minabot-deploy.timer
Normal file
11
deploy/minabot-deploy.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Check Gitea main for Minabot updates every minute
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=60s
|
||||||
|
OnUnitInactiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
Unit=minabot-deploy.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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 |
|
| GET | `/habits/:id` | Current habit configuration and revision ID |
|
||||||
| PATCH | `/habits/:id` | Change name, method, method settings, schedule or archived flag |
|
| PATCH | `/habits/:id` | Change name, method, method settings, schedule or archived flag |
|
||||||
| DELETE | `/habits/:id` | Archive from today, preserving history; 204 |
|
| 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/history` | `{revisions: [...]}` newest first, including superseded same-day revisions |
|
||||||
| GET | `/habits/:id/tasks` | `{tasks: [...]}` for current task method |
|
| GET | `/habits/:id/tasks` | `{tasks: [...]}` for current task method |
|
||||||
| POST | `/habits/:id/tasks` | Add task; 201 |
|
| 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)
|
||||||
22
docs/LAUNCH-VERIFICATION.md
Normal file
22
docs/LAUNCH-VERIFICATION.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Launch feature verification — September 4, 2026
|
||||||
|
|
||||||
|
| Feature | Implementation and verification |
|
||||||
|
| --- | --- |
|
||||||
|
| Historical check-ins | Calendar selection and date picker open dated count, checkbox, and task controls. Integration coverage changes a habit's method and corrects yesterday without changing today. Browser verified a saved historical count and refreshed calendar. |
|
||||||
|
| Account settings | Timezone changes refresh the dashboard; JSON exports include owned records without session credentials. Typed-confirmation deletion removes dependent records and revokes sessions. API tests cover ownership, Origin checks, confirmation, foreign keys, and another account remaining intact. Browser verified timezone persistence. |
|
||||||
|
| Discord sharing controls | SQLite reservations enforce one new post per user per minute and ten per channel per minute; duplicate checks remain safe. Tests cover cooldowns, channel quotas, allowlists, and rejected/uncertain delivery behavior. |
|
||||||
|
| Backup and recovery | Production snapshots, pre-migration snapshot, retention, optional replica directory, and fresh-path recovery. Tests restore committed WAL progress, revoke sessions, disable recovered reminders, verify replicas and retention, reject unavailable storage and existing destinations, and execute both CLIs. |
|
||||||
|
| Monitoring | Request IDs and sanitized structured error events; database/backup-aware health; independent monitor with sustained-failure and recovery transitions. Tests exercise a closed database and live HTTP probes; one-shot CLI passed against the disposable server. |
|
||||||
|
| Archive and restore | Dashboard archive action, history view, and restore. Integration and browser checks confirm a restored habit returns to today's dashboard while earlier completion remains intact. |
|
||||||
|
| Daily reminders | Opt-in settings, timezone/quiet hours, unfinished-only private messages, persistent daily reservations, rate-limit deferral, and delivery status. Tests cover concurrent workers, DST fall-back, opt-out during channel creation, uncertain sends, ownership, export, and deletion. Browser saved the selected time, delivered one message through the local stub, showed Delivered, and saved opt-out. |
|
||||||
|
|
||||||
|
Validation commands: `bun run typecheck`, `bun test`, `bun run test:smoke`, and
|
||||||
|
`git diff --check`. Browser checks used the in-memory preview on port 3107 and
|
||||||
|
verified desktop and narrow layouts (480px minimum layout viewport available in
|
||||||
|
the in-app browser), with no page-level horizontal overflow in settings.
|
||||||
|
|
||||||
|
Live Discord messages, production account deletion, and deployment were not
|
||||||
|
performed. Set the production origin, bot credentials/permissions, any sharing
|
||||||
|
allowlist, durable backup volume/replica, and independent monitor/log collection
|
||||||
|
when deploying. Off-machine storage and an external alert destination must be
|
||||||
|
provided by the deployment environment; their provisioning is outside the app.
|
||||||
87
docs/OPERATIONS.md
Normal file
87
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 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
|
||||||
|
when the latest snapshot is older than `BACKUP_INTERVAL_HOURS` (24 by default).
|
||||||
|
The server checks once a minute. Snapshot files are private (0600), created with
|
||||||
|
SQLite `VACUUM INTO`, and checked for integrity and foreign-key errors before being
|
||||||
|
published. Seven snapshots are retained by default; `BACKUP_RETAIN` accepts 2–365.
|
||||||
|
Pre-migration snapshots are mandatory; a failure prevents migration. Set
|
||||||
|
`BACKUP_ENABLED=false` only to disable the periodic runner if an external backup
|
||||||
|
system already handles it. Manual `bun run db:backup` always takes a snapshot.
|
||||||
|
|
||||||
|
`BACKUP_DIR` defaults to `backups` beside the database. Relative backup paths are
|
||||||
|
resolved relative to the database directory, consistently in CLI and production.
|
||||||
|
Use a durable volume for both the database and backups. Configure
|
||||||
|
`BACKUP_REPLICA_DIR` to an existing mounted directory on a separate machine/storage
|
||||||
|
service to keep an off-machine copy. The app does not provision or authenticate
|
||||||
|
that storage. If the directory is unavailable, backup reports failure and does
|
||||||
|
not prune old snapshots. Local-only backups do not protect against machine loss.
|
||||||
|
Snapshots run synchronously; for larger databases use the backup CLI from an
|
||||||
|
external scheduler and disable the in-process periodic runner.
|
||||||
|
|
||||||
|
To rehearse a restore without touching the live database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run db:backup
|
||||||
|
bun run db:restore /absolute/path/to/backup.sqlite /absolute/path/to/recovered.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore requires a new destination, verifies integrity, and revokes all sessions
|
||||||
|
in the restored copy. It never overwrites a database or its WAL/SHM sidecars.
|
||||||
|
Test the recovered copy with the matching release and an isolated local port.
|
||||||
|
For an actual recovery, stop the service, set `DATABASE_PATH` to the recovered
|
||||||
|
file, then start the service and sign in again. Keep the original file for rollback.
|
||||||
|
Do not copy only the live `.sqlite` file: recent commits may be in its WAL.
|
||||||
|
|
||||||
|
Backups include account data and session hashes. Protect backup storage and expire
|
||||||
|
copies according to your published retention period. Restoring an older backup
|
||||||
|
can restore deleted accounts; reapply deletions performed since the snapshot
|
||||||
|
before reopening access. Discord images already posted are separate from app data.
|
||||||
|
|
||||||
|
Verification: `bun test src/ops/backups.test.ts` exercises WAL progress, restored
|
||||||
|
content, revoked sessions, replicas, retention, missing storage, and refusal to
|
||||||
|
overwrite an existing destination.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Every API response includes a server-generated `X-Request-ID`. Unexpected errors
|
||||||
|
return that reference to the browser and write a JSON `request_failed` event to
|
||||||
|
stderr with the request ID, method, route template, error type, and stack locations.
|
||||||
|
Error messages, SQL values, request bodies, cookies, and query strings are omitted
|
||||||
|
so credentials and habit content do not enter logs. Production source maps in
|
||||||
|
`dist/` help map stack locations back to the source. Backup outcomes also emit
|
||||||
|
`backup_completed` and `backup_failed` events.
|
||||||
|
|
||||||
|
`GET /api/health` checks SQLite and returns 503 if enabled automatic backups are
|
||||||
|
failing or have never succeeded. It returns no account data, paths, or credentials.
|
||||||
|
Run the health monitor independently of the application process:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run monitor --once
|
||||||
|
bun run monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
`MONITOR_URL` defaults to `http://127.0.0.1:$PORT/api/health`. For an outside-in
|
||||||
|
check, run the monitor on another machine and set it to the public HTTPS endpoint.
|
||||||
|
The one-shot command exits nonzero on failure. Continuous mode checks every minute,
|
||||||
|
times out after ten seconds, reports `health_failed` after three consecutive
|
||||||
|
failures, and emits one `health_recovered` event on recovery. It stays quiet while
|
||||||
|
state is unchanged. Run it under a process supervisor and route these JSON events
|
||||||
|
to your deployment platform's alerting/log collection. No external alert provider
|
||||||
|
or off-machine monitor is provisioned automatically by this repository.
|
||||||
|
|
||||||
|
## Reminder worker
|
||||||
|
|
||||||
|
The production process checks opt-in reminders every minute, with a lock preventing
|
||||||
|
overlapping runs within the process and transactional delivery reservations across
|
||||||
|
processes. A crash after reservation may leave a reminder unconfirmed; the worker
|
||||||
|
chooses not to resend it that day. Known rate-limit rejections defer until Discord's
|
||||||
|
retry deadline. Quiet hours and completion are checked again immediately before
|
||||||
|
posting. Recovery disables saved reminder opt-ins to avoid replaying notifications
|
||||||
|
from an old backup. Use the disposable preview and stubbed transport tests for QA;
|
||||||
|
real Discord delivery depends on the bot token, shared server, and user DM permissions.
|
||||||
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.
|
||||||
80
docs/design/audit-2026-09-04.md
Normal file
80
docs/design/audit-2026-09-04.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Design-system audit — September 4, 2026
|
||||||
|
|
||||||
|
Three parallel audits covered component structure, accessibility/interaction,
|
||||||
|
and visual hierarchy/responsiveness. The existing serif/monochrome direction and
|
||||||
|
unsaved local demos were preserved. No authentication or API behavior was changed.
|
||||||
|
|
||||||
|
## Findings resolved
|
||||||
|
|
||||||
|
- Extracted a shared `Field` for consistent native controls, labels, and hints;
|
||||||
|
the Components specimen now matches the editor and color picker.
|
||||||
|
- Added configurable semantic heading levels to `Card`; Layout cards are h4
|
||||||
|
beneath h3 groups, with appearance independent of heading level.
|
||||||
|
- Documented the eyebrow type role and introduced a shared medium-weight
|
||||||
|
sans-serif role for compact interface headings.
|
||||||
|
- Replaced compressed annual heatmap cells (5.39px at a 480px viewport) with
|
||||||
|
minimum 24px targets in a local horizontal scroller.
|
||||||
|
- Associated explicit scrolling and keyboard instructions with calendar controls.
|
||||||
|
- Corrected calendar focus when an editor rejects a selection or changes the
|
||||||
|
visible month window; rejected requests cannot steal focus on later updates.
|
||||||
|
- Reveal the selected calendar date when a previously hidden panel opens or its
|
||||||
|
viewport resizes, without stealing focus from the current control.
|
||||||
|
- Labeled task groups and restored useful focus after adding/removing tasks,
|
||||||
|
with live feedback about draft changes.
|
||||||
|
- Linked editor hints and custom-color validation feedback programmatically.
|
||||||
|
- Enlarged counters, checkbox labels, month controls, and color choices to 44px.
|
||||||
|
- Added a higher-contrast shared control-boundary token.
|
||||||
|
- Allowed palette, spacing, form, toolbar, and chart content to wrap on narrow
|
||||||
|
layouts; restored description line breaks and stacked split sections earlier.
|
||||||
|
- Added native checkbox rendering and visible state boundaries for forced colors.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Full typecheck, test suite, production build, and whitespace checks pass.
|
||||||
|
- Regression coverage includes semantic headings, Field IDs/hints, mounted-panel
|
||||||
|
reference integrity, calendar focus rejection, shifting date windows, task
|
||||||
|
focus, color validation, contrast tokens, and minimum target styles.
|
||||||
|
- Live browser: all seven tabs at 480px and 2560px had document width equal to
|
||||||
|
viewport width. Calendar cells measured 24.11px after the change.
|
||||||
|
- Live interactions: form focus/hints/submission feedback and task add/remove
|
||||||
|
focus were verified. The desktop preview reported no console errors/warnings.
|
||||||
|
- Verified all four calendar ranges, selected-date visibility after tab reveal,
|
||||||
|
and matching keyboard focus/selection in the live desktop preview.
|
||||||
|
- The browser's native unsaved-change confirmation stalled the browser tool;
|
||||||
|
rejection behavior was verified in component tests, not end-to-end through
|
||||||
|
that dialog. Verification continued in a fresh preview.
|
||||||
|
|
||||||
|
The available browser viewport clamps at 480px, so 320px behavior and actual
|
||||||
|
screen-reader/forced-color rendering have not been manually certified. Responsive
|
||||||
|
rules and forced-color fallbacks have source-level regression coverage.
|
||||||
|
|
||||||
|
|
||||||
|
## Focused hierarchy and palette follow-up
|
||||||
|
|
||||||
|
- Increased `type-ui-heading` from 14/21 to 18/27 using a shared 1.125rem token.
|
||||||
|
Specimen headings, component headings, field labels, task/schedule legends, and
|
||||||
|
the habit-color legend share it. Supporting text remains 14/21; serif display,
|
||||||
|
section, and component-title roles retain their existing sizes.
|
||||||
|
- Used 8px heading-to-caption spacing in component rows and calendar state keys.
|
||||||
|
- Removed redundant section introductions, repeated demo notices, and decorative
|
||||||
|
encouragement beside working controls. Kept save/cancel, historical-edit,
|
||||||
|
calendar keyboard, validation, and preview-scope guidance.
|
||||||
|
- Replaced ad hoc #737373/#888 with secondary, #777 with control-border, #333
|
||||||
|
hover with secondary, and #f3f3f3/#ebebeb with the existing #f5f5f5 surface.
|
||||||
|
Named the already-used surface and empty-calendar fills as `--ds-surface` and
|
||||||
|
`--ds-empty`. All neutral CSS literals now live in the token declarations;
|
||||||
|
calendar progress ramps and habit identity/custom colors are unchanged.
|
||||||
|
- Updated Foundations specimens and README role guidance alongside the styles.
|
||||||
|
|
||||||
|
Follow-up verification: typecheck, production build, all 123 tests (0 failures),
|
||||||
|
and `git diff --check` passed. A static production preview confirmed 18/27px
|
||||||
|
interface headings, 14/21px hints, and 8px field gaps. All seven tabs had document
|
||||||
|
width equal to the 480px viewport; sampled specimen headings, fields, and color
|
||||||
|
swatches had no internal overflow. Desktop Components and Editing fit at 1440px;
|
||||||
|
serif card titles remained 32px. Visually inspected desktop Components/Editing
|
||||||
|
and mobile Components/Layout/Editing. The name-preview submit and counter worked,
|
||||||
|
and the browser recorded no warnings or errors. The browser clamps requested
|
||||||
|
mobile widths below 480px, so narrower rendered widths remain unverified.
|
||||||
|
|
||||||
|
This follow-up preserved the pre-existing uncommitted work and did not modify
|
||||||
|
backend code, database state, or habit calculation behavior.
|
||||||
@@ -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.
|
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`
|
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
|
## Habit detail
|
||||||
|
|
||||||
Saved asset: `src/assets/design/habit-detail.png`
|
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
|
## 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:
|
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.
|
||||||
19
drizzle/0004_low_carmella_unuscione.sql
Normal file
19
drizzle/0004_low_carmella_unuscione.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE `discord_connections` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`encrypted_webhook` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`channel_id` text NOT NULL,
|
||||||
|
`guild_id` text NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `discord_deliveries` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`image_hash` text NOT NULL,
|
||||||
|
`status` text NOT NULL,
|
||||||
|
`message_url` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
1
drizzle/0005_bored_firelord.sql
Normal file
1
drizzle/0005_bored_firelord.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE `discord_connections`;
|
||||||
5
drizzle/0006_sharing_limits.sql
Normal file
5
drizzle/0006_sharing_limits.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE `sharing_limits` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`started_at` integer NOT NULL,
|
||||||
|
`count` integer NOT NULL
|
||||||
|
);
|
||||||
20
drizzle/0007_reminders.sql
Normal file
20
drizzle/0007_reminders.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE `reminder_deliveries` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`date` text NOT NULL,
|
||||||
|
`status` text NOT NULL,
|
||||||
|
`retry_at` integer,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `reminder_deliveries_user_date_idx` ON `reminder_deliveries` (`user_id`,`date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `reminder_settings` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`time` text DEFAULT '20:00' NOT NULL,
|
||||||
|
`quiet_start` text DEFAULT '22:00' NOT NULL,
|
||||||
|
`quiet_end` text DEFAULT '08:00' NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
4
drizzle/0008_reminder_cooldown.sql
Normal file
4
drizzle/0008_reminder_cooldown.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE `reminder_worker_state` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`blocked_until` integer NOT NULL
|
||||||
|
);
|
||||||
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;
|
||||||
823
drizzle/meta/0004_snapshot.json
Normal file
823
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "480b54f2-4f2f-4200-8f18-22f28b38b62b",
|
||||||
|
"prevId": "0893a83a-3004-440a-8e89-3a30f26a578d",
|
||||||
|
"tables": {
|
||||||
|
"combined_charts": {
|
||||||
|
"name": "combined_charts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"habit_ids": {
|
||||||
|
"name": "habit_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"combined_charts_user_idx": {
|
||||||
|
"name": "combined_charts_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"combined_charts_user_id_users_id_fk": {
|
||||||
|
"name": "combined_charts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "combined_charts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"discord_connections": {
|
||||||
|
"name": "discord_connections",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"encrypted_webhook": {
|
||||||
|
"name": "encrypted_webhook",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"channel_id": {
|
||||||
|
"name": "channel_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guild_id": {
|
||||||
|
"name": "guild_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"discord_connections_user_id_users_id_fk": {
|
||||||
|
"name": "discord_connections_user_id_users_id_fk",
|
||||||
|
"tableFrom": "discord_connections",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"discord_deliveries": {
|
||||||
|
"name": "discord_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_hash": {
|
||||||
|
"name": "image_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message_url": {
|
||||||
|
"name": "message_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"discord_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "discord_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "discord_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_calendar_settings": {
|
||||||
|
"name": "habit_calendar_settings",
|
||||||
|
"columns": {
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_calendar_settings",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_days": {
|
||||||
|
"name": "habit_days",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_id": {
|
||||||
|
"name": "revision_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ends_at": {
|
||||||
|
"name": "ends_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count_set": {
|
||||||
|
"name": "count_set",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_days_lookup_idx": {
|
||||||
|
"name": "habit_days_lookup_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"date",
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_days_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_days_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||||
|
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habit_revisions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_revisions": {
|
||||||
|
"name": "habit_revisions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_date": {
|
||||||
|
"name": "effective_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_revisions_date_idx": {
|
||||||
|
"name": "habit_revisions_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"effective_date",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_revisions_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_revisions",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habits": {
|
||||||
|
"name": "habits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_date": {
|
||||||
|
"name": "created_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"materialized_through": {
|
||||||
|
"name": "materialized_through",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habits_user_idx": {
|
||||||
|
"name": "habits_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habits_user_id_users_id_fk": {
|
||||||
|
"name": "habits_user_id_users_id_fk",
|
||||||
|
"tableFrom": "habits",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"progress_events": {
|
||||||
|
"name": "progress_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurrence_id": {
|
||||||
|
"name": "occurrence_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"name": "before",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"name": "after",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"progress_events_day_idx": {
|
||||||
|
"name": "progress_events_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"progress_events_day_id_habit_days_id_fk": {
|
||||||
|
"name": "progress_events_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "progress_events",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_occurrences": {
|
||||||
|
"name": "task_occurrences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"name": "task_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expired_at": {
|
||||||
|
"name": "expired_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"closed_at": {
|
||||||
|
"name": "closed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"task_occurrences_day_idx": {
|
||||||
|
"name": "task_occurrences_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_occurrences_day_id_habit_days_id_fk": {
|
||||||
|
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "task_occurrences",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
757
drizzle/meta/0005_snapshot.json
Normal file
757
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18",
|
||||||
|
"prevId": "480b54f2-4f2f-4200-8f18-22f28b38b62b",
|
||||||
|
"tables": {
|
||||||
|
"combined_charts": {
|
||||||
|
"name": "combined_charts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"habit_ids": {
|
||||||
|
"name": "habit_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"combined_charts_user_idx": {
|
||||||
|
"name": "combined_charts_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"combined_charts_user_id_users_id_fk": {
|
||||||
|
"name": "combined_charts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "combined_charts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"discord_deliveries": {
|
||||||
|
"name": "discord_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_hash": {
|
||||||
|
"name": "image_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message_url": {
|
||||||
|
"name": "message_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"discord_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "discord_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "discord_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_calendar_settings": {
|
||||||
|
"name": "habit_calendar_settings",
|
||||||
|
"columns": {
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_calendar_settings",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_days": {
|
||||||
|
"name": "habit_days",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_id": {
|
||||||
|
"name": "revision_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ends_at": {
|
||||||
|
"name": "ends_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count_set": {
|
||||||
|
"name": "count_set",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_days_lookup_idx": {
|
||||||
|
"name": "habit_days_lookup_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"date",
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_days_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_days_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||||
|
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habit_revisions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_revisions": {
|
||||||
|
"name": "habit_revisions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_date": {
|
||||||
|
"name": "effective_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_revisions_date_idx": {
|
||||||
|
"name": "habit_revisions_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"effective_date",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_revisions_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_revisions",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habits": {
|
||||||
|
"name": "habits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_date": {
|
||||||
|
"name": "created_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"materialized_through": {
|
||||||
|
"name": "materialized_through",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habits_user_idx": {
|
||||||
|
"name": "habits_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habits_user_id_users_id_fk": {
|
||||||
|
"name": "habits_user_id_users_id_fk",
|
||||||
|
"tableFrom": "habits",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"progress_events": {
|
||||||
|
"name": "progress_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurrence_id": {
|
||||||
|
"name": "occurrence_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"name": "before",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"name": "after",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"progress_events_day_idx": {
|
||||||
|
"name": "progress_events_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"progress_events_day_id_habit_days_id_fk": {
|
||||||
|
"name": "progress_events_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "progress_events",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_occurrences": {
|
||||||
|
"name": "task_occurrences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"name": "task_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expired_at": {
|
||||||
|
"name": "expired_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"closed_at": {
|
||||||
|
"name": "closed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"task_occurrences_day_idx": {
|
||||||
|
"name": "task_occurrences_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_occurrences_day_id_habit_days_id_fk": {
|
||||||
|
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "task_occurrences",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
788
drizzle/meta/0006_snapshot.json
Normal file
788
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b5822e36-a1a9-42dd-9439-9490297bc097",
|
||||||
|
"prevId": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18",
|
||||||
|
"tables": {
|
||||||
|
"combined_charts": {
|
||||||
|
"name": "combined_charts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"habit_ids": {
|
||||||
|
"name": "habit_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"combined_charts_user_idx": {
|
||||||
|
"name": "combined_charts_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"combined_charts_user_id_users_id_fk": {
|
||||||
|
"name": "combined_charts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "combined_charts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"discord_deliveries": {
|
||||||
|
"name": "discord_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_hash": {
|
||||||
|
"name": "image_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message_url": {
|
||||||
|
"name": "message_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"discord_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "discord_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "discord_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_calendar_settings": {
|
||||||
|
"name": "habit_calendar_settings",
|
||||||
|
"columns": {
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_calendar_settings",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_days": {
|
||||||
|
"name": "habit_days",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_id": {
|
||||||
|
"name": "revision_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ends_at": {
|
||||||
|
"name": "ends_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count_set": {
|
||||||
|
"name": "count_set",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_days_lookup_idx": {
|
||||||
|
"name": "habit_days_lookup_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"date",
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_days_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_days_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||||
|
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habit_revisions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_revisions": {
|
||||||
|
"name": "habit_revisions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_date": {
|
||||||
|
"name": "effective_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_revisions_date_idx": {
|
||||||
|
"name": "habit_revisions_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"effective_date",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_revisions_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_revisions",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habits": {
|
||||||
|
"name": "habits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_date": {
|
||||||
|
"name": "created_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"materialized_through": {
|
||||||
|
"name": "materialized_through",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habits_user_idx": {
|
||||||
|
"name": "habits_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habits_user_id_users_id_fk": {
|
||||||
|
"name": "habits_user_id_users_id_fk",
|
||||||
|
"tableFrom": "habits",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"progress_events": {
|
||||||
|
"name": "progress_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurrence_id": {
|
||||||
|
"name": "occurrence_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"name": "before",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"name": "after",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"progress_events_day_idx": {
|
||||||
|
"name": "progress_events_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"progress_events_day_id_habit_days_id_fk": {
|
||||||
|
"name": "progress_events_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "progress_events",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sharing_limits": {
|
||||||
|
"name": "sharing_limits",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_occurrences": {
|
||||||
|
"name": "task_occurrences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"name": "task_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expired_at": {
|
||||||
|
"name": "expired_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"closed_at": {
|
||||||
|
"name": "closed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"task_occurrences_day_idx": {
|
||||||
|
"name": "task_occurrences_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_occurrences_day_id_habit_days_id_fk": {
|
||||||
|
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "task_occurrences",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
933
drizzle/meta/0007_snapshot.json
Normal file
933
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "1839b194-bee2-4f37-bf75-1235d2965a48",
|
||||||
|
"prevId": "b5822e36-a1a9-42dd-9439-9490297bc097",
|
||||||
|
"tables": {
|
||||||
|
"combined_charts": {
|
||||||
|
"name": "combined_charts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"habit_ids": {
|
||||||
|
"name": "habit_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"combined_charts_user_idx": {
|
||||||
|
"name": "combined_charts_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"combined_charts_user_id_users_id_fk": {
|
||||||
|
"name": "combined_charts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "combined_charts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"discord_deliveries": {
|
||||||
|
"name": "discord_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_hash": {
|
||||||
|
"name": "image_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message_url": {
|
||||||
|
"name": "message_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"discord_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "discord_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "discord_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_calendar_settings": {
|
||||||
|
"name": "habit_calendar_settings",
|
||||||
|
"columns": {
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_calendar_settings",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_days": {
|
||||||
|
"name": "habit_days",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_id": {
|
||||||
|
"name": "revision_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ends_at": {
|
||||||
|
"name": "ends_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count_set": {
|
||||||
|
"name": "count_set",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_days_lookup_idx": {
|
||||||
|
"name": "habit_days_lookup_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"date",
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_days_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_days_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||||
|
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habit_revisions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_revisions": {
|
||||||
|
"name": "habit_revisions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_date": {
|
||||||
|
"name": "effective_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_revisions_date_idx": {
|
||||||
|
"name": "habit_revisions_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"effective_date",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_revisions_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_revisions",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habits": {
|
||||||
|
"name": "habits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_date": {
|
||||||
|
"name": "created_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"materialized_through": {
|
||||||
|
"name": "materialized_through",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habits_user_idx": {
|
||||||
|
"name": "habits_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habits_user_id_users_id_fk": {
|
||||||
|
"name": "habits_user_id_users_id_fk",
|
||||||
|
"tableFrom": "habits",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"progress_events": {
|
||||||
|
"name": "progress_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurrence_id": {
|
||||||
|
"name": "occurrence_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"name": "before",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"name": "after",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"progress_events_day_idx": {
|
||||||
|
"name": "progress_events_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"progress_events_day_id_habit_days_id_fk": {
|
||||||
|
"name": "progress_events_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "progress_events",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"reminder_deliveries": {
|
||||||
|
"name": "reminder_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"retry_at": {
|
||||||
|
"name": "retry_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"reminder_deliveries_user_date_idx": {
|
||||||
|
"name": "reminder_deliveries_user_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reminder_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "reminder_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "reminder_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"reminder_settings": {
|
||||||
|
"name": "reminder_settings",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"name": "time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'20:00'"
|
||||||
|
},
|
||||||
|
"quiet_start": {
|
||||||
|
"name": "quiet_start",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'22:00'"
|
||||||
|
},
|
||||||
|
"quiet_end": {
|
||||||
|
"name": "quiet_end",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'08:00'"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reminder_settings_user_id_users_id_fk": {
|
||||||
|
"name": "reminder_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "reminder_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sharing_limits": {
|
||||||
|
"name": "sharing_limits",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_occurrences": {
|
||||||
|
"name": "task_occurrences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"name": "task_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expired_at": {
|
||||||
|
"name": "expired_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"closed_at": {
|
||||||
|
"name": "closed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"task_occurrences_day_idx": {
|
||||||
|
"name": "task_occurrences_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_occurrences_day_id_habit_days_id_fk": {
|
||||||
|
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "task_occurrences",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
957
drizzle/meta/0008_snapshot.json
Normal file
957
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "1861c134-87ad-4e7e-a113-0f845763483a",
|
||||||
|
"prevId": "1839b194-bee2-4f37-bf75-1235d2965a48",
|
||||||
|
"tables": {
|
||||||
|
"combined_charts": {
|
||||||
|
"name": "combined_charts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"habit_ids": {
|
||||||
|
"name": "habit_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"combined_charts_user_idx": {
|
||||||
|
"name": "combined_charts_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"combined_charts_user_id_users_id_fk": {
|
||||||
|
"name": "combined_charts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "combined_charts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"discord_deliveries": {
|
||||||
|
"name": "discord_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"image_hash": {
|
||||||
|
"name": "image_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message_url": {
|
||||||
|
"name": "message_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"discord_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "discord_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "discord_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_calendar_settings": {
|
||||||
|
"name": "habit_calendar_settings",
|
||||||
|
"columns": {
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_calendar_settings",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_days": {
|
||||||
|
"name": "habit_days",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_id": {
|
||||||
|
"name": "revision_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ends_at": {
|
||||||
|
"name": "ends_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count_set": {
|
||||||
|
"name": "count_set",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_days_lookup_idx": {
|
||||||
|
"name": "habit_days_lookup_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"date",
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_days_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_days_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||||
|
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habit_revisions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_revisions": {
|
||||||
|
"name": "habit_revisions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_date": {
|
||||||
|
"name": "effective_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_revisions_date_idx": {
|
||||||
|
"name": "habit_revisions_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"effective_date",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_revisions_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_revisions",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habits": {
|
||||||
|
"name": "habits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_date": {
|
||||||
|
"name": "created_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"materialized_through": {
|
||||||
|
"name": "materialized_through",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habits_user_idx": {
|
||||||
|
"name": "habits_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habits_user_id_users_id_fk": {
|
||||||
|
"name": "habits_user_id_users_id_fk",
|
||||||
|
"tableFrom": "habits",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"progress_events": {
|
||||||
|
"name": "progress_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurrence_id": {
|
||||||
|
"name": "occurrence_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"name": "before",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"name": "after",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"progress_events_day_idx": {
|
||||||
|
"name": "progress_events_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"progress_events_day_id_habit_days_id_fk": {
|
||||||
|
"name": "progress_events_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "progress_events",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"reminder_deliveries": {
|
||||||
|
"name": "reminder_deliveries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"retry_at": {
|
||||||
|
"name": "retry_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"reminder_deliveries_user_date_idx": {
|
||||||
|
"name": "reminder_deliveries_user_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reminder_deliveries_user_id_users_id_fk": {
|
||||||
|
"name": "reminder_deliveries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "reminder_deliveries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"reminder_settings": {
|
||||||
|
"name": "reminder_settings",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"name": "time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'20:00'"
|
||||||
|
},
|
||||||
|
"quiet_start": {
|
||||||
|
"name": "quiet_start",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'22:00'"
|
||||||
|
},
|
||||||
|
"quiet_end": {
|
||||||
|
"name": "quiet_end",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'08:00'"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reminder_settings_user_id_users_id_fk": {
|
||||||
|
"name": "reminder_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "reminder_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"reminder_worker_state": {
|
||||||
|
"name": "reminder_worker_state",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"blocked_until": {
|
||||||
|
"name": "blocked_until",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sharing_limits": {
|
||||||
|
"name": "sharing_limits",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_occurrences": {
|
||||||
|
"name": "task_occurrences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"name": "task_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expired_at": {
|
||||||
|
"name": "expired_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"closed_at": {
|
||||||
|
"name": "closed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"task_occurrences_day_idx": {
|
||||||
|
"name": "task_occurrences_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_occurrences_day_id_habit_days_id_fk": {
|
||||||
|
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "task_occurrences",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -29,6 +29,83 @@
|
|||||||
"when": 1788507928967,
|
"when": 1788507928967,
|
||||||
"tag": "0003_finalize_occurrence_deadlines",
|
"tag": "0003_finalize_occurrence_deadlines",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788533580335,
|
||||||
|
"tag": "0004_low_carmella_unuscione",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788536554543,
|
||||||
|
"tag": "0005_bored_firelord",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788539042148,
|
||||||
|
"tag": "0006_sharing_limits",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788539729696,
|
||||||
|
"tag": "0007_reminders",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
12
package.json
12
package.json
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "minabot",
|
"name": "mina-habits",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts",
|
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --watch src/index.ts",
|
||||||
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
|
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
|
||||||
"build": "bun run build.ts",
|
"build": "bun run build.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -13,9 +13,14 @@
|
|||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"test:smoke": "bun run build && bun scripts/api-smoke.ts"
|
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
|
||||||
|
"test:pwa": "bun run build && bun scripts/pwa-smoke.ts",
|
||||||
|
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
|
||||||
|
"db:restore": "bun scripts/restore.ts",
|
||||||
|
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.8",
|
||||||
"@nivo/calendar": "^0.99.0",
|
"@nivo/calendar": "^0.99.0",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -36,6 +41,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"happy-dom": "^20.14.0",
|
"happy-dom": "^20.14.0",
|
||||||
|
"playwright": "^1.63.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Respo
|
|||||||
const port = probe.port!; probe.stop(true);
|
const port = probe.port!; probe.stop(true);
|
||||||
const origin = `http://127.0.0.1:${port}`;
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
||||||
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', DISCORD_BOT_TOKEN: '', DISCORD_SHARING_CHANNEL_ID: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
||||||
const token = 's'.repeat(43);
|
const token = 's'.repeat(43);
|
||||||
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
||||||
async function boot() {
|
async function boot() {
|
||||||
@@ -56,6 +56,10 @@ try {
|
|||||||
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
||||||
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
||||||
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
|
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
|
||||||
|
const livePreview = await fetch(`${origin}/api/sharing/live/preview?theme=light`, { headers: { Cookie: `minabot_session=${token}` } });
|
||||||
|
check(livePreview.ok, 'Production live PNG rendering failed');
|
||||||
|
const png = Buffer.from(await livePreview.arrayBuffer());
|
||||||
|
check(png.readUInt32BE(16) === 1920 && png.readUInt32BE(20) === 1080, 'Live image is not 16:9');
|
||||||
await stop(); await boot();
|
await stop(); await boot();
|
||||||
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
||||||
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
||||||
|
|||||||
7
scripts/backup.ts
Normal file
7
scripts/backup.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { databasePath } from "../src/db/config";
|
||||||
|
import { backupConfig, createBackup } from "../src/ops/backups";
|
||||||
|
// 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,11 +1,38 @@
|
|||||||
|
import { browserFont } from "../src/browser-fonts";
|
||||||
// Isolated browser QA server. Never opens or changes the user's database.
|
// Isolated browser QA server. Never opens or changes the user's database.
|
||||||
|
import { sendDueReminders } from "../src/reminders/worker";
|
||||||
|
import type { DiscordFetch } from "../src/sharing/routes";
|
||||||
import { fixture } from "../src/habits/test-fixture";
|
import { fixture } from "../src/habits/test-fixture";
|
||||||
import { createApi } from "../src/api";
|
import { createApi } from "../src/api";
|
||||||
import index from "../src/index.html";
|
import index from "../src/index.html";
|
||||||
|
|
||||||
const f = fixture();
|
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 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" || 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" });
|
||||||
|
}
|
||||||
|
return Response.json({ type: 0, id: "223456789012345678", guild_id: "323456789012345678", name: "preview-only" });
|
||||||
|
};
|
||||||
|
if (sharingPreview) {
|
||||||
|
f.setTime("2026-08-01T12:00:00Z");
|
||||||
|
const reading = await f.json("/habits", "POST", { name: "A few pages, every day", method: "count", target: 10, unit: "pages", color: "#90647e" }, 201);
|
||||||
|
const walking = await f.json("/habits", "POST", { name: "An afternoon walk", method: "manual", schedule: { type: "weekdays", days: [1, 3, 5] }, color: "#397f76" }, 201);
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
const date = new Date(Date.UTC(2026, 7, 1 + i)).toISOString().slice(0, 10);
|
||||||
|
f.setTime(`${date}T12:00:00Z`);
|
||||||
|
if (i % 4 !== 0) await f.json(`/habits/${reading.id}/days/${date}/progress`, "PUT", { count: i % 3 === 0 ? 5 : 10 });
|
||||||
|
if ([1, 3, 5].includes(new Date(`${date}T12:00:00Z`).getUTCDay()) && i % 4 !== 0) await f.json(`/habits/${walking.id}/days/${date}/progress`, "PUT", { done: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
const app = createApi(
|
const app = createApi(
|
||||||
f.db,
|
f.db,
|
||||||
{
|
{
|
||||||
@@ -16,11 +43,22 @@ const app = createApi(
|
|||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
() => Date.parse("2026-09-04T12:00:00Z"),
|
() => Date.parse("2026-09-04T12:00:00Z"),
|
||||||
|
sharingPreview ? previewDiscord : undefined,
|
||||||
|
sharingPreview ? { token: "preview-bot-token", channelId: "223456789012345678" } : undefined,
|
||||||
);
|
);
|
||||||
|
let reminderRunning = false;
|
||||||
|
if (sharingPreview) setInterval(async () => {
|
||||||
|
if (reminderRunning) return; reminderRunning = true;
|
||||||
|
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({
|
const server = Bun.serve({
|
||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
port: 3107,
|
port,
|
||||||
routes: {
|
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": () =>
|
"/__preview/login": () =>
|
||||||
new Response(null, {
|
new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { backupConfig, createBackup } from "../src/ops/backups";
|
||||||
|
import { databasePath } from "../src/db/config";
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { db, sqlite } from "../src/db";
|
import { db, sqlite } from "../src/db";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (process.env.NODE_ENV === "production" && sqlite.query("SELECT name FROM sqlite_master WHERE name = 'habits'").get()) {
|
||||||
|
// Fail before migrations if the safety snapshot cannot be created.
|
||||||
|
createBackup(sqlite, backupConfig(databasePath));
|
||||||
|
}
|
||||||
migrate(db, { migrationsFolder: "./drizzle" });
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
console.log("Database migrations applied.");
|
console.log("Database migrations applied.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
21
scripts/monitor.ts
Normal file
21
scripts/monitor.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { probeHealth, healthObserver } from "../src/ops/monitor";
|
||||||
|
const target = new URL(process.env.MONITOR_URL || `http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`);
|
||||||
|
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) throw new Error("MONITOR_URL must be an HTTP(S) URL without credentials");
|
||||||
|
const emit = (event: Record<string, unknown>) => console.log(JSON.stringify(event));
|
||||||
|
if (process.argv.includes("--once")) {
|
||||||
|
const result = await probeHealth(target.href);
|
||||||
|
emit({ event: "health_probe", ...result });
|
||||||
|
process.exitCode = result.healthy ? 0 : 1;
|
||||||
|
} else {
|
||||||
|
const observe = healthObserver(emit);
|
||||||
|
let running = false;
|
||||||
|
const check = async () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
try { observe(await probeHealth(target.href)); } finally { running = false; }
|
||||||
|
};
|
||||||
|
await check();
|
||||||
|
const timer = setInterval(() => void check(), 60000);
|
||||||
|
const stop = () => { clearInterval(timer); process.exit(0); };
|
||||||
|
process.on("SIGTERM", stop); process.on("SIGINT", stop);
|
||||||
|
}
|
||||||
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.');
|
||||||
|
}
|
||||||
5
scripts/restore.ts
Normal file
5
scripts/restore.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import { restoreBackup } from "../src/ops/backups";
|
||||||
|
const [source, destination] = process.argv.slice(2);
|
||||||
|
if (!source || !destination || process.argv.length !== 4) throw new Error("Usage: bun scripts/restore.ts BACKUP NEW_DATABASE_PATH");
|
||||||
|
console.log(`Restored and verified: ${restoreBackup(resolve(source), resolve(destination))}. All sessions revoked.`);
|
||||||
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>);
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { backupConfig } from "../src/ops/backups";
|
||||||
import { databasePath } from "../src/db/config";
|
import { databasePath } from "../src/db/config";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Bun's built HTML assets resolve from dist; keep the database path absolute.
|
// Bun's built HTML assets resolve from dist; keep the database path absolute.
|
||||||
process.env.DATABASE_PATH = databasePath;
|
process.env.DATABASE_PATH = databasePath;
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
|
const backups = backupConfig(databasePath);
|
||||||
|
process.env.BACKUP_DIR = backups.directory;
|
||||||
|
if (backups.replica) process.env.BACKUP_REPLICA_DIR = backups.replica;
|
||||||
process.chdir(fileURLToPath(new URL("../dist", import.meta.url)));
|
process.chdir(fileURLToPath(new URL("../dist", import.meta.url)));
|
||||||
const entrypoint = new URL("../dist/index.js", import.meta.url).href;
|
const entrypoint = new URL("../dist/index.js", import.meta.url).href;
|
||||||
await import(entrypoint);
|
await import(entrypoint);
|
||||||
|
|||||||
1292
src/App.test.tsx
1292
src/App.test.tsx
File diff suppressed because it is too large
Load Diff
69
src/App.tsx
69
src/App.tsx
@@ -1,57 +1,24 @@
|
|||||||
import { Route, Routes, useLocation, useNavigate } from "react-router";
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
import { AuthControls } from "./components/AuthControls";
|
import { deferred } from "./components/design-system/deferred";
|
||||||
|
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
|
||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { About } from "./pages/About";
|
import { AuthProvider } from "./components/AuthProvider";
|
||||||
import { Settings } from "./pages/Settings";
|
|
||||||
import { DesignSystem } from "./pages/DesignSystem";
|
import { ThemeProvider } from "./components/ThemeProvider";
|
||||||
import { AuthProvider, useAuth } from "./components/AuthProvider";
|
|
||||||
import { Landing } from "./pages/Landing";
|
|
||||||
import { Button } from "./components/design-system/primitives";
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { pathname } = useLocation();
|
|
||||||
if (pathname === "/design-system" || pathname === "/design-system/") return <DesignSystem />;
|
|
||||||
return <AuthProvider><AppRoutes /></AuthProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppRoutes() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { user, loading, accountError, retry } = useAuth();
|
|
||||||
|
|
||||||
if (pathname === "/") {
|
|
||||||
if (loading || accountError) return (
|
|
||||||
<div className="ds-root landing-root">
|
|
||||||
<main className="landing-account-state">
|
|
||||||
<a className="ds-wordmark" href="/">minabot.</a>
|
|
||||||
{loading ? <p role="status">Loading your account…</p> : <>
|
|
||||||
<p role="alert">{accountError}</p>
|
|
||||||
<Button variant="secondary" onClick={retry}>Try again</Button>
|
|
||||||
</>}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (!user) return <Landing />;
|
|
||||||
return <Home />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ThemeProvider><Routes>
|
||||||
<nav aria-label="Main navigation">
|
<Route path="/design-system" element={<DesignSystem />} />
|
||||||
<button type="button" disabled={pathname === "/"} onClick={() => navigate("/")}>Home</button>{" "}
|
<Route
|
||||||
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
|
path="/"
|
||||||
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
|
element={
|
||||||
{" "}<button type="button" onClick={() => navigate("/design-system")}>Design system</button>
|
<AuthProvider>
|
||||||
</nav>
|
<Home />
|
||||||
<AuthControls />
|
</AuthProvider>
|
||||||
<main>
|
}
|
||||||
<Routes>
|
/>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
<Route path="/about" element={<About />} />
|
</Routes></ThemeProvider>
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="*" element={<h1>Page not found</h1>} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/account/routes.test.ts
Normal file
33
src/account/routes.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import { fixture } from '../habits/test-fixture';
|
||||||
|
|
||||||
|
test('export is complete, owned, and excludes sessions; deletion removes only the authenticated account', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const a = await f.json('/habits', 'POST', { name: 'Private tasks', method: 'tasks', tasks: [{ name: 'Stretch' }] }, 201);
|
||||||
|
const b = await f.json('/habits', 'POST', { name: 'Bob only', method: 'manual' }, 201, 'b');
|
||||||
|
const day = await f.json(`/habits/${a.id}/days/2026-09-04`);
|
||||||
|
await f.json(`/habits/${a.id}/days/2026-09-04/tasks/${day.tasks[0].taskId}`, 'PUT', { done: true });
|
||||||
|
await f.json('/charts', 'POST', { name: 'Mine', habitIds: [a.id] }, 201);
|
||||||
|
const response = await f.request('/account/export');
|
||||||
|
expect(response.headers.get('content-disposition')).toContain('attachment');
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.habits.map((h: any) => h.id)).toEqual([a.id]);
|
||||||
|
expect(data.tasks[0].done).toBe(true);
|
||||||
|
expect(data.progressEvents.length).toBe(1);
|
||||||
|
expect(data.revisions.length).toBeGreaterThan(0);
|
||||||
|
expect(data.charts.length).toBe(1);
|
||||||
|
expect(JSON.stringify(data)).not.toContain('tokenHash');
|
||||||
|
expect(JSON.stringify(data)).not.toContain('Bob only');
|
||||||
|
expect((await f.request('/account', 'DELETE', { confirmation: 'DELETE' }, 'a', { Origin: 'https://wrong.test' })).status).toBe(403);
|
||||||
|
expect((await f.request('/account', 'DELETE', {})).status).toBe(422);
|
||||||
|
const removed = await f.request('/account', 'DELETE', { confirmation: 'DELETE' });
|
||||||
|
expect(removed.status).toBe(204);
|
||||||
|
expect(removed.headers.get('set-cookie')).toContain('Max-Age=0');
|
||||||
|
expect((await f.request('/me')).status).toBe(401);
|
||||||
|
expect((await f.request('/account/export')).status).toBe(401);
|
||||||
|
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits.map((h: any) => h.id)).toEqual([b.id]);
|
||||||
|
expect(f.sqlite.query('PRAGMA foreign_key_check').all()).toEqual([]);
|
||||||
|
expect(f.sqlite.query('SELECT * FROM progress_events').all()).toEqual([]);
|
||||||
|
} finally { f.close(); }
|
||||||
|
});
|
||||||
56
src/account/routes.ts
Normal file
56
src/account/routes.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||||
|
import * as schema from "../db/schema";
|
||||||
|
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 => HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||||
|
const user = c.get("user");
|
||||||
|
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));
|
||||||
|
return {
|
||||||
|
formatVersion: 1, exportedAt: new Date(now()).toISOString(),
|
||||||
|
user: tx.select().from(schema.users).where(eq(schema.users.id, user.id)).get(),
|
||||||
|
habits: tx.select().from(schema.habits).where(eq(schema.habits.userId, user.id)).all(),
|
||||||
|
revisions: tx.select().from(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).all(),
|
||||||
|
days: tx.select().from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).all(),
|
||||||
|
tasks: tx.select().from(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).all(),
|
||||||
|
progressEvents: tx.select().from(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).all(),
|
||||||
|
calendarSettings: tx.select().from(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).all(),
|
||||||
|
charts: tx.select().from(schema.combinedCharts).where(eq(schema.combinedCharts.userId, user.id)).all(),
|
||||||
|
reminderSettings: tx.select().from(schema.reminderSettings).where(eq(schema.reminderSettings.userId, user.id)).all(),
|
||||||
|
reminderDeliveries: tx.select().from(schema.reminderDeliveries).where(eq(schema.reminderDeliveries.userId, user.id)).all(),
|
||||||
|
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
tx.delete(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).run();
|
||||||
|
tx.delete(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).run();
|
||||||
|
tx.delete(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).run();
|
||||||
|
tx.delete(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).run();
|
||||||
|
tx.delete(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).run();
|
||||||
|
tx.delete(schema.combinedCharts).where(eq(schema.combinedCharts.userId, userId)).run();
|
||||||
|
tx.delete(schema.habits).where(eq(schema.habits.userId, userId)).run();
|
||||||
|
tx.delete(schema.users).where(eq(schema.users.id, userId)).run();
|
||||||
|
});
|
||||||
|
auth.clearSession(c);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
38
src/api.ts
38
src/api.ts
@@ -1,3 +1,8 @@
|
|||||||
|
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";
|
||||||
import { createHabitRoutes } from "./habits/routes";
|
import { createHabitRoutes } from "./habits/routes";
|
||||||
import { ApiError } from "./habits/service";
|
import { ApiError } from "./habits/service";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -5,22 +10,49 @@ import { sql } from "drizzle-orm";
|
|||||||
import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
|
import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
|
||||||
import type { AuthConfig } from "./auth/config";
|
import type { AuthConfig } from "./auth/config";
|
||||||
import type { FetchDiscord } from "./auth/discord";
|
import type { FetchDiscord } from "./auth/discord";
|
||||||
|
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
||||||
|
import type { DiscordSharingConfig } from "./sharing/config";
|
||||||
|
|
||||||
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) {
|
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void; discord?: CommandConfig } = {}) {
|
||||||
const app = new Hono<AuthEnv>();
|
const app = new Hono<AuthEnv>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
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 auth = createAuth(db, config, request, now);
|
||||||
|
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||||
|
const sharing = createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
await next();
|
||||||
|
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
|
||||||
|
liveSharing.changed(c.get("user").id);
|
||||||
|
});
|
||||||
app.get("/api/health", c => {
|
app.get("/api/health", c => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
db.get(sql`SELECT 1`);
|
db.get(sql`SELECT 1`);
|
||||||
|
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed, { progress: { sharing, live: liveSharing } }));
|
||||||
|
app.route("/api/sharing/live", liveSharing.routes);
|
||||||
app.route("/api/auth", auth.routes);
|
app.route("/api/auth", auth.routes);
|
||||||
|
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||||
|
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
|
||||||
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||||
|
app.route("/api/sharing", sharing);
|
||||||
app.notFound(c => c.json({ error: "Not found" }, 404));
|
app.notFound(c => c.json({ error: "Not found" }, 404));
|
||||||
app.onError((_error, c) => {
|
app.onError((_error, c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
|
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
|
||||||
return c.json({ error: "Internal server error" }, 500);
|
const requestId = c.get("requestId");
|
||||||
|
const event = { event: "request_failed", timestamp: new Date().toISOString(), requestId, method: c.req.method,
|
||||||
|
route: c.req.routePath, ...errorDetails(_error) };
|
||||||
|
(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());
|
afterEach(() => sqlite.close());
|
||||||
|
|
||||||
describe("Discord sign-in and sessions", () => {
|
describe("Discord sign-in and sessions", () => {
|
||||||
|
test("trusted TLS proxy supports sign-in and secure callback cookies", async () => {
|
||||||
|
const publicOrigin = "https://habits.example";
|
||||||
|
const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp);
|
||||||
|
const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } });
|
||||||
|
const location = new URL(response.headers.get("Location")!);
|
||||||
|
expect(location.origin).toBe("https://discord.com");
|
||||||
|
expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`);
|
||||||
|
expect(response.headers.get("Set-Cookie")).toContain("Secure");
|
||||||
|
const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, {
|
||||||
|
headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") },
|
||||||
|
});
|
||||||
|
expect(cookie(callback, "__Host-minabot_session")).not.toBe("");
|
||||||
|
expect(callback.headers.get("Set-Cookie")).toContain("Secure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwarded protocol is opt-in and cannot override the actual hostname", async () => {
|
||||||
|
for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) {
|
||||||
|
const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp);
|
||||||
|
const response = await proxyApp.request(`http://${host}/api/auth/discord`, {
|
||||||
|
headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" },
|
||||||
|
});
|
||||||
|
expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example");
|
||||||
|
expect(response.headers.get("Set-Cookie")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("me requires a valid session and health stays public", async () => {
|
test("me requires a valid session and health stays public", async () => {
|
||||||
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
||||||
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AuthConfig = {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
cookieSecret: string;
|
cookieSecret: string;
|
||||||
|
trustProxy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readAuthConfig(env = process.env): AuthConfig {
|
export function readAuthConfig(env = process.env): AuthConfig {
|
||||||
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
|
|||||||
clientId: env.DISCORD_CLIENT_ID ?? "",
|
clientId: env.DISCORD_CLIENT_ID ?? "",
|
||||||
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
||||||
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
||||||
|
trustProxy: env.TRUST_PROXY === "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { fetchDiscordProfile, type FetchDiscord } from "./discord";
|
|||||||
const { users, sessions } = schema;
|
const { users, sessions } = schema;
|
||||||
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||||
const STATE_SECONDS = 10 * 60;
|
const STATE_SECONDS = 10 * 60;
|
||||||
export type AuthEnv = { Variables: { user: PublicUser } };
|
export type AuthEnv = { Variables: { user: PublicUser; requestId: string } };
|
||||||
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
|
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
|
||||||
export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||||
const randomToken = () => randomBytes(32).toString("base64url");
|
const randomToken = () => randomBytes(32).toString("base64url");
|
||||||
@@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicUser(user: typeof users.$inferSelect): PublicUser {
|
export function publicUser(user: typeof users.$inferSelect): PublicUser {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
discordId: user.discordId,
|
discordId: user.discordId,
|
||||||
@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
|
|||||||
routes.get("/discord", async c => {
|
routes.get("/discord", async c => {
|
||||||
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
||||||
// Use one configured origin for redirects and cookie ownership.
|
// Use one configured origin for redirects and cookie ownership.
|
||||||
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
const requestUrl = new URL(c.req.url);
|
||||||
|
// Enable only behind a private proxy that overwrites X-Forwarded-Proto.
|
||||||
|
// Keep the actual Host; never trust a client-supplied forwarded hostname.
|
||||||
|
if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:";
|
||||||
|
if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
||||||
const state = randomToken();
|
const state = randomToken();
|
||||||
await setSignedCookie(c, stateName, JSON.stringify({
|
await setSignedCookie(c, stateName, JSON.stringify({
|
||||||
state,
|
state,
|
||||||
@@ -142,5 +146,5 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
|
|||||||
deleteCookie(c, sessionName, cookieOptions);
|
deleteCookie(c, sessionName, cookieOptions);
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
});
|
});
|
||||||
return { routes, requireAuth, requireSameOrigin };
|
return { routes, requireAuth, requireSameOrigin, clearSession: (c: Parameters<typeof requireAuth>[0]) => deleteCookie(c, sessionName, cookieOptions) };
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
53
src/components/AccountSettings.tsx
Normal file
53
src/components/AccountSettings.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ThemeControl } from "./ThemeProvider";
|
||||||
|
import { ReminderSettings } from "./ReminderSettings";
|
||||||
|
import { useId, useRef, useState } from "react";
|
||||||
|
import type { PublicUser } from "../shared/user";
|
||||||
|
import { habitRequest } from "../lib/dashboard";
|
||||||
|
import { Button, ButtonLink } from "./design-system/primitives";
|
||||||
|
import { Download, Trash2 } from "lucide-react";
|
||||||
|
import { FormMessage, SettingRow, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||||
|
import { Field } from "./design-system/Field";
|
||||||
|
|
||||||
|
export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) {
|
||||||
|
const [timezone, setTimezone] = useState(user.timezone);
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [reminderBusy, setReminderBusy] = useState(false);
|
||||||
|
const saving = useRef(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const listId = useId();
|
||||||
|
async function run(action: () => Promise<unknown>) {
|
||||||
|
if (saving.current || reminderBusy) return;
|
||||||
|
saving.current = true; setBusy(true); setError("");
|
||||||
|
try { await action(); onChanged(); }
|
||||||
|
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||||
|
finally { saving.current = false; setBusy(false); }
|
||||||
|
}
|
||||||
|
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)} />}
|
||||||
|
</Field>
|
||||||
|
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
|
||||||
|
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || reminderBusy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
|
||||||
|
</form>
|
||||||
|
</SettingRow>
|
||||||
|
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
|
||||||
|
<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> :
|
||||||
|
<form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
|
||||||
|
<p className="type-ui-heading">This cannot be undone.</p>
|
||||||
|
<p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.</p>
|
||||||
|
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy || reminderBusy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
|
||||||
|
<div className="ds-action-row"><Button type="submit" disabled={busy || reminderBusy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy || reminderBusy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
|
||||||
|
</form>}
|
||||||
|
</SettingRow>
|
||||||
|
<FormMessage error>{error}</FormMessage>
|
||||||
|
</WorkspacePanel>;
|
||||||
|
}
|
||||||
85
src/components/ArchivedHabits.tsx
Normal file
85
src/components/ArchivedHabits.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
|
import { Button } from "./design-system/primitives";
|
||||||
|
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("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const saving = useRef(false);
|
||||||
|
const expired = useRef(onExpired); expired.current = onExpired;
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController(); setLoading(true); setError("");
|
||||||
|
habitRequest<{ habits: TodayHabit[] }>(`/days/${date}`, { signal: controller.signal })
|
||||||
|
.then(data => { if (!controller.signal.aborted) setHabits(data.habits.filter(habit => habit.requirements?.archived)); })
|
||||||
|
.catch(error => { if (!controller.signal.aborted) { setError(error.message); if (error.message.includes("session has expired")) expired.current(); } })
|
||||||
|
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [date, revision, attempt]);
|
||||||
|
async function restore(habit: TodayHabit) {
|
||||||
|
if (saving.current) return;
|
||||||
|
saving.current = true; setBusy(true); setError(""); setNotice("");
|
||||||
|
try {
|
||||||
|
await habitRequest(`/habits/${habit.habitId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archived: false }) });
|
||||||
|
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
|
||||||
|
setNotice(`${habit.name} restored. Check-ins resume today.`); onChanged();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Could not restore. Try again.";
|
||||||
|
setError(message); if (message.includes("session has expired")) expired.current();
|
||||||
|
} finally { saving.current = false; setBusy(false); }
|
||||||
|
}
|
||||||
|
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>}
|
||||||
|
<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">
|
||||||
|
<div className="ds-record-copy"><h3 id={`archive-title-${habit.habitId}`}>{habit.name ?? "Habit"}</h3><p>{scheduleLabel(habit.requirements?.schedule)} · Archived</p></div>
|
||||||
|
<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={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>;
|
||||||
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useAuth } from "./AuthProvider";
|
import { useAuth } from "./AuthProvider";
|
||||||
|
import { Button } from "./design-system/primitives";
|
||||||
|
import { DiscordSignInButton } from "./DiscordSignInButton";
|
||||||
|
|
||||||
export function AuthControls() {
|
export function AuthControls() {
|
||||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Account">
|
<section className="ds-account-controls" aria-label="Account">
|
||||||
{loading ? <p role="status">Loading account…</p> : user ? (
|
{loading ? <p role="status">Loading account…</p> : user ? (
|
||||||
<p>
|
<p>
|
||||||
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
|
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
|
||||||
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
|
<Button variant="secondary" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</Button>{" "}
|
||||||
<a href="/api/me">View my profile</a>
|
<a href="/api/me">View my profile</a>
|
||||||
</p>
|
</p>
|
||||||
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
) : !accountError && <p><DiscordSignInButton onClick={signIn} /></p>}
|
||||||
{error && <p role="alert">{error}</p>}
|
{error && <p role="alert">{error}</p>}
|
||||||
{accountError && <button type="button" onClick={retry}>Try again</button>}
|
{accountError && <Button variant="secondary" onClick={retry}>Try again</Button>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function signIn() {
|
function signIn() {
|
||||||
|
if (navigator.onLine === false) return;
|
||||||
let timezone = "UTC";
|
let timezone = "UTC";
|
||||||
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
||||||
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||||
@@ -21,6 +22,7 @@ type AuthState = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
accountError: string;
|
accountError: string;
|
||||||
|
connectionError: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
signIn: () => void;
|
signIn: () => void;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [accountError, setAccountError] = useState("");
|
const [accountError, setAccountError] = useState("");
|
||||||
|
const [connectionError, setConnectionError] = useState(false);
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
|
let reachedServer = false;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/me", { signal: controller.signal });
|
setAccountError("");
|
||||||
|
setConnectionError(false);
|
||||||
|
const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) });
|
||||||
|
reachedServer = true;
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
if (response.status === 401) { setUser(null); return; }
|
if (response.status === 401) { setUser(null); return; }
|
||||||
if (!response.ok) throw new Error("Could not load your account. Please try again.");
|
if (!response.ok) throw new Error("Could not load your account. Please try again.");
|
||||||
const account: PublicUser = await response.json();
|
const account: PublicUser = await response.json();
|
||||||
if (!controller.signal.aborted) setUser(account);
|
if (!controller.signal.aborted) setUser(account);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
|
if (!controller.signal.aborted) {
|
||||||
|
setConnectionError(!reachedServer);
|
||||||
|
setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account.");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [attempt]);
|
}, [attempt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reconnect = () => { if (accountError) retry(); };
|
||||||
|
const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); };
|
||||||
|
const timer = window.setInterval(check, 15_000);
|
||||||
|
window.addEventListener("online", reconnect);
|
||||||
|
window.addEventListener("focus", check);
|
||||||
|
return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); };
|
||||||
|
}, [accountError, connectionError]);
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
setAccountError("");
|
setAccountError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
|
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
|
||||||
const response = await fetch("/api/auth/logout", { method: "POST" });
|
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||||
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
|
<AuthContext.Provider value={{ user, loading, busy, accountError, connectionError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
27
src/components/CalendarCache.test.ts
Normal file
27
src/components/CalendarCache.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
|
||||||
|
import { CalendarCache } from "./CalendarCache";
|
||||||
|
const fetchSpy = spyOn(globalThis, "fetch");
|
||||||
|
afterEach(() => fetchSpy.mockReset());
|
||||||
|
afterAll(() => fetchSpy.mockRestore());
|
||||||
|
const range = { from: "2026-07-01", to: "2026-09-30" };
|
||||||
|
test("calendar cache shares in-flight reads, retains wider history and invalidates changed versions", async () => {
|
||||||
|
fetchSpy.mockImplementation(Object.assign(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }), { preconnect: fetch.preconnect }));
|
||||||
|
const cache = new CalendarCache();
|
||||||
|
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(fetchSpy.mock.calls[0]![0])).toContain("compact=true");
|
||||||
|
const subset = await cache.load("habit", "v1", { from: "2026-09-01", to: range.to });
|
||||||
|
expect(subset.days).toHaveLength(1);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
await cache.load("habit", "v2", range);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
await new CalendarCache().load("habit", "v2", range);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
test("a failed history request can be retried without retaining a rejected cache entry", async () => {
|
||||||
|
fetchSpy.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(Response.json({ ...range, days: [] }));
|
||||||
|
const cache = new CalendarCache();
|
||||||
|
await expect(cache.load("habit", "v1", range)).rejects.toThrow("offline");
|
||||||
|
await expect(cache.load("habit", "v1", range)).resolves.toMatchObject({ days: [] });
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
34
src/components/CalendarCache.tsx
Normal file
34
src/components/CalendarCache.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||||
|
import type { CalendarResponse } from "../shared/calendar";
|
||||||
|
import { habitRequest } from "../lib/dashboard";
|
||||||
|
|
||||||
|
type Range = { from: string; to: string };
|
||||||
|
type Entry = Range & { version: string; value: Promise<CalendarResponse> };
|
||||||
|
/** Account-scoped, bounded cache; never survives signing out or changing accounts. */
|
||||||
|
export class CalendarCache {
|
||||||
|
private entries = new Map<string, Entry[]>();
|
||||||
|
load(habitId: string, version: string, range: Range) {
|
||||||
|
const entries = (this.entries.get(habitId) ?? []).filter(entry => entry.version === version);
|
||||||
|
const cached = entries.find(entry => entry.from <= range.from && entry.to >= range.to);
|
||||||
|
if (cached) return cached.value.then(value => ({ ...value, ...range, days: value.days.filter(day => day.date >= range.from && day.date <= range.to) }));
|
||||||
|
const entry: Entry = { ...range, version, value: habitRequest<CalendarResponse>(`/habits/${habitId}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`) };
|
||||||
|
entries.push(entry);
|
||||||
|
this.entries.delete(habitId);
|
||||||
|
this.entries.set(habitId, entries.slice(-8));
|
||||||
|
if (this.entries.size > 100) this.entries.delete(this.entries.keys().next().value!);
|
||||||
|
entry.value.catch(() => { const current = this.entries.get(habitId); if (current) this.entries.set(habitId, current.filter(item => item !== entry)); });
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const Context = createContext<CalendarCache | null>(null);
|
||||||
|
export function CalendarCacheProvider({ children }: { children: ReactNode }) {
|
||||||
|
const cache = useRef<CalendarCache | null>(null);
|
||||||
|
cache.current ??= new CalendarCache();
|
||||||
|
return <Context.Provider value={cache.current}>{children}</Context.Provider>;
|
||||||
|
}
|
||||||
|
export function useCalendarCache() {
|
||||||
|
const shared = useContext(Context);
|
||||||
|
const local = useRef<CalendarCache | null>(null);
|
||||||
|
local.current ??= new CalendarCache();
|
||||||
|
return shared ?? local.current;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user