From 9f5582a9dbb729431498e4dc0ea6f6416b33fd87 Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Sun, 22 Feb 2026 17:10:45 -0800 Subject: [PATCH 01/24] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20added=20hea?= =?UTF-8?q?der=20+=20nutrition=20breakdown=20column=20on=20nutrition=20pag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 32 ++++++------- apps/next/src/components/progress-donut.tsx | 6 +-- .../src/components/ui/nutrition-breakdown.tsx | 48 ++++--------------- .../src/components/ui/nutrition-goals.tsx | 1 + apps/next/src/components/ui/toolbar.tsx | 4 ++ 5 files changed, 32 insertions(+), 59 deletions(-) create mode 100644 apps/next/src/components/ui/nutrition-goals.tsx diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index 8574cf0e..8eb35c20 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -58,26 +58,24 @@ export default function MealTracker() { activeDayIndex !== null ? mealsGroupedByDay[activeDayIndex] : null; return ( -
-
- {mealsGroupedByDay.map((day, index) => ( - - ))} - {mealsGroupedByDay.length === 0 &&
No meals logged recently.
} +
+
+

+ Tracker +

+

+ Keep track of your health using out Nutrition Tracker! Add dishes to + count them towards your totals! +

-
- {selectedDay && ( +
+ {mealsGroupedByDay.length === 0 ? ( +
No meals logged recently.
+ ) : ( )}
diff --git a/apps/next/src/components/progress-donut.tsx b/apps/next/src/components/progress-donut.tsx index 990b1167..b9845390 100644 --- a/apps/next/src/components/progress-donut.tsx +++ b/apps/next/src/components/progress-donut.tsx @@ -31,7 +31,7 @@ export function ProgressDonut({ return (
-
+
Progress Donut - / {max_value} + / {max_value}
diff --git a/apps/next/src/components/ui/nutrition-breakdown.tsx b/apps/next/src/components/ui/nutrition-breakdown.tsx index 8f0e3d75..938be58a 100644 --- a/apps/next/src/components/ui/nutrition-breakdown.tsx +++ b/apps/next/src/components/ui/nutrition-breakdown.tsx @@ -75,34 +75,33 @@ const NutritionBreakdown = ({ dateString, mealsEaten }: Props) => { return (
-
{dateString}
-
-
-
Calories
+
+
+ Calories
-
-
Protein
+
+ Protein
-
-
Carbs
+
+ Carbs
-
-
Fat
+
+ Fat { />
-
- {mealsEaten?.map((meal) => ( -
-
-

- {meal.servings} serving{meal.servings > 1 ? "s" : ""} of{" "} - {meal.dishName} -

-

- {Math.round(meal.calories * meal.servings)} calories |  - {Math.round(meal.protein * meal.servings)}g protein |  - {Math.round(meal.carbs * meal.servings)}g carbs |  - {Math.round(meal.fat * meal.servings)}g fat -

-
- - -
- ))} -
); }; diff --git a/apps/next/src/components/ui/nutrition-goals.tsx b/apps/next/src/components/ui/nutrition-goals.tsx new file mode 100644 index 00000000..f9221d72 --- /dev/null +++ b/apps/next/src/components/ui/nutrition-goals.tsx @@ -0,0 +1 @@ +// tbd, will be the blue bar where users can enter nutritional info diff --git a/apps/next/src/components/ui/toolbar.tsx b/apps/next/src/components/ui/toolbar.tsx index 9884d103..d739a4e1 100644 --- a/apps/next/src/components/ui/toolbar.tsx +++ b/apps/next/src/components/ui/toolbar.tsx @@ -69,6 +69,10 @@ const TOOLBAR_ELEMENTS: ToolbarElement[] = [ title: "My Foods", href: "/my-foods", }, + { + title: "Nutrition", + href: "/nutrition", + }, ]; function ToolbarDropdown({ element }: { element: ToolbarElement }) { From 98bf0d8a164ee3dcfcadb6bbedb1ec89ce1111ed Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Sun, 22 Feb 2026 19:01:16 -0800 Subject: [PATCH 02/24] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20added=20use?= =?UTF-8?q?rGoal=20table=20and=20macro=20counting=20component=20on=20nutri?= =?UTF-8?q?tion=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 22 +- .../components/ui/card/tracked-meal-card.tsx | 31 + .../src/components/ui/nutrition-breakdown.tsx | 23 +- .../src/components/ui/nutrition-goals.tsx | 93 +- packages/api/src/nutrition/router.ts | 39 +- packages/db/drizzle.config.ts | 1 + packages/db/migrations/0007_peterplate.sql | 9 + .../db/migrations/meta/0007_snapshot.json | 1797 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/userGoals.ts | 15 + 11 files changed, 2028 insertions(+), 10 deletions(-) create mode 100644 apps/next/src/components/ui/card/tracked-meal-card.tsx create mode 100644 packages/db/migrations/0007_peterplate.sql create mode 100644 packages/db/migrations/meta/0007_snapshot.json create mode 100644 packages/db/src/schema/userGoals.ts diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index 8eb35c20..020598ea 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -3,7 +3,9 @@ import type { SelectLoggedMeal } from "@peterplate/db"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import TrackedMealCard from "@/components/ui/card/tracked-meal-card"; import NutritionBreakdown from "@/components/ui/nutrition-breakdown"; +import NutritionGoals from "@/components/ui/nutrition-goals"; import { useUserStore } from "@/context/useUserStore"; import { trpc } from "@/utils/trpc"; @@ -23,7 +25,12 @@ export default function MealTracker() { data: meals, isLoading, error, - } = trpc.nutrition.getMealsInLastWeek.useQuery({ userId: userId! }); + } = trpc.nutrition.getMealsInLastWeek.useQuery({ userId: userId ?? "" }); + + const { data: goals } = trpc.nutrition.getGoals.useQuery({ + userId: userId ?? "", + }); + const [activeDayIndex, setActiveDayIndex] = useState(null); const mealsGroupedByDay = useMemo(() => { @@ -63,10 +70,17 @@ export default function MealTracker() {

Tracker

-

+

Keep track of your health using out Nutrition Tracker! Add dishes to count them towards your totals!

+ {userId && } + +
+ {selectedDay?.items.map((meal) => ( + + ))} +
@@ -76,6 +90,10 @@ export default function MealTracker() { )}
diff --git a/apps/next/src/components/ui/card/tracked-meal-card.tsx b/apps/next/src/components/ui/card/tracked-meal-card.tsx new file mode 100644 index 00000000..4e5de2f0 --- /dev/null +++ b/apps/next/src/components/ui/card/tracked-meal-card.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { SelectLoggedMeal } from "@peterplate/db"; + +type LoggedMealJoinedWithNutrition = SelectLoggedMeal & { + calories: number; + protein: number; + carbs: number; + fat: number; +}; + +interface Props { + meal: LoggedMealJoinedWithNutrition; +} + +export default function TrackedMealCard({ meal }: Props) { + return ( +
+

{meal.dishName}

+

+ {meal.servings} serving{meal.servings !== 1 ? "s" : ""} +

+
+ {Math.round(meal.calories * meal.servings)} cal + {Math.round(meal.protein * meal.servings)}g protein + {Math.round(meal.carbs * meal.servings)}g carbs + {Math.round(meal.fat * meal.servings)}g fat +
+
+ ); +} diff --git a/apps/next/src/components/ui/nutrition-breakdown.tsx b/apps/next/src/components/ui/nutrition-breakdown.tsx index 938be58a..0613268a 100644 --- a/apps/next/src/components/ui/nutrition-breakdown.tsx +++ b/apps/next/src/components/ui/nutrition-breakdown.tsx @@ -45,9 +45,20 @@ function compileMealData( interface Props { dateString: string; mealsEaten: LoggedMealJoinedWithNutrition[]; + calorieGoal: number; + proteinGoal: number; + carbGoal: number; + fatGoal: number; } -const NutritionBreakdown = ({ dateString, mealsEaten }: Props) => { +const NutritionBreakdown = ({ + dateString, + mealsEaten, + calorieGoal, + proteinGoal, + carbGoal, + fatGoal, +}: Props) => { const nutrition: NutritionData = compileMealData(mealsEaten); const utils = trpc.useUtils(); @@ -75,12 +86,12 @@ const NutritionBreakdown = ({ dateString, mealsEaten }: Props) => { return (
-
+
Calories
@@ -88,7 +99,7 @@ const NutritionBreakdown = ({ dateString, mealsEaten }: Props) => { Protein
@@ -96,7 +107,7 @@ const NutritionBreakdown = ({ dateString, mealsEaten }: Props) => { Carbs
@@ -104,7 +115,7 @@ const NutritionBreakdown = ({ dateString, mealsEaten }: Props) => { Fat
diff --git a/apps/next/src/components/ui/nutrition-goals.tsx b/apps/next/src/components/ui/nutrition-goals.tsx index f9221d72..2aa0c236 100644 --- a/apps/next/src/components/ui/nutrition-goals.tsx +++ b/apps/next/src/components/ui/nutrition-goals.tsx @@ -1 +1,92 @@ -// tbd, will be the blue bar where users can enter nutritional info +"use client"; + +import { useEffect, useState } from "react"; +import { trpc } from "@/utils/trpc"; + +interface Props { + userId: string; +} + +export default function NutritionGoals({ userId }: Props) { + const { data: goals } = trpc.nutrition.getGoals.useQuery({ userId }); + const utils = trpc.useUtils(); + const upsertGoals = trpc.nutrition.upsertGoals.useMutation({ + onSuccess: () => { + utils.nutrition.invalidate(); + }, + }); + + const [calorieGoal, setCalorieGoal] = useState(2000); + const [proteinGoal, setProteinGoal] = useState(75); + const [carbGoal, setCarbGoal] = useState(250); + const [fatGoal, setFatGoal] = useState(50); + + useEffect(() => { + if (goals) { + setCalorieGoal(goals.calorieGoal); + setProteinGoal(goals.proteinGoal); + setCarbGoal(goals.carbGoal); + setFatGoal(goals.fatGoal); + } + }, [goals]); + + const handleSave = () => { + upsertGoals.mutate({ userId, calorieGoal, proteinGoal, carbGoal, fatGoal }); + }; + + return ( +
+ + + + + +
+ ); +} diff --git a/packages/api/src/nutrition/router.ts b/packages/api/src/nutrition/router.ts index d8eef711..fc4de8f0 100644 --- a/packages/api/src/nutrition/router.ts +++ b/packages/api/src/nutrition/router.ts @@ -1,5 +1,5 @@ import { createTRPCRouter, publicProcedure } from "@api/trpc"; -import { loggedMeals, nutritionInfos } from "@peterplate/db"; +import { loggedMeals, nutritionInfos, userGoals } from "@peterplate/db"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, gt } from "drizzle-orm"; import { z } from "zod"; @@ -104,4 +104,41 @@ export const nutritionRouter = createTRPCRouter({ return result[0]; }), + + getGoals: publicProcedure + .input(z.object({ userId: z.string() })) + .query(async ({ ctx, input }) => { + const result = await ctx.db.query.userGoals.findFirst({ + where: (userGoals, { eq }) => eq(userGoals.userId, input.userId), + }); + return result ?? null; + }), + + upsertGoals: publicProcedure + .input( + z.object({ + userId: z.string(), + calorieGoal: z.number().min(100).max(10000), + proteinGoal: z.number().min(1).max(500), + carbGoal: z.number().min(1).max(1000), + fatGoal: z.number().min(1).max(500), + }), + ) + .mutation(async ({ ctx, input }) => { + const result = await ctx.db + .insert(userGoals) + .values(input) + .onConflictDoUpdate({ + target: userGoals.userId, + set: { + calorieGoal: input.calorieGoal, + proteinGoal: input.proteinGoal, + carbGoal: input.carbGoal, + fatGoal: input.fatGoal, + }, + }) + .returning(); + + return result[0]; + }), }); diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 56167971..a3deb648 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ "./src/schema/loggedMeals.ts", "./src/schema/userAllergies.ts", "./src/schema/userDietaryPreferences.ts", + "./src/schema/userGoals.ts", ], dbCredentials: { url: process.env.DATABASE_URL, ssl: false }, verbose: !process.env.CI, diff --git a/packages/db/migrations/0007_peterplate.sql b/packages/db/migrations/0007_peterplate.sql new file mode 100644 index 00000000..943879f6 --- /dev/null +++ b/packages/db/migrations/0007_peterplate.sql @@ -0,0 +1,9 @@ +CREATE TABLE "user_goals" ( + "user_id" text PRIMARY KEY NOT NULL, + "calorie_goal" integer DEFAULT 2000 NOT NULL, + "protein_goal" integer DEFAULT 75 NOT NULL, + "carb_goal" integer DEFAULT 250 NOT NULL, + "fat_goal" integer DEFAULT 50 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/meta/0007_snapshot.json b/packages/db/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..3e93bfcc --- /dev/null +++ b/packages/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,1797 @@ +{ + "id": "479afc2d-ff9f-45f9-ab19-10780e86f815", + "prevId": "769f654f-23bb-45b0-9b1e-f26d974e70db", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contributors": { + "name": "contributors", + "schema": "", + "columns": { + "login": { + "name": "login", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contributions": { + "name": "contributions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'PeterPlate Contributor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diet_restrictions": { + "name": "diet_restrictions", + "schema": "", + "columns": { + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contains_eggs": { + "name": "contains_eggs", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_fish": { + "name": "contains_fish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_milk": { + "name": "contains_milk", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_peanuts": { + "name": "contains_peanuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_sesame": { + "name": "contains_sesame", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_shellfish": { + "name": "contains_shellfish", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_soy": { + "name": "contains_soy", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_tree_nuts": { + "name": "contains_tree_nuts", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "contains_wheat": { + "name": "contains_wheat", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_gluten_free": { + "name": "is_gluten_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_halal": { + "name": "is_halal", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_kosher": { + "name": "is_kosher", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_locally_grown": { + "name": "is_locally_grown", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_organic": { + "name": "is_organic", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegan": { + "name": "is_vegan", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_vegetarian": { + "name": "is_vegetarian", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "diet_restrictions_dish_id_dishes_id_fk": { + "name": "diet_restrictions_dish_id_dishes_id_fk", + "tableFrom": "diet_restrictions", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dishes": { + "name": "dishes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredients": { + "name": "ingredients", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Ingredient Statement Not Available'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Other'" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "num_ratings": { + "name": "num_ratings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_rating": { + "name": "total_rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dishes_station_id_idx": { + "name": "dishes_station_id_idx", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dishes_name_idx": { + "name": "dishes_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dishes_category_idx": { + "name": "dishes_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dishes_station_id_stations_id_fk": { + "name": "dishes_station_id_stations_id_fk", + "tableFrom": "dishes", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "restaurant_id": { + "name": "restaurant_id", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end": { + "name": "end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "events_restaurant_id_idx": { + "name": "events_restaurant_id_idx", + "columns": [ + { + "expression": "restaurant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_restaurant_id_restaurants_id_fk": { + "name": "events_restaurant_id_restaurants_id_fk", + "tableFrom": "events", + "tableTo": "restaurants", + "columnsFrom": [ + "restaurant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "events_pk": { + "name": "events_pk", + "columns": [ + "title", + "restaurant_id", + "start" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "favorites_dish_id_dishes_id_fk": { + "name": "favorites_dish_id_dishes_id_fk", + "tableFrom": "favorites", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "pins_pk": { + "name": "pins_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dishes_to_menus": { + "name": "dishes_to_menus", + "schema": "", + "columns": { + "menu_id": { + "name": "menu_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "dishes_to_menus_menu_id_menus_id_fk": { + "name": "dishes_to_menus_menu_id_menus_id_fk", + "tableFrom": "dishes_to_menus", + "tableTo": "menus", + "columnsFrom": [ + "menu_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dishes_to_menus_dish_id_dishes_id_fk": { + "name": "dishes_to_menus_dish_id_dishes_id_fk", + "tableFrom": "dishes_to_menus", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dishes_to_menus_pk": { + "name": "dishes_to_menus_pk", + "columns": [ + "menu_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menus": { + "name": "menus", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "period_id": { + "name": "period_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "restaurant_id": { + "name": "restaurant_id", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "menus_restaurant_date_idx": { + "name": "menus_restaurant_date_idx", + "columns": [ + { + "expression": "restaurant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "menus_restaurant_id_idx": { + "name": "menus_restaurant_id_idx", + "columns": [ + { + "expression": "restaurant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "menus_date_idx": { + "name": "menus_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "menus_period_id_idx": { + "name": "menus_period_id_idx", + "columns": [ + { + "expression": "period_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "menus_restaurant_id_restaurants_id_fk": { + "name": "menus_restaurant_id_restaurants_id_fk", + "tableFrom": "menus", + "tableTo": "restaurants", + "columnsFrom": [ + "restaurant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "menus_period_id_date_restaurant_id_periods_id_date_restaurant_id_fk": { + "name": "menus_period_id_date_restaurant_id_periods_id_date_restaurant_id_fk", + "tableFrom": "menus", + "tableTo": "periods", + "columnsFrom": [ + "period_id", + "date", + "restaurant_id" + ], + "columnsTo": [ + "id", + "date", + "restaurant_id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "menus_price_nonnegative": { + "name": "menus_price_nonnegative", + "value": "price IS NULL OR price >= 0" + } + }, + "isRLSEnabled": false + }, + "public.nutrition_infos": { + "name": "nutrition_infos", + "schema": "", + "columns": { + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "serving_size": { + "name": "serving_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serving_unit": { + "name": "serving_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calories": { + "name": "calories", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_fat_g": { + "name": "total_fat_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "trans_fat_g": { + "name": "trans_fat_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "saturated_fat_g": { + "name": "saturated_fat_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cholesterol_mg": { + "name": "cholesterol_mg", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "sodium_mg": { + "name": "sodium_mg", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_carbs_g": { + "name": "total_carbs_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "dietary_fiber_g": { + "name": "dietary_fiber_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "sugars_g": { + "name": "sugars_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "calcium": { + "name": "calcium", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "iron": { + "name": "iron", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "vitamin_a": { + "name": "vitamin_a", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "vitamin_c": { + "name": "vitamin_c", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "nutrition_infos_dish_id_dishes_id_fk": { + "name": "nutrition_infos_dish_id_dishes_id_fk", + "tableFrom": "nutrition_infos", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logged_meals": { + "name": "logged_meals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_name": { + "name": "dish_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "servings": { + "name": "servings", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "eaten_at": { + "name": "eaten_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "logged_meals_user_id_users_id_fk": { + "name": "logged_meals_user_id_users_id_fk", + "tableFrom": "logged_meals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "logged_meals_dish_id_dishes_id_fk": { + "name": "logged_meals_dish_id_dishes_id_fk", + "tableFrom": "logged_meals", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "servings_is_valid": { + "name": "servings_is_valid", + "value": "((\"logged_meals\".\"servings\" * 2) = floor(\"logged_meals\".\"servings\" * 2)) AND (\"logged_meals\".\"servings\" >= 0.5)" + } + }, + "isRLSEnabled": false + }, + "public.periods": { + "name": "periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "restaurant_id": { + "name": "restaurant_id", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "start": { + "name": "start", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end": { + "name": "end", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "periods_restaurant_date_idx": { + "name": "periods_restaurant_date_idx", + "columns": [ + { + "expression": "restaurant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "periods_restaurant_id_restaurants_id_fk": { + "name": "periods_restaurant_id_restaurants_id_fk", + "tableFrom": "periods", + "tableTo": "restaurants", + "columnsFrom": [ + "restaurant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "periods_pk": { + "name": "periods_pk", + "columns": [ + "id", + "date", + "restaurant_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dish_id": { + "name": "dish_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ratings_dish_id_dishes_id_fk": { + "name": "ratings_dish_id_dishes_id_fk", + "tableFrom": "ratings", + "tableTo": "dishes", + "columnsFrom": [ + "dish_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "ratings_pk": { + "name": "ratings_pk", + "columns": [ + "user_id", + "dish_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restaurants": { + "name": "restaurants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "restaurant_name_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stations": { + "name": "stations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "restaurant_id": { + "name": "restaurant_id", + "type": "restaurant_id_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "stations_restaurant_id_idx": { + "name": "stations_restaurant_id_idx", + "columns": [ + { + "expression": "restaurant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stations_restaurant_id_restaurants_id_fk": { + "name": "stations_restaurant_id_restaurants_id_fk", + "tableFrom": "stations", + "tableTo": "restaurants", + "columnsFrom": [ + "restaurant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hasOnboarded": { + "name": "hasOnboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_users_id_fk": { + "name": "account_userId_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_users_id_fk": { + "name": "session_userId_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergies": { + "name": "user_allergies", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergy": { + "name": "allergy", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergies_userId_users_id_fk": { + "name": "user_allergies_userId_users_id_fk", + "tableFrom": "user_allergies", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_allergies_userId_allergy_pk": { + "name": "user_allergies_userId_allergy_pk", + "columns": [ + "userId", + "allergy" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_dietary_preferences": { + "name": "user_dietary_preferences", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preference": { + "name": "preference", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_dietary_preferences_userId_users_id_fk": { + "name": "user_dietary_preferences_userId_users_id_fk", + "tableFrom": "user_dietary_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "user_dietary_preferences_userId_preference_pk": { + "name": "user_dietary_preferences_userId_preference_pk", + "columns": [ + "userId", + "preference" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_goals": { + "name": "user_goals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "calorie_goal": { + "name": "calorie_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2000 + }, + "protein_goal": { + "name": "protein_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 75 + }, + "carb_goal": { + "name": "carb_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 250 + }, + "fat_goal": { + "name": "fat_goal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + } + }, + "indexes": {}, + "foreignKeys": { + "user_goals_user_id_users_id_fk": { + "name": "user_goals_user_id_users_id_fk", + "tableFrom": "user_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.restaurant_id_enum": { + "name": "restaurant_id_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + }, + "public.restaurant_name_enum": { + "name": "restaurant_name_enum", + "schema": "public", + "values": [ + "anteatery", + "brandywine" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 23d77d4e..b2fd5f1d 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1771019416483, "tag": "0006_peterplate", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1771811376183, + "tag": "0007_peterplate", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index f2e7f3fc..ae85d250 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -15,6 +15,7 @@ export * from "./restaurants"; export * from "./stations"; export * from "./userAllergies"; export * from "./userDietaryPreferences"; +export * from "./userGoals"; export * from "./users"; // import { drizzle } from 'drizzle-orm/node-postgres'; // or your DB driver diff --git a/packages/db/src/schema/userGoals.ts b/packages/db/src/schema/userGoals.ts new file mode 100644 index 00000000..dff86cf5 --- /dev/null +++ b/packages/db/src/schema/userGoals.ts @@ -0,0 +1,15 @@ +import { integer, pgTable, text } from "drizzle-orm/pg-core"; +import { users } from "./users"; + +export const userGoals = pgTable("user_goals", { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + calorieGoal: integer("calorie_goal").notNull().default(2000), + proteinGoal: integer("protein_goal").notNull().default(75), + carbGoal: integer("carb_goal").notNull().default(250), + fatGoal: integer("fat_goal").notNull().default(50), +}); + +export type InsertUserGoals = typeof userGoals.$inferInsert; +export type SelectUserGoals = typeof userGoals.$inferSelect; From c166e2e0f034478ee53929435d3e4a8e858a1b35 Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Sun, 22 Feb 2026 19:03:01 -0800 Subject: [PATCH 03/24] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20remove=20da?= =?UTF-8?q?tastring=20from=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/components/ui/nutrition-breakdown.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/next/src/components/ui/nutrition-breakdown.tsx b/apps/next/src/components/ui/nutrition-breakdown.tsx index 0613268a..b4ca7c45 100644 --- a/apps/next/src/components/ui/nutrition-breakdown.tsx +++ b/apps/next/src/components/ui/nutrition-breakdown.tsx @@ -43,7 +43,6 @@ function compileMealData( } interface Props { - dateString: string; mealsEaten: LoggedMealJoinedWithNutrition[]; calorieGoal: number; proteinGoal: number; @@ -52,7 +51,6 @@ interface Props { } const NutritionBreakdown = ({ - dateString, mealsEaten, calorieGoal, proteinGoal, From 6a4663d3bfe9f2bfcc83e80e97811c642c031a73 Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Sun, 1 Mar 2026 12:02:15 -0800 Subject: [PATCH 04/24] =?UTF-8?q?feat(frontend):=20=E2=9C=A8=20edited=20ma?= =?UTF-8?q?cro=20breakdown=20+=20added=20history=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 56 ++++--- apps/next/src/components/progress-donut.tsx | 28 +++- .../src/components/ui/nutrition-breakdown.tsx | 68 ++++----- .../src/components/ui/nutrition-goals.tsx | 137 +++++++++++------- apps/next/src/components/ui/toolbar.tsx | 2 +- .../components/ui/tracker-history-dialog.tsx | 1 + .../src/components/ui/tracker-history.tsx | 54 +++++++ 7 files changed, 231 insertions(+), 115 deletions(-) create mode 100644 apps/next/src/components/ui/tracker-history-dialog.tsx create mode 100644 apps/next/src/components/ui/tracker-history.tsx diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index 020598ea..5509a776 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import TrackedMealCard from "@/components/ui/card/tracked-meal-card"; import NutritionBreakdown from "@/components/ui/nutrition-breakdown"; import NutritionGoals from "@/components/ui/nutrition-goals"; +import TrackerHistory from "@/components/ui/tracker-history"; import { useUserStore } from "@/context/useUserStore"; import { trpc } from "@/utils/trpc"; @@ -65,16 +66,44 @@ export default function MealTracker() { activeDayIndex !== null ? mealsGroupedByDay[activeDayIndex] : null; return ( -
+

Tracker

-

- Keep track of your health using out Nutrition Tracker! Add dishes to - count them towards your totals! -

- {userId && } +
+

+ Keep track of your health using our Nutrition Tracker! Add dishes to + count them towards your totals! +

+ {userId && ( + { + const index = mealsGroupedByDay.findIndex( + (day) => day.rawDate.toDateString() === date.toDateString(), + ); + if (index !== -1) setActiveDayIndex(index); + }} + /> + )} +
+ +
+ {mealsGroupedByDay.length === 0 ? ( +
No meals logged recently.
+ ) : ( + + )} +
+ {userId && } +
+
{selectedDay?.items.map((meal) => ( @@ -82,21 +111,6 @@ export default function MealTracker() { ))}
- -
- {mealsGroupedByDay.length === 0 ? ( -
No meals logged recently.
- ) : ( - - )} -
); } diff --git a/apps/next/src/components/progress-donut.tsx b/apps/next/src/components/progress-donut.tsx index b9845390..8231df6c 100644 --- a/apps/next/src/components/progress-donut.tsx +++ b/apps/next/src/components/progress-donut.tsx @@ -18,41 +18,57 @@ interface Props { * The unit of measurement to display within the progress donut. Ex: 'g' for grams */ display_unit: string; + + /** + * ring colors, passing props to add custom colors to certain goals + */ + trackColor?: string; + progressColor?: string; } export function ProgressDonut({ progress_value, max_value, display_unit, + trackColor = "#ffffff", + progressColor = "#0084D1", }: Props) { const value = Math.max(0, Math.min(progress_value, max_value)); const percent = value / max_value; const strokeDashoffset = CIRCLE_CIRCUMFERENCE * (1 - percent); return ( -
+
Progress Donut + {/* outer translucent white circle */} + + {/* inner white circle */} + + {/* background arc track - semicircle */} + {/* progress arc */} diff --git a/apps/next/src/components/ui/nutrition-breakdown.tsx b/apps/next/src/components/ui/nutrition-breakdown.tsx index b4ca7c45..422f7624 100644 --- a/apps/next/src/components/ui/nutrition-breakdown.tsx +++ b/apps/next/src/components/ui/nutrition-breakdown.tsx @@ -83,40 +83,40 @@ const NutritionBreakdown = ({ }; return ( -
-
-
- Calories - -
-
- Protein - -
-
- Carbs - -
-
- Fat - -
+
+
+ Calories + +
+
+ Protein + +
+
+ Carbs + +
+
+ Fat +
); diff --git a/apps/next/src/components/ui/nutrition-goals.tsx b/apps/next/src/components/ui/nutrition-goals.tsx index 2aa0c236..36c227cb 100644 --- a/apps/next/src/components/ui/nutrition-goals.tsx +++ b/apps/next/src/components/ui/nutrition-goals.tsx @@ -1,5 +1,7 @@ "use client"; +import EditIcon from "@mui/icons-material/Edit"; +import { Button, Dialog, DialogContent, DialogTitle } from "@mui/material"; import { useEffect, useState } from "react"; import { trpc } from "@/utils/trpc"; @@ -16,6 +18,7 @@ export default function NutritionGoals({ userId }: Props) { }, }); + const [open, setOpen] = useState(false); const [calorieGoal, setCalorieGoal] = useState(2000); const [proteinGoal, setProteinGoal] = useState(75); const [carbGoal, setCarbGoal] = useState(250); @@ -30,63 +33,91 @@ export default function NutritionGoals({ userId }: Props) { } }, [goals]); - const handleSave = () => { - upsertGoals.mutate({ userId, calorieGoal, proteinGoal, carbGoal, fatGoal }); + const handleUpdate = (updates: Partial) => { + upsertGoals.mutate({ + userId, + calorieGoal, + proteinGoal, + carbGoal, + fatGoal, + ...updates, + }); }; return ( -
- - - - - + + + + {open && ( +
+ + + + +
+ )}
); } diff --git a/apps/next/src/components/ui/toolbar.tsx b/apps/next/src/components/ui/toolbar.tsx index d739a4e1..3f379f5b 100644 --- a/apps/next/src/components/ui/toolbar.tsx +++ b/apps/next/src/components/ui/toolbar.tsx @@ -70,7 +70,7 @@ const TOOLBAR_ELEMENTS: ToolbarElement[] = [ href: "/my-foods", }, { - title: "Nutrition", + title: "Tracker", href: "/nutrition", }, ]; diff --git a/apps/next/src/components/ui/tracker-history-dialog.tsx b/apps/next/src/components/ui/tracker-history-dialog.tsx new file mode 100644 index 00000000..327b5cc4 --- /dev/null +++ b/apps/next/src/components/ui/tracker-history-dialog.tsx @@ -0,0 +1 @@ +// will be component when u select a day on the calendar diff --git a/apps/next/src/components/ui/tracker-history.tsx b/apps/next/src/components/ui/tracker-history.tsx new file mode 100644 index 00000000..0a271cfc --- /dev/null +++ b/apps/next/src/components/ui/tracker-history.tsx @@ -0,0 +1,54 @@ +"use client"; + +import RestoreIcon from "@mui/icons-material/Restore"; +import { Button } from "@mui/material"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { StaticDatePicker } from "@mui/x-date-pickers/StaticDatePicker"; +import { useState } from "react"; + +interface Props { + onDateSelect: (date: Date) => void; +} + +export default function TrackerHistory({ onDateSelect }: Props) { + const [open, setOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ( +
+ + + {open && ( +
+ + { + setSelectedDate(date ?? null); + if (date) onDateSelect(date); + }} + onAccept={() => setOpen(false)} + displayStaticWrapperAs="desktop" + slotProps={{ + actionBar: { actions: [] }, + }} + sx={{ + "& .MuiDateCalendar-root": { + height: "300px", + }, + }} + /> + +
+ )} +
+ ); +} From 5b61ef26938d11aa592244c5a98a62d571a71a26 Mon Sep 17 00:00:00 2001 From: Yitong Liu Date: Sun, 1 Mar 2026 17:48:19 -0800 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20=E2=9C=A8=20tracked-meal-card=20d?= =?UTF-8?q?isplays=20food=20card=20details,=20has=20working=20delete=20but?= =?UTF-8?q?ton=20&=20WIP=20diet=20plan=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 27 ++- .../components/ui/card/tracked-meal-card.tsx | 197 ++++++++++++++++-- 2 files changed, 207 insertions(+), 17 deletions(-) diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index 5509a776..e4dad08f 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -1,6 +1,5 @@ "use client"; -import type { SelectLoggedMeal } from "@peterplate/db"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import TrackedMealCard from "@/components/ui/card/tracked-meal-card"; @@ -14,8 +13,9 @@ export default function MealTracker() { const router = useRouter(); const userId = useUserStore((s) => s.userId); + const toNum = (v: string | null | undefined) => Number(v ?? 0); + useEffect(() => { - // TODO: use [MUI snackbar](https://mui.com/material-ui/react-snackbar/) to warn users of issue if (!userId) { alert("Login to track meals!"); router.push("/"); @@ -38,7 +38,7 @@ export default function MealTracker() { if (!meals) return []; const groups: Record = {}; - meals.forEach((meal: SelectLoggedMeal) => { + meals.forEach((meal) => { const dateKey = new Date(meal.eatenAt).toDateString(); if (!groups[dateKey]) groups[dateKey] = []; groups[dateKey].push(meal); @@ -93,7 +93,15 @@ export default function MealTracker() {
No meals logged recently.
) : ( ({ + ...m, + calories: toNum(m.calories), + protein: toNum(m.protein), + carbs: toNum(m.carbs), + fat: toNum(m.fat), + }))} calorieGoal={goals?.calorieGoal ?? 2000} proteinGoal={goals?.proteinGoal ?? 75} carbGoal={goals?.carbGoal ?? 250} @@ -107,7 +115,16 @@ export default function MealTracker() {
{selectedDay?.items.map((meal) => ( - + ))}
diff --git a/apps/next/src/components/ui/card/tracked-meal-card.tsx b/apps/next/src/components/ui/card/tracked-meal-card.tsx index 4e5de2f0..3b95597a 100644 --- a/apps/next/src/components/ui/card/tracked-meal-card.tsx +++ b/apps/next/src/components/ui/card/tracked-meal-card.tsx @@ -1,6 +1,15 @@ "use client"; +import { Delete, LibraryBooksOutlined } from "@mui/icons-material"; +import { Dialog, Drawer } from "@mui/material"; import type { SelectLoggedMeal } from "@peterplate/db"; +import React from "react"; +import FoodDialogContent from "@/components/ui/food-dialog-content"; +import FoodDrawerContent from "@/components/ui/food-drawer-content"; +import { useDate } from "@/context/date-context"; +import { useHallStore } from "@/context/useHallStore"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { trpc } from "@/utils/trpc"; type LoggedMealJoinedWithNutrition = SelectLoggedMeal & { calories: number; @@ -14,18 +23,182 @@ interface Props { } export default function TrackedMealCard({ meal }: Props) { + /* Handle Display Food Card Info */ + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const { selectedDate } = useDate(); + const today = useHallStore((s) => s.today); + + const { data, isLoading } = trpc.peterplate.useQuery( + { date: selectedDate ?? today }, + { enabled: open }, + ); + + const dish = React.useMemo(() => { + const halls = [data?.anteatery, data?.brandywine].filter(Boolean); + for (const hall of halls) { + for (const menu of hall?.menus ?? []) { + for (const station of menu.stations ?? []) { + const found = station.dishes?.find((d) => d.id === meal.dishId); + if (found) return found; + } + } + } + return undefined; + }, [data, meal.dishId]); + + const utils = trpc.useUtils(); + + /* Handle Diet Plan Button */ + const [dietPlanActive, setDietPlanActive] = React.useState(false); + // TODO: implement diet plan functionality + + /* Handle Delete Button */ + const deleteLoggedMeal = trpc.nutrition.deleteLoggedMeal.useMutation({ + onSuccess: async () => { + await utils.nutrition.invalidate(); + }, + onError: (err) => { + console.error(err.message); + }, + }); + return ( -
-

{meal.dishName}

-

- {meal.servings} serving{meal.servings !== 1 ? "s" : ""} -

-
- {Math.round(meal.calories * meal.servings)} cal - {Math.round(meal.protein * meal.servings)}g protein - {Math.round(meal.carbs * meal.servings)}g carbs - {Math.round(meal.fat * meal.servings)}g fat -
-
+ <> + + + {/* Delete button */} + +
+
+ + + {isDesktop ? ( + + {dish ? ( + + ) : ( +
+ {isLoading ? "Loading..." : "Dish not found"} +
+ )} +
+ ) : ( + + {dish ? ( + + ) : ( +
+ {isLoading ? "Loading..." : "Dish not found"} +
+ )} +
+ )} + ); } From 4e62ab2e69481b276455f7917e6f5abdb54fe37c Mon Sep 17 00:00:00 2001 From: Yitong Liu Date: Sun, 1 Mar 2026 18:48:15 -0800 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20=F0=9F=90=9B=20corrected=20tracked?= =?UTF-8?q?-meal-card=20to=20reflect=20food-card's=20implemenetation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ui/card/tracked-meal-card.tsx | 266 +++++++++++------- packages/api/src/nutrition/router.ts | 50 +++- 2 files changed, 211 insertions(+), 105 deletions(-) diff --git a/apps/next/src/components/ui/card/tracked-meal-card.tsx b/apps/next/src/components/ui/card/tracked-meal-card.tsx index 3b95597a..ef86cae6 100644 --- a/apps/next/src/components/ui/card/tracked-meal-card.tsx +++ b/apps/next/src/components/ui/card/tracked-meal-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Delete, LibraryBooksOutlined } from "@mui/icons-material"; -import { Dialog, Drawer } from "@mui/material"; +import { Card, CardContent, Dialog, Drawer } from "@mui/material"; import type { SelectLoggedMeal } from "@peterplate/db"; import React from "react"; import FoodDialogContent from "@/components/ui/food-dialog-content"; @@ -10,6 +10,7 @@ import { useDate } from "@/context/date-context"; import { useHallStore } from "@/context/useHallStore"; import { useMediaQuery } from "@/hooks/useMediaQuery"; import { trpc } from "@/utils/trpc"; +import { cn } from "@/utils/tw"; type LoggedMealJoinedWithNutrition = SelectLoggedMeal & { calories: number; @@ -22,6 +23,101 @@ interface Props { meal: LoggedMealJoinedWithNutrition; } +interface TrackedMealCardContentProps + extends React.HTMLAttributes { + meal: LoggedMealJoinedWithNutrition; + dietPlanActive?: boolean; + onToggleDietPlan?: () => void; + onDelete?: () => void; + deleteDisabled?: boolean; +} + +const TrackedMealCardContent = React.forwardRef< + HTMLDivElement, + TrackedMealCardContentProps +>( + ( + { + meal, + dietPlanActive = false, + onToggleDietPlan, + onDelete, + deleteDisabled, + className, + ...divProps + }, + ref, + ) => { + return ( +
+ + +
+
+
+

+ {meal.dishName} +

+

+ {meal.servings} serving{meal.servings !== 1 ? "s" : ""} +

+
+ +
+ {Math.round(meal.calories * meal.servings)} cal + + {Math.round(meal.protein * meal.servings)}g protein + + {Math.round(meal.carbs * meal.servings)}g carbs + {Math.round(meal.fat * meal.servings)}g fat +
+
+ +
+ {/* Diet Plan button */} + + + {/* Delete button */} + +
+
+
+
+
+ ); + }, +); +TrackedMealCardContent.displayName = "TrackedMealCardContent"; + export default function TrackedMealCard({ meal }: Props) { /* Handle Display Food Card Info */ const isDesktop = useMediaQuery("(min-width: 768px)"); @@ -54,7 +150,6 @@ export default function TrackedMealCard({ meal }: Props) { /* Handle Diet Plan Button */ const [dietPlanActive, setDietPlanActive] = React.useState(false); - // TODO: implement diet plan functionality /* Handle Delete Button */ const deleteLoggedMeal = trpc.nutrition.deleteLoggedMeal.useMutation({ @@ -66,72 +161,25 @@ export default function TrackedMealCard({ meal }: Props) { }, }); - return ( - <> - - - {/* Delete button */} - -
-
- + if (isDesktop) + return ( + <> + setDietPlanActive((v) => !v)} + onDelete={handleDelete} + deleteDisabled={deleteLoggedMeal.isPending} + onClick={handleOpen} + /> - {isDesktop ? ( )} - ) : ( - + ); + + return ( + <> + setDietPlanActive((v) => !v)} + onDelete={handleDelete} + deleteDisabled={deleteLoggedMeal.isPending} + onClick={handleOpen} + /> + + - {dish ? ( - - ) : ( -
- {isLoading ? "Loading..." : "Dish not found"} -
- )} -
- )} + }, + }} + sx={{ + "& .MuiDrawer-paper": { + borderTopLeftRadius: "10px", + borderTopRightRadius: "10px", + marginTop: "96px", + height: "auto", + maxHeight: "85vh", + }, + }} + > + {dish ? ( + + ) : ( +
+ {isLoading ? "Loading..." : "Dish not found"} +
+ )} +
); } diff --git a/packages/api/src/nutrition/router.ts b/packages/api/src/nutrition/router.ts index fc4de8f0..4e82411e 100644 --- a/packages/api/src/nutrition/router.ts +++ b/packages/api/src/nutrition/router.ts @@ -1,7 +1,7 @@ import { createTRPCRouter, publicProcedure } from "@api/trpc"; import { loggedMeals, nutritionInfos, userGoals } from "@peterplate/db"; import { TRPCError } from "@trpc/server"; -import { and, desc, eq, gt } from "drizzle-orm"; +import { and, desc, eq, gt, gte, lt } from "drizzle-orm"; import { z } from "zod"; const LoggedMealSchema = z.object({ @@ -19,6 +19,52 @@ export const nutritionRouter = createTRPCRouter({ logMeal: publicProcedure .input(LoggedMealSchema) .mutation(async ({ ctx, input }) => { + // Check if the user has already logged this dish today + const eatenAt = input.eatenAt ?? new Date(); + + const startOfDay = new Date(eatenAt); + startOfDay.setHours(0, 0, 0, 0); + + const startOfNextDay = new Date(startOfDay); + startOfNextDay.setDate(startOfNextDay.getDate() + 1); + + const existing = await ctx.db + .select({ + id: loggedMeals.id, + servings: loggedMeals.servings, + }) + .from(loggedMeals) + .where( + and( + eq(loggedMeals.userId, input.userId), + eq(loggedMeals.dishId, input.dishId), + gte(loggedMeals.eatenAt, startOfDay), + lt(loggedMeals.eatenAt, startOfNextDay), + ), + ) + .limit(1); + + // If already logged today, update servings instead of creating a new entry + if (existing[0]) { + const updated = await ctx.db + .update(loggedMeals) + .set({ + servings: existing[0].servings + input.servings, + }) + .where(eq(loggedMeals.id, existing[0].id)) + .returning(); + + if (!updated[0]) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update logged meal", + }); + } + + return updated[0]; + } + + // Log entry as usual const result = await ctx.db .insert(loggedMeals) .values({ @@ -26,7 +72,7 @@ export const nutritionRouter = createTRPCRouter({ dishId: input.dishId, dishName: input.dishName, servings: input.servings, - eatenAt: input.eatenAt ?? new Date(), + eatenAt, }) .returning(); From c930fe87cf09c5c75b31800abadd78b1f0522505 Mon Sep 17 00:00:00 2001 From: Yitong Liu Date: Sun, 1 Mar 2026 20:46:41 -0800 Subject: [PATCH 07/24] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20edit=20servin?= =?UTF-8?q?gs/bowls=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 68 ++++++-- .../components/ui/card/tracked-meal-card.tsx | 154 ++++++++++++++++-- packages/api/src/nutrition/router.ts | 24 +++ 3 files changed, 219 insertions(+), 27 deletions(-) diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index e4dad08f..8eca03d1 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -65,6 +65,13 @@ export default function MealTracker() { const selectedDay = activeDayIndex !== null ? mealsGroupedByDay[activeDayIndex] : null; + const countedMeals = (selectedDay?.items ?? []).filter( + (m) => (m.servings ?? 0) > 0, + ); + const uncountedMeals = (selectedDay?.items ?? []).filter( + (m) => (m.servings ?? 0) === 0, + ); + return (
@@ -113,19 +120,54 @@ export default function MealTracker() {
-
- {selectedDay?.items.map((meal) => ( - - ))} + {/* Counted Foods */} +
+

+ Counted Foods +

+ {countedMeals.length === 0 ? ( +

No counted foods.

+ ) : ( +
+ {countedMeals.map((meal) => ( + + ))} +
+ )} +
+ + {/* Uncounted Foods */} +
+

+ Uncounted Foods +

+ {uncountedMeals.length === 0 ? ( +

No uncounted foods.

+ ) : ( +
+ {uncountedMeals.map((meal) => ( + + ))} +
+ )}
diff --git a/apps/next/src/components/ui/card/tracked-meal-card.tsx b/apps/next/src/components/ui/card/tracked-meal-card.tsx index ef86cae6..4be0e571 100644 --- a/apps/next/src/components/ui/card/tracked-meal-card.tsx +++ b/apps/next/src/components/ui/card/tracked-meal-card.tsx @@ -1,14 +1,22 @@ "use client"; -import { Delete, LibraryBooksOutlined } from "@mui/icons-material"; +import { + ArrowDropDown, + ArrowDropUp, + Delete, + LibraryBooksOutlined, + Restaurant, +} from "@mui/icons-material"; import { Card, CardContent, Dialog, Drawer } from "@mui/material"; import type { SelectLoggedMeal } from "@peterplate/db"; +import Image from "next/image"; import React from "react"; import FoodDialogContent from "@/components/ui/food-dialog-content"; import FoodDrawerContent from "@/components/ui/food-drawer-content"; import { useDate } from "@/context/date-context"; import { useHallStore } from "@/context/useHallStore"; import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { getFoodIcon } from "@/utils/funcs"; import { trpc } from "@/utils/trpc"; import { cn } from "@/utils/tw"; @@ -26,10 +34,15 @@ interface Props { interface TrackedMealCardContentProps extends React.HTMLAttributes { meal: LoggedMealJoinedWithNutrition; + dishNameForIcon?: string; + imageUrl?: string; dietPlanActive?: boolean; onToggleDietPlan?: () => void; onDelete?: () => void; deleteDisabled?: boolean; + servingsDraft: number; + onChangeServings: (next: number) => void; + servingsDisabled?: boolean; } const TrackedMealCardContent = React.forwardRef< @@ -39,15 +52,28 @@ const TrackedMealCardContent = React.forwardRef< ( { meal, + dishNameForIcon, + imageUrl, dietPlanActive = false, onToggleDietPlan, onDelete, deleteDisabled, + servingsDraft, + onChangeServings, + servingsDisabled, className, ...divProps }, ref, ) => { + const [imageError, setImageError] = React.useState(false); + const showImage = + typeof imageUrl === "string" && imageUrl.trim() !== "" && !imageError; + + const IconComponent = + (dishNameForIcon ? getFoodIcon(dishNameForIcon) : undefined) ?? + Restaurant; + return (
-
-
-

- {meal.dishName} -

-

- {meal.servings} serving{meal.servings !== 1 ? "s" : ""} -

+
+
+ {showImage && imageUrl ? ( + setImageError(true)} + /> + ) : ( + + )} + +
+

+ {meal.dishName} +

+ + {/* Edit Servings */} +
+
+
+ {servingsDraft} +
+ +
+ + + +
+
+ + + serving{servingsDraft !== 1 ? "s" : ""}/bowl + {servingsDraft !== 1 ? "s" : ""} + +
+
- {Math.round(meal.calories * meal.servings)} cal + {Math.round(meal.calories * servingsDraft)} cal - {Math.round(meal.protein * meal.servings)}g protein + {Math.round(meal.protein * servingsDraft)}g protein - {Math.round(meal.carbs * meal.servings)}g carbs - {Math.round(meal.fat * meal.servings)}g fat + {Math.round(meal.carbs * servingsDraft)}g carbs + {Math.round(meal.fat * servingsDraft)}g fat
-
{/* Diet Plan button */} - {open && ( -
- - - - -
+ {isMobile ? ( + setOpen(false)}> +
{inputs}
+
+ ) : ( + open && ( +
+ {inputs} +
+ ) )}
); From 8084181d37fee8e32e1609401003de6dcc2b01c1 Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Tue, 3 Mar 2026 16:06:34 -0800 Subject: [PATCH 14/24] =?UTF-8?q?feat(fix):=20=E2=9C=A8=20=F0=9F=90=9B=20c?= =?UTF-8?q?hanged=20protein=20default=20to=20100=20g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/db/src/schema/userGoals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/schema/userGoals.ts b/packages/db/src/schema/userGoals.ts index dff86cf5..ec971846 100644 --- a/packages/db/src/schema/userGoals.ts +++ b/packages/db/src/schema/userGoals.ts @@ -6,7 +6,7 @@ export const userGoals = pgTable("user_goals", { .primaryKey() .references(() => users.id, { onDelete: "cascade" }), calorieGoal: integer("calorie_goal").notNull().default(2000), - proteinGoal: integer("protein_goal").notNull().default(75), + proteinGoal: integer("protein_goal").notNull().default(100), carbGoal: integer("carb_goal").notNull().default(250), fatGoal: integer("fat_goal").notNull().default(50), }); From 7874fc589a43b51cba41498b592301d634ddcffb Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Wed, 4 Mar 2026 15:51:27 -0800 Subject: [PATCH 15/24] =?UTF-8?q?feat(fix):=20=E2=9C=A8=20=F0=9F=90=9B=20c?= =?UTF-8?q?licking=20outside=20edit=20goals=20component=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ui/nutrition-goals.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/next/src/components/ui/nutrition-goals.tsx b/apps/next/src/components/ui/nutrition-goals.tsx index fae4e317..09349c62 100644 --- a/apps/next/src/components/ui/nutrition-goals.tsx +++ b/apps/next/src/components/ui/nutrition-goals.tsx @@ -2,7 +2,7 @@ import EditIcon from "@mui/icons-material/Edit"; import { Button, Drawer } from "@mui/material"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMediaQuery } from "@/hooks/useMediaQuery"; import { trpc } from "@/utils/trpc"; @@ -22,7 +22,7 @@ export default function NutritionGoals({ userId }: Props) { const [open, setOpen] = useState(false); const [calorieGoal, setCalorieGoal] = useState(2000); - const [proteinGoal, setProteinGoal] = useState(75); + const [proteinGoal, setProteinGoal] = useState(100); const [carbGoal, setCarbGoal] = useState(250); const [fatGoal, setFatGoal] = useState(50); @@ -46,6 +46,24 @@ export default function NutritionGoals({ userId }: Props) { }); }; + const containerRef = useRef(null); + + useEffect(() => { + if (isMobile || !open) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open, isMobile]); + const inputs = ( <>
); diff --git a/apps/next/src/components/ui/tracker-history.tsx b/apps/next/src/components/ui/tracker-history.tsx index c6d84ba1..da6a9219 100644 --- a/apps/next/src/components/ui/tracker-history.tsx +++ b/apps/next/src/components/ui/tracker-history.tsx @@ -16,16 +16,21 @@ interface Props { export default function TrackerHistory({ onDateSelect, onDayClick }: Props) { const isMobile = useMediaQuery("(max-width: 768px)"); const [open, setOpen] = useState(false); - const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); const containerRef = useRef(null); useEffect(() => { if (isMobile || !open) return; const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + const isInsideDialog = document + .querySelector(".MuiDialog-root") + ?.contains(target); if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(target) && + !isInsideDialog ) { setOpen(false); } From a3465840c66a7cd97096e4765bf802b76ca3f82a Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Wed, 4 Mar 2026 21:13:04 -0800 Subject: [PATCH 19/24] =?UTF-8?q?feat(fix):=20=E2=9C=A8=20=F0=9F=90=9B=20r?= =?UTF-8?q?emoved=20diet=20plan=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 30 +------------ .../components/ui/card/tracked-meal-card.tsx | 45 ------------------- 2 files changed, 1 insertion(+), 74 deletions(-) diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index 0706892c..cff1c535 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -43,11 +43,6 @@ export default function MealTracker() { const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [historyDate, setHistoryDate] = useState(null); - // Diet plan toggle - const [dietPlanByMealId, setDietPlanByMealId] = useState< - Record - >({}); - const mealsGroupedByDay = useMemo(() => { if (!meals) return []; const groups: Record = {}; @@ -102,28 +97,12 @@ export default function MealTracker() { Boolean(hallData) && !availableDishIds.has(dishId); // remove dish from tracker if unavailable AND diet plan toggle off - const visibleMeals = useMemo(() => { - const items = selectedDay?.items ?? []; - - return items.filter((m) => { - const unavailable = Boolean(hallData) && !availableDishIds.has(m.dishId); - - const dietOn = Boolean(dietPlanByMealId[m.id]); - - return !(unavailable && !dietOn); - }); - }, [selectedDay, availableDishIds, dietPlanByMealId, hallData]); + const visibleMeals = selectedDay?.items ?? []; const countedMeals = visibleMeals.filter( (m) => (m.servings ?? 0) > 0 && !isUnavailable(m.dishId), ); - const uncountedMeals = visibleMeals.filter((m) => { - const unavailable = isUnavailable(m.dishId); - const dietOn = Boolean(dietPlanByMealId[m.id]); - return (m.servings ?? 0) === 0 || (unavailable && dietOn); - }); - if (isLoading) return
Loading meals...
; if (error) return
Error loading meals
; @@ -259,13 +238,6 @@ export default function MealTracker() { - setDietPlanByMealId((prev) => ({ - ...prev, - [meal.id]: !prev[meal.id], - })) - } meal={{ ...meal, calories: toNum(meal.calories), diff --git a/apps/next/src/components/ui/card/tracked-meal-card.tsx b/apps/next/src/components/ui/card/tracked-meal-card.tsx index e8e7001b..de171c96 100644 --- a/apps/next/src/components/ui/card/tracked-meal-card.tsx +++ b/apps/next/src/components/ui/card/tracked-meal-card.tsx @@ -4,7 +4,6 @@ import { ArrowDropDown, ArrowDropUp, Delete, - LibraryBooksOutlined, Restaurant, } from "@mui/icons-material"; import { Card, CardContent, Dialog, Drawer } from "@mui/material"; @@ -30,10 +29,6 @@ type LoggedMealJoinedWithNutrition = SelectLoggedMeal & { interface Props { meal: LoggedMealJoinedWithNutrition; isUnavailable?: boolean; - - // diet plan toggle - dietPlanActive?: boolean; - onToggleDietPlan?: () => void; } interface TrackedMealCardContentProps @@ -42,8 +37,6 @@ interface TrackedMealCardContentProps dishNameForIcon?: string; imageUrl?: string; isUnavailable?: boolean; - dietPlanActive?: boolean; - onToggleDietPlan?: () => void; onDelete?: () => void; deleteDisabled?: boolean; servingsDraft: number; @@ -61,8 +54,6 @@ const TrackedMealCardContent = React.forwardRef< dishNameForIcon, imageUrl, isUnavailable = false, - dietPlanActive = false, - onToggleDietPlan, onDelete, deleteDisabled, servingsDraft, @@ -178,24 +169,6 @@ const TrackedMealCardContent = React.forwardRef<
- {/* Diet Plan button */} - - {/* Delete button */}
- setHistoryDialogOpen(false)} - selectedDate={historyDate} - allMeals={ - meals?.map((m) => ({ - ...m, - calories: toNum(m.calories), - protein: toNum(m.protein), - carbs: toNum(m.carbs), - fat: toNum(m.fat), - })) ?? [] - } - calorieGoal={goals?.calorieGoal ?? 2000} - proteinGoal={goals?.proteinGoal ?? 100} - carbGoal={goals?.carbGoal ?? 250} - fatGoal={goals?.fatGoal ?? 50} - userId={userId ?? ""} - /> + {isMobile ? ( + setHistoryDialogOpen(false)} + selectedDate={historyDate} + allMeals={ + meals?.map((m) => ({ + ...m, + calories: toNum(m.calories), + protein: toNum(m.protein), + carbs: toNum(m.carbs), + fat: toNum(m.fat), + })) ?? [] + } + calorieGoal={goals?.calorieGoal ?? 2000} + proteinGoal={goals?.proteinGoal ?? 100} + carbGoal={goals?.carbGoal ?? 250} + fatGoal={goals?.fatGoal ?? 50} + userId={userId ?? ""} + /> + ) : ( + setHistoryDialogOpen(false)} + selectedDate={historyDate} + allMeals={ + meals?.map((m) => ({ + ...m, + calories: toNum(m.calories), + protein: toNum(m.protein), + carbs: toNum(m.carbs), + fat: toNum(m.fat), + })) ?? [] + } + calorieGoal={goals?.calorieGoal ?? 2000} + proteinGoal={goals?.proteinGoal ?? 100} + carbGoal={goals?.carbGoal ?? 250} + fatGoal={goals?.fatGoal ?? 50} + userId={userId ?? ""} + /> + )} {/* Counted Foods */}
diff --git a/apps/next/src/components/ui/tracker-history-drawer.tsx b/apps/next/src/components/ui/tracker-history-drawer.tsx new file mode 100644 index 00000000..6b69a88a --- /dev/null +++ b/apps/next/src/components/ui/tracker-history-drawer.tsx @@ -0,0 +1,89 @@ +"use client"; + +import CloseIcon from "@mui/icons-material/Close"; +import { Drawer, IconButton } from "@mui/material"; +import MobileCalorieCard from "@/components/ui/mobile-calorie-card"; +import MobileNutritionBars from "@/components/ui/mobile-nutrition-bars"; +import type { LoggedMealJoinedWithNutrition } from "@/components/ui/nutrition-breakdown"; +import TrackerHistoryMealTable from "@/components/ui/tracker-history-meal-table"; + +interface Props { + open: boolean; + onClose: () => void; + selectedDate: Date | null; + allMeals: LoggedMealJoinedWithNutrition[]; + calorieGoal: number; + proteinGoal: number; + carbGoal: number; + fatGoal: number; + userId: string; +} + +export default function TrackerHistoryDrawer({ + open, + onClose, + selectedDate, + allMeals, + calorieGoal, + proteinGoal, + carbGoal, + fatGoal, + userId, +}: Props) { + const mealsForDay = allMeals.filter((m) => + selectedDate + ? new Date(m.eatenAt).toDateString() === selectedDate.toDateString() + : false, + ); + + const dateLabel = selectedDate + ? selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }) + : ""; + + return ( + +
+
+ + What you ate on {dateLabel} + + + + +
+ + + + + + +
+
+ ); +} diff --git a/apps/next/src/components/ui/tracker-history-meal-table.tsx b/apps/next/src/components/ui/tracker-history-meal-table.tsx index 8cc7974b..f3bba948 100644 --- a/apps/next/src/components/ui/tracker-history-meal-table.tsx +++ b/apps/next/src/components/ui/tracker-history-meal-table.tsx @@ -25,11 +25,13 @@ export default function TrackerHistoryMealTable({ mealsEaten }: Props) { {mealsEaten.map((meal) => ( - - {meal.dishName} - - {meal.servings} - + +
+ {meal.dishName} + + {meal.servings} + +
{Math.round(meal.calories * meal.servings)} From ab75af86f9b7bf99654669473066e0a6ae166f8e Mon Sep 17 00:00:00 2001 From: SAMIKA BHAVESH BHATKAR Date: Thu, 5 Mar 2026 10:29:20 -0800 Subject: [PATCH 21/24] =?UTF-8?q?feat(build):=20=E2=9C=A8=20=F0=9F=93=A6?= =?UTF-8?q?=EF=B8=8F=20added=20usergoalsbyday=20table=20+=20trpc=20procedu?= =?UTF-8?q?res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/next/src/app/nutrition/page.tsx | 43 +- .../src/components/ui/mobile-calorie-card.tsx | 4 +- .../src/components/ui/nutrition-goals.tsx | 56 +- packages/api/src/nutrition/router.ts | 61 +- packages/db/drizzle.config.ts | 1 + packages/db/migrations/0008_peterplate.sql | 1 + packages/db/migrations/0009_peterplate.sql | 11 + .../db/migrations/meta/0008_snapshot.json | 1797 ++++++++++++++++ .../db/migrations/meta/0009_snapshot.json | 1872 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 14 + packages/db/src/index.ts | 2 + packages/db/src/schema/userGoalsByDay.ts | 19 + 12 files changed, 3849 insertions(+), 32 deletions(-) create mode 100644 packages/db/migrations/0008_peterplate.sql create mode 100644 packages/db/migrations/0009_peterplate.sql create mode 100644 packages/db/migrations/meta/0008_snapshot.json create mode 100644 packages/db/migrations/meta/0009_snapshot.json create mode 100644 packages/db/src/schema/userGoalsByDay.ts diff --git a/apps/next/src/app/nutrition/page.tsx b/apps/next/src/app/nutrition/page.tsx index 71ede623..344ff2bc 100644 --- a/apps/next/src/app/nutrition/page.tsx +++ b/apps/next/src/app/nutrition/page.tsx @@ -38,13 +38,30 @@ export default function MealTracker() { { enabled: !!userId }, ); - const { data: goals } = trpc.nutrition.getGoals.useQuery({ + const today = new Date().toISOString().split("T")[0]; + + const { data: defaultGoals } = trpc.nutrition.getGoals.useQuery({ userId: userId ?? "", }); + const { data: dayGoals } = trpc.nutrition.getGoalsByDay.useQuery( + { userId: userId ?? "", date: today }, + { enabled: !!userId }, + ); + const goals = dayGoals ?? defaultGoals; const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [historyDate, setHistoryDate] = useState(null); + const historyDateStr = historyDate + ? historyDate.toISOString().split("T")[0] + : today; + + const { data: historyDayGoals } = trpc.nutrition.getGoalsByDay.useQuery( + { userId: userId ?? "", date: historyDateStr }, + { enabled: !!userId && !!historyDate }, + ); + const historyGoals = historyDayGoals ?? defaultGoals; + const mealsGroupedByDay = useMemo(() => { if (!meals) return []; const groups: Record = {}; @@ -178,7 +195,12 @@ export default function MealTracker() { /> )}
- {userId && } + {userId && ( + + )}
@@ -194,6 +216,7 @@ export default function MealTracker() { }))} calorieGoal={goals?.calorieGoal ?? 2000} userId={userId ?? ""} + date={new Date().toISOString().split("T")[0]} /> ({ @@ -223,10 +246,10 @@ export default function MealTracker() { fat: toNum(m.fat), })) ?? [] } - calorieGoal={goals?.calorieGoal ?? 2000} - proteinGoal={goals?.proteinGoal ?? 100} - carbGoal={goals?.carbGoal ?? 250} - fatGoal={goals?.fatGoal ?? 50} + calorieGoal={historyGoals?.calorieGoal ?? 2000} + proteinGoal={historyGoals?.proteinGoal ?? 100} + carbGoal={historyGoals?.carbGoal ?? 250} + fatGoal={historyGoals?.fatGoal ?? 50} userId={userId ?? ""} /> ) : ( @@ -243,10 +266,10 @@ export default function MealTracker() { fat: toNum(m.fat), })) ?? [] } - calorieGoal={goals?.calorieGoal ?? 2000} - proteinGoal={goals?.proteinGoal ?? 100} - carbGoal={goals?.carbGoal ?? 250} - fatGoal={goals?.fatGoal ?? 50} + calorieGoal={historyGoals?.calorieGoal ?? 2000} + proteinGoal={historyGoals?.proteinGoal ?? 100} + carbGoal={historyGoals?.carbGoal ?? 250} + fatGoal={historyGoals?.fatGoal ?? 50} userId={userId ?? ""} /> )} diff --git a/apps/next/src/components/ui/mobile-calorie-card.tsx b/apps/next/src/components/ui/mobile-calorie-card.tsx index 68147cda..4b4a01b9 100644 --- a/apps/next/src/components/ui/mobile-calorie-card.tsx +++ b/apps/next/src/components/ui/mobile-calorie-card.tsx @@ -12,6 +12,7 @@ interface Props { calorieGoal: number; userId: string; hideEditButton?: boolean; + date?: string; } export default function MobileCalorieCard({ @@ -19,13 +20,14 @@ export default function MobileCalorieCard({ calorieGoal, userId, hideEditButton = false, + date, }: Props) { const nutrition = compileMealData(mealsEaten); return (
- {!hideEditButton && } + {!hideEditButton && }
Calories
diff --git a/apps/next/src/components/ui/nutrition-goals.tsx b/apps/next/src/components/ui/nutrition-goals.tsx index 09349c62..13bab17b 100644 --- a/apps/next/src/components/ui/nutrition-goals.tsx +++ b/apps/next/src/components/ui/nutrition-goals.tsx @@ -8,16 +8,26 @@ import { trpc } from "@/utils/trpc"; interface Props { userId: string; + date?: string; } -export default function NutritionGoals({ userId }: Props) { +export default function NutritionGoals({ userId, date }: Props) { const isMobile = useMediaQuery("(max-width: 768px)"); - const { data: goals } = trpc.nutrition.getGoals.useQuery({ userId }); const utils = trpc.useUtils(); + + const { data: defaultGoals } = trpc.nutrition.getGoals.useQuery({ userId }); + const { data: dayGoals } = trpc.nutrition.getGoalsByDay.useQuery( + { userId, date: date ?? "" }, + { enabled: !!date }, + ); + const goals = dayGoals ?? defaultGoals; + const upsertGoals = trpc.nutrition.upsertGoals.useMutation({ - onSuccess: () => { - utils.nutrition.invalidate(); - }, + onSuccess: () => utils.nutrition.invalidate(), + }); + + const upsertGoalsByDay = trpc.nutrition.upsertGoalsByDay.useMutation({ + onSuccess: () => utils.nutrition.invalidate(), }); const [open, setOpen] = useState(false); @@ -25,6 +35,7 @@ export default function NutritionGoals({ userId }: Props) { const [proteinGoal, setProteinGoal] = useState(100); const [carbGoal, setCarbGoal] = useState(250); const [fatGoal, setFatGoal] = useState(50); + const containerRef = useRef(null); useEffect(() => { if (goals) { @@ -35,22 +46,8 @@ export default function NutritionGoals({ userId }: Props) { } }, [goals]); - const handleUpdate = (updates: Partial) => { - upsertGoals.mutate({ - userId, - calorieGoal, - proteinGoal, - carbGoal, - fatGoal, - ...updates, - }); - }; - - const containerRef = useRef(null); - useEffect(() => { if (isMobile || !open) return; - const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && @@ -59,11 +56,30 @@ export default function NutritionGoals({ userId }: Props) { setOpen(false); } }; - document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [open, isMobile]); + const handleUpdate = (updates: { + calorieGoal?: number; + proteinGoal?: number; + carbGoal?: number; + fatGoal?: number; + }) => { + const merged = { + calorieGoal, + proteinGoal, + carbGoal, + fatGoal, + ...updates, + }; + if (date) { + upsertGoalsByDay.mutate({ userId, date, ...merged }); + } else { + upsertGoals.mutate({ userId, ...merged }); + } + }; + const inputs = ( <>