OpenFoodJournal — Project Knowledge
This is the single source of truth for any LLM agent working on this project. Read this before writing code. Update it when things change.
Quick Facts
| Key | Value |
|---|
| Platform | iOS 26.2+ (iPhone) |
| UI Framework | SwiftUI + Liquid Glass (no #available gating needed) |
| Data Layer | SwiftData (@Model) + CloudKit Private Database. Release: iCloud.k3vnc.OpenFoodJournal; Debug: iCloud.k3vnc.OpenFoodJournal.dev (separate containers, not the same container in two environments) |
| State Pattern | @Observable + @Environment injection (no singletons) |
| Bundle IDs | Release: k3vnc.OpenFoodJournal; Debug: k3vnc.OpenFoodJournal.dev (OFJ Dev on device) |
| Build System | Xcode (xcodebuild), no SPM dependencies |
| Build Verify Command | Never build locally. gh workflow run cloud-ci.yml --ref <branch> — ask the user first. See .claude/rules/ios-builds.md. |
| Build Notes | No simulator devices exist, so Xcode's run destination falls through to the connected iPhone carrying real data. Local xcodebuild also evicts signing artifacts from shared DerivedData. Cloud CI isolates DerivedData per job and is the only sanctioned way to find out whether something compiles. |
| AI Backend | Direct Gemini REST API (BYOK — user provides own API key, stored in Keychain) |
| App Entry | MacrosApp in OpenFoodJournalApp.swift |
Architecture Overview
MacrosApp (creates ModelContainer w/ CloudKit + 4 @Observable services)
└─ ContentView (4-tab TabView)
├─ Journal tab → DailyLogView (WeeklyCalendarStrip, macro summary, meal sections, RadialMenuButton)
├─ Food Bank tab → FoodBankView (searchable, sortable saved food list, swipe-to-edit, "+" menu: Scan/Manual/Search OFF)
├─ History tab → HistoryView (CalendarGridView with progress rings, MacroChartView, macro cards → NutritionDetailView)
└─ Settings tab → SettingsView (goals, health, data export)
Radial FAB: DailyLogView uses RadialMenuButton — a "+" icon at bottom center that fans out Scan / Manual / Containers / Food Bank in an upper semicircle (210°–330°). Supports tap-to-toggle and drag-to-action. Containers are accessed from here instead of a separate tab.
Service injection: All services (NutritionStore, ScanService, HealthKitService, UserGoals, OpenFoodFactsService) are created in MacrosApp.init() and passed via .environment(). Views consume them with @Environment(ServiceType.self). SyncService was removed — CloudKit handles sync natively via ModelConfiguration(cloudKitDatabase:).
Sheet management: DailyLogView uses a single DailyLogSheet enum with .sheet(item:) — never multiple booleans.
Data Models
See references/models.md for full property lists.
DailyLog — @Model, keyed by date normalized to midnight (no @Attribute(.unique) — CloudKit can't enforce uniqueness; app-level dedup in fetchOrCreateLog(for:)). Owns [NutritionEntry]? (optional for CloudKit) via cascade delete. Uses safeEntries computed property for reads.
NutritionEntry — @Model, stores core macros (cal/protein/carbs/fat) + dynamic micronutrients: [String: MicronutrientValue] + brand/serving/servingCount/servingQuantity/servingUnit/servingMappings. @Attribute(.externalStorage) on sourceImage.
SavedFood — @Model, reusable food template in Food Bank. Same fields as NutritionEntry minus meal/log context. Includes lastUsedAt: Date (defaults to createdAt) for "Last Used" sorting. Created from entries, manual input, or directly from ManualEntryView's "Add to Journal & Food Bank" action.
TrackedContainer — @Model, weight-based container tracking. Snapshots food nutrition at creation time. Start weight → final weight → derived consumption via consumedServings math.
UserGoals — @Observable @MainActor, uses @ObservationIgnored @AppStorage for each goal property to avoid property-wrapper conflicts.
Preferences — @Model, singleton row for UI customization. Stores ringSlot1..5 (nutrient IDs for MacroSummaryBar configurable rings). Preferences.current(in:) static factory fetches-or-creates the singleton. Synced to Turso via GET/PUT /api/preferences. Added to ModelContainer in app init. Sheets use @Bindable var prefs: Preferences for direct binding.
MealType — enum: .breakfast, .lunch, .dinner, .snack
ScanMode — enum: .label, .foodPhoto, .barcode, .manual
ServingSize — enum: .mass(grams:), .volume(ml:), .both(grams:ml:). Stores canonical SI values. Has availableUnits: [String] (dimension-appropriate unit list), convert(_:from:to:) -> Double? (handles same-dimension and cross-dimension via density for .both). Static tables: massConversions (g/oz/kg/lb), volumeConversions (mL/cup/tbsp/tsp/fl oz/L). type: String returns "mass"/"volume"/"both" for JSON serialization.
ServingMapping — Codable struct with from: ServingAmount / to: ServingAmount for per-food unit conversions (e.g. 1 cup = 244g). Legacy field kept alongside ServingSize.
MicronutrientValue — Codable struct with value: Double / unit: String for dynamic micronutrient storage
Services
See references/services.md for full API contracts.
NutritionStore — SwiftData CRUD. log(), fetchLog(), fetchLogs(), delete(), saveEntry(), exportCSV(). Pure local operations — CloudKit sync is handled automatically by SwiftData's ModelConfiguration(cloudKitDatabase:). No sync service reference, no fire-and-forget Tasks.
ScanService — Resizes images to max 2000px (UIGraphicsImageRenderer) then JPEG 0.90 before direct Gemini REST API call (generativelanguage.googleapis.com) → NutritionEntry (not yet inserted). Uses GeminiModelConfig static configs: .labelScan (gemini-3.1-flash-lite-preview, MINIMAL thinking) and .foodPhotoScan (gemini-3.1-pro-preview, HIGH thinking). Includes automatic fallback to gemini-2.5-flash/pro on 500/503 errors. Loads API key from KeychainService. User reviews in ScanResultCard before committing. Logs scan duration via ContinuousClock and stores it on NutritionEntry.scanDurationMs.
KeychainService — Static helper for secure Keychain storage (Security framework). Stores Gemini API key under service k3vnc.OpenFoodJournal, account gemini-api-key. Methods: save(_:for:), load(for:), delete(for:), hasGeminiAPIKey, geminiAPIKey.
ServingConverter — Pure-value struct encapsulating all serving-unit conversion math. 4-strategy factorFor(_:) (ServingSize tables → direct mapping → chain → SI bridge), availableUnits, and scaledCalories/Protein/Carbs/Fat. Used by both EditEntryView and LogFoodSheet to eliminate duplicate conversion logic.
HealthKitService — Opt-in Apple Health writes (one HKQuantitySample per macro). Reads activeEnergyBurned.- OpenFoodFactsService — Text search and barcode lookup against the Open Food Facts REST API. Search uses search.openfoodfacts.org (Elasticsearch-backed, the v1 CGI endpoint returns 503). Barcode lookup uses world.openfoodfacts.org/api/v2/product/{code}. Batch-fetches full product details after search using withTaskGroup. OFFProduct model stores full nutrition. lookupBarcode() manages UI state (isLoading/errorMessage); internal fetchProductByBarcode() is stateless for batch use. Used by both OpenFoodFactsSearchView (text search) and ScanCaptureView (barcode camera scan).- UserGoals — Daily targets for cal/protein/carbs/fat, persisted in UserDefaults.
View Map
See references/views.md for detailed view hierarchy and notable patterns.
Scan Flow (Core Value Prop)
User taps Scan → CameraController (AVCaptureSession) → JPEG
→ Prompt overlay: food photo shows text input, label scan skips prompt
→ ScanService.scan(image, mode) → loads API key from KeychainService
→ Builds JSON request with base64-encoded image + prompt
→ POST to https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}
→ ContinuousClock measures full round-trip duration
→ Label mode: gemini-3.1-flash-lite-preview (fast, MINIMAL thinking)
→ Food photo mode: gemini-3.1-pro-preview (HIGH thinking for estimation)
→ Fallback on 500/503: gemini-2.5-flash (labels) / gemini-2.5-pro (food photos)
→ Parses last text part (skips thinking parts) → JSON → GeminiNutritionResponse
→ GeminiNutritionResponse → NutritionEntry (NOT inserted yet)
→ Entry gets scanDurationMs set from ContinuousClock measurement
→ ScanResultCard (editable, shows duration badge) → User taps "Add to Journal"
→ NutritionStore.log(entry, to: date) → SwiftData insert (CloudKit syncs automatically)
→ Auto-creates SavedFood in Food Bank
→ HealthKitService.write(entry) if enabled
Barcode Scan Flow
User taps "Scan Barcode" card → CameraController (AVCaptureSession) → JPEG
→ promptOverlay: "Look Up" button (no text prompt for barcode mode)
→ Vision VNDetectBarcodesRequest detects barcode from photo
→ OpenFoodFactsService.lookupBarcode(barcodeValue)
→ GET https://world.openfoodfacts.org/api/v2/product/{code}?fields=...
→ OFFProduct → ManualEntryView(defaultDate:, prefillProduct:)
→ User reviews/edits pre-filled nutrition data → saves to journal
Open Food Facts Search Flow
User taps "Search Open Food Facts" in Food Bank "+" menu
→ OpenFoodFactsSearchView (searchable, Enter-only via .onSubmit)
→ GET https://search.openfoodfacts.org/search?q={query}&fields=...&page_size=25
→ Returns barcodes → batch-fetch full products via withTaskGroup
→ OFFProductRow (styled like SavedFoodRowView — macros, calories, serving)
→ Tap row → ManualEntryView(defaultDate:, prefillProduct: product)
→ User reviews/edits pre-filled data → saves to journal
CloudKit Sync Architecture (app-store branch)
iOS App (SwiftData + CloudKit)
←→ iCloud Private Database (automatic, free, multi-device)
→ ScanService → Direct Gemini REST API (BYOK, zero server dependency)
→ API key stored in iOS Keychain via KeychainService
Strategy: SwiftData's ModelConfiguration(cloudKitDatabase: .private("iCloud.k3vnc.OpenFoodJournal")) handles all sync automatically. No sync code in the iOS app. Zero server maintenance for data operations.
CloudKit requirements enforced on all models:
- All stored properties have defaults (including fully qualified enum defaults:
MealType.snack not .snack)
- No
@Attribute(.unique) (CloudKit can't enforce uniqueness)
- Relationships are optional (
var entries: [NutritionEntry]? = [])
- No
.deny delete rules
Entitlements: OpenFoodJournal.entitlements includes iCloud (CloudKit), Push Notifications (aps-environment), background mode (remote-notification).
Data migration: TursoMigrationView was deleted — CloudKit replaced Turso and the migration tool is dead code. All data now syncs via CloudKit Private Database automatically.
Server: Express.js on Render — exists in repo for main branch usage but is NOT used by the app-store branch at all. The iOS app calls Gemini REST API directly (BYOK). No server dependency.
Turso migration pattern (server/db.js): Use PRAGMA table_info(table_name) to check existing columns before running ALTER TABLE ... ADD COLUMN. Idempotent on re-deploy. Example:
const info = await db.execute("PRAGMA table_info(nutrition_entries)");
const cols = info.rows.map(r => r.name);
if (!cols.includes("serving_type")) {
await db.execute("ALTER TABLE nutrition_entries ADD COLUMN serving_type TEXT");
// ...
}
Server tables: daily_logs, nutrition_entries, saved_foods, tracked_containers, user_goals — schema in server/db.js.
API endpoints (mounted at /api):
GET /api/sync?since=ISO_TIMESTAMP — Full or incremental data pull
POST/GET/PUT/DELETE /api/entries, /api/foods, /api/containers
GET/PUT /api/goals
- Entries auto-create their parent
daily_log on POST
Sync integration points — CloudKit sync is fully automatic. No per-view sync calls needed. All SwiftData mutations are automatically pushed to CloudKit.
Environment vars (Render): TURSO_DATABASE_URL, TURSO_AUTH_TOKEN (falls back to file:local.db for dev)
Known Gotchas
- CLI builds are stricter than the Xcode IDE — missing
import SwiftData may compile in previews but fail in CLI builds. Verify on Cloud CI, never with a local xcodebuild.
.easeInOut is a static property — don't write .easeInOut(value:). The value: belongs to .animation(_:value:).
- Ternary type mismatch —
.primary is HierarchicalShapeStyle, .orange is Color. Use Color.primary to unify.
@ObservationIgnored on @AppStorage — required in UserGoals to avoid double-wrapper conflict. Every new @AppStorage property needs it.
private enum across structs — private scopes to the type, not the file. Use fileprivate for shared-file enums.
ModelContainer(for:configurations:) — not schema:. The Schema wrapper is only for migrations.
- Swift named parameters must be in declaration order — adding a new
init parameter doesn't let you put it anywhere in call sites. Reorder both the declaration and all call sites to match. Build will catch mismatches.
PRAGMA table_info() not IF NOT EXISTS for ALTER TABLE — SQLite doesn't support ALTER TABLE ADD COLUMN IF NOT EXISTS. Use PRAGMA to check first.
servingQuantity/servingUnit must be set after scaling in LogFoodSheet — toNutritionEntry() copies the food template's serving values; after scaling macros in logButton, write entry.servingQuantity = quantity and entry.servingUnit = selectedUnit so EditEntryView opens with the correct baseline.
.swipeActions is silently ignored outside a List — it does nothing in LazyVStack, ScrollView, or VStack. If swipe actions don't fire, check that the ForEach is inside a List. The modifier compiles without error regardless of container.
- Smooth tappable List rows with swipeActions: use
Button + .buttonStyle(.plain) — Default Button style causes ~150ms tap disambiguation delay. onTapGesture + contentShape(Rectangle()) eliminates tap delay but makes swipes choppy (TapGesture blocks swipe tracking). The correct pattern is Button { } label: { YourRowView() }.buttonStyle(.plain) — UIKit-optimized for List swipe coexistence, smooth on both taps and swipes.
- ZStack gesture priority — in a
ZStack, the last (top) view receives gestures first. Put a Color.clear.contentShape(Rectangle()).onTapGesture { dismiss() } layer before the overlay content to create an outside-tap dismiss. Views on top intercept taps; the clear layer catches everything that falls through.
Color.clear needs .contentShape(Rectangle()) — transparent views have no hit-test area by default. Without contentShape, taps pass through as if the view doesn't exist.
@Model enum defaults must be fully qualified — var mealType: MealType = .snack fails during macro expansion. Use MealType.snack. The error message is unhelpful (just says macro expansion failed).
- CloudKit optional relationships need
safeEntries pattern — var entries: [NutritionEntry]? = [] requires unwrapping everywhere. Add var safeEntries: [NutritionEntry] { entries ?? [] } and use that for reads. Use log.entries?.append(entry) for writes.
KnownMicronutrient.Category cases are .vitamin/.mineral — not .vitamins/.minerals. The enum raw values are plural ("Vitamins"/"Minerals") but the Swift case names are singular.
- Debug must remain a separate installed app and data domain — Debug uses bundle ID
k3vnc.OpenFoodJournal.dev, display name OFJ Dev, and CloudKit container iCloud.k3vnc.OpenFoodJournal.dev; Release uses k3vnc.OpenFoodJournal and iCloud.k3vnc.OpenFoodJournal. Do not collapse either the bundle IDs or container identifiers.
- Gemini image thinking levels are a model-specific enum —
gemini-3.1-flash-lite-image officially supports exactly minimal and high: https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite-image. Production uses the typed GeminiFlashLiteImageThinkingLevel.high. Never send low to this image model or reuse a text-model thinking string. The exact production request builder is covered by deterministic tests and a protected live TestFlight API contract.
AVCaptureSession.startRunning() / stopRunning() block — they take hundreds of milliseconds to a second. CameraController runs them on k3vnc.openfoodjournal.camera.session, not the main actor. Calling them from @MainActor setup() freezes the scan sheet for ~1s.
What's New Sheet Pattern
WhatsNewSheet is version-gated via @AppStorage("lastSeenVersion") in ContentView. On appear, if lastSeenVersion != currentVersion (from CFBundleShortVersionString), the sheet is presented. On dismiss, lastSeenVersion is updated. To add features for a new version, edit WhatsNewSheet.swift and bump the version header text. Each feature is a FeatureRow(icon:, color:, title:, description:).
v1.1 features: Open Food Facts search, barcode scanning, faster food photo scans (lite model default), reorderable nutrient rings, nutrition history navigation (day/week/month with date picker).
Entitlements (app-store branch)
Already configured in OpenFoodJournal.entitlements:
com.apple.developer.icloud-services: CloudKit
com.apple.developer.icloud-container-identifiers: iCloud.k3vnc.OpenFoodJournal
aps-environment: development (auto-switches to production on Archive)
UIBackgroundModes: remote-notification
Still needed (must add to entitlements):
com.apple.developer.healthkit (HealthKit usage compiles but entitlement missing)
Still needed (provisioning profile):
- Enable iCloud + Push Notifications capabilities in Apple Developer portal
- Regenerate provisioning profile
Branches
main — Original Turso sync architecture. SyncService.swift present, all views have fire-and-forget sync Tasks. Used for developer's personal Turso instance.
app-store — CloudKit sync, no Turso dependency. SyncService.swift deleted, all sync Tasks removed. TursoMigrationView for one-time data import. This is the App Store submission branch.
Conventions
- Comments: Explain why, not what. Entry-level devs should understand each function's purpose.
- File creation: Build large files in small chunks to avoid network errors.
- Retrospectives: Live in
docs/. Update when later fixes change the story.
- Skills: This file is the project skill. Update it when architecture or requirements change.
- Commits: Descriptive messages. Push after every significant change.
DailyLogView container: Uses a List (not ScrollView+LazyVStack) with .listStyle(.plain) + .scrollContentBackground(.hidden). WeeklyCalendarStrip and MacroSummaryBar are plain List rows with listRowBackground(Color.clear) + listRowSeparator(.hidden). Meal sections use MealSectionView which returns a Section{} that becomes a proper sticky List section header in a List context. All swipe actions are on the MealSectionView Button wrapper (not on EntryRowView).
RadialMenuButton: Option bubbles support direct .onTapGesture (as well as drag-to-select). A Color.clear.contentShape(Rectangle()).ignoresSafeArea() layer behind GlassEffectContainer dismisses the menu when tapping outside. The layer is only inserted into the ZStack when isOpen == true. Option label text has a subtle drop shadow for legibility over light/glass backgrounds.
Swipe mappings:
FoodBankView row: trailing (swipe left) = Edit (blue) + Delete (red, no full-swipe); leading (swipe right) = Add to journal (green)
MealSectionView row: trailing (swipe left) = Delete; leading (swipe right) = Edit — both on the outer Button wrapper (never on EntryRowView to avoid double-registration gesture lag)
FoodNutrientBreakdownView: Inverse of NutrientBreakdownView — shows all nutrients (macros + micros) for a specific food across the selected period. Accessed by tapping a food row in NutrientBreakdownView's "By Food" section.
EditEntryView: Has full serving-mappings section (same as LogFoodSheet). Uses shared AddServingMappingSheet (defined in LogFoodSheet.swift, internal not private). addMapping() calls nutritionStore.saveEntry(entry).
Current State (Last Updated: 2026-07-31)
- Branch:
app-store — CloudKit migration complete
- Turso is live, not removed.
TursoMirrorService is constructed in OpenFoodJournalApp.init and is a push-only, generation-pruned mirror. It is hard-disabled in Debug builds — isEnabled returns false and loadCredentials() throws — so no network path reaches Turso from a developer build. Do not re-enable it.
- Debug builds install as
OFJ Dev (k3vnc.OpenFoodJournal.dev) alongside the TestFlight app, with a separate local sandbox
- App structure complete: all models, services, and views implemented
- 4-tab layout: Journal, Food Bank, History, Settings (Containers accessed via RadialMenuButton)
- Compile verification runs on Cloud CI (
.github/workflows/cloud-ci.yml), never locally
- SwiftData + CloudKit Private Database for data persistence and sync
- Render proxy deployed at
openfoodjournal.onrender.com (Gemini scan proxy only)
- Food Bank: save foods from scan/manual entry, browse/search/sort, log to journal
- Open Food Facts integration: search 4M+ products, add to journal/food bank, debounced search, per-serving nutrition
- Food Bank "+" toolbar menu: Search Open Food Facts, Manual Entry (replaces empty-state-only guidance)
- Settings: OFF contribute toggle (
off.contributeEnabled, default off) in Integrations section
- Container Tracking: create from Food Bank food, enter start weight, complete with final weight
- Serving Mappings: per-food unit conversions, editable in EditEntryView
- NutritionDetailView:
ScrollView on JournalCalorieBackground (same page treatment as History/calendar). Macro summary and vitamin/mineral sections use NutritionSurface glass cards. Avoid insetGrouped List here — glass on grouped-list chrome creates a double-card halo.
- External TestFlight:
ci/release-config.json carries externalTestFlightGroupID (empty by default). When set, asc publish testflight distributes to it alongside the internal group and --submit --confirm asks for Apple's Beta App Review. asc skips the submission when no external group was added, so an empty value keeps internal-only behaviour.
- App Store product-page copy:
app-store.yml resolves description, keywords and promotionalText before the protected approval gate, in order: committed metadata/releases/<version>/<locale>/store.json, the Claude Code agentic harness, the Bifrost router, then keeping the live text. Every tier must pass .github/scripts/validate-store-metadata.sh. The proposed copy appears in the approval summary, and generated fields are excluded from --copy-metadata-from so they are not overwritten.
- History micronutrient pins: long-press chip → context menu Pin/Unpin. Persisted by
PinnedMicronutrientSettings (history.pinnedMicronutrientIDs) and included in AppSettingsRecord backups.
- FoodNutrientBreakdownView: inverse of NutrientBreakdownView — food → all nutrients. NavigationLink from NutrientBreakdownView "By Food" rows
- LogFoodSheet editable micronutrients: collapsible DisclosureGroup with editable text fields for each micronutrient, values applied at log time
- Radial menu text shadow: option labels have
.shadow() for legibility over glass
- Swipe gesture lag fix: swipe actions consolidated on MealSectionView Button wrapper (removed from EntryRowView), SavedFoodRowView has
.contentShape(Rectangle()), EntryRowView uses static DateFormatter
- WeeklyCalendarStrip: horizontally scrollable week strip with momentum snapping
- Comprehensive micronutrient tracking: 30 FDA nutrients with daily values
- Entitlements configured: iCloud (CloudKit), Push Notifications, Camera, HealthKit descriptions
- BYOK Gemini integration: direct REST API calls, no server proxy needed
- KeychainService for secure API key storage
- Onboarding flow: 6 pages (Welcome → API Key → Goals → Camera → Radial Menu Tutorial → HealthKit)
- RadialMenuDemo: pure SwiftUI phase-based animation teaching press-and-drag gesture (in onboarding page 4)
- Settings: API key management section (save/delete/change key), "Show Onboarding" replay button, "Sources & Disclaimers" link
- App Store Guideline 1.4.1 compliance: HealthDisclaimerView with FDA citations (21 CFR §101.9), AI estimation disclaimers, Atwater system citation, and general medical disclaimer. Inline citations on NutritionDetailView, ScanResultCard, and GoalsEditorView.
- App Store audit complete: HealthKit entitlement, Privacy Policy, PrivacyInfo.xcprivacy, AGPL→MIT licensing
- sourceImage removed from NutritionEntry and SavedFood (was stored but never displayed)
- TursoMigrationView deleted — CloudKit replaced Turso, migration tool is dead code
- No unit tests beyond Xcode template stubs
App Store Submission Notes
First submission rejected (Guideline 1.4.1 — Physical Harm): App provided health/nutrition data without citations. Fixed by adding HealthDisclaimerView (Settings → Sources & Disclaimers) with FDA Daily Values links, Atwater system reference, AI estimation disclaimer, and general medical disclaimer. Inline citations added to NutritionDetailView, ScanResultCard, and GoalsEditorView.
Other audit findings to address before next submission:
- README.md describes
main branch architecture (Turso/Express), not app-store branch. Update README or add branch-specific note.
- Reviewer notes previously contained false HealthKit claim ("data is never read") — corrected to list all read/write types.
server/ directory and render.yaml are dead code on app-store branch — reviewers clicking the GitHub link may be confused.
- AGPL-3.0 license has App Store exception in LICENSE_NOTICE.md — Apple may or may not flag this.
- Privacy policy is web-only (GitHub link) — fails offline. Consider embedding a copy in-app.
Planned Work (Backlog)
UX / Feature Improvements
- Weekly/monthly view should display daily averages — In
NutritionDetailView, when selectedPeriod is .weekly or .monthly, macroTotals currently accumulates totals. For multi-day periods, display the daily average (total ÷ days in period) so "14,000 kcal this week" becomes "2,000 kcal/day avg". The period picker label should reflect this ("Weekly Avg", "Monthly Avg"). Same applies to the comparisonCard values in HistoryView.weekComparisonSection (already divides by 7 — verify .monthly path does the same).
Open Food Facts Integration
- Open Food Facts (OFF) is a free, open database of 4M+ food products. Integrated into the Food Bank for searching and adding foods without scanning.
- Service:
OpenFoodFactsService — @Observable @MainActor service for OFF REST API calls (text search + barcode lookup). Injected via .environment() from MacrosApp.
- API endpoints used:
- Text search:
GET https://search.openfoodfacts.org/search?q={query}&fields=product_name,brands,code&page_size=25 — dedicated Elasticsearch-backed search service (the legacy v1 CGI endpoint returns 503)
- Barcode/product lookup (v2):
GET https://world.openfoodfacts.org/api/v2/product/{barcode}?fields=... — full nutrition data
- Two-step flow: Search returns lightweight hits (name, brand, code via
OFFSearchHit); tapping a result fetches full nutrition via barcode lookup → OFFProduct
- Fields requested (for product detail):
product_name,brands,nutriments,serving_size,serving_quantity,code
- Nutriments mapping:
energy-kcal_100g → calories, proteins_100g → protein, carbohydrates_100g → carbs, fat_100g → fat, plus micronutrients (fiber, sugar, sodium, etc.)
- User-Agent:
OpenFoodJournal/1.0 (openfoodjournal@example.com) — required by OFF API policy
- Rate limits: 10 req/min for search, 100 req/min for product reads
- No auth required for reads; writes (contribute) require OFF account credentials
- UI access points:
- FoodBankView toolbar: "+" button (leading of sort button) → Menu with 3 options: Scan, Manual Entry, Search Open Food Facts
- Scan and Manual Entry reuse existing sheets (
ScanView via DailyLogSheet, ManualEntryView)
- Search Open Food Facts opens
OpenFoodFactsSearchView — debounced text input, paginated results, tap to review details
- Result action: Same pattern as ManualEntryView — "Add to Journal" or "Add to Journal & Food Bank" via toolbar Menu
- Settings:
@AppStorage("off.contributeEnabled") toggle (default: false) in SettingsView. When enabled, scanned nutrition data is submitted back to OFF after the user logs it. Write API uses POST /cgi/product_jqm2.pl with user credentials.
- Data flow: OFF search result →
OFFProduct struct (Codable) → converted to NutritionEntry / SavedFood with scanMode: .manual and brand preserved. Nutrition values from OFF are per-100g; conversion to serving-based values uses serving_size field when available.
- No SPM dependency: Direct
URLSession calls, no OFF Swift SDK imported (keeps zero-dependency architecture).