Skip to content

Latest commit

 

History

194 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 webdist/ 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 24.20.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 or run corepack enable. 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: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 database

compose.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.

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). 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 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:8081localhost:8080 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 (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-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. 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:8080 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:8080 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). 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 written into settings.timeZone once 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'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, 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. useSignOut ends the session, clears the TanStack cache, dispatches resetApp and shows sign-in.
  • 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 :8081 with /api piped 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 the e2e job with a Postgres service.

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, 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.

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 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.

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 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.

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, 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.

API (apps/api)

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-api

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:8081). 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. 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).
  • 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.
  • 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 (idleMs per day in stats.*).
  • 計測なし / 除外: a past day without a tap is auto_unused while auto_exclude_unused_days is 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.moveStart moves ±15 min, clamped ≥1 min from its neighbours and from now; replaceDay rewrites one day in a transaction and backs 「元に戻す」 (the client keeps the previous rows). activities.reorder must receive a permutation of the active ids; the current state's activity and the last active one cannot be archived.

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.
  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 packages/shared; packages/shared/src/activity-palette.test.ts fails when they drift.
  • 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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages