ProductPort is a local-first, offline-capable web application that turns a short onboarding questionnaire into a personalized weekly meal plan. It recommends recipes that respect the user's diet, allergies, budget, cooking ability, and health goals, calculates calorie/macronutrient splits, and lists missing ingredients for recipes matched against items in their fridge.
Follow these steps to run, test, and build the application locally:
Clone the repository and install dependencies:
npm installStart the local Vite development server:
npm run devRun the Vitest test suite once (includes unit, integration, and accessibility checks):
npm run test --runOr run the tests in interactive watch mode:
npm run testRun oxlint code linting:
npm run lintVerify type checks and compile optimized static assets:
npm run buildOnce the build is complete, run the production preview server locally:
npm run previewProductPort is designed using a clean, layered architecture with a clear separation between the UI components, business logic engines, and database persistence layers.
graph TD
UI[React UI Components] --> Store[Zustand Stores - src/app/store.ts]
Store --> Root[Composition Root - src/app/compositionRoot.ts]
Root --> Engines[Core Logic Engines - src/core/]
Engines --> Recommender[RuleBasedRecommender]
Engines --> Nutrition[StaticNutritionProvider]
Store --> Repos[Repository Helpers - src/data/repo.ts]
Repos --> DB[(Dexie IndexedDB - src/data/db.ts)]
- React UI (src/features/ & src/components/): Mobile-first layouts representing screens (Recipes library, Planner, Onboarding, Profile settings, Fridge ingredient matcher).
- State Management (src/app/store.ts): Zustand stores containing application states (UI modal state, profile details, active weekly plans, and user pantry items).
- Local Database (src/data/db.ts & src/data/repo.ts): Local-first storage powered by Dexie and IndexedDB. Fully functional offline with zero server dependencies.
- Logic Engines (src/core/): Contains clean, pure TypeScript business logic including
nutrition.ts(BMR/TDEE calculations),ruleBasedRecommender.ts(meal planning & constraints verification), andfridgeMatcher.ts(ingredients mapping). - Offline Support: App assets and skeleton are cached offline using a Service Worker (
public/sw.js). All dynamic data persists locally in IndexedDB, meaning ProductPort runs fully offline.
To support clean drop-in replacements, ProductPort abstracts planning and scoring behind provider interfaces. You can plug in a future AIRecommendationProvider without modifying any UI components or Zustand stores.
All recommendation logic must conform to the RecommendationProvider interface defined in src/core/providers.ts:
export interface RecommendationProvider {
scoreRecipes(candidates: Recipe[], ctx: RecommendationContext): ScoredRecipe[];
generateWeeklyPlan(input: PlanInput): MealPlan;
suggestSwap(
dayOfWeek: number,
slot: MealType,
plan: MealPlan,
recipes: Recipe[],
profile: UserProfile
): Recipe[];
}-
Create the Provider: Create a new file
src/core/aiRecommender.tsimplementingRecommendationProvider:import type { RecommendationProvider, RecommendationContext, PlanInput, ScoredRecipe } from './providers'; import type { Recipe, UserProfile, MealPlan, MealType } from '../data/types'; export class AIRecommendationProvider implements RecommendationProvider { scoreRecipes(candidates: Recipe[], ctx: RecommendationContext): ScoredRecipe[] { // Call your AI model endpoint or local LLM logic here to score candidates return []; } generateWeeklyPlan(input: PlanInput): MealPlan { // Call LLM logic to generate a full 7-day meal plan return {} as MealPlan; } suggestSwap(dayOfWeek: number, slot: MealType, plan: MealPlan, recipes: Recipe[], profile: UserProfile): Recipe[] { // AI-driven meal alternatives suggestions return []; } }
-
Register in the Composition Root: Open the composition root file src/app/compositionRoot.ts and swap out the provider:
import { AIRecommendationProvider } from '../core/aiRecommender'; // 1. Import new provider import { StaticNutritionProvider } from '../core/staticNutritionProvider'; import type { RecommendationProvider, NutritionProvider } from '../core/providers'; export interface AppProviders { recommender: RecommendationProvider; nutritionProvider: NutritionProvider; } export const providers: AppProviders = { recommender: new AIRecommendationProvider(), // 2. Instantiate new provider here! nutritionProvider: new StaticNutritionProvider(), }; export function setProviders(newProviders: Partial<AppProviders>) { Object.assign(providers, newProviders); }
No UI file or store changes are required, as they consume recommendations directly via providers.recommender references managed by the composition root.