* 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>
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.js — bun <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/expowith 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 indocs/content/docs/. API docs are generated fromdocs/openapi.jsonviafumadocs-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/logger—createLogger(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/components →
const { t } = useLingui()from@lingui/react/macro, thent`string` - Strings outside React (plain modules) →
import { msg } from "@lingui/core/macro"+import { i18n } from "@sofa/i18n", theni18n._(msg`string`) - Pluralization →
import { plural } from "@lingui/core/macro". Use standalone when it's the whole string:plural(count, { one: "# item", other: "# items" }). Nest insidetonly 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. Usei18n._(msg...)instead. - Date/number formatting → use
formatDate,formatRelativeTime,formatNumber,formatBytesfrom@sofa/i18n/format(Intl-based, locale-aware). Never usedate-fnsin app code. - Native Intl polyfills →
@formatjs/intl-*polyfills loaded inapps/native/src/lib/intl-polyfills.ts(strict dependency order, with locale data for all 6 languages). - Error messages → Server throws
ORPCErrorwithdata: { code: AppErrorCode.XXX }. Clients map codes to localized strings via per-apperror-messages.ts. Never displayerror.messageto users. - After adding/changing strings, run
bun run i18n:extract. The Vite and Metro plugins compile.pocatalogs 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_URLandCACHE_DIRderived 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— Defaulttrue. Setfalsefor direct TMDB CDN URLs.LOG_LEVEL—error/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/setupis the Vitest setup file (mocks@sofa/db/clientwith in-memory SQLite, polyfillsBun.randomUUIDv7).@sofa/test/dbexports the test DB instance, seed helpers (insertUser,insertTitle, etc.), andclearAllTables.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.tswithsetupFiles: ["@sofa/test/setup"], add"test": "vitest run"to scripts, and add the project path to the rootvitest.config.tsprojectsarray. Do not add@sofa/testto the package'sdevDependencies— it resolves via hoistednode_modules, and adding it would causeturbo pruneto pullbetter-sqlite3into 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.