Skip to content

About

Switch Time — a clock that always holds exactly one state (家事/仕事/休息/睡眠/食事/娯楽). iOS, Android, macOS menubar, Web.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

593 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Switch Time

Tap to switch what you are doing; the app keeps the clock. Universal Expo app (Web first, iOS/Android later) with a Hono + oRPC API on DigitalOcean.

Roadmap and decisions live in the epic #1. Design sources live in design/ (pen.dev file is the source of truth) and design-system/ (styles.css + theme.json); the app never reformats or lints them.

Workspace

Path Package Purpose
apps/app @switch-time/app Expo SDK 57 universal app (Expo Router, src/app/), expo export -p web → dist/ for the DigitalOcean static site
apps/api @switch-time/api Hono 4 + oRPC 1.15 API: GET /api/healthz, RPC at /api/rpc/*, Better Auth at /api/auth/*, Drizzle ORM 1.0 RC + pg
packages/shared @switch-time/shared Activity palette, default activities and Zod schemas for app and API (consumed from MVP-08/MVP-13 on); pinned to design-system/theme.json by tests

Prerequisites

  • Node.js 26.10.0 (.node-version; use fnm/nodenv/Volta)
  • pnpm 12.3.4 — pinned in packageManager. pnpm 10+ downloads and runs the pinned version by default (pmOnFail: download); if yours does not, install it explicitly with npm install -g pnpm@12.3.4 (Node 25+ no longer bundles Corepack; npm install -g corepack && corepack enable also works). CI installs it through pnpm/setup in .github/actions/prepare.
  • Docker with Compose v2.24+ (compose.yaml uses env_file: required: false) — only for the local backend below
pnpm install --frozen-lockfile
pnpm check

Scripts

Command What it does
pnpm typecheck tsc in every workspace package (TypeScript ~6.0.3, same pin as Expo SDK 57)
pnpm lint ESLint 10 flat config: eslint-config-ts-prefixer + React Compiler-aware React rules
pnpm format:check Prettier (singleQuote, no semicolons); pnpm format writes
pnpm test Vitest in every package
pnpm build Every workspace build script (API bundle, expo export -p web) as they land
pnpm sherif Monorepo hygiene (consistent dependency versions, private root, …)
pnpm dead-code / pnpm dupes / pnpm health Fallow dead code, duplication and health checks
pnpm check Everything above, in the order CI runs it

git commit runs lint-staged (Prettier on staged files) through Husky.

Local backend

cp .env.example .env          # DATABASE_URL, TEST_DATABASE_URL, PORT, APP_ORIGIN, BETTER_AUTH_SECRET
pnpm dev:backend              # docker compose up --build: Postgres 18 + the API (tsx watch) on http://localhost:4100
pnpm db:psql                  # psql into the switchtime database
pnpm db:reset                 # docker compose down -v: drop the volume, next `up` starts from an empty database

compose.yaml builds the dev target of apps/api/Dockerfile and bind-mounts apps/api/src, apps/api/drizzle and packages/shared/src, so editing a file restarts the API inside the container and a new migration only needs docker compose restart api. Postgres 18 matches the newest major DigitalOcean Managed Databases offers; docker/postgres/init.sql also creates switchtime_test for Vitest. Inside Compose the database host is db (set on the api service); .env keeps localhost so pnpm --filter api dev on the host reaches the same Postgres. apps/api/src/env.ts loads the repo-root .env when it exists, so every pnpm --filter api … script sees it.

Database (Drizzle ORM 1.0 RC)

drizzle-orm and drizzle-kit are pinned to the same 1.0.0-rc.N (no caret; re-pin deliberately). Driver is pg; DATABASE_CA_CERT (PEM) switches the pool to TLS for DigitalOcean Managed Postgres and is required when NODE_ENV=production (no silent fallback to plain TCP). Local Compose stays plain TCP: compose.yaml overrides DATABASE_URL for the container and therefore blanks DATABASE_CA_CERT too; on the host both come from the same .env, so keep them describing the same database.

pnpm --filter api db:generate   # schema (src/db/schema/*.ts) → SQL under apps/api/drizzle — review it, commit it
pnpm --filter api db:migrate    # tsx src/db/migrate.ts: the dev container runs it on start; App Platform's PRE_DEPLOY job runs the same script as `node dist/db/migrate.js`
pnpm --filter api db:check      # drizzle-kit check: migration folder consistency
pnpm --filter api db:studio     # Drizzle Studio against DATABASE_URL

drizzle-kit push is never run against production. Tests (pnpm --filter api test) need TEST_DATABASE_URL: the Vitest global setup migrates that database, every test starts by truncating every public table, and files run serially because they share the database. CI provides the database as a postgres:18 service in .github/workflows/test.yml.

Auth (Better Auth 1.7)

Email + password only, served by the same Hono process at /api/auth/* (apps/api/src/auth.ts): @better-auth/drizzle-adapter/relations-v2 on the Drizzle instance, the @better-auth/expo server plugin, adapter writes in one transaction, baseURL = the API's own origin (APP_ORIGIN in production, http://localhost:$PORT otherwise), trustedOrigins = APP_ORIGIN plus switchtime:// (and exp://** in development). Sign-up opens no session (autoSignIn: false), so it answers 200 with a null token for a new address and a taken one alike; a hooks.before on /sign-up/email answers 400 to a body whose top-level string holds a NUL or a lone UTF-16 surrogate (text Postgres cannot store as sent), for both kinds of address, before Better Auth looks the address up. Rate limiting keeps Better Auth's default (production only) and keys by the App Platform ingress's do-connecting-ip header (advanced.ipAddress.ipAddressHeaders; the ingress writes its own hop into x-forwarded-for, and without the header Better Auth warns and uses one shared bucket). BETTER_AUTH_SECRET is required (openssl rand -base64 32), and in production APP_ORIGIN must be https:// (the cookie Secure flag derives from it). Every /api/* request body is capped at 100 KB.

Mail is optional. With both SMTP_URL (smtp:// or smtps://, the login in the URL) and MAIL_FROM (a bare address or Name <address>) set, apps/api/src/mail.ts sends plain-text Japanese mail through nodemailer (mail-text.ts holds the words, drawn on the pen board 「ST Phone / メール確認とパスワード再設定」) and auth-mail.ts turns on requireEmailVerification: a sign-up mails a confirmation link (1 hour), a sign-in with the right password on an unconfirmed address is refused with 403 EMAIL_NOT_VERIFIED and mails it again, opening the link confirms the address without signing anyone in (autoSignInAfterVerification: false) and lands on /sign-in?verified=1, a sign-up for an address that already has an account still answers like a new one and mails its owner a note instead of a link (onExistingUserSignUp), and /api/auth/request-password-reset mails a reset link only for a known address (same answer either way; revokeSessionsOnPasswordReset ends the other sessions). A mail is sent in the background and a failure is logged without the address or the link, so neither the answer nor its timing shows whether the mail went. Set both or neither (the env check refuses one); with neither, nothing changes: sign-up and sign-in work as before and GET /api/auth-config answers { "emailVerification": false }, which is how the app knows to hide 「パスワードを忘れた方」 and to keep the plain 登録しました notice. Production sets neither yet (TODOS.md).

  • Auth tables come from the CLI, never by hand: npx auth@1.7.5 generate --config src/auth.ts --output src/db/schema/auth.ts -y (CLI pinned to the runtime version) (run from apps/api), then pnpm --filter api db:generate for the SQL.
  • oRPC procedures read the session from the request headers (src/rpc/router.ts): authed procedures throw UNAUTHORIZED without one; me returns the current user.
  • Dev cookies: localhost:4101 → localhost:4100 is same-site, so the defaults (sameSite: lax) work; the client sends credentials: 'include'. Production is same-origin (MVP-09).

App (apps/app)

pnpm --filter app dev         # expo start --port 4101 (press i / a / w, or scan the QR code)
pnpm --filter app web         # expo start --web --port 4101 → http://localhost:4101
pnpm --filter app build:web   # expo export -p web → apps/app/dist (`build` aliases it, so `pnpm build` / CI run it too)
cd apps/app && npx expo-doctor

Scaffolded from expo-template-default@sdk-57 (src/app/ routes, typed routes, React Compiler); create-expo-app is broken on npm 12, so unpack the template tarball instead. Routes stay platform-UI only: no expo-font, no fontFamily. Expo packages are pinned like everything else, so minimumReleaseAge may hold them one patch behind what expo-doctor expects for a day — bump when the release is 24h old. pnpm isolated node_modules works with Metro here without node-linker=hoisted; react-native-web is reached through Metro's platform aliasing and is therefore listed in .fallowrc.json#ignoreDependencies. The app imports only type { AppRouterClient } from @switch-time/api (from MVP-08 on), which Metro erases.

Styling (Uniwind + Tailwind v4)

src/global.css is the only place the app spells a colour: the design-system tokens (design-system/styles.css, theme.json) are re-declared there as Tailwind theme variables, both bands under @layer theme with @variant dark / @variant light, and the web-only overrides under @variant web. Uniwind compiles that file inside Metro (metro.config.js, no native code, so Expo Go works) and gives every React Native component a className; uniwind.d.ts supplies the prop types because tsc runs without Metro (Metro regenerates the same file as the gitignored uniwind-types.d.ts). Activity colours are data (activities.color, always a palette entry), so components receive them as style values, never as classes. Ticking digits take the tabular utility. components.json + src/lib/utils.ts (cn) are the React Native Reusables set-up; its CLI only scaffolds new projects, so components are vendored by hand into src/components/ui when first used. pnpm --filter app audit:web (also in the Build workflow) fails the web export on CSS react-native-web cannot draw (grid, sticky, backdrop-filter, filter, gradients, pseudo-elements) and on any hex colour outside theme.json.

Data layer (oRPC + TanStack Query + Redux Toolkit)

  • src/lib/orpc.ts builds the typed oRPC client from AppRouterClient (a type-only import from @switch-time/api, so Metro never bundles server code) and exposes orpc.<procedure>.queryOptions() for TanStack Query. Every call, on every screen, fails with RequestTimeoutError once REQUEST_TIMEOUT_MS (30 s, src/lib/deadline.ts) passes without a whole answer, the body included. Server data lives in TanStack Query only; it is never copied into Redux.
  • EXPO_PUBLIC_API_ORIGIN selects the API origin: unset means http://localhost:4100 in dev and same-origin ('') in the production web build. For a physical device point it at the machine's LAN IP, e.g. EXPO_PUBLIC_API_ORIGIN=http://192.168.1.10:4100 pnpm --filter app dev. That http:// origin is for development only: a native release build refuses to start unless the origin is https://, because the SecureStore session rides on every request as a Cookie header.
  • src/store holds client-only state: clock (ticks every second while the app is active, pauses in background), registration (the address sign-up hands to sign-in, and whether its 登録しました notice still shows), correction (the correction sheet's 「元に戻す」, its last failure's line and its archived notice, each per day, with the epoch that keeps a late answer from reaching the next account) and syncedZone (the zone this device last synced per account, src/store/synced-zone.ts, the only slice kept on the device: @laststance/redux-storage-middleware saves it under switch-time.device in localStorage on the web and SecureStore on native, deviceStorage skips a save that would store the same text, and resetApp keeps it). Components use useAppSelector / useAppDispatch from @/store; the root layout runs useClock, useThemeSync (the stored theme from useSettings, resolved by resolveTheme in src/lib/theme.ts against the clock) and useTimeZoneSync (the device's zone, read by useDeviceZone again whenever the app returns to the foreground, written into settings.timeZone once the row has loaded and no settings write is in flight, only when it differs from the zone this device last synced, which the syncedZone slice keeps per account; zoneSyncAction in src/lib/settings.ts decides, and acts only on a row whose userId is the session's, so the previous account's cached row is never taken for the next account's; each write names the account it was decided for as forUserId, which settings.update refuses with CONFLICT from any other session, so a sign-in in another tab cannot take the write, and the zone is remembered for the account the returned row belongs to; a failed write is not sent again for the same account and zone until the next launch or sign-in). Sheets are routes and the correction day rides on ?day=, so there is no UI slice, and the user's preferences are the server's settings row, never mirrored.
  • /debug (dev only) renders the ping and me queries and the clock. pnpm --filter app test runs the Vitest unit tests in src/**/*.test.ts.

