- Add `ContinueWatching` (systemSmall) and `Upcoming` (systemSmall) SwiftUI widgets via `expo-widgets`, registered in `app.json` under the `group.com.jakejarvis.sofa` app group
- Add `widget-images` local Expo module with a Swift `WidgetImagesModule` that downloads and resizes remote poster images into the shared app group container for use by widgets; also copies bundled assets (sofa icon fallback)
- Add `src/lib/widgets.ts` to fetch dashboard data, cache poster images, and write a JSON timeline to the shared container via `expo-widgets`
- Add `ContinueWatching` and `Upcoming` SwiftUI widget views in `src/widgets/`
- Add `useWidgetRefresh` hook that refreshes widgets on foreground (throttled to once per minute) and on session ready
- Debounce widget refresh in `invalidateTitleQueries` so rapid mutations (e.g. marking multiple episodes watched) batch into a single refresh
- Expose `titles.upcoming` from the dashboard procedure and add `upcomingTitles` field to the dashboard schema
- Bump iOS deployment target to 16.2 (required by WidgetKit timeline APIs)
- Add `GET /api/export/user-data` route that streams a JSON attachment of the authenticated user's full library data, named `sofa-export-<name>-<date>.json`
- Add `generateUserExport` in `@sofa/core/export` and a matching `parseSofaExport` parser so exported files can be re-imported via the existing import pipeline
- Register `sofa` as a new `ImportSource` in the API contract/schemas and wire up `parseSofaExport` in the `parseFile` procedure
- Add `EXPORT_FAILED` error code to the API error registry and surface it in web and native error-message maps
- Update the account settings section with export/import UI (download button, import progress)
- Add tests for `generateUserExport`, `parseSofaExport`, and round-trip fidelity
- Block all non-health API requests with 503 while a database restore is in progress (`withDatabaseAccessBlocked` in `@sofa/db/client`); pause and resume cron jobs around the restore window
- Replace the two-hook first-user admin promotion with an atomic `claimInitialAdmin` query that uses a DB-level unique constraint so concurrent sign-ups during the first-run window can't each see `userCount === 0`
- Fix `refreshAvailability` to always call `replaceAvailabilityTransaction` (clearing stale rows) even when TMDB returns no US providers, instead of returning early and leaving old data in place
- Fix `performUpdateCheck` to read `release_url` (snake_case) from the public API response instead of `releaseUrl`
- Fix `createJob` import handler to catch `SQLITE_CONSTRAINT_UNIQUE` and surface it as an `IMPORT_ALREADY_RUNNING` conflict error instead of a 500
- Relax public API telemetry schema to accept `string | number` for `users` and `titles` fields
- Add tests for availability clearing, import deduplication, and `claimInitialAdmin`
- Rename `watchlist` → `in_watchlist` and `in_progress` → `watching` across the full stack (DB migration, Drizzle schema, core services, API contract, web, native)
- Add a new `caught_up` status for TV shows where all aired episodes are watched but the show is still airing
- Add `titles.watchAll` procedure and `markAllWatched` action to mark every episode of a TV show as watched in one step; replace the old "Mark as Watching" / "Mark as Completed" context-menu actions with "Mark All Watched" (TV) and "Mark as Watched" (movie)
- Extract `display-status.ts` in `@sofa/api` to share status → display label/color logic between web and native
- Add `getDisplayStatusesByTitleIds` to `@sofa/core/tracking` and wire it into the dashboard library feed so clients receive resolved display statuses
- Show a destructive Alert confirmation before removing a title from the library (native)
- Remove "Mark as Completed" from the continue-watching card context menu
- Update i18n catalogs for all 6 locales (de, en, es, fr, it, pt)
The unique constraint on titles is (tmdbId, type), meaning the same
numeric TMDB ID can exist as both a movie and TV show. Several places
were keying maps by tmdbId alone, which caused UNIQUE constraint
violations when an UPDATE inadvertently changed a row's type, and
silently dropped titles when both types existed for the same ID.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create 18 new query modules under `packages/db/src/queries/` covering every domain (availability, cache, colors, credits, cron, discovery, image-cache, imports, integrations, lists, metadata, person, settings, system-health, thumbhash, title, tracking, webhooks)
- Remove all direct `db` client imports and raw Drizzle expressions from `@sofa/core` services and `apps/server` procedures/cron; replace with named query functions
- Extract cron DB helpers (`startCronRun`, `completeCronRun`, `failCronRun`, `getLibraryTitleIds`, `getStaleLibraryTitles`, etc.) into `packages/core/src/cron.ts`
- Extract integration DB helpers into `packages/core/src/integrations.ts`
- Add `drizzle-orm` as a direct dependency of `@sofa/db` and remove it from `@sofa/core`
- Update `AGENTS.md` to document the strict layered architecture rule
- Enable `perf: warn` in `.oxlintrc.json` and add `builtin` env; disable `no-await-in-loop` and `react/no-array-index-key` globally
- Reorder plugins so `oxc` is first
- Add `.oxfmtrc.json` override to suppress trailing commas in JSON/JSONC files
- Replace spread-into-new-object patterns (`{ ...x, key: val }`) with `Object.assign(x, { key: val })` in discover, explore, integrations, search, credits, and image-cache to satisfy perf rules
- Memoize `ChartContext` and `ToggleGroupContext` provider values to avoid unnecessary re-renders
Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter)
and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule
coverage.
- Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins
and correctness/suspicious categories
- Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind
class sorting (all 30+ custom className attributes migrated)
- Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin
- Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`,
`oxfmt --check`
- Add `format:check` turbo task and CI step
- Update VS Code settings/extensions to use `oxc.oxc-vscode`
- Update CI path triggers from `biome.json` to new config files
- Remove all `biome-ignore` comments and fix shadowed variables
- Delete `biome.json` and `docs/biome.json`
- Reformat entire codebase with oxfmt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add i18n with LingUI, Crowdin integration, and typed error codes
- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
`defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
`@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
(navigator.language / expo-localization) and persistence
(localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
`appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
localized strings; update global `QueryCache.onError` handlers to stop
leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
`bun run i18n:compile` convenience scripts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: add Crowdin language mapping and ignore context file
* fix(i18n): address 20 localization quality issues
- Stop leaking raw error.message to users in change-password and
account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen
* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings
* chore(i18n): crowdin sync
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All explore, discover, search, recommendation, and person-credit listing procedures now include the internal database `id` on every item so clients can navigate and act without a separate resolve round-trip.
- Remove `titles.resolve` and `people.resolve` mutation calls from the native search screen, hero banners, poster rows, and cast cards; replace with direct `Link` navigation using the pre-returned `id`.
- Change `titles.quickAdd` to accept `{ id }` instead of `{ tmdbId, type }` and update every call site on native and web.
- Key all user-status and episode-progress lookups by `id` instead of `tmdbId-type` composite strings across `PosterCard`, `HorizontalPosterRow`, `FilterableTitleRow`, and `usePosterActions`; drop the `tmdbId` prop from `PosterCard` entirely.
- Remove the `titles.hydrateSeasons` auto-trigger from the title detail screen; season hydration now happens server-side on resolve.
- Delete the `browse-thumbhashes` and `browse-title-ids` server procedures and remove them from the router.
- Extend `@sofa/api` schemas with an `id` field on all listing-item types; update `packages/core` services and add a DB migration accordingly.
Convert trending, search, people.detail, and dashboard.library to page-based responses (`page` / `totalPages`) so clients can load results incrementally.
Update `@sofa/core` discovery and person services to accept a `page` / `limit` input and slice results accordingly. Add a new DB migration to persist the data needed to back paginated filmography queries.
On the web, introduce a `useInfiniteScroll` hook and wire up `useInfiniteQuery` in the explore, person detail, and filterable title row components with an intersection-observer sentinel. On native, switch the same screens from `useQuery` to `useInfiniteQuery` with `onEndReached` / `ListFooterComponent` loading indicators. Also replace remaining `FlatList` usages with `FlashList` in the home and title detail screens.
The people.detail endpoint now fetches a person's complete filmography
from TMDB's combined_credits API (cast + crew) instead of only returning
locally-enriched titles.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch `mock.module("../src/image-cache", ...)` stubs in thumbhash, person-detail, colors, and credits tests to `spyOn(globalThis, "fetch")` instead. This avoids the cross-file persistence problem with `mock.module` and tests the real code paths more faithfully.
Update `image-cache.ts` to call `globalThis.fetch` so the spy intercepts it, add `mock.restore()` to the global `afterEach` in `preload.ts`, and set `IMAGE_CACHE_ENABLED=false` in affected tests to avoid disk writes during test runs.
Add `purgeMetadataCache` and `purgeImageCache` procedures that let admins free disk space on demand. Metadata purge removes un-enriched stub titles not in any user's library plus orphaned person records; image purge deletes all cached TMDB files from disk.
Expose both as POST endpoints under `/admin/cache/` via the oRPC contract, implement the core logic in a new `@sofa/core/cache` module, and surface them in a new "Danger Zone" section on the Settings page with individual confirmation dialogs and a combined "Purge all" action.
Add an opt-in telemetry system that sends anonymised instance
statistics to the public API once per day, which proxies them to
PostHog when `POSTHOG_API_KEY` is configured.
- Add `packages/core/src/telemetry.ts` — `performTelemetryReport`
and `isTelemetryEnabled`; user and title counts are bucketed
before sending to avoid exposing exact figures
- Add `getInstanceId()` to `settings.ts` — generates and persists a
stable UUIDv7 for the instance
- Schedule a daily `telemetryReport` cron job (00:30)
- Add `POST /v1/telemetry` to `apps/public-api`; forwards payload
to PostHog or returns 204 silently if key is absent
- Add `admin.telemetry` and `admin.toggleTelemetry` oRPC procedures
for inspecting and toggling the setting
- Expose `instanceId` on `system.publicInfo`
- Add `apps/public-api` (`@sofa/public-api`) — minimal Hono app
deployable to Vercel; `GET /v1/version` fetches the latest GitHub
release and returns `{ version, releaseUrl }` with a 15-minute
CDN cache (`s-maxage=900, stale-while-revalidate=3600`)
- Update `packages/core/src/update-check.ts` to call
`PUBLIC_API_URL/v1/version` instead of the GitHub API directly;
`PUBLIC_API_URL` defaults to `https://public-api.sofa.watch`
* Convert to Turborepo monorepo with shared API contract package
Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).
- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Extract standalone Hono API server and split shared packages
Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.
- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
`INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure
* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog
Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.
Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Extract @sofa/logger and @sofa/config shared packages
- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
extracted from `@sofa/db/logger`; update all imports across server,
core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
`DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
fast path in `index.ts` for cached files before falling back to the
TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
`ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
the oRPC client (always same-origin in both dev and production)
* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution
- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
return 404 for missing asset paths instead of falling back to
`index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
relative paths work regardless of the process working directory
* Migrate @sofa/logger to pino for structured logging
- Replace custom logger implementation in `packages/logger/` with pino
+ pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
error directly instead of wrapping it in `{ error }` to match pino's
serializer expectations
* Rename discoverProcedure/statsProcedure exports to discover/stats
* Add TanStackDevtools unified panel and VS Code workspace config
- Replace separate Router/Query devtools with unified `TanStackDevtools`
from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
`routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)
* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml
Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.
Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix devtools plugin order and whitespace-only TMDB token check
Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>