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.
| 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 |
- Node.js
24.20.0(.node-version; use fnm/nodenv/Volta) - pnpm
12.3.4— pinned inpackageManager. pnpm 10+ downloads and runs the pinned version by default (pmOnFail: download); if yours does not, install it explicitly withnpm install -g pnpm@12.3.4or runcorepack enable. CI installs it throughpnpm/setupin.github/actions/prepare. - Docker with Compose v2.24+ (
compose.yamlusesenv_file: required: false) — only for the local backend below
pnpm install --frozen-lockfile
pnpm check| 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.
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:8080
pnpm db:psql # psql into the switchtime database
pnpm db:reset # docker compose down -v: drop the volume, next `up` starts from an empty databasecompose.yaml builds the dev target of apps/api/Dockerfile and bind-mounts apps/api/src, so editing a file restarts the API inside the container. 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.
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_URLdrizzle-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.
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). 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.
- Auth tables come from the CLI, never by hand:
npx auth@1.7.3 generate --config src/auth.ts --output src/db/schema/auth.ts -y(CLI pinned to the runtime version) (run fromapps/api), thenpnpm --filter api db:generatefor the SQL. - oRPC procedures read the session from the request headers (
src/rpc/router.ts):authedprocedures throwUNAUTHORIZEDwithout one;mereturns the current user. - Dev cookies:
localhost:8081→localhost:8080is same-site, so the defaults (sameSite: lax) work; the client sendscredentials: 'include'. Production is same-origin (MVP-09).
pnpm --filter app dev # expo start (press i / a / w, or scan the QR code)
pnpm --filter app web # expo start --web → http://localhost:8081
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-doctorScaffolded 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.
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.
src/lib/orpc.tsbuilds the typed oRPC client fromAppRouterClient(a type-only import from@switch-time/api, so Metro never bundles server code) and exposesorpc.<procedure>.queryOptions()for TanStack Query. Server data lives in TanStack Query only; it is never copied into Redux.EXPO_PUBLIC_API_ORIGINselects the API origin: unset meanshttp://localhost:8080in 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:8080 pnpm --filter app dev. Thathttp://origin is for development only: a native release build refuses to start unless the origin ishttps://, because the SecureStore session rides on every request as aCookieheader.src/storeholds client-only state:clock(ticks every second while the app is active, pauses in background). Components useuseAppSelector/useAppDispatchfrom@/store; the root layout runsuseClock,useThemeSync(the stored theme fromuseSettings, resolved byresolveThemeinsrc/lib/theme.tsagainst the clock) anduseTimeZoneSync(the device's zone written intosettings.timeZoneonce the row has loaded). Sheets are routes and the correction day rides on?day=, so there is no UI slice, and the user's preferences are the server'ssettingsrow, never mirrored./debug(dev only) renders thepingandmequeries and the clock.pnpm --filter app testruns the Vitest unit tests insrc/**/*.test.ts.
src/lib/auth-client.ts:createAuthClientfrombetter-auth/react; on native the Expo plugin keeps the session inexpo-secure-storeandsrc/lib/orpc.tsreplays it as aCookieheader, on web the first-party cookie does the work.- Route groups:
(auth)/sign-in,(auth)/sign-up(Zod schemassignInSchema/signUpSchemafrom@switch-time/shared, first issue per field inline, Better Auth's message above the form) and(app)/…guarded in(app)/_layout.tsx: anonymous visitors are redirected to/sign-in?next=<path>and return there after signing in.useSignOutends the session, clears the TanStack cache, dispatchesresetAppand shows sign-in. - Playwright (web):
pnpm --filter app test:e2eexports the site exactly as the production image does, with noEXPO_PUBLIC_API_ORIGIN(--clear, likebuild:web: Metro's transform cache is not keyed onEXPO_PUBLIC_*values, so an export after one with a different origin would ship the stale origin), then serves it on :8081 with/apipiped to the API bundle on :8080 (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 thee2ejob with a Postgres service.
(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.
(app)/(tabs)/index.tsx gates on switches.current: the bare frame while it loads, FirstLaunch while it is null, otherwise the hero (NowPanel with the react-native-svg Dial, elapsed from the clock slice via formatElapsed), the SwitchButton row and the 24-h TodayFlow bar. Server state comes through hooks: useActivities (live rows only), useCurrentActivity (also colours the rail badge), useSwitchTo (optimistic switches.current in onMutate, rollback on error, invalidates switches.current / switches.listByDay / stats.* on settle) 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 (useWebKeydown + hotkeyIndex) and 「訂正」 opens the correction sheet over Home. e2e/home.spec.ts covers the first-launch hand-off and the restart of the counter.
(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 over chip and the footnote links to /excluded-days; past days link to /correction?day=…. 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.
(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 four edits (moveStart ±15 min, changeActivity, mergeIntoPrevious, splitInHalf) and 「元に戻す」, which keeps the day's rows as they were before the last edit and writes them back through switches.replaceDay (undo is not itself undoable). Every edit invalidates switches.* and stats.*, so Home and History refetch at once, and every control waits while a fetch or an edit is in flight so an undo snapshot is always settled data. The pure part is src/lib/correction.ts: rows newest first with the carried-in state last and read-only, 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; a split needs two minutes of room). The 12 px strip above the list dims every span but the selected row's. The 活動を変える picker lists live activities only (useActivities), as ActivityPills; ActivityChip is the coloured glyph square shared with 状態別. e2e/correction.spec.ts merges and undoes a row on yesterday and splits today's open state, then checks Home counts the new switch without a reload.
(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, then settings.* and stats.* refetch). 外観 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). 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 未使用日の自動除外 row opens (app)/excluded-days.tsx: the 自動で除外する toggle, the 無操作とみなす時間 picker (6/8/10/12 h into idleThresholdMinutes) 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, flips the theme and checks the tab chrome's colour, and returns a seeded exclusion.
pnpm --filter api dev # tsx watch, http://localhost:8080 (env is Zod-validated at boot: src/db/env.ts for the database, src/env.ts for the server)
curl localhost:8080/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 . # build context = repo root
# 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.
docker run --rm --network switch-time_default -p 8080:8080 --env-file .env -e NODE_ENV=development -e DATABASE_CA_CERT= -e DATABASE_URL=postgres://switchtime:switchtime@db:5432/switchtime switch-time-apiThe 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:8081). apps/app imports only type { AppRouterClient } from @switch-time/api, so no server code reaches the Metro bundle.
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. 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:migrateto apply locally (CI and App Platform rundist/db/migrate.js). - Every day boundary is computed in
user_settings.time_zone(dayBounds/localDayinpackages/shared/src/time.ts); the app writes the device's zone into it throughsettings.updatewhen the settings row loads (useTimeZoneSyncin the root layout), so a new account leaves the seeded Asia/Tokyo on first launch. - Idle rule (無操作とみなす時間): a segment longer than
idle_threshold_minutes(default 720 = 12 h, above the 8 h work / 7 h sleep targets) is shown but left out of totals (idleMsper day instats.*). - 計測なし / 除外: a past day without a tap is
auto_unusedwhileauto_exclude_unused_daysis on (today is only "in progress"); manual exclusions (excludedDays.exclude) are stored, auto ones are computed per request. The streak counts measured days back from today (from yesterday until today has a tap) and skips manual exclusions (packages/shared/src/stats.ts). - Corrections:
switches.moveStartmoves ±15 min, clamped ≥1 min from its neighbours and from now;replaceDayrewrites one day in a transaction and backs 「元に戻す」 (the client keeps the previous rows).activities.reordermust receive a permutation of the active ids; the current state's activity and the last active one cannot be archived.
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):
brew install doctl && doctl auth init && doctl account get- Database:
doctl databases options versions --engine pg, thendoctl 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 inpublic, so connect asdoadminto theswitchtimedatabase (doctl databases connection <cluster-id> --format URI, withdefaultdbswapped forswitchtime) and runGRANT CREATE ON DATABASE switchtime TO switchtime_app; GRANT CREATE ON SCHEMA public TO switchtime_app;; otherwise thedb-migratejob fails withpermission denied for database switchtimeonCREATE SCHEMA "drizzle". Pincompose.yamlto the same major. - 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, putvalue: <openssl rand -base64 32>underBETTER_AUTH_SECRETin the copy,doctl apps create --spec /tmp/app.yaml --wait,rm /tmp/app.yaml..do/app.yamlcarries theEV[1:…]value thatdoctl apps spec get <app-id>returned after that create: encrypted by App Platform, safe to commit, and required sodoctl apps update --speckeeps the secret; never the plaintext. - Verify: the deployment log shows
db-migraterunning the Drizzle migrations,curl https://<app>.ondigitalocean.app/api/healthzreturns{"status":"ok"},/api/auth/okanswers through the ingress, and/renders the web build. Ifdb-migratecannot 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.
- React Compiler is on.
apps/appsetsexperiments.reactCompiler: trueexplicitly (the SDK 57 template ships it; the SDK itself defaults to off). Lint useseslint-plugin-react-hooks@7(compiler rules included) and the "React Compiler Setup" of@laststance/react-next-eslint-plugin, so do not hand-writeuseMemo/useCallback/React.memo. - Design tokens come from
design-system/theme.json. Change the JSON first, thenpackages/shared;packages/shared/src/activity-palette.test.tsfails when they drift. - Dependencies are pinned and
minimumReleaseAge: 1440refuses releases younger than 24h. New install scripts must be allow-listed inpnpm-workspace.yaml#allowBuilds. - Tests:
testoverit, AAA comments, hard-coded expected values, names describe observable behaviour.
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.