Auth (Better Auth client)

  • src/lib/auth-client.ts: createAuthClient from better-auth/react; on native the Expo plugin keeps the session in expo-secure-store and src/lib/orpc.ts replays it as a Cookie header, on web the first-party cookie does the work.
  • Route groups: (auth)/sign-in, (auth)/sign-up (Zod schemas signInSchema / signUpSchema from @switch-time/shared, first issue per field inline; above the form, the Japanese line authErrorMessage (src/lib/auth-errors.ts) gives for Better Auth's error code, never its English message, with one wording for every refusal to register) and (app)/… guarded in (app)/_layout.tsx: anonymous visitors are redirected to /sign-in?next=<path> and return there after signing in. Sign-up does not sign in (autoSignIn: false), so it answers an address that already has an account like a new one: it moves on to sign-in with the address filled in, the password focused and 「登録しました。サインインしてください」 above them, which stays while the password is typed and after a failed try (the error takes its box, so the card does not move) and goes when the address is edited (registrationSlice carries the address, never the URL). That closes the check in one request's answer only (its timing can still tell): signing up and then signing in with the password just chosen still shows whether the address was taken, until a mail server is set (SMTP_URL, see Auth): then only the owner of an address can confirm it. With one, sign-up says 「登録しました。確認メールのリンクを開いてから、サインインしてください」 (sign-up first waits for /api/auth-config, asked once per session by loadEmailVerification in lib/auth-config.ts, so the notice is never chosen by a config that was still loading; an API that cannot answer counts as no mail), sign-in shows 「パスワードを忘れた方」 and, from the mailed links, ?verified=1 / ?reset=1 notices and an ?error= line for a used-up link, (auth)/forgot-password asks for the reset mail (authClient.requestPasswordReset) and (auth)/reset-password?token= chooses the new password (authClient.resetPassword, then back to sign-in). Sign-up passes a callbackURL and forgot-password a redirectTo (lib/auth-links.ts: this origin on web, the app scheme on native); sign-in passes none, because with one Better Auth's client redirects after a sign-in that went through, so the API lands the link it mails again on the same screen (landOnSignIn). useSignOut ends the session, clears the TanStack cache, dispatches resetApp and shows sign-in. A sign-in does both as well once it goes through (from useAuthForm's mutation options, so also when its screen has gone by the time the answer lands), since a session that expired or was revoked elsewhere reaches sign-in without signing out; sign-up opens no session and resets nothing. When another tab signs in as someone else, this tab's session turns to that account on focus without either running, so useAccountScope (mounted in (app)/_layout.tsx) resets the cached queries (and starts a new session of taps) and starts the store's undo slots over as soon as it sees a different user id.
  • Playwright (web): pnpm --filter app test:e2e exports the site exactly as the production image does, with no EXPO_PUBLIC_API_ORIGIN (--clear, like build:web: Metro's transform cache is not keyed on EXPO_PUBLIC_* values, so an export after one with a different origin would ship the stale origin), then serves it on :4101 with /api piped to the API bundle on :4100 (scripts/serve-spa.mts; node ../api/dist/server.js, reused when the Compose API already listens there), the one-origin shape App Platform's ingress gives the app. A relative API URL that breaks only in that shape therefore fails every e2e test. CI runs the same in the e2e job with a Postgres service. A second project, mail (e2e/mail.spec.ts), runs against a second API and site two ports up (:4102 / :4103) whose API has SMTP_URL pointing at an SMTP server the spec starts itself (e2e/mail-sink.ts, smtp-server + mailparser, on :4104), so the confirmation and reset links are read from real mail; the first pair stays without a mail server, so every other test registers and signs in as before. E2E_API_PORT / E2E_APP_PORT move both ports (and the mail trio, always +2 / +2 / +4) when another project holds :4100 / :4101; set them together or not at all (the config refuses one without the other: a reused Compose API keeps its own APP_ORIGIN, so a lone app port would fail every sign-up with Invalid origin). The reused Compose API applies migrations only at start (docker compose restart api after a new one, before test:e2e); packages/shared/src is bind-mounted like apps/api/src, so shared edits reach it live.

Shell (expo-router)

(app)/(tabs)/_layout.tsx is a headless expo-router/ui Tabs: from 800 px up (useWide) the TabList is the design's 76 px rail on the left, below that the 60 px bottom bar; both render NavItem (react-native-svg stroke icons with the design's own paths, role="tab"). Screens sit inside Screen (the centred 640 px column, side rules when wide) under a ScreenHeader. Sheets are routes on the (app) Stack (correction, activity-editor, excluded-days): native gets presentation: 'modal', web a transparentModal without animation where Sheet draws the scrim and the dialog itself (✕, the scrim, Escape or a 「完了」 button call dismissSheet: router.back() when there is history, else /; the wide dialog is capped at the viewport so a long list scrolls inside it). +not-found.tsx covers unknown URLs. e2e/shell.spec.ts checks rail vs bar geometry, keyboard navigation, the sheet route and not-found.

