Files
sofa/AGENTS.md
T
4fc6b2f8d1 feat: add filtering and sorting across library, explore, and upcoming (#21)
* feat: add filtering and sorting across library, explore, and upcoming

Add comprehensive filtering and sorting to all list views on both web and mobile.

Library (new dedicated page):
- New /library route (web) with URL search param persistence
- New Library tab (5th tab, mobile) with FlashList grid
- Filters: status (multi-select), type, genre (library-only), user rating range,
  release year (decade presets + custom), content rating, available to stream
- Sort by: title, date added, release date, popularity, user rating, TMDB rating
- Text search within collection
- Filter popover (web) / bottom sheet with Apply (mobile)
- Remove old dashboard.library endpoint in favor of library.list

Explore/Discover:
- New Discover section on Explore page with inline filter controls
- TMDB filters: genre (now optional), year range, min rating, sort order,
  original language, streaming provider
- Watch provider list endpoint (WATCH_REGION env var, default "US")
- Default to popular content when no filters selected

Upcoming:
- Type filter (All/Movies/TV Shows) and status filter (All/Watching/Watchlist)
- Toggle chips on both web (URL params) and mobile
- Backend: mediaType and statusFilter params on upcoming endpoint

Navigation:
- Library added to web nav bar (desktop + mobile tab bar)
- Library added as 5th tab on mobile (between Home and Explore)
- Dashboard library section simplified to compact preview with "See all" link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add user streaming platform subscriptions and redesign filter UI

Replace the denormalized availabilityOffers table with a normalized
platforms + titleAvailability + userPlatforms schema. Users can now
declare which streaming services they subscribe to, enabling
personalized "On your services" availability display and filtered
library/explore results.

Backend: new platforms table seeded from TMDB provider data on startup,
new API endpoints (platforms.list, account.platforms, account.updatePlatforms),
title detail annotates availability with isUserSubscribed flag, library
"on my services" filter checks user's subscribed platforms.

Web UI: post-registration onboarding page for platform selection,
streaming services settings section, title detail split into "On your
services" / "Also available on", explore dropdown uses platforms table
with active filter highlighting. Library filters redesigned from
cluttered popover to clean collapsible inline strip with consolidated
dropdowns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address race conditions, stale state, and edge cases in platforms feature

- Fix debounced autosave race in streaming services settings: use ref
  for latest selection + counter guard so only the most recent save
  updates UI. Clean up timers on unmount.
- Fix explore provider dropdown desyncing from query filter when
  switching Movie/TV type: reset selectedPlatformId alongside providerId.
- Fix platformIdsExist rejecting duplicate valid IDs by deduplicating
  input before comparing against DB results.
- Fix stale platform metadata: always upsert name/logo on TMDB refresh
  instead of skipping when platform already exists.
- Fix invisible ratingMax filter: clear ratingMax when user changes
  ratingMin since the UI only exposes a single rating dropdown now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clear stale saved-state timer and preserve falsy filter values

- Clear previous savedTimerRef timeout before scheduling a new one in
  streaming-services-section, preventing an older timer from hiding the
  "Saved" indicator too early on rapid successive saves
- Replace `value || undefined` with explicit empty-value check in
  library filter handler so numeric 0 (e.g. ratingMin=0) is not
  incorrectly dropped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TanStack Form and migrate auth + change password forms

Set up TanStack Form v1 composition layer with createFormHook, pre-bound
field components (TextField, TextareaField, CheckboxField, SwitchField,
SubmitButton), and migrate the two traditional submit-based forms:

- AuthForm: replace 4 useState calls with useAppForm, use form.Field
  for each input while preserving motion animation layout
- ChangePasswordDialog: replace 6 useState calls with useAppForm + Zod
  schema validation (cross-field password match, min length), per-field
  error display, form.reset() on dialog close

Also adds @tanstack/react-form to the monorepo catalog and updates both
web and native apps to reference it via catalog:.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:07:14 -04:00

11 KiB

CLAUDE.md

Commands

# Root commands (via Turborepo)
bun run dev              # Start API, Vite dev, and Expo dev servers
bun run dev:docs         # Start the fumadocs/Next.js dev server
bun run build            # Production build (both apps)
bun run lint             # Oxlint lint check
bun run format           # Oxfmt format (auto-fix)
bun run check-types      # TypeScript type check
bun run test             # Run tests
bun run generate:openapi # Regenerate OpenAPI spec + docs API pages (run after contract/schema changes)
bun run i18n:extract     # Run LingUI's string extraction
bun run i18n:claude      # Prompt Claude Code to fill in untranslated strings

# Database commands (run from packages/db/)
cd packages/db && bun run db:push       # Push schema changes to SQLite database
cd packages/db && bun run db:generate   # Generate Drizzle migration files
cd packages/db && bun run db:migrate    # Run Drizzle migrations
cd packages/db && bun run db:studio     # Open Drizzle Studio (visual DB browser)

Use Bun, not Node.jsbun <file>, bun install, bun run <script>, bunx <pkg>. Bun auto-loads .env. Never run bun test directly — always use bun run test which runs Vitest via Turborepo.

CRITICAL: Pre-commit checks

Before declaring a task is complete, all four commands MUST pass with zero errors or warnings:

bun run lint
bun run format:check
bun run check-types
bun run test

Architecture

Sofa is a self-hosted movie & TV tracking app built as a Turborepo monorepo.

couch-potato/
├── apps/
│   ├── native/        # @sofa/native — Expo 55 React Native app (Expo Router, UniWind)
│   ├── public-api/    # @sofa/public-api — Hono microservice on Vercel (version check, telemetry)
│   ├── server/        # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
│   └── web/           # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
├── docs/              # sofa-docs — Fumadocs (Next.js) documentation site + OpenAPI reference
├── packages/
│   ├── api/           # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
│   ├── auth/          # @sofa/auth — Better Auth server config (JIT)
│   ├── config/        # @sofa/config — Path constants + TMDB URLs from env (JIT)
│   ├── core/          # @sofa/core — Business logic services (JIT, DB-agnostic)
│   ├── db/            # @sofa/db — Drizzle schema, client, queries, migrations (JIT)
│   ├── logger/        # @sofa/logger — Pino-based structured logging (JIT)
│   ├── test/          # @sofa/test — Vitest setup + in-memory DB test helpers
│   └── tmdb/          # @sofa/tmdb — TMDB API client + image URL helper (JIT)
├── .oxlintrc.json
├── .oxfmtrc.json
├── Dockerfile
├── package.json
└── turbo.json

All shared packages are JIT (raw TypeScript exports, no build step).

Apps

  • @sofa/server — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. Dev: port 3001. Prod: serves SPA static files too, port 3000.
  • @sofa/web — Vite SPA with TanStack Router (file-based routing). No SSR, no DB. All data via oRPC. Vite dev server proxies /api/* and /rpc/* to the API server.
  • @sofa/native — Expo Router app with 4-tab layout (Home, Explore, Search, Settings). UniWind for styling, @better-auth/expo with SecureStore for auth, oRPC client for API calls. Dark-only cinema theme matching web.
  • @sofa/public-api — Minimal Hono microservice deployed on Vercel. Endpoints: GET /v1/version (latest release from GitHub), POST /v1/telemetry (forwards instance stats to PostHog), and OAuth device-code proxy for Trakt/Simkl imports (/v1/import/:provider/device-code, /v1/import/:provider/poll). Dev: port 3002.
  • sofa-docs — Fumadocs site (Next.js) with landing page, Markdown docs, and auto-generated OpenAPI API reference. Content lives in docs/content/docs/. API docs are generated from docs/openapi.json via fumadocs-openapi.

Stack

  • Frontend: Vite 7 SPA, React 19, TanStack Router (file-based), Tailwind CSS v4, shadcn, Jotai
  • Mobile: Expo 55, Expo Router, UniWind (Tailwind for RN), @tabler/icons-react-native
  • API: Hono on Bun, oRPC (contract-first) with @orpc/tanstack-query
  • Database: SQLite via bun:sqlite + Drizzle ORM (WAL mode, sync queries, auto-migrations)
  • Auth: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
  • Monorepo: Turborepo with Bun workspaces
  • Docs: Fumadocs (Next.js), fumadocs-openapi for API reference
  • Testing: Vitest (unit tests on Node.js, browser component tests via Playwright)
  • Linting: Oxlint + Oxfmt (2-space indent, organized imports, Tailwind class sorting)
  • External API: TMDB (The Movie Database)
  • Translation: LingUI + Crowdin

Package imports

Path aliases: @/* maps to src/ in both apps/web/ and apps/native/.

Cross-package imports:

  • @sofa/api/contract, @sofa/api/schemas — Contract and Zod types
  • @sofa/db/queries/* — Query layer (e.g., @sofa/db/queries/tracking, @sofa/db/queries/metadata)
  • @sofa/db/client, @sofa/db/schema, @sofa/db/migrate
  • @sofa/test/setup, @sofa/test/db — Test setup (Vitest) and DB helpers (in-memory SQLite, seed functions)
  • @sofa/tmdb/client, @sofa/tmdb/image
  • @sofa/core/metadata, @sofa/core/tracking, @sofa/core/imports, etc.
  • @sofa/auth/server, @sofa/auth/config
  • @sofa/config — Path constants (DATA_DIR, DATABASE_URL, CACHE_DIR, BACKUP_DIR, AVATAR_DIR) and TMDB URLs
  • @sofa/loggercreateLogger(name) for structured logging

Key patterns

Layered architecture — Strict separation: apps/server/ → @sofa/core/* → @sofa/db/queries/* → @sofa/db/client. Server procedures/routes call core services for all business logic. Core services call query functions for all DB access. Core must never import @sofa/db/client directly. Type-only imports from @sofa/db/schema are allowed in core (for $inferInsert/$inferSelect), but runtime imports are not.

Query layer — All Drizzle ORM queries live in packages/db/src/queries/, organized by domain (tracking, metadata, discovery, etc.). Plain exported functions, transactions hidden as implementation details. Wildcard export: "./queries/*": "./src/queries/*.ts".

oRPC queries use orpc.*.queryOptions() with TanStack Query. Mutations use client.*() directly. Route loaders prefetch via queryClient.ensureQueryData().

Auth guards — Web routes use beforeLoad + authClient.getSession(). API procedures use auth middleware that calls auth.api.getSession().

TMDB images — Only paths stored in DB. The API server resolves full URLs via tmdbImageUrl(). When IMAGE_CACHE_ENABLED (default), images are downloaded to disk and served via /images/:category/:filename.

Database IDs — All app tables use UUIDv7 text PKs via Bun.randomUUIDv7().

i18n (LingUI) — All user-facing strings are wrapped with LingUI macros. The @sofa/i18n shared package holds the i18n singleton, locale metadata, and Intl-based format utilities. Config lives at the repo root (lingui.config.ts). PO catalogs and compiled TS files live in packages/i18n/src/po/. Crowdin syncs via crowdin.yml with languages_mapping to map regional codes (es-ES→es, pt-PT→pt).

  • JSX text<Trans> from @lingui/react/macro
  • Strings in hooks/componentsconst { t } = useLingui() from @lingui/react/macro, then t`string`
  • Strings outside React (plain modules) → import { msg } from "@lingui/core/macro" + import { i18n } from "@sofa/i18n", then i18n._(msg`string`)
  • Pluralizationimport { plural } from "@lingui/core/macro". Use standalone when it's the whole string: plural(count, { one: "# item", other: "# items" }). Nest inside t only when there's surrounding text: t`You have ${plural(count, { one: "# item", other: "# items" })} in your cart`
  • DO NOT use t(i18n) — it's deprecated in v5 and removed in v6. Use i18n._(msg...) instead.
  • Date/number formatting → use formatDate, formatRelativeTime, formatNumber, formatBytes from @sofa/i18n/format (Intl-based, locale-aware). Never use date-fns in app code.
  • Native Intl polyfills@formatjs/intl-* polyfills loaded in apps/native/src/lib/intl-polyfills.ts (strict dependency order, with locale data for all 6 languages).
  • Error messages → Server throws ORPCError with data: { code: AppErrorCode.XXX }. Clients map codes to localized strings via per-app error-messages.ts. Never display error.message to users.
  • After adding/changing strings, run bun run i18n:extract. The Vite and Metro plugins compile .po catalogs on the fly — no manual compile step needed.

Environment variables

Required: TMDB_API_READ_ACCESS_TOKEN, BETTER_AUTH_SECRET, BETTER_AUTH_URL.

Optional:

  • DATA_DIR — Root for DB + cache (default ./data). DATABASE_URL and CACHE_DIR derived from it but overridable.
  • TMDB_API_BASE_URL, TMDB_IMAGE_BASE_URL — Override TMDB endpoints.
  • PUBLIC_API_URL — Base URL for centralized public API (default: https://public-api.sofa.watch). Used for update checks and OAuth import proxy.
  • IMAGE_CACHE_ENABLED — Default true. Set false for direct TMDB CDN URLs.
  • LOG_LEVELerror/warn/info/debug (default: info).
  • OIDC: OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER_URL, OIDC_PROVIDER_NAME, OIDC_AUTO_REGISTER, DISABLE_PASSWORD_LOGIN.

Testing

Vitest (not Bun's built-in test runner). Always run tests via bun run test (never bun test).

  • @sofa/test — Shared test package. @sofa/test/setup is the Vitest setup file (mocks @sofa/db/client with in-memory SQLite, polyfills Bun.randomUUIDv7). @sofa/test/db exports the test DB instance, seed helpers (insertUser, insertTitle, etc.), and clearAllTables.
  • packages/core/test/ — Unit tests for business logic. Config: packages/core/vitest.config.ts.
  • apps/web/ — Browser component tests via @vitest/browser + Playwright. Config: apps/web/vitest.config.ts.
  • To add tests to a new package: create a vitest.config.ts with setupFiles: ["@sofa/test/setup"], add "test": "vitest run" to scripts, and add the project path to the root vitest.config.ts projects array. Do not add @sofa/test to the package's devDependencies — it resolves via hoisted node_modules, and adding it would cause turbo prune to pull better-sqlite3 into Docker builds.

Docker

Single container, single process. Hono serves API + SPA on port 3000. API routes (/rpc/*, /api/*) mounted first; unmatched routes fall back to index.html.

Browser Automation

Use agent-browser for web automation (agent-browser --help for all commands). Auth credentials: demo@sofa.watch / password.