Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions apps/web/eslint.i18n-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@
"apps/web/src/modules/fizruk/pages/Progress.tsx",
"apps/web/src/modules/fizruk/pages/Workouts.tsx",
"apps/web/src/modules/nutrition/NutritionApp.tsx",
"apps/web/src/modules/nutrition/pages/NutritionLogPage.tsx",
"apps/web/src/modules/nutrition/pages/NutritionMenuPage.tsx",
"apps/web/src/modules/nutrition/pages/NutritionPantryPage.tsx",
"apps/web/src/modules/nutrition/pages/NutritionStartPage.tsx",
"apps/web/src/modules/nutrition/components/AddMealSheet.tsx",
"apps/web/src/modules/nutrition/components/BarcodeScanner.tsx",
"apps/web/src/modules/nutrition/components/DailyPlanCard.tsx",
Expand Down
417 changes: 89 additions & 328 deletions apps/web/src/modules/nutrition/NutritionApp.tsx

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions apps/web/src/modules/nutrition/hooks/useNutritionPrefsState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import type { NutritionPrefs } from "@sergeant/nutrition-domain";
import {
loadNutritionPrefs,
persistNutritionPrefs,
} from "../lib/nutritionStorage";
import { getCachedNutritionSqliteState } from "../lib/sqliteReader";

interface UseNutritionPrefsStateResult {
prefs: NutritionPrefs;
setPrefs: Dispatch<SetStateAction<NutritionPrefs>>;
prefsStorageErr: string;
}

/**
* Hydrates nutrition prefs from `localStorage` synchronously, then
* overlays the SQLite cache once it's warm (Stage 4 PR #033 + Stage 8
* PR #057n). Persists every update back to `localStorage` and surfaces
* a banner string when persist fails.
*/
export function useNutritionPrefsState(
sqliteCacheTick: number,
): UseNutritionPrefsStateResult {
const [prefs, setPrefs] = useState(() => loadNutritionPrefs());
const [prefsStorageErr, setPrefsStorageErr] = useState("");

useEffect(() => {
setPrefsStorageErr(
persistNutritionPrefs(prefs) ? "" : "Не вдалося зберегти налаштування.",
);
}, [prefs]);

useEffect(() => {
const cache = getCachedNutritionSqliteState();
if (cache.refreshedAt === null) return;
if (cache.prefs) setPrefs(cache.prefs);
}, [sqliteCacheTick]);

return { prefs, setPrefs, prefsStorageErr };
}
69 changes: 69 additions & 0 deletions apps/web/src/modules/nutrition/hooks/useNutritionPwaAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { NutritionPage } from "../lib/nutritionRouter";
import type { useNutritionLog } from "./useNutritionLog";
import type { usePhotoAnalysis } from "./usePhotoAnalysis";

type LogController = ReturnType<typeof useNutritionLog>;
type PhotoController = ReturnType<typeof usePhotoAnalysis>;

interface UseNutritionPwaActionArgs {
pwaAction?: string | null;
log: LogController;
photo: PhotoController;
setActivePageAndHash: (page: NutritionPage) => void;
setPhotoCardForceOpen: Dispatch<SetStateAction<boolean>>;
onPwaActionConsumed?: () => void;
}