Home (ホーム)

(app)/(tabs)/index.tsx gates on switches.current: the bare frame while it loads, FirstLaunch while it is null (the activity buttons with the DetoxRow under them, so a new account can start on detox), otherwise the hero (NowPanel with the react-native-svg Dial, elapsed from the clock slice via formatElapsed), the SwitchButton row, the full-width DetoxRow under it (pressed while switches.current has no activity; role="button" + aria-pressed like the buttons, so exactly one switch is pressed at any moment) and the 24-h TodayFlow bar, where detox spans are outlined solid in sub (idle spans stay dashed, in sub like 記録's dashed days) and the wide legend names detox (legendEntries). Home shows its body for detox once the activity list has answered (homeReady); the hero's texts and colour come from nowLook (detox: no colour, 「どの行動にも積み上がりません」), and its 「… から」 label from formatSince, which adds the day (9月16日 21:20, as the correction sheet writes it) when the record started before today. switches.current also answers runStartDay, the day the running detox run started in the stored zone (null while an activity runs), so Home counts the detox week from the run rather than from a record a cut started later. With the unused-day rule on, the run's last measured day (runStartDay + DETOX_MEASURED_DAYS_MAX) shows 「明日から計測に入りません」 and how to renew it under the hero (detoxLastDay, no server read). Once the run started more than DETOX_MEASURED_DAYS_MAX days before today and today has no tap (detoxPastWeek; with the rule off the server measures every day), Home also asks stats.day for today; when the server reads that day as neither measured nor excluded, detoxStopped puts 「今日は計測に入りません」 and the rule under the hero (pen ST Phone / ホーム・detox の状態). A loading, failed or offline-paused answer shows nothing, since unknown is not "stopped". Past the week, with the rule on, pressing detox again (the detox row or 0) starts a new run (detoxRenewable, the only press on the active state Home sends; the API also renews only while the rule is on; the row's hint then reads 「押し直すと新しく始まります」): the notice goes, and the timer and since label count from the press. History's today cell does not repeat the notice: it draws the day as the server classes it once the day is over. Server state comes through hooks: useActivities (live rows only), useCurrentActivity (also colours the rail badge; badgeRing gives it the sub ring while detox), useSwitchTo (optimistic switches.current in onMutate; every tap shares one mutation scope, so a burst of taps or hotkeys reaches the API one at a time, in the order it was made, and the last pick is the one left running. src/lib/optimistic-switch.ts keeps the last state the server confirmed: a refused tap puts it back only while its own row is still shown, so it never brings back another tap's placeholder, and only the last tap of a burst invalidates switches.current / switches.listByDay / stats.* (plus settings.get when that tap is detox and no settings write is in flight, so a tab that missed the unused-day rule going off stops offering a renewal), since an earlier refetch would wipe the queued taps' rows; it waits only for the day's own reads, not for stats.* or the settings, so the next tap is not held back, and a sign-in, a sign-out or useAccountScope starts a new session of taps with startTapSession, so a late tap of the previous account neither becomes the next account's fallback nor refetches over its picks, and its taps still queued are dropped before they go out with the next account's cookie) and useToday (day, bounds and segments in the stored settings.timeZone, so the bar and 「今日 n 回切替」 agree with the API; the pure parts live in src/lib/today.ts). On web the digit keys pick activities by position and 0 starts detox, on Home and on the first-launch screen alike (useSwitchHotkeys + hotkeyPick; a held key's repeats pick nothing, and Home weighs a key against the cached row, so a second key inside one frame sees the first one's pick, on the first-launch screen too, whose buttons go through the same check), and 「訂正」 opens the correction sheet over Home. e2e/home.spec.ts covers the first-launch hand-off (an activity, or detox as the first press, by button or hotkey, and one tap for the same digit or button pressed twice inside one frame), refused taps (one, and two queued, which keep the second on screen until its own answer), an accepted tap that leaves a queued pick on screen, a tap that does not wait for the previous tap's settings refetch, two digits inside one frame, digits ignored while the correction sheet is open and the restart of the counter, and a detox carried in from an earlier day: dated with the last-day warning on the seventh day after it started (without asking stats.day), the notice on the eighth until an activity is tapped, and a detox re-tap (the row or 0) on the eighth that starts a new run, or keeps the run when another device turned the rule off, and 0 twice inside one frame that renews once.

History (記録)

