尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
An open-source food and workout tracker that grades what you eat for your diet, allergies and goals.
Santul is a mobile-first daily food tracker: MyFitnessPal-style logging made effortless by scanning. It tells you how healthy each food is, personalised to your diet, allergies and goals, and suggests a better option sold in your country.檢視完整 README ↗Santul is a mobile-first daily food tracker: MyFitnessPal-style logging made effortless by scanning. It tells you how healthy each food is, personalised to your diet, allergies and goals, and suggests a better option sold in your country.
Next.js 16 (App Router) and React 19 · TypeScript · Tailwind CSS 4 and Base UI · PostgreSQL with Drizzle ORM · Better Auth · Vercel AI SDK with Gemini · Cloudflare R2 · Vitest
The product was renamed from EATRi8 to Santul in October 2026. Internal identifiers (the
eatri8-themecookie, browser storage keys, database, package and CI names) still sayeatri8on purpose: renaming them would reset people's theme and lose in-progress workouts.
You can have Santul running locally in a few minutes. Barcode scanning, the food catalogue, the diary and workouts need only Postgres and a Google OAuth client; AI photo scans also need a Gemini API key (see Scanning).
package.json engines pins 24.x, which is also what Vercel builds with)corepack enable or npm i -g pnpm)cp .env.example .env.local
Fill in .env.local:
BETTER_AUTH_SECRET — generate with openssl rand -base64 32GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET — create an OAuth 2.0 Web client in Google Cloud Console and add http://localhost:3000/api/auth/callback/google as an authorised redirect URI.env.example already has working local defaultsThen:
pnpm install
pnpm db:up # starts local Postgres 17 in Docker
pnpm db:migrate # applies schema migrations
pnpm seed:foods # seeds the food catalogue (~14.8k foods, idempotent)
pnpm dev
The app runs at http://localhost:3000.
| Script | What it does |
|---|---|
pnpm dev | Start the dev server (Turbopack) |
pnpm build | Production build |
pnpm start | Serve the production build |
pnpm lint | ESLint, zero warnings allowed |
pnpm typecheck | tsc --noEmit |
pnpm test | Unit tests (vitest, offline) |
pnpm test:int | Integration tests against local Postgres (vitest, needs pnpm db:up) |
pnpm db:up | Start local Postgres 17 via Docker Compose |
pnpm db:generate | Generate a Drizzle migration from schema changes |
pnpm db:migrate | Apply Drizzle migrations. Always use this, never drizzle-kit push: the daily_ai_cost view (migration 0003) isn't declared in schema.ts, so push would drop it |
pnpm fetch:sources | Download/refresh raw catalogue sources (INDB, USDA FNDDS, OFF India) |
pnpm seed:foods | Normalise and seed the food catalogue into Postgres (idempotent on (source, sourceRef)) |
pnpm regrade | Recompute stored food grades after a gradeVersion bump |
pnpm search:smoke | Manual smoke check of search ranking against a fixed query list |
pnpm scan:try <image...> | Manual smoke test of the scanning engine against 1–3 real image files (needs GOOGLE_GENERATIVE_AI_API_KEY) |
pnpm seed:demo | Dev only: create/refresh the demo account and write its session cookie (see Demo data and screenshots) |
pnpm shot <path> | Dev only: screenshot one page as the demo user |
| Var | Required | Source |
|---|---|---|
DATABASE_URL | yes | Local: postgres://eatri8:eatri8@localhost:5432/eatri8 from pnpm db:up. Hosted (Neon): the pooled connection string from the Neon dashboard. |
BETTER_AUTH_SECRET | yes | openssl rand -base64 32 |
BETTER_AUTH_URL | yes | http://localhost:3000 locally, your deployed URL in prod |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | yes | Google Cloud Console → OAuth 2.0 Web client; redirect URI {BETTER_AUTH_URL}/api/auth/callback/google |
CREDIT_TOMBSTONE_PEPPER | no | Keys the email HMAC kept when an account is deleted (so deleting and signing up again can't reset AI-scan usage). Falls back to BETTER_AUTH_SECRET; set a dedicated value (≥ 16 chars, openssl rand -base64 32) so rotating the auth secret doesn't orphan tombstones. In production the fallback logs a one-time warning. |
OFF_CONTACT_EMAIL | no | Contact email sent in the User-Agent header on Open Food Facts requests (Santul/2.0 (<contact>)) |
TEST_DATABASE_URL | yes, for pnpm test:int | Points at a separate local database, e.g. postgres://eatri8:eatri8@localhost:5432/eatri8_test |
Server code runs in Vercel's Singapore region (sin1, set in vercel.json) because the Neon database is in ap-southeast-1, also Singapore. Every page makes several database round trips, so the two must stay side by side: if the database ever moves, change regions to match.
All env vars are validated at boot through lib/env.ts (zod; an empty string counts as unset). There are no NEXT_PUBLIC_ secrets.
Scanning (barcode and AI-assisted photo extraction) is implemented from M2 onward.
| Var | Required | Source |
|---|---|---|
GOOGLE_GENERATIVE_AI_API_KEY | For AI-assisted (photo) scans | Google AI Studio API key. Set it in .env.local, which is git-ignored (see .gitignore's .env* entry) — never commit a real key anywhere else. Without it, barcode scans still work (free, no model call), but photo scans return 503 SERVICE_BUSY. |
MODEL_FAST | no | Model id for the (only) tier the engine currently calls. Defaults to gemini-3.5-flash-lite (lib/engine/models.ts). |
MODEL_STRONG | no | Reserved for a future strong-tier pass; not yet called by the engine. Defaults to gemini-3.5-flash. |
DAILY_AI_SCAN_CAP | no | Global cap on model-calling scans per UTC day, across all users combined. Defaults to 300. Once hit, new AI scans get 503 SERVICE_BUSY until the day rolls over (lib/scans/deps.ts, lib/rate-limit.ts). |
PRO_GATES_ENFORCED | no | Launch switch for the Pro-only features (200 AI scans a month, Progress month view, workout insights (Month stats, trends, top exercises, how often), CSV data export, custom daily targets; lib/credits/plan-features.ts). Off by default, so everything stays open until Pro launches. Set true (or 1) to enforce: Basic users get a "Pro" lock on those features and an upgrade sheet that joins the waitlist; GET /api/v1/progress?range=month, GET /api/v1/fitness/stats?range=month and GET /api/v1/export return 403 PRO_REQUIRED (the Workouts page quietly falls back to Week and shows one blurred Pro preview in place of the insight cards); saving custom targets on Basic is rejected. Reads are gated too: a Basic user's stored custom targets are ignored and their goal preset applies everywhere (Today, Progress, scan grading), with a one-time notice on Today and Me. The stored overrides are kept, so they come back on upgrading. Try Pro locally with pnpm plan:set <email> pro. |
lib/credits/plans.ts). The allowance resets lazily — on the first read or spend after the UTC month rolls over, not on a schedule (lib/credits/ledger.ts).failScanTx in lib/scans/service.ts).lib/credits/tombstone.ts) — so signing up again with the same email starts from the same usage. A tombstone is kept only for its month: once that month ends it is deleted (pruneTombstones), on the next account deletion or sign-up.DAILY_AI_SCANS_PER_USER in lib/rate-limit.ts); refunded scans still count toward it, so a refund can't be looped for free scans.Each successful AI photo scan keeps one image: a 480 px (long edge) WebP made from the first photo, about 40–50 KB, rotated upright with no metadata (no EXIF, no GPS). It is kept until the scan is deleted. Originals and the other photos are never stored. Nothing is uploaded for a failed scan: the upload happens only after the scan has succeeded (storeScanImage in lib/scans/photo-storage.ts, processing in lib/scans/photos.ts with sharp), in the scan's background job, and can never fail, change or delay the scan or its charge. URLs are presigned for 10 minutes. Deleting a scan deletes its image (best effort, after the response); deleting the account deletes the user's whole thumb/u/{userId}/ prefix first. Barcode scans have no image. There is no lifecycle or expiry rule.
Storage is off unless all four vars are set; off, the app behaves exactly as without it (no uploads, imageUrl: null).
| Var | Required | Source |
|---|---|---|
R2_ACCOUNT_ID | for photo storage | Cloudflare dashboard → R2 → Account details (the account ID in https://<id>.r2.cloudflarestorage.com) |
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY | for photo storage | R2 → Manage API tokens → Create API token: Object Read & Write, applied to this bucket only. Shown once. |
R2_BUCKET | for photo storage | The bucket's name, e.g. eatri8-photos |
Key: thumb/u/{userId}/{scanId}.webp (lib/storage/keys.ts). The bucket stays private (no public access, no custom domain): the app only hands out presigned GET URLs.
Set up once per bucket, in three steps: (1) create the bucket (R2 → Create bucket, location Automatic, Standard storage class); (2) create the bucket-scoped Object Read & Write token above; (3) put the four env vars in .env.local (and the deployment's env).
A bucket used with an earlier build of this branch may hold old objects under display/. Delete that prefix once (Cloudflare dashboard → the bucket → select the display/ folder → Delete), and remove the eatri8-display-30d lifecycle rule if it was applied. No released version ever wrote there.
pnpm db:migrate against the production database before each deploy that adds migrations (this release: 0008–0012). Nothing runs them automatically, and the new code reads columns they add, so an unmigrated database returns a 500 on every signed-in page. Migrating first is safe for the build already live.R2_* variables are set; the server logs a warning when some but not all are.NEXT_PUBLIC_GEMINI_API_KEY is still in master history (added in 3afd1c4, removed in 4d2b34c). Revoke it in Google AI Studio and make sure GOOGLE_GENERATIVE_AI_API_KEY is a different, server-only key.pnpm eval runs the extraction engine against a local fixture suite and reports per-field accuracy, schema-failure rate, triage accuracy, latency and cost. It needs GOOGLE_GENERATIVE_AI_API_KEY and fixtures under eval/fixtures/; with either missing it prints skipped: ... and exits 0. See eval/README.md for how to add fixtures.
pnpm scan:try <image...> runs one real extraction against 1–3 image files and prints the result, usage and cost — a quick way to check a single label/photo without the eval harness.
Scan photos are held in memory for the model call. The photos are never written to disk. Since Phase 2, a successful AI scan also keeps one small optimised image of its first photo in a private R2 bucket (see Scan photos on Cloudflare R2); without R2 configured, nothing is stored.
The scan screen decodes EAN-13 / EAN-8 / UPC-A barcodes with the browser's BarcodeDetector where it
supports EAN-13 (Chrome on Android/macOS), and otherwise lazily loads zxing-wasm
(only on /scan). For M2 the zxing wasm binary is fetched from zxing-wasm's default CDN
(fastly.jsdelivr.net/npm/zxing-wasm@<version>/dist/reader/zxing_reader.wasm). No Content-Security-Policy is
configured yet; when one is added, allow that origin in connect-src (and script-src 'wasm-unsafe-eval'),
or self-host the wasm via prepareZXingModule({ overrides: { locateFile } }).
/workouts is the fitness hub. On first visit it asks for a weekly goal, goal weight and height ("Set up your training", skippable); until the first workout it shows the six ways to start a session; after that it has the weekly goal and next preset in your rotation, a week strip, weekly stats (sessions, volume, time, calories burned), body weight with its 30-day trend and the full paged history. Pro adds workout insights: a Week | Month switch, a Calendar | Volume trends card (monthly day-type calendar, 8-week volume), top exercises and how often you train each day type. With PRO_GATES_ENFORCED on, Basic users see one blurred preview with a "See Pro" button instead.
| Route | Purpose |
|---|---|
/workouts | The hub (setup form, empty state or full hub; ?range=month is Pro) |
GET /api/v1/fitness/stats | Stats for ?range=week|month (month and the insight fields are Pro) |
GET /api/v1/workouts/history | Paged workout history (?cursor=) |
POST /api/v1/me/fitness/setup | Saves the first-visit setup (goal, goal weight, height) or skips it |
Sign-in is Google-only, so headless checks use a seeded demo account instead:
pnpm seed:demo # [email protected] ("Aarav Kapoor"), 10 days of diary, 4 scans, 18/20 credits,
# this week's Push/Pull/Legs + 2 walks, 30 days of weight (goal 70 kg, 5 days a week)
pnpm shot /today --w 390 # → .superpowers/shots/today-390-light.png
pnpm shot /history --w 1280 --dark --full # desktop, dark, full page; --out file.png to pick the path
seed:demo is idempotent (re-run it any time; it rebuilds the diary, workouts and weight relative to today in IST) and refuses to run with NODE_ENV=production or a non-localhost DATABASE_URL. It needs pnpm seed:foods first. It seeds 10 weeks of workouts (one rest week, so the streak breaks) and writes a 30-day Better Auth session cookie to .superpowers/demo-cookie.txt (git-ignored). It also creates two more local users, touching no others: [email protected] (no fitness setup, so /workouts shows the setup form; .superpowers/demo-fresh-cookie.txt) and [email protected] (set up, 68 kg, no workouts, so /workouts shows the empty hub; .superpowers/demo-starter-cookie.txt) and checks it against /api/v1/me when pnpm dev is running. pnpm shot needs the dev server and Google Chrome in /Applications.
The C1 "Lime & Ink" design (spec: docs/superpowers/specs/2026-10-07-redesign-c1-design.md, mock: docs/design/mock-c1.html).
app/globals.css: the light palette on :root, the dark palette in two identical blocks ([data-theme="dark"] and [data-theme="system"] under prefers-color-scheme: dark; app/globals-css.test.ts keeps them in step), mapped to Tailwind colours in @theme inline. Components use token colours only (text-ink, bg-brand, text-protein-ink…), never hex values or Tailwind palette colours. Appearance (Dark, the default / Light / System) is the eatri8-theme cookie, applied before paint by the head script in lib/theme.ts.lucide-react) on buttons, rows, tiles, chips and flags. They are decorative: aria-hidden, with the text label carrying the meaning.whitespace-nowrap; if a label doesn't fit at 360 px, shorten it or let an icon carry it.--brand-deep; text on --brand-soft uses --on-brand-soft; text on lime uses --brand-ink. Grade letters use --on-grade / --on-grade-light, and macro numbers --protein-ink / --carbs-ink / --fat-ink, so all text meets WCAG AA.::before/::after hit area, or a parent that is the hit area).pnpm ui:audit checks those rules in headless Chrome on every screen and the main interactive states (add-food and edit-entry sheets, date picker, delete confirm, the Today energy strip, the Workouts page in each of its states (setup, empty, week, month, Volume tab, Pro-locked), /weight and its log sheet, the Me Fitness sheet), at 390 × 844 and 1280 × 800, in Dark and Light (plus System under both OS schemes on /today). It exits 1 on any offender and saves screenshots to docs/design/qa/ (git-ignored); see docs/design/qa/README.md for the page list and options. It is read-only (it never saves, deletes or completes onboarding) and needs pnpm dev and pnpm seed:demo:
pnpm brand:assets rewrites the static brand files that are committed with the app: the icons (app/icon.svg, app/favicon.ico, app/apple-icon.png, public/icons/), the share card (app/opengraph-image.png and app/twitter-image.png, 1200 × 630, with their alt text) and the home page's phone screenshots (public/landing/). The screenshots are cut from pnpm ui:audit's 390 px shots of Today, a label scan and Workouts, so run the audit first; re-run it whenever those screens change. It needs Google Chrome in /Applications and the network once (for the Geist font). The mark itself is drawn in scripts/brand/mark.ts and components/brand/logo.tsx.
pnpm seed:demo && pnpm ui:audit
pnpm ui:audit --only today,foods-dal-sheet --w 390 --theme light # a subset
Contributions are welcome: bug reports, food data fixes, design polish and features. CONTRIBUTING.md has the full rules; the short version:
master and keep each pull request to one change.pnpm lint, pnpm typecheck, pnpm test, and pnpm ui:audit if you touched the UI..env.local, which is git-ignored.Found a security problem? Please don't open a public issue; see Reporting a security issue.
The food catalogue is built from:
Full attribution is shown in-app at /about/data.
docs/superpowers/specs/2026-10-06-eatri8-v2-design.mddocs/superpowers/plans/2026-10-06-m1-tracker.mdThe code is released under the MIT licence. The food data under data/sources/ is not covered by it: each dataset keeps its own licence (see Data attribution and data/sources/ATTRIBUTION.md).
pnpm ui:audit | Dev only: the UI audit (no-wrap, lime-as-text, contrast, tap targets) over every screen; see UI |
pnpm eval [--models=fast,strong] | Scanning eval harness against a local fixture suite (needs GOOGLE_GENERATIVE_AI_API_KEY and fixtures — see eval/README.md) |