/**
* Reacts to the `pwaAction` prop from the PWA shell:
* - `add_meal` → route to «Щоденник» and open the AddMealSheet.
* - `add_meal_photo` → route to «Старт», force the photo disclosure open
* and pop the native file picker (RAF + 80 ms fallback for mobile).
*
* Cleans up RAF / timeout when the effect tears down so a slow PWA
* navigation can't dangling-click a torn-down file input.
*/
export function useNutritionPwaAction({
pwaAction,
log,
photo,
setActivePageAndHash,
setPhotoCardForceOpen,
onPwaActionConsumed,
}: UseNutritionPwaActionArgs): void {
useEffect(() => {
if (pwaAction === "add_meal") {
setActivePageAndHash("log");
log.setAddMealSheetOpen(true);
onPwaActionConsumed?.();
return undefined;
}
if (pwaAction === "add_meal_photo") {
setActivePageAndHash("start");
setPhotoCardForceOpen(true);
const raf = requestAnimationFrame(() => {
try {
photo.fileRef.current?.click();
} catch {
/* noop — picker may be blocked without a user gesture */
}
});
const fallback = window.setTimeout(() => {
try {
photo.fileRef.current?.click();
} catch {
/* noop */
}
}, 80);
onPwaActionConsumed?.();
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(fallback);
};
}
return undefined;
// `photo.fileRef` is a stable ref; `setPhotoCardForceOpen` is a setter.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [log, onPwaActionConsumed, pwaAction]);
}
55 changes: 55 additions & 0 deletions apps/web/src/modules/nutrition/hooks/useNutritionRecipeCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { NutritionPage, MenuSubTab } from "../lib/nutritionRouter";
import type { NutritionRecipe } from "./useNutritionUiState";
import { readRecipeCache } from "../lib/recipeCache";
import { stableRecipeId } from "../lib/recipeIds";

interface UseNutritionRecipeCacheArgs {
activePage: NutritionPage;
menuSubTab: MenuSubTab;
recipeCacheKey: string;
setRecipes: Dispatch<SetStateAction<NutritionRecipe[]>>;
setRecipesRaw: Dispatch<SetStateAction<string>>;
setRecipesTried: Dispatch<SetStateAction<boolean>>;
}

/**
* Recipes moved to a sub-tab inside the "menu" page. Only read the
* recipe cache when the menu page is actually showing the recipes
* tab — avoids touching `localStorage` for users who never open it.
* Cache shape is normalised: `id` is filled via `stableRecipeId` when
* the raw payload didn't carry one.
*/
export function useNutritionRecipeCache({
activePage,
menuSubTab,
recipeCacheKey,
setRecipes,
setRecipesRaw,
setRecipesTried,
}: UseNutritionRecipeCacheArgs): void {
useEffect(() => {
if (activePage !== "menu" || menuSubTab !== "recipes") return;
const c = readRecipeCache<Record<string, unknown>>(recipeCacheKey);
if (c?.recipes?.length) {
setRecipes(
c.recipes.map((r) => {
const rawId = (r as { id?: unknown })?.id;
return {
...r,
id: rawId ? String(rawId) : stableRecipeId(r),
};
}),
);
setRecipesRaw(c.recipesRaw || "");
setRecipesTried(true);
}
}, [
activePage,
menuSubTab,
recipeCacheKey,
setRecipes,
setRecipesRaw,
setRecipesTried,
]);
}
56 changes: 56 additions & 0 deletions apps/web/src/modules/nutrition/pages/NutritionLogPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Dispatch, SetStateAction } from "react";
import type { Meal } from "@sergeant/nutrition-domain";
import { SectionErrorBoundary } from "@shared/components/ui/SectionErrorBoundary";
import { showUndoToast } from "@shared/lib/ui/undoToast";
import type { useToast } from "@shared/hooks/useToast";
import { LogCard } from "../components/LogCard";
import type { useNutritionLog } from "../hooks/useNutritionLog";
import type { EditingMealState } from "../hooks/useNutritionUiState";

type LogController = ReturnType<typeof useNutritionLog>;
type Toast = ReturnType<typeof useToast>;

interface NutritionLogPageProps {
log: LogController;
toast: Toast;
setEditingMeal: Dispatch<SetStateAction<EditingMealState | null>>;
}

export function NutritionLogPage({
log,
toast,
setEditingMeal,
}: NutritionLogPageProps) {
return (
<SectionErrorBoundary key="page-log" title="Не вдалось показати «Щоденник»">
<LogCard
log={log.nutritionLog}
selectedDate={log.selectedDate}
setSelectedDate={log.setSelectedDate}
onAddMeal={() => {
log.setAddMealPhotoResult(null);
log.setAddMealSheetOpen(true);
}}
onAddMealFromSearch={(meal) => {
const id = `meal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
log.handleAddMeal({ ...meal, id });
}}
onRemoveMeal={(date: string, meal: Meal) => {
if (!meal?.id) return;
log.handleRemoveMeal(date, meal);
showUndoToast(toast, {
msg: "Запис видалено",
onUndo: () => log.handleRestoreMeal(date, meal),
});
}}
onEditMeal={(date: string, meal: Meal) => {
setEditingMeal({ date, ...meal });
log.setAddMealPhotoResult(null);
log.setAddMealSheetOpen(true);
}}
onDuplicateYesterday={log.duplicateYesterday}
onTrimLog={log.trimLogToLastDays}
/>
</SectionErrorBoundary>
);
}
134 changes: 134 additions & 0 deletions apps/web/src/modules/nutrition/pages/NutritionMenuPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { Dispatch, SetStateAction, ReactNode } from "react";
import type { Meal, NutritionPrefs } from "@sergeant/nutrition-domain";
import {
DataState,
type DataStateQueryLike,
} from "@shared/components/ui/DataState";
import { SectionErrorBoundary } from "@shared/components/ui/SectionErrorBoundary";
import { DailyPlanCard } from "../components/DailyPlanCard";
import type { PlanMeal } from "../components/DailyPlanMealRow";
import { RecipesCard } from "../components/RecipesCard";
import { SubTabs } from "../components/SubTabs";
import type { useNutritionPantries } from "../hooks/useNutritionPantries";
import type {
NutritionDayPlan,
NutritionRecipe,
NutritionWeekPlan,
} from "../hooks/useNutritionUiState";
import type { MenuSubTab } from "../lib/nutritionRouter";
import type { RecipeCacheEntry } from "../lib/recipeCache";
import { fmtMacro } from "../lib/nutritionFormat";

type PantryController = ReturnType<typeof useNutritionPantries>;

interface NutritionMenuPageProps {
menuSubTab: MenuSubTab;
setMenuSubTab: (id: MenuSubTab) => void;
pantry: PantryController;
prefs: NutritionPrefs;
setPrefs: Dispatch<SetStateAction<NutritionPrefs>>;
busy: boolean;
err: string;
dayPlan: NutritionDayPlan | null;
dayPlanBusy: boolean;
dayPlanQuery: DataStateQueryLike<NutritionDayPlan | null>;
dayPlanLoadingSkeleton: ReactNode;
fetchDayPlan: (mealType: string | null) => void | Promise<void>;
addMealFromPlan: (meal: PlanMeal) => void | Promise<void>;
weekPlan: NutritionWeekPlan | null;
weekPlanRaw: string;
weekPlanBusy: boolean;
fetchWeekPlan: () => void | Promise<void>;
firstRunHint: boolean;
onDismissFirstRunHint: () => void;
recommendRecipes: () => void | Promise<void>;
recipes: NutritionRecipe[];
recipesTried: boolean;
recipesRaw: string;
recipeCacheEntry: RecipeCacheEntry<unknown> | null;
wrappedSaveMeal: (meal: Meal) => void | Promise<void>;
selectedDate: string;
}

export function NutritionMenuPage({
menuSubTab,
setMenuSubTab,
pantry,
prefs,
setPrefs,
busy,
err,
dayPlan,
dayPlanBusy,
dayPlanQuery,
dayPlanLoadingSkeleton,
fetchDayPlan,
addMealFromPlan,
weekPlan,
weekPlanRaw,
weekPlanBusy,
fetchWeekPlan,
firstRunHint,
onDismissFirstRunHint,
recommendRecipes,
recipes,
recipesTried,
recipesRaw,
recipeCacheEntry,
wrappedSaveMeal,
selectedDate,
}: NutritionMenuPageProps) {
return (
<SectionErrorBoundary key="page-menu" title="Не вдалось показати «Меню»">
<>
<SubTabs
value={menuSubTab}
onChange={(id) => setMenuSubTab(id as MenuSubTab)}
tabs={[
{ id: "plan", label: "План на день" },
{ id: "recipes", label: "Рецепти" },
]}
/>
{menuSubTab === "plan" ? (
<DataState query={dayPlanQuery} skeleton={dayPlanLoadingSkeleton}>
{() => (
<DailyPlanCard
prefs={prefs}
setPrefs={setPrefs}
pantryItems={pantry.effectiveItems}
busy={busy}
dayPlan={dayPlan}
dayPlanBusy={dayPlanBusy}
fetchDayPlan={() => fetchDayPlan(null)}
regenMeal={(mealType) => fetchDayPlan(mealType)}
addMealToLog={addMealFromPlan}
weekPlan={weekPlan}
weekPlanRaw={weekPlanRaw}
weekPlanBusy={weekPlanBusy}
fetchWeekPlan={fetchWeekPlan}
firstRunHint={firstRunHint}
onDismissFirstRunHint={onDismissFirstRunHint}
/>
)}
</DataState>
) : (
<RecipesCard
busy={busy}
activePantry={pantry.activePantry}
prefs={prefs}
setPrefs={setPrefs}
recommendRecipes={recommendRecipes}
recipes={recipes}
recipesTried={recipesTried}
recipesRaw={recipesRaw}
err={err}
fmtMacro={fmtMacro}
recipeCacheEntry={recipeCacheEntry}
addMealToLog={wrappedSaveMeal}
selectedDate={selectedDate}
/>
)}
</>
</SectionErrorBoundary>
);
}
Loading
Loading