From 92cbfeedf21d88dc5259c3229e2238dbd5de330d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:26:18 +0000 Subject: [PATCH] refactor(web): decomp NutritionApp.tsx into per-page components (initiative 0013, Sprint 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits 766-LOC apps/web/src/modules/nutrition/NutritionApp.tsx into: - pages/NutritionStartPage.tsx (108 LOC): NutritionDashboard + photo disclosure (PhotoAnalyzeCard) - pages/NutritionPantryPage.tsx (124 LOC): pantry items / shopping list subtabs - pages/NutritionLogPage.tsx (59 LOC): meal log - pages/NutritionMenuPage.tsx (137 LOC): day plan / recipes subtabs - hooks/useNutritionPwaAction.ts (69 LOC): pwaAction effect (add_meal, add_meal_photo) extracted з NutritionApp - hooks/useNutritionRecipeCache.ts (58 LOC): sessionStorage recipe cache hydration when on menu/recipes tab - hooks/useNutritionPrefsState.ts (45 LOC): prefs useState + persistNutritionPrefs effect + SQLite-cache overlay NutritionApp.tsx стає orchestrator ~451 effective LOC (530 raw) — drops з eslint.config.js max-lines:600 allowlist (initiative 0013). i18n allowlist (apps/web/eslint.i18n-allowlist.json) розширено 4 новими page-файлами — Cyrillic JSX literals (SectionErrorBoundary title, SubTabs labels, photo-card summary) перенесено без змін. Strict refactor — no behavioral changes; усі 263 nutrition test pass. Co-Authored-By: dmytro.s.stakhov --- apps/web/eslint.i18n-allowlist.json | 4 + .../src/modules/nutrition/NutritionApp.tsx | 417 ++++-------------- .../nutrition/hooks/useNutritionPrefsState.ts | 40 ++ .../nutrition/hooks/useNutritionPwaAction.ts | 69 +++ .../hooks/useNutritionRecipeCache.ts | 55 +++ .../nutrition/pages/NutritionLogPage.tsx | 56 +++ .../nutrition/pages/NutritionMenuPage.tsx | 134 ++++++ .../nutrition/pages/NutritionPantryPage.tsx | 122 +++++ .../nutrition/pages/NutritionStartPage.tsx | 108 +++++ eslint.config.js | 1 - 10 files changed, 677 insertions(+), 329 deletions(-) create mode 100644 apps/web/src/modules/nutrition/hooks/useNutritionPrefsState.ts create mode 100644 apps/web/src/modules/nutrition/hooks/useNutritionPwaAction.ts create mode 100644 apps/web/src/modules/nutrition/hooks/useNutritionRecipeCache.ts create mode 100644 apps/web/src/modules/nutrition/pages/NutritionLogPage.tsx create mode 100644 apps/web/src/modules/nutrition/pages/NutritionMenuPage.tsx create mode 100644 apps/web/src/modules/nutrition/pages/NutritionPantryPage.tsx create mode 100644 apps/web/src/modules/nutrition/pages/NutritionStartPage.tsx diff --git a/apps/web/eslint.i18n-allowlist.json b/apps/web/eslint.i18n-allowlist.json index 0c256f2e8..d0f67c8c1 100644 --- a/apps/web/eslint.i18n-allowlist.json +++ b/apps/web/eslint.i18n-allowlist.json @@ -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", diff --git a/apps/web/src/modules/nutrition/NutritionApp.tsx b/apps/web/src/modules/nutrition/NutritionApp.tsx index b7c39dc3a..ac471022c 100644 --- a/apps/web/src/modules/nutrition/NutritionApp.tsx +++ b/apps/web/src/modules/nutrition/NutritionApp.tsx @@ -5,39 +5,26 @@ import { SkeletonText, Skeleton, } from "@shared/components/ui/Skeleton"; -import { - DataState, - type DataStateQueryLike, -} from "@shared/components/ui/DataState"; +import type { DataStateQueryLike } from "@shared/components/ui/DataState"; import type { NutritionDayPlan } from "./hooks/useNutritionUiState"; import { NutritionHeader } from "./components/NutritionHeader"; import { NutritionBottomNav } from "./components/NutritionBottomNav"; -import { SubTabs } from "./components/SubTabs"; -import { PhotoAnalyzeCard } from "./components/PhotoAnalyzeCard"; -import { NutritionDashboard } from "./components/NutritionDashboard"; -import { PantryCard } from "./components/PantryCard"; -import { RecipesCard } from "./components/RecipesCard"; -import { DailyPlanCard } from "./components/DailyPlanCard"; -import { ShoppingListCard } from "./components/ShoppingListCard"; -import { LogCard } from "./components/LogCard"; import { NutritionPantrySelector } from "./components/NutritionPantrySelector"; import { NutritionOverlays } from "./components/NutritionOverlays"; +import { NutritionStartPage } from "./pages/NutritionStartPage"; +import { NutritionPantryPage } from "./pages/NutritionPantryPage"; +import { NutritionLogPage } from "./pages/NutritionLogPage"; +import { NutritionMenuPage } from "./pages/NutritionMenuPage"; import { Banner } from "@shared/components/ui/Banner"; import { ModuleAccentProvider } from "@shared/components/layout"; -import { Icon } from "@shared/components/ui/Icon"; import { PullToRefresh } from "@shared/components/ui/PullToRefresh"; import { requestCloudPull } from "@shared/lib/modules/cloudPullRequest"; import { useQueryClient } from "@tanstack/react-query"; import { nutritionKeys } from "@shared/lib/api/queryKeys"; -import { - loadNutritionPrefs, - persistNutritionPrefs, -} from "./lib/nutritionStorage"; import { useNutritionPantries } from "./hooks/useNutritionPantries"; import { useNutritionLog } from "./hooks/useNutritionLog"; import { useNutritionDualWriteBoot } from "./hooks/useNutritionDualWriteBoot"; import { useNutritionSqliteReadBoot } from "./hooks/useNutritionSqliteReadBoot"; -import { getCachedNutritionSqliteState } from "./lib/sqliteReader"; import { useNutritionSqliteReadTick } from "./lib/sqliteReadGate"; import { usePhotoAnalysis } from "./hooks/usePhotoAnalysis"; import { useShoppingList } from "./hooks/useShoppingList"; @@ -52,13 +39,12 @@ import { useNutritionReminders } from "./hooks/useNutritionReminders"; import { usePantryBarcodeScan } from "./hooks/usePantryBarcodeScan"; import { useNutritionCloudBackup } from "./hooks/useNutritionCloudBackup"; import { useNutritionRemoteActions } from "./hooks/useNutritionRemoteActions"; +import { useNutritionPwaAction } from "./hooks/useNutritionPwaAction"; +import { useNutritionRecipeCache } from "./hooks/useNutritionRecipeCache"; +import { useNutritionPrefsState } from "./hooks/useNutritionPrefsState"; import { buildRecipeCacheKey, readRecipeCache } from "./lib/recipeCache"; -import { stableRecipeId } from "./lib/recipeIds"; import { fileToThumbnailBlob, saveMealThumbnail } from "./lib/mealPhotoStorage"; import { useToast } from "@shared/hooks/useToast"; -import { showUndoToast } from "@shared/lib/ui/undoToast"; -import { fmtMacro, todayISODate } from "./lib/nutritionFormat"; -import { SectionErrorBoundary } from "@shared/components/ui/SectionErrorBoundary"; import { useModuleFirstRun } from "../../core/onboarding/useModuleFirstRun"; interface NutritionAppProps { @@ -79,15 +65,10 @@ export default function NutritionApp({ const [err, setErr] = useState(""); const [statusText, setStatusText] = useState(""); - // Stage 4 PR #032: install the dual-write context once the user is - // known and the flag is on. Without this the `triggerNutritionDualWrite` - // calls from `nutritionStorage.ts` early-out at the - // `isNutritionDualWriteRegistered()` gate, leaving SQLite empty. + // Stage 4 PR #032 / #033: install the dual-write context and warm + // the SQLite read cache once auth is known; both are no-ops when + // the corresponding flags are off. useNutritionDualWriteBoot(); - - // Stage 4 PR #033: warm the SQLite read cache once after auth so the - // overlay reads in `useNutritionLog` / `useNutritionPantries` / - // saved-recipe consumers can hydrate. No-op when the read flag is off. useNutritionSqliteReadBoot(); const { @@ -167,63 +148,18 @@ export default function NutritionApp({ // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot on mount; subsequent edits to firstRun must not retrigger }, []); - useEffect(() => { - if (pwaAction === "add_meal") { - setActivePageAndHash("log"); - log.setAddMealSheetOpen(true); - onPwaActionConsumed?.(); - return undefined; - } - if (pwaAction === "add_meal_photo") { - setActivePageAndHash("start"); - setPhotoCardForceOpen(true); - // Defer the file-picker click until after the disclosure has - // rendered the ``. requestAnimationFrame is - // enough on desktop; the 80ms fallback covers mobile browsers - // that stall the first frame behind route transitions. - 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]); + useNutritionPwaAction({ + pwaAction, + log, + photo, + setActivePageAndHash, + setPhotoCardForceOpen, + onPwaActionConsumed, + }); - const [prefs, setPrefs] = useState(() => loadNutritionPrefs()); - const [prefsStorageErr, setPrefsStorageErr] = useState(""); const sqliteCacheTick = useNutritionSqliteReadTick(); - - useEffect(() => { - setPrefsStorageErr( - persistNutritionPrefs(prefs) ? "" : "Не вдалося зберегти налаштування.", - ); - }, [prefs]); - - // Stage 4 PR #033 + Stage 8 PR #057n: overlay nutrition prefs from - // the SQLite cache once it's warm. The LS read above stays as a - // synchronous fallback. - useEffect(() => { - const cache = getCachedNutritionSqliteState(); - if (cache.refreshedAt === null) return; - if (cache.prefs) setPrefs(cache.prefs); - }, [sqliteCacheTick]); + const { prefs, setPrefs, prefsStorageErr } = + useNutritionPrefsState(sqliteCacheTick); const { editingMeal, @@ -280,32 +216,14 @@ export default function NutritionApp({ ], ); - useEffect(() => { - // Recipes moved to a sub-tab inside the "menu" page (#8). Only read - // the cache when the menu page is actually showing the recipes tab. - if (activePage !== "menu" || menuSubTab !== "recipes") return; - const c = readRecipeCache>(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); - } - }, [ + useNutritionRecipeCache({ activePage, menuSubTab, recipeCacheKey, setRecipes, setRecipesRaw, setRecipesTried, - ]); + }); useNutritionReminders(prefs); @@ -499,238 +417,81 @@ export default function NutritionApp({
{activePage === "start" && ( - - <> - setActivePageAndHash("log")} - onGoToDailyPlan={() => { - setActivePageAndHash("menu"); - }} - onFetchDayHint={fetchDayHint} - dayHintText={dayHintText} - dayHintBusy={dayHintBusy} - onAddMeal={() => { - log.setSelectedDate(todayISODate()); - setActivePageAndHash("log"); - scheduleTransient(() => { - log.setAddMealPhotoResult(null); - log.setAddMealSheetOpen(true); - }, 80); - }} - /> -
{ - if (!e.currentTarget.open) setPhotoCardForceOpen(false); - }} - > - - - Аналіз фото страви - -
- } - onPickPhoto={photo.onPickPhoto} - photoPreviewUrl={photo.photoPreviewUrl} - photoResult={photo.photoResult} - fmtMacro={fmtMacro} - portionGrams={photo.portionGrams} - setPortionGrams={photo.setPortionGrams} - refinePhoto={photo.refinePhoto} - answers={photo.answers} - setAnswers={photo.setAnswers} - onSaveToLog={ - photo.photoResult ? handleSaveToLog : undefined - } - /> -
-
- -
+ )} {activePage === "pantry" && ( - - <> - setPantrySubTab(id as PantrySubTab)} - tabs={[ - { id: "items", label: "Склад" }, - { id: "shopping", label: "Покупки" }, - ]} - /> - {pantrySubTab === "items" ? ( - <> - { - if (pantry.pantryItems.length > 0) { - const removed = pantry.pantryItems[idx]; - pantry.removeItemAt(idx); - if (removed) { - showUndoToast(toast, { - msg: `Прибрано «${removed.name}» з комори`, - onUndo: () => pantry.upsertItem(removed), - }); - } - } else if (name) { - pantry.removeItem(name); - } - }} - pantryItemsLength={pantry.pantryItems.length} - pantrySummary={pantry.pantrySummary} - onScanBarcode={() => { - setPantryScanStatus(""); - setPantryScannerOpen(true); - }} - /> - {pantryScanStatus && ( -
- {pantryScanStatus} -
- )} - - ) : ( - - )} - -
+ setPantrySubTab(id as PantrySubTab)} + pantryScanStatus={pantryScanStatus} + setPantryScanStatus={setPantryScanStatus} + setPantryScannerOpen={setPantryScannerOpen} + toast={toast} + generateShoppingList={generateShoppingList} + addCheckedItemsToPantry={addCheckedItemsToPantry} + /> )} {activePage === "log" && ( - - { - 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, meal) => { - if (!meal?.id) return; - log.handleRemoveMeal(date, meal); - showUndoToast(toast, { - msg: "Запис видалено", - onUndo: () => log.handleRestoreMeal(date, meal), - }); - }} - onEditMeal={(date, meal) => { - setEditingMeal({ date, ...meal }); - log.setAddMealPhotoResult(null); - log.setAddMealSheetOpen(true); - }} - onDuplicateYesterday={log.duplicateYesterday} - onTrimLog={log.trimLogToLastDays} - /> - + )} {activePage === "menu" && ( - - <> - setMenuSubTab(id as MenuSubTab)} - tabs={[ - { id: "plan", label: "План на день" }, - { id: "recipes", label: "Рецепти" }, - ]} - /> - {menuSubTab === "plan" ? ( - - {() => ( - fetchDayPlan(null)} - regenMeal={(mealType) => fetchDayPlan(mealType)} - addMealToLog={addMealFromPlan} - weekPlan={weekPlan} - weekPlanRaw={weekPlanRaw} - weekPlanBusy={weekPlanBusy} - fetchWeekPlan={fetchWeekPlan} - firstRunHint={firstRunNutritionActive} - onDismissFirstRunHint={() => { - markNutritionSeen(); - setFirstRunNutritionSurface(false); - }} - /> - )} - - ) : ( - - )} - - + setMenuSubTab(id as MenuSubTab)} + pantry={pantry} + prefs={prefs} + setPrefs={setPrefs} + busy={busy} + err={err} + dayPlan={dayPlan} + dayPlanBusy={dayPlanBusy} + dayPlanQuery={dayPlanQuery} + dayPlanLoadingSkeleton={dayPlanLoadingSkeleton} + fetchDayPlan={fetchDayPlan} + addMealFromPlan={addMealFromPlan} + weekPlan={weekPlan} + weekPlanRaw={weekPlanRaw} + weekPlanBusy={weekPlanBusy} + fetchWeekPlan={fetchWeekPlan} + firstRunHint={firstRunNutritionActive} + onDismissFirstRunHint={() => { + markNutritionSeen(); + setFirstRunNutritionSurface(false); + }} + recommendRecipes={recommendRecipes} + recipes={recipes} + recipesTried={recipesTried} + recipesRaw={recipesRaw} + recipeCacheEntry={recipeCacheEntry} + wrappedSaveMeal={wrappedSaveMeal} + selectedDate={log.selectedDate} + /> )}
diff --git a/apps/web/src/modules/nutrition/hooks/useNutritionPrefsState.ts b/apps/web/src/modules/nutrition/hooks/useNutritionPrefsState.ts new file mode 100644 index 000000000..dfb743695 --- /dev/null +++ b/apps/web/src/modules/nutrition/hooks/useNutritionPrefsState.ts @@ -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>; + 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 }; +} diff --git a/apps/web/src/modules/nutrition/hooks/useNutritionPwaAction.ts b/apps/web/src/modules/nutrition/hooks/useNutritionPwaAction.ts new file mode 100644 index 000000000..aa909ec52 --- /dev/null +++ b/apps/web/src/modules/nutrition/hooks/useNutritionPwaAction.ts @@ -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; +type PhotoController = ReturnType; + +interface UseNutritionPwaActionArgs { + pwaAction?: string | null; + log: LogController; + photo: PhotoController; + setActivePageAndHash: (page: NutritionPage) => void; + setPhotoCardForceOpen: Dispatch>; + 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]); +} diff --git a/apps/web/src/modules/nutrition/hooks/useNutritionRecipeCache.ts b/apps/web/src/modules/nutrition/hooks/useNutritionRecipeCache.ts new file mode 100644 index 000000000..4439fe26e --- /dev/null +++ b/apps/web/src/modules/nutrition/hooks/useNutritionRecipeCache.ts @@ -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>; + setRecipesRaw: Dispatch>; + setRecipesTried: Dispatch>; +} + +/** + * 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>(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, + ]); +} diff --git a/apps/web/src/modules/nutrition/pages/NutritionLogPage.tsx b/apps/web/src/modules/nutrition/pages/NutritionLogPage.tsx new file mode 100644 index 000000000..4e7dd98a3 --- /dev/null +++ b/apps/web/src/modules/nutrition/pages/NutritionLogPage.tsx @@ -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; +type Toast = ReturnType; + +interface NutritionLogPageProps { + log: LogController; + toast: Toast; + setEditingMeal: Dispatch>; +} + +export function NutritionLogPage({ + log, + toast, + setEditingMeal, +}: NutritionLogPageProps) { + return ( + + { + 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} + /> + + ); +} diff --git a/apps/web/src/modules/nutrition/pages/NutritionMenuPage.tsx b/apps/web/src/modules/nutrition/pages/NutritionMenuPage.tsx new file mode 100644 index 000000000..e4b3fc90b --- /dev/null +++ b/apps/web/src/modules/nutrition/pages/NutritionMenuPage.tsx @@ -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; + +interface NutritionMenuPageProps { + menuSubTab: MenuSubTab; + setMenuSubTab: (id: MenuSubTab) => void; + pantry: PantryController; + prefs: NutritionPrefs; + setPrefs: Dispatch>; + busy: boolean; + err: string; + dayPlan: NutritionDayPlan | null; + dayPlanBusy: boolean; + dayPlanQuery: DataStateQueryLike; + dayPlanLoadingSkeleton: ReactNode; + fetchDayPlan: (mealType: string | null) => void | Promise; + addMealFromPlan: (meal: PlanMeal) => void | Promise; + weekPlan: NutritionWeekPlan | null; + weekPlanRaw: string; + weekPlanBusy: boolean; + fetchWeekPlan: () => void | Promise; + firstRunHint: boolean; + onDismissFirstRunHint: () => void; + recommendRecipes: () => void | Promise; + recipes: NutritionRecipe[]; + recipesTried: boolean; + recipesRaw: string; + recipeCacheEntry: RecipeCacheEntry | null; + wrappedSaveMeal: (meal: Meal) => void | Promise; + 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 ( + + <> + setMenuSubTab(id as MenuSubTab)} + tabs={[ + { id: "plan", label: "План на день" }, + { id: "recipes", label: "Рецепти" }, + ]} + /> + {menuSubTab === "plan" ? ( + + {() => ( + fetchDayPlan(null)} + regenMeal={(mealType) => fetchDayPlan(mealType)} + addMealToLog={addMealFromPlan} + weekPlan={weekPlan} + weekPlanRaw={weekPlanRaw} + weekPlanBusy={weekPlanBusy} + fetchWeekPlan={fetchWeekPlan} + firstRunHint={firstRunHint} + onDismissFirstRunHint={onDismissFirstRunHint} + /> + )} + + ) : ( + + )} + + + ); +} diff --git a/apps/web/src/modules/nutrition/pages/NutritionPantryPage.tsx b/apps/web/src/modules/nutrition/pages/NutritionPantryPage.tsx new file mode 100644 index 000000000..d7cb6e94a --- /dev/null +++ b/apps/web/src/modules/nutrition/pages/NutritionPantryPage.tsx @@ -0,0 +1,122 @@ +import type { Dispatch, SetStateAction } from "react"; +import { SectionErrorBoundary } from "@shared/components/ui/SectionErrorBoundary"; +import { showUndoToast } from "@shared/lib/ui/undoToast"; +import type { useToast } from "@shared/hooks/useToast"; +import { PantryCard } from "../components/PantryCard"; +import { ShoppingListCard } from "../components/ShoppingListCard"; +import { SubTabs } from "../components/SubTabs"; +import type { + NutritionRecipe, + NutritionWeekPlan, +} from "../hooks/useNutritionUiState"; +import type { useNutritionPantries } from "../hooks/useNutritionPantries"; +import type { useShoppingList } from "../hooks/useShoppingList"; +import type { PantrySubTab } from "../lib/nutritionRouter"; + +type PantryController = ReturnType; +type ShoppingController = ReturnType; +type Toast = ReturnType; + +interface NutritionPantryPageProps { + pantry: PantryController; + shopping: ShoppingController; + recipes: NutritionRecipe[]; + weekPlan: NutritionWeekPlan | null; + shoppingBusy: boolean; + busy: boolean; + pantrySubTab: PantrySubTab; + setPantrySubTab: (id: PantrySubTab) => void; + pantryScanStatus: string; + setPantryScanStatus: Dispatch>; + setPantryScannerOpen: Dispatch>; + toast: Toast; + generateShoppingList: (source: string) => void | Promise; + addCheckedItemsToPantry: () => void; +} + +export function NutritionPantryPage({ + pantry, + shopping, + recipes, + weekPlan, + shoppingBusy, + busy, + pantrySubTab, + setPantrySubTab, + pantryScanStatus, + setPantryScanStatus, + setPantryScannerOpen, + toast, + generateShoppingList, + addCheckedItemsToPantry, +}: NutritionPantryPageProps) { + return ( + + <> + setPantrySubTab(id as PantrySubTab)} + tabs={[ + { id: "items", label: "Склад" }, + { id: "shopping", label: "Покупки" }, + ]} + /> + {pantrySubTab === "items" ? ( + <> + { + if (pantry.pantryItems.length > 0) { + const removed = pantry.pantryItems[idx]; + pantry.removeItemAt(idx); + if (removed) { + showUndoToast(toast, { + msg: `Прибрано «${removed.name}» з комори`, + onUndo: () => pantry.upsertItem(removed), + }); + } + } else if (name) { + pantry.removeItem(name); + } + }} + pantryItemsLength={pantry.pantryItems.length} + pantrySummary={pantry.pantrySummary} + onScanBarcode={() => { + setPantryScanStatus(""); + setPantryScannerOpen(true); + }} + /> + {pantryScanStatus && ( +
{pantryScanStatus}
+ )} + + ) : ( + + )} + +
+ ); +} diff --git a/apps/web/src/modules/nutrition/pages/NutritionStartPage.tsx b/apps/web/src/modules/nutrition/pages/NutritionStartPage.tsx new file mode 100644 index 000000000..396763834 --- /dev/null +++ b/apps/web/src/modules/nutrition/pages/NutritionStartPage.tsx @@ -0,0 +1,108 @@ +import type { Dispatch, Ref, SetStateAction } from "react"; +import type { NutritionPrefs } from "@sergeant/nutrition-domain"; +import { Icon } from "@shared/components/ui/Icon"; +import { SectionErrorBoundary } from "@shared/components/ui/SectionErrorBoundary"; +import { NutritionDashboard } from "../components/NutritionDashboard"; +import { PhotoAnalyzeCard } from "../components/PhotoAnalyzeCard"; +import type { useNutritionLog } from "../hooks/useNutritionLog"; +import type { usePhotoAnalysis } from "../hooks/usePhotoAnalysis"; +import type { NutritionPage } from "../lib/nutritionRouter"; +import { fmtMacro, todayISODate } from "../lib/nutritionFormat"; + +type LogController = ReturnType; +type PhotoController = ReturnType; + +interface NutritionStartPageProps { + log: LogController; + photo: PhotoController; + prefs: NutritionPrefs; + busy: boolean; + setActivePageAndHash: (page: NutritionPage) => void; + fetchDayHint: () => void | Promise; + dayHintText: string; + dayHintBusy: boolean; + scheduleTransient: ( + cb: () => void, + delayMs: number, + ) => ReturnType; + photoCardForceOpen: boolean; + setPhotoCardForceOpen: Dispatch>; + onSaveToLog: () => void; +} + +export function NutritionStartPage({ + log, + photo, + prefs, + busy, + setActivePageAndHash, + fetchDayHint, + dayHintText, + dayHintBusy, + scheduleTransient, + photoCardForceOpen, + setPhotoCardForceOpen, + onSaveToLog, +}: NutritionStartPageProps) { + return ( + + <> + setActivePageAndHash("log")} + onGoToDailyPlan={() => { + setActivePageAndHash("menu"); + }} + onFetchDayHint={fetchDayHint} + dayHintText={dayHintText} + dayHintBusy={dayHintBusy} + onAddMeal={() => { + log.setSelectedDate(todayISODate()); + setActivePageAndHash("log"); + scheduleTransient(() => { + log.setAddMealPhotoResult(null); + log.setAddMealSheetOpen(true); + }, 80); + }} + /> +
{ + if (!e.currentTarget.open) setPhotoCardForceOpen(false); + }} + > + + + Аналіз фото страви + +
+ } + onPickPhoto={photo.onPickPhoto} + photoPreviewUrl={photo.photoPreviewUrl} + photoResult={photo.photoResult} + fmtMacro={fmtMacro} + portionGrams={photo.portionGrams} + setPortionGrams={photo.setPortionGrams} + refinePhoto={photo.refinePhoto} + answers={photo.answers} + setAnswers={photo.setAnswers} + onSaveToLog={photo.photoResult ? onSaveToLog : undefined} + /> +
+
+ +
+ ); +} diff --git a/eslint.config.js b/eslint.config.js index 45df905e6..23bd1f025 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1096,7 +1096,6 @@ export default [ // file shows up in `git blame` / `git log` against this rule. { files: [ - "apps/web/src/modules/nutrition/NutritionApp.tsx", "apps/web/src/core/lib/hubChatContext.ts", "apps/web/src/core/hub/HubDashboard.tsx", "apps/web/src/core/lib/chatActions/fizrukActions.ts",