GitHub - SigmaPilotAI/enjoyread: Mobile-first browser reading application that removes choice overload by asking "how long do you have right now?" and recommending the best-fit reading unit for the moment. · GitHub
Skip to content

SigmaPilotAI/enjoyread

Folders and files

Repository files navigation

EnjoyRead.ai

Mobile-first browser reading application that removes choice overload by asking "how long do you have right now?" and recommending the best-fit reading unit for the moment.

Status: Milestone 5 plus first private-beta/admin controls — lightweight completion feedback, affinity ranking, explainable recommendation handoff, global language switching, auto-saved reading-profile edits, menu-based history/settings, account/history deletion, admin catalog management/demo cleanup, source management, ingestion health visibility, source quality analytics, and scheduled feed ingestion foundations are live on top of the hardened M1-M4 foundation. Full MVP spec: docs/enjoyread_mvp_spec.md MVP execution roadmap: docs/mvp-roadmap.md M3 catalog plan: docs/m3-catalog-plan.md Catalog automation plan: docs/catalog-automation.md Personalization and AI roadmap: docs/personalization-and-ai.md Roadmap/backlog: docs/roadmap.md M1 design: docs/superpowers/specs/2026-04-16-foundation-design.md M1 plan: docs/superpowers/plans/2026-04-16-m1-foundation.md Visual reference (all 7 surfaces): mockups/enjoyread-design.html (open in browser)

Deployment readiness: Railway config is checked in. The app is deployable for private real-world smoke testing after Railway services, env vars, Google OAuth, PostHog, and either starter catalog seed or scheduled feed sources are configured. It is not yet a public MVP/beta product.

Stack

  • Framework: Next.js 16 (App Router) + TypeScript + React 19 — single modular-monolith container
  • ORM / DB: Prisma 6 + PostgreSQL 16 with pgvector
  • Auth: Auth.js v5 (Google OAuth + JWT sessions, Prisma-free proxy config)
  • i18n: next-intl with explicit locale prefixes (/en, /zh-Hans)
  • Design system: shadcn/ui on Tailwind CSS, bridged to a paper/ink/oxblood palette + Fraunces/Newsreader/Public Sans type stack
  • Analytics: PostHog (server + client wrapper)
  • Logging: pino (JSON to stdout for Railway ingestion)
  • Testing: Vitest + Playwright
  • Hosting: Railway (railway.json + Dockerfile build + pre-deploy migrations + healthcheck)

Local development

Requirements: Node 22+, Docker Desktop (or OrbStack), make.

cp .env.example .env     # then fill AUTH_SECRET, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
make install             # install deps + generate Prisma client
make db-up               # start local Postgres + pgvector
make migrate             # apply Prisma migrations
make dev                 # boot Next.js dev server on :3000

Visit http://localhost:3000/en. Sign in with Google → complete onboarding if needed → land on the simplified dashboard → use the reading-length slider to start a timed session or resume the active one → review the explained recommendation/alternates → read with automatic progress saving → save and close to resume later, or complete the session and answer the feedback popup with a quick rating plus optional tuning chips. Profile overview/editing, history, and settings live in the top-right account menu. The top-right language selector changes the app locale and the catalog language used for new recommendations.

Recommendation v1 is deterministic and interpretable: it filters by rights, locale/script, session length, and profile difficulty, then scores duration fit, difficulty fit, stop quality, and chip-aware feedback affinity. Reading history, progress, feedback, and durable interaction taste memory are documented in docs/personalization-and-ai.md, along with the roadmap for AI enrichment, semantic retrieval, richer feedback, and smarter future recommendations.

The recommender consumes a unified user preference vector — see docs/operations/recommender.md. Behavioural signals (completions, feedback, saves) update an interaction-source UserTasteVector; the onboarding genre-multiselect step seeds the vector at first signup. Score components are duration, difficulty, quality, tag affinity, and genre fit; the operator-facing per-genre rejection breakdown lives on the admin catalog page.

The preference vector decays on a 60-day half-life so stale tastes fade without operator intervention; the dashboard exposes a "Show me different reads" button that regenerates the selection excluding the prior candidates; and the profile page surfaces the learned signals (top genres, tags, locale affinities, plus completion/feedback/saved counters) as a read-only panel. Locale is always a hard filter on URL prefix — switching between /en and /zh-Hans switches reading language end-to-end; there is no cross-locale mixing.

Catalog Operations

The catalog model is AI-generated fiction at scale, supplemented by a public-domain external lane (currently inactive). A distributed cron service plans single-job fiction batches throughout the day across English and Simplified Chinese. Operators tune prompts, audit rejection breakdowns, and edit individual segments — they do not review every item one by one. The source-feed admin form below remains in place for future re-activation; it is not currently used by cron.

The starter catalog is still useful for smoke tests. Run it only after migrations have been applied against the target database:

make migrate-deploy
CATALOG_SEED_ACTOR=operator@example.com make catalog-seed

The seed command is idempotent for the included starter fixtures. It creates or updates source/work/segment records and records an IngestionRun. The starter catalog currently contains 15 original smoke-test segments: English, Simplified Chinese, and Traditional Chinese coverage across under-10, under-30, under-60, and 60+ minute ranges.

To register a scheduled JSON feed source, sign in as an ADMIN_EMAILS operator, open /en/admin/catalog, fill Source management, and save the source. Submitting the same source name and source URL updates that source. CLI fallback:

make catalog-source-upsert ARGS="\
  --name 'Trusted Partner Feed' \
  --source-url https://partner.example/catalog \
  --feed-url https://partner.example/enjoyread-feed.json \
  --rights-type licensed \
  --host-allowed true \
  --excerpt-allowed true \
  --attribution-required true \
  --attribution-text 'Licensed partner content.' \
  --verified-at 2026-04-30T00:00:00.000Z \
  --cadence daily"

Imported feed rows publish immediately when source rights guardrails pass. Uncertain feeds should not be registered until their source rights are clear; operators can delete bad excerpts after import. Run due scheduled feeds manually with:

CATALOG_INGEST_ACTOR=operator@example.com make catalog-ingest-due

Railway or another scheduler can call POST /api/admin/catalog/ingest with Authorization: Bearer <CATALOG_INGEST_SECRET>. This endpoint runs the same due-feed ingestion path. The route returns 503 until CATALOG_INGEST_SECRET is configured, so it is safe to deploy before cron is enabled.

Manual JSON import remains only a bootstrap/backfill fallback:

CATALOG_IMPORT_ACTOR=operator@example.com make catalog-import FILE=docs/catalog-import-example.json

For Railway production, run the importer from a local checkout with Railway env vars injected:

CATALOG_IMPORT_ACTOR=operator@example.com railway run npm run catalog:import -- path/to/production-catalog.json

The JSON importer accepts a single fixture, an array of fixtures, or an object with a fixtures array. Use docs/catalog-import-example.json as the template. Imports are idempotent by source name + sourceUrl, work title/language/script, and segment number. Fallback imports are promoted through the same rights guardrails as trusted sources; invalid rights metadata fails the import instead of creating a manual publish queue. The admin paste form accepts JSON payloads up to 5 MB; larger backfills should use scheduled feeds or the CLI importer.

Existing catalog rows imported before the immediate-availability rollout are backfilled by production migrations when they pass the same rights and metadata checks. The backfill publishes only draft/review rows with verified rights, allowed hosting/excerpts, attribution or source URL when required, language/script, positive estimated minutes, and valid scores. Suppressed/deleted rows stay hidden. If older rows remain unavailable after deploy, fix their source rights metadata or re-import the same JSON with corrected metadata; the importer updates matching rows instead of creating duplicates.

Allowlisted operators can register/update feed sources and run scheduled ingestion from /en/admin/catalog. The paste JSON form is intentionally labeled Fallback JSON import and should be used only for bootstrap or emergency backfill. Use source feeds for normal catalog growth.

To remove demo/testing materials without touching real catalog rows:

CATALOG_SEED_ACTOR=operator@example.com make catalog-clear-demo

The cleanup command only matches the known starter fixture source names and fixture metadata: EnjoyRead Original Seeds, EnjoyRead Simplified Chinese Seeds, and EnjoyRead Traditional Chinese Seeds, with no source URL and original rights. It deletes those demo sources, their works, their segments, segment-tag join rows, and reading sessions/progress/feedback tied to those demo segments. It does not delete ContentTag records or any source with a non-demo name, so production materials must be imported under their own real source names. An IngestionRun is recorded for every cleanup attempt.

If the dashboard shows "No matching reads are published yet" after clicking Find my read, the app is healthy but the target database does not have published segments for that locale/length-range/difficulty request. Seed the target database or configure/run scheduled feed ingestion, then confirm source rights guardrails are passing before retrying.