(app)/(tabs)/history.tsx shows stats.week (the trailing seven days ending today; ‹ › step by a week) or stats.month (a calendar month, Sunday-first rows) in the stored settings.timeZone via useLocalToday (the day string from the clock slice, so the screen re-renders at midnight rather than every tick; useToday builds on it). Every number on the screen comes from that one answer: src/lib/history.ts only turns it into the render model (stacked 24-h bars in position order, the 「計測できた日」 / 「連続記録」 cards, the 状態別 rows with 1日あたり = total ÷ measured days and the bar as 1日あたり ÷ targetHours). 計測なし days draw dashed (sub) over chip, with a 1 px chip gap inside the dash (EXCLUDED_INSET_PX) that keeps a detox outline off the dashes and the footnote links to /excluded-days; a measured day with no activity time and at least as much detox as idle time is outlined solid in sub with the wind glyph (detoxMs, detoxGlyphSize), so a day off the clock reads as neither an untapped nor an excluded day. Every other day stacks its detox time as a solid sub outline on top of its activity fills (dropped under 2 px), and 状態別 ends with a detox row summed over the measured days; past days link to /correction?day=…. Every slice is clamped to the room left on the 24-h track, so the 25-hour day when summer time ends is cut at the top instead of overflowing the cell, and the rounded ends go to the slices holding the stack's outer half pixel, not to a sliver too thin to see. Each day cell's aria-label (cellLabel) reads the date, then every activity's time and the detox time (9月9日(水)・仕事 9h 00m・detox 6h 00m), leaving out times that would read 0m; a dashed day adds 「・平均から除外」 before its times and an outlined detox day reads 「・detox の日」 with its time. The label uses the day's real times, so a clamped slice is still read in full. e2e/history.spec.ts seeds a week through the API (apiAs reuses the page's session cookie) and checks the unused day stays out of the average (its dash in sub), that the untapped days a detox runs through show as detox, count as measured and keep the streak, and that a day worked then spent in detox draws its detox part on top and lists detox in 状態別, asserting each of those cells' labels.

Correction (訂正)

(app)/correction.tsx is the sheet behind 「訂正」 on Home and behind a day on History (?day=, validated with daySchema; anything else means today, and the title names the day when it is not today). useCorrection owns the data: switches.listByDay for the day, activities.list for the names, the edits (moveStart ±15 min, changeActivity, mergeIntoPrevious, mergeIntoNext, and splitAt for 「ここで分割」 on any row) and 「元に戻す」 (not itself undoable). Every edit of the day's own rows sends the day as the sheet listed it (dayBaseline: the day, the stored zone, each row's id, activity, start and revision, the carried-in record's id and revision, and the first switch after the day, so a merge or cut from the next day's sheet that changes where the day's last row ends, a row changed and changed back, or a change to the carried-in record, also counts as a change), and the API refuses it once the day reads otherwise. A day listing more than DAY_ROWS_MAX (300) rows sends the rows' dayDigest (packages/shared/src/day-digest.ts) in place of the rows, and its 「元に戻す」 names the rows it expects the same way, so the request stays under the 100 KB body limit; a day over UNDO_ROWS_MAX (600) rows arms no day 「元に戻す」. 「元に戻す」 keeps the day's rows as they were before the last edit and the rows the edit left (rowsAfterEdit, from that baseline and the row the edit returned, with the row the edit reshaped without returning it, the previous row after a ±15 min move or the row a cut was made on, one revision on), and writes the former back through switches.replaceDay only while the day still holds the latter, so a switch or edit from another device in the meantime turns 元に戻す off instead of being erased; a pick on the carried-in record changes a record that reaches an earlier day, so its undo is changeActivity with the revision the pick left instead, which lands only while no other write has reached the record since (a change from another device, or a merge or cut that moved where the record ends, turns 元に戻す off, and an activity archived meanwhile turns it off with a notice). The undo lives in the Redux store per day (correctionSlice in src/store/correction.ts), armed from each edit's own mutateAsync answer, so a second tap before the controls dim, or an edit that lands after the sheet closed, still arms it, and reopening that day offers it. It is offered only while the day as listed still reads as the edit left it (offeredUndo, the same rows, next switch and zone replaceDay checks, or the picked record at the pick's revision, carried in or, after a zone change, among the day's own rows), so a tap on Home, or another device's change the list has since read, turns it off rather than leaving a press that can only be refused; sign-out, every sign-in and a different account seen in another tab clear it, and an answer that lands after any of them is ignored (each press carries the store's epoch, which every reset draws afresh). The day undo also sends the account the edit was written as (the returned row's userId, so what the cookie said rather than what the tab believed), and switches.replaceDay refuses it from any other session, since a detox-only snapshot of an empty day passes every other check. After 「ここで分割」 the sheet selects and focuses the new later part, and its undo selects the row it was made on again (on the day's own rows by its start, since replaceDay writes the day's rows back under new ids); after a merge, focus moves to the row that kept the merged span, so a keyboard user keeps their place. Every edit invalidates switches.* and stats.*, so Home and History refetch at once (a day-changed refusal also refetches settings.*, since the stored zone may be what changed elsewhere), and every control waits while a fetch or any switches.* or settings.* write is in flight (this sheet's, a tap on Home, an edit still landing from a sheet closed mid-flight, or a zone write that may move the day's window) so an undo snapshot is always settled data. One line between the rows and the footer says why nothing happened or why the controls wait (statusLine in src/lib/correction.ts, drawn in the pen file's 訂正シート・状態行 board): the last failed edit or undo in Japanese (failureMessage: a refusal's data.reason, worded without blaming another device, since a double tap or this device's own late write reads the same; a NOT_FOUND a procedure answered reads as the record having changed, while a 4xx no procedure wrote, such as an unknown route, reads as not saved), shown as soon as the answer arrives (the mutation's onError, before the re-read it waits for) and only while the sheet shows the day it was pressed on, until the next press, selection or undo, or until a later read of the day shows it moved on (useDayReads in src/hooks/use-day-reads.ts watches the query cache for each landed listByDay fetch, and afterDayRead decides); else 「反映しています…」 once a write has been in flight for WRITING_LINE_DELAY_MS (400 ms, so a write that lands at once never moves the footer), or 「オフラインです。接続が戻ると反映されます」 while TanStack's onlineManager reports the web page offline (a queued tap is paused while online too, so the line does not read a mutation's isPaused). The line and the archived notice are kept per day in the store with the undo, so a failure that lands after the sheet closed is still said when that day's sheet reopens, and the notice's row opens its panel there. The selection and focus an answer sets (a cut's later part, a merge's kept row, an undo's reselect) apply only while the sheet still shows the day the press was made on: the sheet's own state starts over when the day it shows changes. Every API call gives up after REQUEST_TIMEOUT_MS (30 s, src/lib/deadline.ts, the answer's body included, since oRPC reads it after fetch resolves; readWholeAnswer reads it as text, because native's Response is the whatwg-fetch polyfill, which garbles an ArrayBuffer body), so a request that never answers fails. failureKind sorts every failure: a timeout, a lost connection and a 5xx other than the API's own TIMEOUT are uncertain, since the write may have landed. The line then says 「一覧を読み直しています…」 while the day is read again (that refetch is not awaited, and the controls release once it settles), then asks the user to check the rows rather than to try again, or says 「一覧を読み直せませんでした。表示が古いかもしれません」 when that read fails too, until a later read lands. An uncertain edit turns off any older 「元に戻す」 (it no longer knows what the day holds), while an uncertain 「元に戻す」 stays armed (a replay writes only while the day still holds what the edit left); a refusal that can never succeed (the session ended, or a request the app should not have sent) turns it off, and a session that ended takes the user to sign-in with the way back to the open sheet. An armed 「元に戻す」 is also retired once any read of its day, the edit's own re-read included, shows that pressing it could only be refused (undoOutlived), so a change put back afterwards does not bring it back; a read in another zone, or a settings change made while its sheet is closed, leaves it armed. The pure part is src/lib/correction.ts: rows newest first with the carried-in state last, spans clipped to the day (– いま for the open state, – 24:00 past midnight), and the flags for each control decided with the same clampStart the API uses plus the day's floor and ceiling (the first row never moves before 0:00, the last never past 24:00; 「次の記録に統合」 moves the next row back to the merged row's start, so the last row cannot merge into the next day's first switch, which 「元に戻す」 could not restore; switches.mergeIntoNext answers CONFLICT to that merge as well, judging the day in the stored settings.timeZone). Every row's panel ends in 区切る時刻 (cutRange: quarter hours of the day that keep a minute from the row's start and end and stay a quarter short of now, stepped ±15 min / ±1 h, opening at the middle one) with 「ここで分割」; a row too short for any quarter hour cuts at its middle whole minute with the steps off and 「短い記録のため、真ん中で区切ります」, and one with no whole minute either says 「区切れる時刻がありません」. The time on the readout stays while splitAt still takes it (earliest – latest), so today's clock never moves it, even when a middle minute gains room for quarter hours, where the steps then land. A start moved by ±15 reopens it at the new range's middle. The panels' groups sit 20 apart. A merge or a ±15 min step that carries a record across the idle threshold says so before the press (idleNotes over the row's reshapes, drawn in the pen file's 訂正シート・無操作の注記 board): a merge that makes a counted record idle, under the merge buttons (「前の記録に統合すると、」, 「次の記録に統合すると、」 or 「統合すると、」 when both do), and each record a step moves out of or back into the totals, under 開始時刻; detox is never idle, so it earns no line. The sheet never joins two rows of one activity that a merge leaves side by side (joining could rewrite the next day, which the day's 元に戻す cannot restore); 「今日 n 回切替」 counts no switch for them (countSwitches). The carried-in state has a panel of its own: the date and time the record really started, the same 区切る時刻 with one line per way the cut changes the totals (idle time that a part under the threshold brings back, on any row, and, for the carried-in record only, a day the server counts as unused, auto_unused in stats.day for the viewed day, that the cut's own switch makes 計測できた日, so the note follows the server's day rule, detox cap included, instead of copying it; noteDayClass asks for a past day only, so a sheet left open over midnight asks once the day is past, trusts the day's own rows first, so the note is gone as soon as the cut's row lands in the list, and waits rather than guess while the answer loads or after a failed or offline-paused fetch, whose kept answer may predate the day's rows), and 活動を変える for the whole record, warned first when its activity is archived since that pick cannot be undone. An edit that can change which untapped days a detox run carries says so before it is made (untappedSheetNotes in src/lib/untapped.ts, drawn in the pen file's 訂正シート・タップのない日の注記 board): a pick between detox and an activity names those days under the 活動を変える label (on the carried-in panel too), a merge names them under the merge buttons, and one line above the footer names them for 「元に戻す」 while it is offered. The notes replay the edit on the day as listed and compare the untapped days measured before and after, up to today, with the same run rules as stats.* (switches.listByDay also answers carriedInRunStart, the day the carried-in record's detox run started, or for a carried-in activity the day it would start were it detox, so a run begun on an earlier day keeps its week and a pick to detox joins the run before it). switches.listByDay also answers carriedOutRun, the switches after the first one after the day that its detox run reaches (up to the one that ends it, within DETOX_MEASURED_DAYS_MAX days), so a run a later tap ends is not counted as running through its week; the days excluded by hand in the notes' window (excludedDays.list, asked by untappedExclusions) are left out, as classifyDay does; and the days are named one run of consecutive days at a time (「9月9日、9月16日〜9月17日」). They say nothing while auto_exclude_unused_days is off, since every day then counts, and nothing until the stored settings, the day's list and the excluded days have been read. The 12 px strip above the list dims every span but the selected row's. A detox row lists as detox with the wind glyph and no colour, and 「元に戻す」 writes it back as activityId null. The 活動を変える picker lists live activities (useActivities) plus a detox pill, as ActivityPills, so a span moves onto detox and back; ActivityChip is the coloured glyph square shared with 状態別. e2e/correction.spec.ts merges a row on yesterday into the previous and into the next record, undoing each, and cuts today's open state, then checks Home counts the new switch without a reload. On a day's own row it covers a stepped cut that selects and focuses the later part and its undo, the middle-minute cut of a 13-minute row, 区切る時刻 reopening at the middle after a ±15 move, the panel's 20 spacing on both widths, focus landing on the kept row after either merge, and Control's 2 px ink focus ring (keyboard only) and 70 % pressed look, which every other tap target shares (FOCUS_RING, FOCUS_RING_INSET for a row inside a card that clips, and pressLook in src/lib/press.ts; the pen's 「Control の押下とフォーカス」 board). On the carried-in record it covers a pick and its undo, a cut at 区切る時刻 and its undo, the notes under 「ここで分割」 (on a carried-in detox, no 計測 line on a day inside its first week and one on a day past it, and a cut past the week that measures the cut day only, so the next untapped day still offers the line), the warning before a pick away from an archived activity, a pick or undo refused once another device changed the record, and an undo refused once the previous activity was archived. A weekend detox switched to an activity names the untapped days that stop being measured, and so does 「元に戻す」 until it is pressed; an activity that ends a detox names the untapped days a pick to detox or 前の記録に統合 would measure. On the day's own rows it covers an edit on a list another device has since changed (and the status line naming that refusal until the next selection), and an undo refused once another device added a switch to the day. It also holds a merge's answer while a refetch shows the merged day and checks that 「元に戻す」 still restores the day the merge was pressed on, shows the offline line for an edit made offline until it lands, and lets an answer that never arrives time out. A merge that lands after its sheet closed is undone from the reopened day, one that lands after sign-out leaves the next account nothing to undo, a sign-in as someone else in another tab leaves the first tab neither the previous account's rows nor its undo, a double tap on 「ここで分割」 cuts once and keeps the second press's refusal on screen, a refusal shows even while the re-read after it waits for the network and does not follow today's sheet past midnight, and a day undo refused as archived shows one line. 「元に戻す」 turns off once a tap on Home changed the day after the edit, an edit refused or an undo refused as archived after its sheet closed is said when that day reopens (a pick on the record its notice opened keeps the panel open while it is written, and a notice that lands while another row is selected shows once its record is tapped), and a cut whose answer lands after midnight selects nothing on the new day. The uncertain paths are covered too (a timeout, a lost answer, a 5xx after the write landed, a re-read that fails as well), with the sign-in round trip after a session ends mid-edit, 「元に戻す」 staying off once the edit's own re-read or a later read saw the day change, even after that change was put back, and staying armed across a settings change made while its sheet is closed.

Settings (設定)

(app)/(tabs)/settings.tsx shows the server's settings row through useSettings (one settings.get query for the whole app, gated on the session so the root theme sync can share it, SETTINGS_DEFAULTS until it answers) and writes through useUpdateSettings (settings.update written into the cache first, rolled back on error unless another account's row is cached by then (rolledBackSettings), then settings.*, stats.* and switches.* refetch (SETTINGS_REFETCH_ROUTERS), so a zone change re-windows every day list). 外観 is a Segmented (auto|light|dark; useThemeSync resolves it with resolveTheme and pushes it into Uniwind) and 秒針を表示 a Toggle (role="switch", read by the Dial). タイムゾーン (TimeZoneRow, from useAccountZone) names the account's zone and this device's as cities (zoneRow through zoneLabel: a dash until the session account's own row is read, この端末と同じ when they match, an older alias such as Asia/Calcutta read as its current name); when another device set the account's zone it offers 「この端末に合わせる」, which writes this device's zone for the account it was tapped for (forUserId), and a failed zone write made by hand (the take-back, or a pick on the sheet, even after it closed: useFailedZoneWrite reads the mutation cache and skips the automatic sync's writes by their meta) swaps the sub line for an alert, for that account and zone only, until the next zone write or a read that already matches. The row itself opens (app)/time-zone.tsx, the タイムゾーン sheet: a search over every zone in CATALOG_ZONES (generated from tzdata's zone.tab by scripts/generate-time-zones.mts, since Hermes has no Intl.supportedValuesOf) by Japanese city (CITIES), country or id, folded by foldForSearch; before a search it lists this device's zone, the account's zone as the sheet opened, then the 71 named cities by offset (zoneChoices in src/lib/time-zones.ts). A pick writes at once, dims nothing but holds the rows while any settings write is out, and says how it went in a line under the search (pickStatus, spoken on iOS through useIosAnnouncement). Every zone write remembers the device's zone as this device's sync when it lands (useUpdateSettings' onSuccess), so a pick is not overwritten by useTimeZoneSync on the next start. The button waits while any settings write is in flight. The 活動項目 row opens (app)/activity-editor.tsx: useActivityEditor builds the rows with editorRows (live activities in position order; the current state's activity and the last one cannot be archived) and maps every control to activities.* (update takes the whole input, so each edit resends the row; the colour dot walks cycleColor from @switch-time/shared, the icon cycleIcon, ▲▼ send reorder the full permutation from reorderIds, 「+ 項目を追加」 creates 新しい項目 in spareColor, 🗑 archives; the name and 1日の目安 commit on blur). The archived activities follow under アーカイブ済み (archivedRows, the most recently archived first), and 戻す brings one back through activities.unarchive, so a record merged away on it can be rebuilt with the usual edits. A refused add, ▲▼, 🗑 or 戻す shows an alert line above 「+ 項目を追加」 in the correction sheet's words (failureMessage: busy, the 100-activity cap, a ▲▼ on an order another device changed, a write that may have landed) until the next press. The 未使用日の自動除外 row opens (app)/excluded-days.tsx: the rule in the sheet's hint (an untapped day leaves the averages; a detox counts for DETOX_MEASURED_DAYS_MAX days after the day it started, and a re-tap past them counts its own day and starts another run), the 自動で除外する toggle, the 無操作とみなす時間 picker (6/8/10/12/16 h into idleThresholdMinutes, 16 h the default) and the 除外中の日 list (useExcludedDays: excludedDays.list over the last 365 days, 戻す = excludedDays.include, which also refetches stats). The pure parts are in src/lib/settings.ts. e2e/settings.spec.ts cycles 家事's colour and reloads, archives 休息 and brings it back with 戻す, says the cap when 「+ 項目を追加」 or 戻す meets 100 live activities, the in-use line for a refused 🗑, the busy line for a refused ▲▼ and the uncertain line when an add times out, clears the line on the next press, flips the theme and checks the tab chrome's colour, and returns a seeded exclusion (reading the hint's re-tap sentence on the way). It also takes the zone back from another device (and says so when that write fails, clears the line once a retry lands, waits while a 外観 write is in flight, and drops the line when another account signs in), picks ニューヨーク on the タイムゾーン sheet and reloads, narrows the sheet's list by a search and clears one that matches nothing, shows a failed pick under the search and, after the sheet closed, on 設定's row, holds the other rows while a pick is out, keeps a pick that landed after the sheet closed on a device that had not synced its own zone, keeps Tab inside the sheet and focus back on the row, ignores an IME's Escape, follows a device zone change made while the app stays open, and, after a sign-in as another account through the page's own requests, writes the device's zone to that account even when the previous account's zone write had failed.

API (apps/api)

pnpm --filter api dev        # tsx watch, http://localhost:4100 (env is Zod-validated at boot: src/db/env.ts for the database, src/env.ts for the server)
curl localhost:4100/api/healthz
pnpm --filter api build      # tsdown → dist/server.js (workspace packages inlined, npm deps external)
docker build -f apps/api/Dockerfile -t switch-time-api:prod .   # build context = repo root
# `:prod`, not the bare name: Compose tags its dev build `switch-time-api:latest`, and `docker compose up` without --build
# would then start whichever image was built last (this production one exits on the http:// APP_ORIGIN in .env).
# Joins the Compose network to reach its Postgres as `db` (the host port is loopback-only, unreachable from a container on Docker Engine).
# --env-file supplies BETTER_AUTH_SECRET; NODE_ENV=development because the image defaults to production, which refuses a database without DATABASE_CA_CERT.
# PORT=4100 matches -p: the image defaults to App Platform's 8080, and an .env copied before 4100 still says 4000.
docker run --rm --network switch-time_default -p 4100:4100 --env-file .env -e NODE_ENV=development -e PORT=4100 -e DATABASE_CA_CERT= -e DATABASE_URL=postgres://switchtime:switchtime@db:5432/switchtime switch-time-api:prod

The API owns the /api prefix (/api/healthz, /api/rpc/*, later /api/auth/*); App Platform ingress routes /api to it without stripping the prefix. CORS is enabled only outside production, for the Expo web dev server at APP_ORIGIN (default http://localhost:4101). apps/app imports only type { AppRouterClient } from @switch-time/api, so no server code reaches the Metro bundle.

Domain (activities / switches / stats)

The clock always holds exactly one state: no end times are stored, the latest switches row is the current state and a segment ends when the next one starts. A row with activity_id null is detox (the detox row under the switch grid, 0 on the keyboard): the time until the next row is drawn but recorded to no activity (detoxMs per day in stats.*, in neither the totals nor idleMs). Tables live in apps/api/src/db/schema/app.ts (activities, switches, excluded_days, user_settings); sign-up seeds the 6 default activities and a settings row (apps/api/src/db/seed-user.ts, Better Auth user.create.after).

  • Migrations: pnpm --filter api db:generate --name <name> after editing the schema, pnpm --filter api db:migrate to apply locally (CI and App Platform run dist/db/migrate.js). Dropping a NOT NULL (20260916012834_detox) is forward-only in practice: once a detox row exists, an older API or web bundle does not expect a null activity_id. api and web ship in one App Platform deployment after the db-migrate job, so the only mixed-version window is a browser tab still running the previous web bundle: it shows Home's bare frame while the current state is detox, until it reloads. Likewise, a tab on an older bundle works out the 計測 line under 「ここで分割」 itself until it reloads: one from before the API counted a past day a detox runs through as measured can still say that a cut of a carried-in detox makes such a day 計測できた日, and one from before the detox cap (DETOX_MEASURED_DAYS_MAX) says nothing on the days past a detox's first week, where the cut does make the day measured. Roll back with a forward fix or after delete from switches where activity_id is null, which hands each row's time to the span before it. An account whose only row is detox (the first-launch screen offers it as a first tap) has no span before it and returns to the first-launch screen instead. 20260924193844_unique_switch_start moves switches of one account that started at the same instant 1 ms apart and makes switches_user_started_idx unique, holding writes to switches until it commits, and reads too from its DROP INDEX on (a short stop while the index is rebuilt); the original instants are not kept, so the data change is forward-only. To undo the schema part, DROP INDEX switches_user_started_idx; CREATE INDEX switches_user_started_idx ON switches (user_id, started_at DESC NULLS LAST);, then delete this migration's row from drizzle.__drizzle_migrations, or the next deploy skips it and the unique index never comes back. Between the db-migrate job and the new API going live, the previous API has no 1 ms step, so a tap landing in the same millisecond as the running switch answers 500 instead of recording a tie. 20260925035205_switch_activity_owner replaces the key on switches.activity_id with (activity_id, user_id) → activities (id, user_id) (switches_activity_user_fkey, on the new activities_id_user_id_key), so the database itself refuses a switch that names another account's activity; detox (null) passes. It holds both tables, reads included, until it commits, waits at most 5 s for each of its two locks (SET LOCAL lock_timeout) and gives each statement at most 10 s (SET LOCAL statement_timeout), otherwise it fails the deployment rather than holding every tap behind a long transaction or a slow disk; deploy again. The check of existing rows fails the job too if a switch names another account's activity; every write path has checked ownership in the API, so none should, and select count(*) from switches s join activities a on a.id = s.activity_id where a.user_id <> s.user_id finds any. To undo, in this order: ALTER TABLE switches DROP CONSTRAINT switches_activity_user_fkey; ALTER TABLE activities DROP CONSTRAINT activities_id_user_id_key; ALTER TABLE switches ADD CONSTRAINT switches_activity_id_activities_id_fkey FOREIGN KEY (activity_id) REFERENCES activities (id) ON DELETE CASCADE;, then delete the migration's row from drizzle.__drizzle_migrations. 20260925080445_detox_run_start adds switches.starts_run (default false, so every existing run reads as before) and the partial index switches_run_boundary_idx (user_id, started_at DESC where activity_id is not null or starts_run) that switches.current finds the run's start with; it holds switches, reads included, until it commits, and waits at most 5 s for its lock and 10 s per statement, like the migration before it. Until the new API is live, the previous one keeps a detox re-tap a no-op. To undo, DROP INDEX switches_run_boundary_idx; ALTER TABLE switches DROP COLUMN starts_run; and delete the migration's row; each renewed run then folds back into the run before it. 20260927054548_idle_threshold_16h sets user_settings.idle_threshold_minutes default to 960 and moves rows still at 720 to 960 (a chosen 6 h, 8 h, or 10 h stays). ALTER COLUMN SET DEFAULT takes a brief exclusive lock; it waits at most 5 s for the lock and 10 s per statement, otherwise the deploy fails and the previous default keeps serving. Setting every 960 back to 720 would also reset an account that chose 16 h after the migration.
  • Every day boundary is computed in user_settings.time_zone (dayBounds / localDay in packages/shared/src/time.ts); the app writes the device's zone into it through settings.update when the settings row loads (useTimeZoneSync in the root layout), so a new account leaves the seeded Asia/Tokyo on first launch. timeZoneSchema takes IANA names only (isTimeZone): an offset such as +09:00, which Intl would also accept, is refused and the stored zone stays. daySchema and monthSchema take days from EARLIEST_DAY (1970-01-01) to LATEST_DAY (9999-11-30), and localDay pads the year to four digits, so days keep sorting as strings. A device writes only when its own zone changed since it last synced, so two devices in different zones do not flip the stored one back and forth. The price is that the last device to write keeps the zone: a one-off sign-in from another zone moves every day boundary until the main device takes it back with 「この端末に合わせる」 on 設定 (or its own zone changes). Any other zone can be picked on 設定's タイムゾーン sheet; the device that picked it counts it as its own sync.
  • Idle rule (無操作とみなす時間): a segment longer than idle_threshold_minutes (default 960 = 16 h) is shown dashed and left out of totals (idleMs per day in stats.*). 12 h treated a recorded 12 h 34 m sleep as idle, so the 24 h bar drew it as an empty dash. Accounts still on 720 move to 960 with the migration that raises the default.
  • 計測なし / 除外: a past day without a tap is auto_unused while auto_exclude_unused_days is on (today is only "in progress"), unless a detox record runs through it: detox is the deliberate step off the clock, so the days it covers are measured and keep the streak (detoxCarriedDays), through DETOX_MEASURED_DAYS_MAX (7) calendar days after the day its run started; from the eighth day on, an untapped day is an ordinary unused day, so a detox left on in an abandoned app stops counting. stats.* works this out from the taps on every read, so the cap applies to past weeks and months too. A run is consecutive detox records, so a cut (「ここで分割」) never renews the week, while switching to an activity and back does. Tapping detox while it runs changes nothing inside the week; past it, while auto_exclude_unused_days is on, switchTo inserts a detox row with starts_run set, which starts a new run from its own day (a tap of its own, so that day is measured too); with the rule off it answers the running record unchanged, since no day is left out. Edits keep starts_run and 「元に戻す」 writes it back (replaceDay rows take an optional startsRun), so only a re-tap renews. An activity left running over midnight measures nothing by itself (it is usually a forgotten tap). Changing a carried-in detox record to an activity therefore turns those days back into auto_unused. Manual exclusions (excludedDays.exclude) are stored, auto ones are computed per request: stats.* (rangeStats in apps/api/src/rpc/stats.ts) builds the window's timeline and the day classes from one ordered read of every tap, so an edit from another device landing mid-request cannot count one record as both an activity and detox. The streak counts measured days back from today (from yesterday until today has a tap) and skips manual exclusions (packages/shared/src/stats.ts). The merges hand starts_run to the row they keep with mergedIntoPreviousMark / mergedIntoNextMark from the same file, which the correction sheet's untapped-day notes replay.
  • Corrections: switches.moveStart moves ±15 min, clamped ≥1 min from its neighbours and from now; replaceDay rewrites one day and backs 「元に戻す」: it takes the stored timeZone, exactly one of the day's current rows as expected (when it writes back at most DAY_ROWS_MAX rows) or their dayDigest as expectedDigest, an optional range (from inclusive, to exclusive, inside the day) that limits the rewrite to the rows starting in it, so a busy day sends only the rows its edit changed and has its 「元に戻す」 at any size (a whole-day write holds at most UNDO_ROWS_MAX, 600 rows), and optionally carriedOutId (the first switch after the day, which the day's last row runs into), and answers CONFLICT with data.reason: 'day-changed' when any differs. Rows compare by id, activity, start and, when the client names one, revision. Every other switches.* edit takes an optional baseline (the day, zone, rows, carriedIn and carriedOutId the client listed; above DAY_ROWS_MAX the client sends the rows' digest instead of rows, and a baseline with neither is refused) and refuses the same way on a mismatch, and refuses a start that would leave the baseline's day. With a baseline, every edit but splitAt must name one of the baseline's own rows (BAD_REQUEST "row is not one of the day's own rows" otherwise): the carried-in record reaches an earlier day that the day's 「元に戻す」 cannot restore. splitAt cuts any row running through the day, the carried-in record included, since its new row is always the day's own. Every write to a user's switches, the activity writes that pick or check the live set (activities.create, reorder, archive and unarchive) and a settings.update that changes timeZone run in one transaction under the user's advisory lock (withUserLock), reading what they decide on inside it, so two devices' writes run one after the other. One account's writes first queue in the API process in arrival order, so only the one at the head holds a pool connection; the account may have TIMELINE_WRITES_PER_USER (4) in flight per process, the running one included (TOO_MANY_REQUESTS above that, apps/api/src/rpc/base.ts). Every request gets a deadline of REQUEST_DEADLINE_MS (25 s, apps/api/src/db/client.ts) from its arrival, below the app's 30 s: a write still queued then is refused as busy, and one still running is cut off by destroying its connection, answering TIMEOUT (nothing was saved; sent as status 500 rather than its default 408, which a browser may resend by itself) or, when the cut-off came during a write's COMMIT, GATEWAY_TIMEOUT (it may have been saved; a read always answers TIMEOUT). activities.update, excludedDays.exclude / include, a settings change that leaves the zone alone and the multi-query reads (switches.current, switches.listByDay, stats.*) run under the same deadline on one connection each (inTransaction), and the reads take one of an account's 4 places in flight (a fifth is refused as busy, so one account cannot hold the pool). Every other statement through db, Better Auth's session lookup included, runs on a connection of its own under the request's deadline (requestDeadline), and the request answers TIMEOUT when one reaches it. db.transaction is inTransaction, so the transactions Better Auth's adapter opens (sign-up) stop at the deadline too, and /api/auth/* runs under its own request clock. A write that Postgres itself cuts off (lock_timeout 55P03, statement_timeout 57014) answers TIMEOUT too. A request waits at most POOL_CONNECTION_TIMEOUT_MS (10 s) for a pool connection, and Postgres ends a session left idle inside a transaction for 15 s, which frees the lock of a write cut off on a half-open connection. A unique (user_id, started_at) index (switches_user_started_idx, started_at DESC NULLS FIRST to match the timeline queries' sort order) keeps two switches of one account from starting at the same instant: switchTo starts 1 ms after the running record when a tap lands in the same millisecond. splitAt cuts a record at a given instant into a new row of the same activity, keeping a minute from the record's start and from the next switch or now, and, with a baseline, landing inside the baseline's day (CONFLICT with cannot-split otherwise). Every switch row carries a revision that each write changing its activity or where it ends moves on by one (changeActivity, moveStart on it or on the next row, the merges, splitAt, a tap that ends it, and a replaceDay of the day it runs into); changeActivity takes an optional revision and writes only while the row is still at it (CONFLICT when another write reached it, NOT_FOUND when it is gone). switchTo and changeActivity refuse an archived activity with BAD_REQUEST and data.reason: 'archived'; replaceDay checks only that each activity is the user's, so a day holding an archived activity's records can still be restored, and splitAt keeps the record's own activity. The one exception is the record that becomes the current state: replaceDay with nothing after the day, and mergeIntoPrevious on the running record, refuse an archived activity there with the same reason, since an archived activity never runs. activities.reorder must receive a permutation of the active ids: repeated ids are an input BAD_REQUEST, and a set that is not the live one (read inside the lock, so an activity added or archived meanwhile) is CONFLICT with list-changed. The current state's activity and the last active one cannot be archived (CONFLICT with in-use); an archive of an activity already archived answers it unchanged, as unarchive does for a live one. activities.create and activities.unarchive refuse to take the live set past LIVE_ACTIVITIES_MAX (100, the most ids reorder accepts) with CONFLICT and too-many-activities, counted under the lock. activities.unarchive puts an archived activity back at the end of the live order (an archived row keeps its old position, which a reorder may have given to a live one, and activities_user_position_idx is unique among live rows), answers an already-live one unchanged so a retried request succeeds, and answers NOT_FOUND for another account's id; the 活動項目 sheet's 戻す calls it. Every CONFLICT from switches.* and activities.*, the archived refusals and TOO_MANY_REQUESTS carry data.reason, one of REFUSAL in packages/shared (day-changed, record-changed, archived, no-room, no-neighbour, next-on-later-day, cannot-split, in-use, too-many-activities, list-changed, and busy for TOO_MANY_REQUESTS), which the correction sheet and the 活動項目 sheet map to Japanese (failureMessage); the English message is for logs. The rest carry none: the input BAD_REQUESTs (a row outside the baseline, replaceDay rows outside the day, in the future or out of order) and NOT_FOUND; the sheet reads NOT_FOUND as the record having changed and shows its generic retry line for the others.

Deploy (DigitalOcean App Platform)

One app, region sgp (no Tokyo region; ≈ 75–80 ms from Tokyo), described by .do/app.yaml:

Component Kind Source Route
api Docker service apps/api/Dockerfile, context / /api (prefix preserved)
db-migrate PRE_DEPLOY job same image, node dist/db/migrate.js —
web static site apps/app/Dockerfile, context / → /repo/apps/app/dist / (catch-all index.html)
switch-time-pg Managed PostgreSQL attached by cluster_name —

/ and /api share one origin, so the Better Auth cookie is first-party and CORS stays off. The web export is a single-page bundle (web.output: "single") so deep links such as /history resolve through the catch-all on any static host. doctl apps spec validate --schema-only .do/app.yaml checks the spec without a token.

First deploy (needs the team's DigitalOcean token):

  1. brew install doctl && doctl auth init && doctl account get
  2. Database: doctl databases options versions --engine pg, then doctl databases create switch-time-pg --engine pg --version <newest> --region sgp1 --size db-s-1vcpu-2gb --num-nodes 1, doctl databases db create <cluster-id> switchtime, doctl databases user create <cluster-id> switchtime_app. That user owns nothing and PostgreSQL 15+ no longer lets everyone create in public, so connect as doadmin to the switchtime database (doctl databases connection <cluster-id> --format URI, with defaultdb swapped for switchtime) and run GRANT CREATE ON DATABASE switchtime TO switchtime_app; GRANT CREATE ON SCHEMA public TO switchtime_app;; otherwise the db-migrate job fails with permission denied for database switchtime on CREATE SCHEMA "drizzle". Pin compose.yaml to the same major. While the cluster has trusted sources (production's only one is the app, doctl databases firewalls list <cluster-id>), a psql from a laptop, this doadmin session included, times out without saying why: first allow your address with doctl databases firewalls append <cluster-id> --rule ip_addr:<your-ip>, and remove it once done with doctl databases firewalls remove <cluster-id> --uuid <rule-uuid> (the UUID is in the list output). Keep the app:<app-id> rule: without it db-migrate and the API cannot reach the database.
  3. Authorise the GitHub repository once in the DigitalOcean console (Apps → Create App → GitHub), then create the app from a temporary copy of the spec that carries the secret, so the first deployment does not boot without one: cp .do/app.yaml /tmp/app.yaml, put value: <openssl rand -base64 32> under BETTER_AUTH_SECRET in the copy, doctl apps create --spec /tmp/app.yaml --wait, rm /tmp/app.yaml. .do/app.yaml carries the EV[1:…] value that doctl apps spec get <app-id> returned after that create: encrypted by App Platform, safe to commit, and required so doctl apps update --spec keeps the secret; never the plaintext.
  4. Verify: the deployment log shows db-migrate running the Drizzle migrations, curl https://<app>.ondigitalocean.app/api/healthz returns {"status":"ok"}, /api/auth/ok answers through the ingress, and / renders the web build. If db-migrate cannot reach the database, the cluster has trusted sources enabled without the app: doctl databases firewalls append <cluster-id> --rule app:<app-id>.

After that every push to main builds api and web, runs the migration job and deploys (deploy_on_push: true); spec edits are applied with doctl apps update <app-id> --spec .do/app.yaml. Alerts fire on DEPLOYMENT_FAILED and DOMAIN_FAILED. The web static site is built from apps/app/Dockerfile because the Node.js buildpack runs pnpm install --prod=false, which pnpm 12 rejects (pnpm/pnpm#14553); CI builds that image as well, so a broken web Dockerfile fails the docker check before it can reach a deployment.

Conventions

  • React Compiler is on. apps/app sets experiments.reactCompiler: true explicitly (the SDK 57 template ships it; the SDK itself defaults to off). Lint uses eslint-plugin-react-hooks@7 (compiler rules included) and the "React Compiler Setup" of @laststance/react-next-eslint-plugin, so do not hand-write useMemo/useCallback/React.memo.
  • Design tokens come from design-system/theme.json. Change the JSON first, then its copies (design-system/styles.css, apps/app/src/global.css, packages/shared). apps/app/src/lib/tokens.test.ts fails when a colour in styles.css or global.css drifts from the JSON, or when sub falls below WCAG AA (4.5:1) on a surface or chip; packages/shared/src/activity-palette.test.ts fails when the palette drifts.
  • Dependencies are pinned and minimumReleaseAge: 1440 refuses releases younger than 24h. New install scripts must be allow-listed in pnpm-workspace.yaml#allowBuilds.
  • Tests: test over it, AAA comments, hard-coded expected values, names describe observable behaviour.

CI

Separate GitHub Actions workflows (Lint, TypeCheck, Format, Test, Build, Fallow, Security, Scorecard) mirror pnpm check; all actions are pinned to commit SHAs and run with read-only tokens. Security = CodeQL + Dependency Review + pnpm audit --prod. Build also runs docker build for both App Platform images, apps/api/Dockerfile and apps/app/Dockerfile, from the repository root (never pushed). Dependabot opens one grouped npm PR and one grouped Actions PR weekly (Monday 09:00 JST, two-day cooldown to clear minimumReleaseAge). A ruleset on main requires a pull request, the build, docker, lint, typecheck, format, test, dupes, dead-code and health checks, and blocks force-pushes and deletion.

About

Switch Time — a clock that always holds exactly one state (家事/仕事/休息/睡眠/食事/娯楽). iOS, Android, macOS menubar, Web.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages