Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9f5582a
feat(frontend): ✨ added header + nutrition breakdown column on nutrit…
samikabhatkar-web Feb 23, 2026
98bf0d8
feat(frontend): ✨ added userGoal table and macro counting component o…
samikabhatkar-web Feb 23, 2026
c166e2e
feat(frontend): ✨ remove datastring from props
samikabhatkar-web Feb 23, 2026
6a4663d
feat(frontend): ✨ edited macro breakdown + added history button
samikabhatkar-web Mar 1, 2026
5b61ef2
feat: ✨ tracked-meal-card displays food card details, has working del…
Mar 2, 2026
4e62ab2
fix: 🐛 corrected tracked-meal-card to reflect food-card's implemeneta…
Mar 2, 2026
c930fe8
feat: ✨ added edit servings/bowls functionality
Mar 2, 2026
5ad6ef7
feat: ✨ added checking for unavailable dishes
Mar 2, 2026
102f4c1
fix: 🐛 tracker heading displays date
Mar 2, 2026
ce5553d
feat: ✨ if diet plan tagged but unavailable, move to uncounted foods
Mar 2, 2026
a978a80
fix: 🐛 corrected nutrition breakdown overflow
Mar 2, 2026
1edba0f
Merge branch 'dev' into 643/new-nutrition-page
Mar 3, 2026
2fafa40
feat(frontend): ✨ fixed mobile view of tracker page
samikabhatkar-web Mar 3, 2026
3fcac82
feat(frontend): ✨ fixed edit box bug
samikabhatkar-web Mar 3, 2026
8084181
feat(fix): ✨ 🐛 changed protein default to 100 g
samikabhatkar-web Mar 4, 2026
7874fc5
feat(fix): ✨ 🐛 clicking outside edit goals component works
samikabhatkar-web Mar 4, 2026
6229260
feat(fix): ✨ 🐛 removed duplicate logged meals procedure
samikabhatkar-web Mar 4, 2026
85a136e
feat(frontend): ✨ added prev meals/history logic, need to fix some bugs
samikabhatkar-web Mar 5, 2026
70f6b43
feat(fix): ✨ 🐛 cleaned up dialog click issues
samikabhatkar-web Mar 5, 2026
a346584
feat(fix): ✨ 🐛 removed diet plan logic
samikabhatkar-web Mar 5, 2026
9106722
feat(style): ✨ 🎨 added drawer for meal history tracker
samikabhatkar-web Mar 5, 2026
ab75af8
feat(build): ✨ 📦️ added usergoalsbyday table + trpc procedures
samikabhatkar-web Mar 5, 2026
07be1f1
feat(style): ✨ 🎨 fixed counted food card to be wider on mobile
samikabhatkar-web Mar 5, 2026
66917e2
feat(fix): ✨ 🐛 removed goalsbyday import in index.ts
samikabhatkar-web Mar 5, 2026
97e492e
feat(fix): ✨ 🐛 had to export userGoalsByDay in index.ts (not sure why…
samikabhatkar-web Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 232 additions & 29 deletions apps/next/src/app/nutrition/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import TrackedMealCard from "@/components/ui/card/tracked-meal-card";
import MobileCalorieCard from "@/components/ui/mobile-calorie-card";
import MobileNutritionBars from "@/components/ui/mobile-nutrition-bars";
import NutritionBreakdown from "@/components/ui/nutrition-breakdown";
import NutritionGoals from "@/components/ui/nutrition-goals";
import TrackerHistory from "@/components/ui/tracker-history";
import TrackerHistoryDialog from "@/components/ui/tracker-history-dialog";
import TrackerHistoryDrawer from "@/components/ui/tracker-history-drawer";
import { useSnackbarStore } from "@/context/useSnackbar";
import { useUserStore } from "@/context/useUserStore";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { trpc } from "@/utils/trpc";

export default function MealTracker() {
Expand All @@ -29,7 +37,30 @@ export default function MealTracker() {
{ userId: userId ?? "" },
{ enabled: !!userId },
);
const [activeDayIndex, setActiveDayIndex] = useState<number | null>(null);

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<Date | null>(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 [];
Expand All @@ -50,15 +81,7 @@ export default function MealTracker() {
return result.sort((a, b) => b.rawDate.getTime() - a.rawDate.getTime());
}, [meals]);

useEffect(() => {
if (mealsGroupedByDay.length > 0 && activeDayIndex === null) {
setActiveDayIndex(0);
}
}, [mealsGroupedByDay, activeDayIndex]);

if (isLoading) return <div>Loading meals...</div>;
if (error) return <div>Error loading meals</div>;

const activeDayIndex = mealsGroupedByDay.length > 0 ? 0 : null;
const selectedDay =
activeDayIndex !== null ? mealsGroupedByDay[activeDayIndex] : null;

Expand All @@ -67,35 +90,215 @@ export default function MealTracker() {
return Number.isFinite(n) ? n : 0;
};

// Checks dish availability
const { data: hallData } = trpc.peterplate.useQuery(
{ date: selectedDay?.rawDate ?? new Date() },
{ enabled: Boolean(selectedDay) },
);

const availableDishIds = useMemo(() => {
const set = new Set<string>();
const halls = [hallData?.anteatery, hallData?.brandywine].filter(Boolean);

for (const hall of halls) {
for (const menu of hall?.menus ?? []) {
for (const station of menu.stations ?? []) {
for (const dish of station.dishes ?? []) {
set.add(dish.id);
}
}
}
}
return set;
}, [hallData]);

const isUnavailable = (dishId: string) =>
Boolean(hallData) && !availableDishIds.has(dishId);

// remove dish from tracker if unavailable
const visibleMeals = selectedDay?.items ?? [];

const countedMeals = visibleMeals.filter(
(m) => (m.servings ?? 0) > 0 && !isUnavailable(m.dishId),
);

const isMobile = useMediaQuery("(max-width: 768px)");

if (isLoading) return <div>Loading meals...</div>;
if (error) return <div>Error loading meals</div>;

return (
<div className="cols-container min-h-screen flex">
<div className="mt-12 w-[300px] border-r p-4 flex flex-col gap-2">
{mealsGroupedByDay.map((day, index) => (
<button
type="button"
key={day.dateLabel}
onClick={() => setActiveDayIndex(index)}
className={`text-left p-2 hover:bg-gray-100 ${activeDayIndex === index ? "font-bold bg-gray-200" : ""}`}
>
{day.dateLabel}
</button>
))}
{mealsGroupedByDay.length === 0 && <div>No meals logged recently.</div>}
</div>
<div className="min-h-screen p-8 mt-12">
<div className="px-2 md:px-8">
<h1 className="text-2xl md:text-3xl font-bold text-sky-700 dark:text-sky-400 flex items-center justify-between">
<span className="flex items-center gap-2 flex-nowrap whitespace-nowrap">
Tracker
{selectedDay && (
<span className="font-semibold">
-{" "}
{selectedDay.rawDate.toLocaleDateString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
})}
</span>
)}
</span>
{/* History button - mobile only */}
<div className="flex md:hidden">
{userId && (
<TrackerHistory
onDateSelect={() => {}}
onDayClick={(date) => {
setHistoryDate(date);
setHistoryDialogOpen(true);
}}
/>
)}
</div>
</h1>

{/* Subheading + History - desktop only */}
<div className="hidden md:flex items-center justify-between mt-1">
<p className="text-zinc-800 dark:text-zinc-400">
Keep track of your health using our Nutrition Tracker! Add dishes to
count them towards your totals!
</p>
{userId && (
<TrackerHistory
onDateSelect={() => {}}
onDayClick={(date) => {
setHistoryDate(date);
setHistoryDialogOpen(true);
}}
/>
)}
</div>

<div className="mt-12 p-4">
{selectedDay && (
<NutritionBreakdown
dateString={selectedDay.dateLabel}
mealsEaten={selectedDay.items.map((m) => ({
{/* Desktop: NutritionBreakdown */}
<div className="hidden md:flex items-start gap-4">
{mealsGroupedByDay.length === 0 ? (
<div>No meals logged recently.</div>
) : (
<NutritionBreakdown
mealsEaten={countedMeals.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 ?? 75}
carbGoal={goals?.carbGoal ?? 250}
fatGoal={goals?.fatGoal ?? 50}
/>
)}
<div className="relative mt-4 ml-auto">
{userId && (
<NutritionGoals
userId={userId}
date={new Date().toISOString().split("T")[0]}
/>
)}
</div>
</div>

{/* Mobile: MobileCalorieCard + MobileNutritionBars */}
<div className="flex md:hidden flex-col gap-4 mt-4 w-full">
<MobileCalorieCard
mealsEaten={countedMeals.map((m) => ({
...m,
calories: toNum(m.calories),
protein: toNum(m.protein),
carbs: toNum(m.carbs),
fat: toNum(m.fat),
}))}
calorieGoal={goals?.calorieGoal ?? 2000}
userId={userId ?? ""}
date={new Date().toISOString().split("T")[0]}
/>
<MobileNutritionBars
mealsEaten={countedMeals.map((m) => ({
...m,
calories: toNum(m.calories),
protein: toNum(m.protein),
carbs: toNum(m.carbs),
fat: toNum(m.fat),
}))}
proteinGoal={goals?.proteinGoal ?? 75}
carbGoal={goals?.carbGoal ?? 250}
fatGoal={goals?.fatGoal ?? 50}
/>
</div>

{isMobile ? (
<TrackerHistoryDrawer
open={historyDialogOpen}
onClose={() => 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={historyGoals?.calorieGoal ?? 2000}
proteinGoal={historyGoals?.proteinGoal ?? 100}
carbGoal={historyGoals?.carbGoal ?? 250}
fatGoal={historyGoals?.fatGoal ?? 50}
userId={userId ?? ""}
/>
) : (
<TrackerHistoryDialog
open={historyDialogOpen}
onClose={() => 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={historyGoals?.calorieGoal ?? 2000}
proteinGoal={historyGoals?.proteinGoal ?? 100}
carbGoal={historyGoals?.carbGoal ?? 250}
fatGoal={historyGoals?.fatGoal ?? 50}
userId={userId ?? ""}
/>
)}

{/* Counted Foods */}
<div className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
Counted Foods
</h2>
{countedMeals.length === 0 ? (
<p className="mt-2 text-sm text-zinc-500">No counted foods.</p>
) : (
<div className="flex flex-wrap gap-4 mt-4">
{countedMeals.map((meal) => (
<TrackedMealCard
key={meal.id}
isUnavailable={isUnavailable(meal.dishId)}
meal={{
...meal,
calories: toNum(meal.calories),
protein: toNum(meal.protein),
carbs: toNum(meal.carbs),
fat: toNum(meal.fat),
}}
/>
))}
</div>
)}
</div>
</div>
</div>
);
Expand Down
32 changes: 24 additions & 8 deletions apps/next/src/components/progress-donut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex flex-col items-center justify-center p-4 pt-0">
<div className="relative w-40 h-40">
<div className="flex flex-col items-center justify-center">
<div className="relative w-36 h-36">
<svg viewBox="0 0 100 100" className="w-full h-full">
<title>Progress Donut</title>
{/* outer translucent white circle */}
<circle cx="50" cy="50" r="30" fill="white" fillOpacity="0.5" />
{/* inner white circle */}
<circle cx="50" cy="50" r="25" fill="white" fillOpacity="0.9" />
{/* background arc track - semicircle */}
<circle
cx="50"
cy="50"
r={CIRCLE_RADIUS}
stroke="#e5e7eb"
stroke={trackColor}
strokeWidth="10"
strokeLinecap="round"
fill="none"
strokeDasharray={`${CIRCLE_CIRCUMFERENCE * 0.75} ${CIRCLE_CIRCUMFERENCE * 0.25}`}
transform="rotate(135 50 50)"
/>
{/* progress arc */}
<circle
cx="50"
cy="50"
r={CIRCLE_RADIUS}
stroke="#3b82f6"
stroke={progressColor}
strokeWidth="10"
fill="none"
strokeDasharray={CIRCLE_CIRCUMFERENCE}
strokeDashoffset={strokeDashoffset}
strokeDasharray={`${CIRCLE_CIRCUMFERENCE * 0.75 * percent} ${CIRCLE_CIRCUMFERENCE}`}
strokeLinecap="round"
transform="rotate(-90 50 50)"
transform="rotate(135 50 50)"
style={{ transition: "stroke-dashoffset 0.4s ease" }}
/>
</svg>
Expand All @@ -61,7 +77,7 @@ export function ProgressDonut({
{progress_value}
{display_unit}
</span>
<span className="text-sm text-muted-foreground">/ {max_value}</span>
<span className="text-sm font-bold text-gray-400">/ {max_value}</span>
</div>
</div>
</div>
Expand Down
Loading