Allowlisted operators can review per-genre AI fiction generation at /en/admin/catalog. The dashboard surfaces cron firings (with plan-due running ~hourly), today's catalog quality counters, the rejection breakdown by guard, and the per-segment Edit page (extended with a genre dropdown). External source-feed ingestion is currently inactive while AI generation is the primary lane; the source-feed admin form remains in place for future re-activation but cron does not pick it up.

Set ADMIN_EMAILS to a comma-separated list of Google account emails. The admin surface shows cron health (last batch and in-flight jobs), per-cron firing log (last plan-due/run-due/reap invocation with status), catalog quality counters (today and last 7 days of published/rejected/failed jobs and AI cost), and a rejection breakdown grouped by guard. Operators can register feed sources, run scheduled ingestion, inspect recent ingestion run health/errors/stats, review source quality metrics from recent reads, seed or clear the demo catalog, use fallback JSON import, and page through catalog segments 50 at a time. Per segment, operators can edit title/summary/body/tags/genre, regenerate AI-generated segments (queues a single-job batch that re-uses the originating job's plan), and delete. Delete is a soft removal that preserves audit/history integrity while hiding the excerpt from catalog listing and future recommendations.

Fiction genres

Cron generates one of six genres per piece — romance, sci_fi, crime, horror, historical, suspense — each with a dedicated prompt file at src/modules/generation/prompts/fiction/<genre>/<locale>.ts. Existing fiction generated before this regime carries the legacy genre literary. The cron picks a genre per batch via deterministic shuffle (per-batchId), so over a few days every genre gets representation. Daily target is 7 fiction pieces per locale (en + zh-Hans).

Generation is paused/resumed at runtime through the admin catalog page's "Generation status" card (1h / 4h / 24h pause buttons); the GENERATION_KILL_SWITCH env var remains as the env-level prod-incident stop. Env knobs:

  • GENERATION_DAILY_TARGET_PER_LOCALE (default 7)
  • GENERATION_JOBS_PER_BATCH (default 1)
  • GENERATION_GENERATOR_MODEL (default claude-opus-4-7)
  • GENERATION_KILL_SWITCH (default false)

Readers can save recommendations from the dashboard and revisit them at /dashboard/saved. The history page at /dashboard/history supports filtering by feedback (loved/liked/not-for-me/pending) and status (completed/manual/expired).

Make targets

make help                # list all targets
make typecheck           # tsc --noEmit
make lint                # eslint + prettier --check
make format              # prettier --write
make test                # vitest run
make coverage            # vitest coverage with 85% global thresholds
make e2e                 # Playwright mobile Chromium reader-loop smoke test
make catalog-seed        # import/update starter catalog fixtures
make catalog-import FILE=docs/catalog-import-example.json
make catalog-clear-demo  # remove only starter demo catalog fixtures
make catalog-source-upsert ARGS="--name Feed --feed-url https://..."
make catalog-ingest-due  # run due scheduled catalog feeds
make build               # prisma generate + next build --webpack
make docker-build        # build production image locally
make docker-run          # run production image against local DB
make db-up / db-down     # start/stop local Postgres
make migrate             # interactive prisma migrate dev
make migrate-deploy      # production-safe prisma migrate deploy
make deploy              # railway up (manual escape hatch)

Deployment (Railway)

Auto-deploys from main through railway.json. Railway builds docker/Dockerfile as a multi-stage Next.js standalone image, runs npx prisma migrate deploy as a pre-deploy command, checks /api/healthz, and restarts failed deploys up to three times.

The runtime container runs as the unprivileged node user. Docker copies production dependencies and Next standalone artifacts as node:node so Next can update its runtime prerender cache, including assets such as /favicon.ico, without filesystem permission errors.

One-time Railway setup

  1. Create a Railway project named enjoyread.
  2. Add a Postgres service from the Railway templates. Railway exposes its connection string as ${{Postgres.DATABASE_URL}}.
  3. Add a service for the app:
    • Deploy from GitHub repo (this repo)
    • railway.json selects the Dockerfile builder and docker/Dockerfile
    • Do not switch the service to Nixpacks; this repo expects the Docker build path
  4. Set env vars on the app service (reference Railway variables where possible):
    • DATABASE_URL${{Postgres.DATABASE_URL}}
    • AUTH_SECRET → generate locally via openssl rand -base64 32
    • AUTH_URL → the Railway-assigned URL (e.g. https://enjoyread.up.railway.app)
    • AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET → from Google Cloud Console. Add <AUTH_URL>/api/auth/callback/google to the OAuth client's authorized redirect URIs.
    • NEXT_PUBLIC_POSTHOG_HOST / NEXT_PUBLIC_POSTHOG_KEY → from self-hosted PostHog (also on Railway) or PostHog Cloud
    • ADMIN_EMAILS → comma-separated Google account emails allowed to access /en/admin/catalog
    • CATALOG_INGEST_SECRET → optional until scheduled feeds are configured; required for POST /api/admin/catalog/ingest
    • CATALOG_INGEST_ACTOR / CATALOG_INGEST_LIMIT → optional scheduled ingestion labels/limits for CLI runs
    • ANTHROPIC_API_KEY / OPENAI_API_KEY → optional until the first real AI integration milestone
    • NODE_ENV=production, LOG_LEVEL=info
  5. Generate a public domain for the app service.
  6. Enable pgvector — Railway's managed Postgres supports it. The 0_init_pgvector Prisma migration runs CREATE EXTENSION IF NOT EXISTS vector automatically on first deploy.

Deploy verification checklist

After the first Railway deploy:

  1. Confirm the pre-deploy command completed npx prisma migrate deploy.
  2. Confirm Railway healthcheck passes for /api/healthz.
  3. Visit /api/readyz; it should return database readiness.
  4. Open /en and /zh-Hans. / should redirect once to /en. Any bookmark at /zh-Hant/<path> redirects 307 to /zh-Hans/<path>.
  5. Complete Google sign-in with the production OAuth callback: <AUTH_URL>/api/auth/callback/google.
  6. Complete onboarding, land on the simplified dashboard, then use the top-right account menu to open Reading profile, edit it with auto-save controls, and open Settings.
  7. Seed the starter catalog for smoke tests, or register a real scheduled feed source. Fastest demo UI path: sign in as an ADMIN_EMAILS operator, open /en/admin/catalog, and click Seed demo catalog. CLI path: CATALOG_SEED_ACTOR=operator@example.com railway run npm run catalog:seed.
  8. When moving from smoke testing to real content, clear only demo materials from the same admin page or a checkout with Railway env vars: CATALOG_SEED_ACTOR=operator@example.com railway run npm run catalog:clear-demo. Then register real scheduled feed sources from /en/admin/catalog or with railway run npm run catalog:source:upsert -- ..., and run due ingestion with the admin page, railway run npm run catalog:ingest-due, or POST /api/admin/catalog/ingest.
  9. Confirm scheduled ingestion, source management, source quality, fallback import, demo seed/cleanup, pagination, and per-excerpt Delete from catalog are visible; ingestion health plus source quality sections should show recent run status, imported counts, completion rate, average progress, and not_for_me rate after seed/import/feed runs. Trusted feed imports publish immediately when source rights guardrails pass.
  10. Start under-10, under-30, under-60, and 60+ minute dashboard sessions with the reading-length slider and confirm the primary recommendation and up to two explained alternates appear. The buckets are mutually exclusive: 1-10, 11-29, 30-59, and 60+ minutes. If the dashboard reports no matching published reads, verify catalog seed/feed coverage for the selected locale, length range, difficulty, and published status.
  11. Switch to an alternate, confirm the localized reader route opens, change reader theme/font-size controls, scroll through the text, save and close, resume the active session from the last saved position, then complete the session when finished.
  12. Submit the reader completion feedback popup, then open History from the account menu and confirm the recent-read row appears.
  13. From Settings in the account menu, use a disposable account to confirm reading-history deletion removes sessions/progress/feedback while keeping onboarding, and account deletion signs the user out.
  14. Confirm PostHog receives signed_in, onboarding_completed, profile events, session_started, recommendation_accepted, recommendation_swapped, resume_used, session_ended, content_completed, and feedback_submitted.

The app is ready for a private smoke test once this checklist passes. It is not ready for public beta until recommendation refinement beyond deterministic explanations and lightweight feedback-derived tag/difficulty affinity, external analytics deletion/export policy, legal policy review, and mobile/browser accessibility QA are complete.

Future services (added per milestone)

  • Ingestion worker (M3/M6) — optional second app service built from the same image for higher-volume feed ingestion
  • Redis (M3/M6) — BullMQ queue for high-volume ingestion jobs once feed volume outgrows direct scheduled runs
  • Self-hosted PostHog (optional) — if you'd rather not use PostHog Cloud

Project structure

src/
├── app/                       # Next.js App Router (locale-segmented)
│   ├── [locale]/
│   │   ├── layout.tsx         # NextIntlClientProvider + PostHogProvider
│   │   ├── page.tsx           # landing
│   │   ├── (auth)/sign-in/    # Auth.js redirect target
│   │   └── (app)/             # protected dashboard + history/settings + onboarding + reader
│   ├── api/
│   │   ├── auth/[...nextauth] # Auth.js route handler
│   │   ├── healthz            # liveness probe
│   │   └── readyz             # readiness probe (DB ping)
│   └── layout.tsx             # minimal pass-through
├── modules/                   # business modules (boundary-enforced)
│   ├── admin/                 # env-backed admin access helpers
│   ├── auth/                  # Auth.js v5, Prisma-free proxy split
│   ├── analytics/             # PostHog wrapper (server + client)
│   ├── ai/                    # LLMClient interface (M1: no impls)
│   ├── profile/               # M2 onboarding/profile persistence
│   ├── catalog/               # M3 rights-aware catalog foundation
│   └── session/               # M4/M5 session/candidate/progress/feedback lifecycle
├── components/
│   ├── ui/                    # shadcn/ui copy-ins
│   ├── app-header.tsx         # shared account menu/header + language selector
│   ├── locale-switcher.tsx    # client-side locale dropdown
│   ├── reading-profile-panel.tsx # overview + auto-save profile editor
│   ├── session-length-slider.tsx # dashboard duration slider
│   ├── reader-preferences.tsx # client-side reader theme/type controls
│   ├── reader-completion-dialog.tsx # completion-time feedback popup
│   └── sign-in-button.tsx     # client component
├── i18n/                      # next-intl config + messages
├── lib/                       # db, env, logger, utils
├── types/                     # type augmentation
└── proxy.ts                   # composed intl + auth request proxy

Module boundary rule

ESLint enforces that code outside src/modules/ can only import from a module's index.ts. Deep imports like @/modules/auth/config are rejected. The only exception is proxy.ts, which imports the Prisma-free auth helper from @/modules/auth/edge.

This is cheap to enforce now and impossible to retrofit once modules sprawl.

Testing

make test runs Vitest unit tests — fast, no DB required (DB calls are mocked). Current suite covers env validation, db singleton, logger, health/auth endpoints, admin allowlist checks, analytics tracking, AI client, locale-aware route helpers, rate limiting, onboarding/profile calibration and auto-save API helpers, interaction taste-vector updates, reading-history/account deletion controls, catalog reading-time/rights/filtering/admin-listing/source-quality/fixture-import/JSON-import/source-scheduling/scheduled-ingestion/ingestion-health/demo-cleanup/delete-from-catalog helpers, and session creation/loading/history/selection/acceptance/progress/completion/feedback/API helpers.

make coverage runs vitest run --coverage with global thresholds set to 85% for statements, branches, functions, and lines. Latest local coverage: 95.62% statements, 85.67% branches, 98.41% functions, 97.31% lines across 184 tests.

make e2e runs a Playwright mobile Chromium smoke test for onboarding, slider-based session start, recommendation alternate swap, accepted recommendation handoff, persistent reader theme/font-size controls, automatic reader progress save, dashboard resume tracking, completion-time rating plus feedback-chip submission, profile auto-save, and account-menu navigation to history/settings. The test uses E2E_TEST_USER_ID as a non-production-only auth bypass and seeds stable rights-safe test content into the configured database.

GitHub Actions runs typecheck, lint, coverage, dependency audit, production build, Playwright E2E, Railway config validation, Docker image build, Prisma CLI availability inside the image, and a .env* image smoke check on every pull request and push to main. The workflow uses the Node 24-backed major versions of the first-party GitHub Actions and uploads the generated coverage/ report as an artifact.

Contributing

PRs welcome. GitHub Actions must pass typecheck, lint, 85% coverage, dependency audit, production build, Playwright E2E, and Docker/Railway checks before merge. Railway deploys on merge to main.

About

Mobile-first browser reading application that removes choice overload by asking "how long do you have right now?" and recommending the best-fit reading unit for the moment.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

Contributors