From dc10f496b60ba7c467d35d5870bfd503cadc874c Mon Sep 17 00:00:00 2001 From: Ronen Atsil Date: Mon, 7 Sep 2026 14:37:38 +0300 Subject: [PATCH 1/2] Release 1.24.0: secure offline tracking and focused meal actions --- caloreazi/CHANGELOG.md | 9 +++ caloreazi/Dockerfile | 2 +- caloreazi/README.md | 2 +- caloreazi/app/api/activity/route.ts | 2 +- caloreazi/app/api/admin/health/route.ts | 2 +- caloreazi/app/api/auth/session/route.ts | 6 +- caloreazi/app/api/meals/route.ts | 10 ++- caloreazi/app/api/measurements/route.ts | 1 + caloreazi/app/api/water/route.ts | 5 +- caloreazi/app/globals.css | 7 +- caloreazi/app/health/route.ts | 2 +- caloreazi/app/layout.tsx | 2 - caloreazi/app/offline-queue.ts | 85 +++++++++++++++++----- caloreazi/app/page.tsx | 79 ++++++++++++++------ caloreazi/app/use-modal-accessibility.ts | 50 +++++++++++++ caloreazi/config.yaml | 2 +- caloreazi/docs/PRODUCT_RELIABILITY.md | 30 ++++++++ caloreazi/package-lock.json | 4 +- caloreazi/package.json | 2 +- caloreazi/public/sw.js | 2 +- caloreazi/scripts/meal-benchmark.mjs | 2 +- caloreazi/scripts/sync-version.mjs | 2 +- caloreazi/server/auth.js | 2 +- caloreazi/server/food-image-resolver.js | 2 +- caloreazi/server/meal-reliability.js | 2 +- caloreazi/server/state-database.js | 27 ++++++- caloreazi/server/store.js | 11 ++- caloreazi/test/mobile-contract.test.js | 6 +- caloreazi/test/offline-queue.test.js | 85 ++++++++++++++++++++++ caloreazi/test/transactional-meals.test.js | 13 ++++ caloreazi/test/ui-improvements.test.js | 8 +- caloreazi/tests/multi-user.integration.mjs | 31 +++++++- 32 files changed, 413 insertions(+), 82 deletions(-) create mode 100644 caloreazi/app/use-modal-accessibility.ts create mode 100644 caloreazi/docs/PRODUCT_RELIABILITY.md create mode 100644 caloreazi/test/offline-queue.test.js diff --git a/caloreazi/CHANGELOG.md b/caloreazi/CHANGELOG.md index 81f7d95..ceeebe5 100644 --- a/caloreazi/CHANGELOG.md +++ b/caloreazi/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.24.0 + +- צילום, ארוחה ידנית ומועדפים זמינים מיד; שאר האפשרויות תחת פעולות נוספות. +- תוקנו בדיקת חיבורים שבוטלו, אימות מזהי ניתוח והפרדת תור Offline לפי משתמש. +- צילומים מנותחים נשמרים כטיוטות עד אישור או מחיקה, עם טיפול בהמתנה ובכשלי שמירה. +- תוקנו הוספת שתייה למשתמש חדש, בקשות מקבילות, שינוי תאריך ארוחה וסכומי תזונה בשינוי כמות. +- שמירת כל רכיבי הארוחה במסד, כתיבת יומן ממוקדת ושיפורי נגישות והסברי איכות מידע. +- הורחבו בדיקות האינטגרציה ובדיקות התנהגות הסנכרון. + ## 1.23.3 - כפתור הוספת הארוחה בסרגל התחתון סוגר באופן גורף כל חלון או תהליך פתוח ומחזיר למסך המקורות הראשי. diff --git a/caloreazi/Dockerfile b/caloreazi/Dockerfile index 07e38cc..924a1ef 100644 --- a/caloreazi/Dockerfile +++ b/caloreazi/Dockerfile @@ -22,7 +22,7 @@ RUN chmod +x /etc/services.d/app/run /etc/services.d/nginx/run /etc/services.d/p && rm -f /etc/nginx/http.d/default.conf LABEL \ - io.hass.version="1.23.3" \ + io.hass.version="1.24.0" \ io.hass.type="app" \ io.hass.arch="amd64" diff --git a/caloreazi/README.md b/caloreazi/README.md index 7318585..adc03b7 100644 --- a/caloreazi/README.md +++ b/caloreazi/README.md @@ -1,4 +1,4 @@ -# CALOREAZI 1.23.3 +# CALOREAZI 1.24.0 Private Home Assistant nutrition coach with persistent onboarding, daily tracking, global Admin-managed AI, and AI meal-photo analysis. diff --git a/caloreazi/app/api/activity/route.ts b/caloreazi/app/api/activity/route.ts index d15f029..8f41086 100644 --- a/caloreazi/app/api/activity/route.ts +++ b/caloreazi/app/api/activity/route.ts @@ -10,7 +10,7 @@ export async function POST(request: Request) { if (mutationKey && (initial.systemSettings?.mutationReceipts || []).includes(receipt)) return Response.json(userView(initial, session.userId, session.role === "admin")); const body = await request.json(); const type = String(body.type || "").trim(); const minutes = Math.max(0, Number(body.minutes) || 0); const steps = Math.max(0, Math.round(Number(body.steps) || 0)); const recordedAt = Number.isFinite(Date.parse(String(body.recordedAt || ""))) ? new Date(body.recordedAt).toISOString() : new Date().toISOString(); if (!type || (!minutes && !steps)) return Response.json({ error: "יש להזין סוג פעילות וזמן או צעדים" }, { status: 400 }); - const state = await updateState((latest) => { const data = ensureUserData(latest, session.userId); const localDate = /^\d{4}-\d{2}-\d{2}$/.test(String(body.localDate || "")) ? String(body.localDate) : entryDateFor(data, recordedAt); data.activity.push({ id: crypto.randomUUID(), date: localDate, time: recordedAt, type: type.slice(0, 60), description: String(body.description || "").slice(0, 700), intensity: ["low","medium","high"].includes(body.intensity) ? body.intensity : "medium", minutes, steps, distanceKm: Math.max(0, Number(body.distanceKm) || 0), activeCalories: Math.max(0, Math.round(Number(body.activeCalories) || 0)) }); data.activity = data.activity.slice(-730); if (mutationKey) latest.systemSettings.mutationReceipts = [...(latest.systemSettings.mutationReceipts || []), receipt].slice(-2000); addAudit(latest, { userId: session.userId, action: "activity.created", target: type }); return latest; }); + const state = await updateState((latest) => { if (mutationKey && (latest.systemSettings?.mutationReceipts || []).includes(receipt)) return latest; const data = ensureUserData(latest, session.userId); const localDate = /^\d{4}-\d{2}-\d{2}$/.test(String(body.localDate || "")) ? String(body.localDate) : entryDateFor(data, recordedAt); data.activity.push({ id: crypto.randomUUID(), date: localDate, time: recordedAt, type: type.slice(0, 60), description: String(body.description || "").slice(0, 700), intensity: ["low","medium","high"].includes(body.intensity) ? body.intensity : "medium", minutes, steps, distanceKm: Math.max(0, Number(body.distanceKm) || 0), activeCalories: Math.max(0, Math.round(Number(body.activeCalories) || 0)) }); data.activity = data.activity.slice(-730); if (mutationKey) latest.systemSettings.mutationReceipts = [...(latest.systemSettings.mutationReceipts || []), receipt].slice(-2000); addAudit(latest, { userId: session.userId, action: "activity.created", target: type }); return latest; }); return Response.json(userView(state, session.userId, session.role === "admin")); } diff --git a/caloreazi/app/api/admin/health/route.ts b/caloreazi/app/api/admin/health/route.ts index 28d6f2c..a588a8b 100644 --- a/caloreazi/app/api/admin/health/route.ts +++ b/caloreazi/app/api/admin/health/route.ts @@ -18,6 +18,6 @@ export async function GET(request: Request) { const saveAudits = (state.auditLog || []).filter((item: any) => item.action === "meal.created").slice(-500).map((item: any) => { try { return JSON.parse(item.details || "{}"); } catch { return {}; } }); const timedSaveAudits = saveAudits.filter((item: any) => Number(item.interactionDurationMs || item.durationMs || 0) > 0); const quality = { averageAddSeconds: timedSaveAudits.length ? Math.round(timedSaveAudits.reduce((sum: number, item: any) => sum + Number(item.interactionDurationMs || item.durationMs), 0) / timedSaveAudits.length / 100) / 10 : null, recognitionApprovalRate: recognized.length ? Math.round(recognized.filter((meal: any) => !meal.updatedAt).length / recognized.length * 100) : null, correctionRate: recognized.length ? Math.round(recognized.filter((meal: any) => meal.updatedAt).length / recognized.length * 100) : null, highReliabilityRate: allMeals.length ? Math.round(allMeals.filter((meal: any) => Number(meal.nutritionReliability?.score || 0) >= 88).length / allMeals.length * 100) : null, duplicateBlocks: (state.auditLog || []).filter((item: any) => item.action === "meal.duplicate_blocked").length, failedAnalyses: (state.analysisJobs || []).filter((item: any) => item.status === "failed").length, aiCostPerMeal: monthlyMeals.length ? monthlyAiCost / monthlyMeals.length : 0, aiCostPerActiveUser: activeUserIds.size ? monthlyAiCost / activeUserIds.size : 0 }; - const releaseVersion = "1.23.3"; + const releaseVersion = "1.24.0"; return Response.json({ version: process.env.CALOREAZI_VERSION || releaseVersion, build: process.env.CALOREAZI_BUILD_COMMIT || "development", application: "ok", database, storage: "ok", ai: state.ai.encryptedKey ? "configured" : "attention", users: state.users.length, activeUsers: state.users.filter((user) => !user.disabled).length, activeSessions: (state.sessions || []).filter((item) => !item.revokedAt && new Date(item.expiresAt).getTime() > Date.now()).length, analysisJobs: { pending: (state.analysisJobs || []).filter((item) => ["pending", "processing"].includes(item.status)).length, failed: quality.failedAnalyses }, meals, quality, trashItems: state.trash.length, auditEvents: state.auditLog.length, aiRequests: usage.length, estimatedAiCost: usage.reduce((sum, item) => sum + Number(item.cost || 0), 0), byFeature }); } diff --git a/caloreazi/app/api/auth/session/route.ts b/caloreazi/app/api/auth/session/route.ts index e520bb6..281f1ba 100644 --- a/caloreazi/app/api/auth/session/route.ts +++ b/caloreazi/app/api/auth/session/route.ts @@ -1,4 +1,4 @@ -import { clearSessionCookie, createSessionCookie, currentSession, verifyPassword } from "@/server/auth.js"; +import { clearSessionCookie, createSessionCookie, currentSession, requireUser, verifyPassword } from "@/server/auth.js"; import { addAudit, readState, updateState } from "@/server/store.js"; import { checkRateLimit, clientAddress, requireSameOrigin } from "@/server/security.js"; export const runtime = "nodejs"; @@ -21,6 +21,6 @@ export async function POST(request: Request) { return Response.json({ ok: true, rememberedDays: 30 }, { headers: { "Set-Cookie": createSessionCookie(request, user, sessionId), "Cache-Control": "no-store" } }); } -export async function GET(request: Request) { const state = await readState(); const session = currentSession(request); const user = state.users.find((item) => item.id === session?.userId); if (!user) return Response.json({ error: "יש להתחבר" }, { status: 401 }); return Response.json({ sessions: (state.sessions || []).filter((item) => item.userId === user.id && !item.revokedAt && new Date(item.expiresAt).getTime() > Date.now()).map((item) => ({ ...item, current: item.id === session.sid })) }); } -export async function PATCH(request: Request) { const denied = requireSameOrigin(request); if (denied) return denied; const state = await readState(); const session = currentSession(request); const user = state.users.find((item) => item.id === session?.userId); if (!user) return Response.json({ error: "יש להתחבר" }, { status: 401 }); const body = await request.json(); await updateState((latest) => { latest.sessions = (latest.sessions || []).map((item) => item.userId === user.id && (body.all === true || item.id === body.id) ? { ...item, revokedAt: new Date().toISOString() } : item); addAudit(latest, { userId: user.id, action: body.all === true ? "auth.sessions_revoked_all" : "auth.session_revoked", target: body.id || "all" }); return latest; }); const currentRevoked = body.all === true || body.id === session.sid; return Response.json({ ok: true }, { headers: currentRevoked ? { "Set-Cookie": clearSessionCookie(request) } : undefined }); } +export async function GET(request: Request) { const state = await readState(); const session = requireUser(state, request); const user = state.users.find((item) => item.id === session?.userId); if (!user) return Response.json({ error: "יש להתחבר" }, { status: 401 }); return Response.json({ sessions: (state.sessions || []).filter((item) => item.userId === user.id && !item.revokedAt && new Date(item.expiresAt).getTime() > Date.now()).map((item) => ({ ...item, current: item.id === session.sid })) }); } +export async function PATCH(request: Request) { const denied = requireSameOrigin(request); if (denied) return denied; const state = await readState(); const session = requireUser(state, request); const user = state.users.find((item) => item.id === session?.userId); if (!user) return Response.json({ error: "יש להתחבר" }, { status: 401 }); const body = await request.json(); await updateState((latest) => { latest.sessions = (latest.sessions || []).map((item) => item.userId === user.id && (body.all === true || item.id === body.id) ? { ...item, revokedAt: new Date().toISOString() } : item); addAudit(latest, { userId: user.id, action: body.all === true ? "auth.sessions_revoked_all" : "auth.session_revoked", target: body.id || "all" }); return latest; }); const currentRevoked = body.all === true || body.id === session.sid; return Response.json({ ok: true }, { headers: currentRevoked ? { "Set-Cookie": clearSessionCookie(request) } : undefined }); } export async function DELETE(request: Request) { const session = currentSession(request); if (session?.sid) await updateState((latest) => { const item = latest.sessions?.find((entry) => entry.id === session.sid && entry.userId === session.userId); if (item) item.revokedAt = new Date().toISOString(); return latest; }); return Response.json({ ok: true }, { headers: { "Set-Cookie": clearSessionCookie(request) } }); } diff --git a/caloreazi/app/api/meals/route.ts b/caloreazi/app/api/meals/route.ts index f0a0dff..04ec2b6 100644 --- a/caloreazi/app/api/meals/route.ts +++ b/caloreazi/app/api/meals/route.ts @@ -15,7 +15,9 @@ export async function POST(request: Request) { const startedAt = Date.now(); const initial = await readState(); const session = requireUser(initial, request); if (!session) return Response.json({ error: "יש להתחבר" }, { status: 401 }); - const body = await request.json(); const name = String(body.name || "").trim(); + const body = await request.json(); + if (body.analysisJobId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(body.analysisJobId))) return Response.json({ error: "מזהה ניתוח אינו תקין" }, { status: 400 }); + const name = String(body.name || "").trim(); const clientRequestId = String(body.clientRequestId || request.headers.get("idempotency-key") || "").trim().slice(0, 120); const items = Array.isArray(body.items) ? body.items.slice(0, 30) : []; const calculated = items.length ? calculateMealFromItems(items) : body; @@ -105,7 +107,7 @@ export async function PATCH(request: Request) { if (body.baseUpdatedAt !== undefined && String(meal.updatedAt || "") !== String(body.baseUpdatedAt || "")) { editConflict = true; return latest; } if (body.scale !== undefined) { const scale = Math.max(.25, Math.min(4, Number(body.scale) || 1)); - ["protein", "carbs", "fat"].forEach((field) => { meal[field] = Math.round(Number(meal[field] || 0) * scale * 10) / 10; }); + ["protein", "carbs", "fat", "sugar", "fiber", "sodiumMg", "saturatedFat", "addedSugar"].forEach((field) => { meal[field] = Math.round(Number(meal[field] || 0) * scale * 10) / 10; }); meal.kcal = roundCalories(Number(meal.kcal || 0) * scale); meal.items = (meal.items || []).map((item) => ({ ...item, quantity: Math.round(Number(item.quantity || 1) * scale * 100) / 100 })); } else { @@ -115,12 +117,12 @@ export async function PATCH(request: Request) { meal.period = ["breakfast", "lunch", "dinner", "snack"].includes(body.period) ? body.period : meal.period; meal.kcal = roundCalories(calculated.kcal); meal.protein = Math.max(0, Number(calculated.protein) || 0); meal.carbs = Math.max(0, Number(calculated.carbs) || 0); meal.fat = Math.max(0, Number(calculated.fat) || 0); meal.sugar = Math.max(0, Number(calculated.sugar) || 0); meal.sugarTrackedItems = Math.max(0, Number(calculated.sugarTrackedItems) || 0); meal.items = items; meal.nutritionReliability = assessMealReliability({ ...body, ...calculated, items }); - const requested = new Date(body.occurredAt || meal.time); if (Number.isFinite(requested.getTime()) && requested.getTime() <= Date.now()) meal.time = requested.toISOString(); + const requested = new Date(body.occurredAt || meal.time); if (Number.isFinite(requested.getTime()) && requested.getTime() <= Date.now() && requested.toISOString() !== meal.time) { meal.time = requested.toISOString(); meal.logicalDate = localDateAt(meal.time, userTimeZone(ensureUserData(latest, session.userId))); } } if (body.scale === undefined && editedItems.length) { const data = ensureUserData(latest, session.userId); data.foodCalibration.push(...buildFoodCorrections(previousItems, editedItems, "meal_edit")); data.foodCalibration = data.foodCalibration.slice(-100); } meal.score = calculateMealScore(meal); meal.updatedAt = new Date().toISOString(); const previousDay = found.day; previousDay.meals = previousDay.meals.filter((item) => item.id !== id); restoreOwnedMeal(latest, session.userId, meal); - savedLocalDate = localDateAt(meal.time, userTimeZone(ensureUserData(latest, session.userId))); + savedLocalDate = meal.logicalDate || localDateAt(meal.time, userTimeZone(ensureUserData(latest, session.userId))); addAudit(latest, { userId: session.userId, action: "meal.updated", target: id }); return latest; }); if (!foundMeal) return Response.json({ error: "הארוחה לא נמצאה" }, { status: 404 }); diff --git a/caloreazi/app/api/measurements/route.ts b/caloreazi/app/api/measurements/route.ts index 46e677d..27d1831 100644 --- a/caloreazi/app/api/measurements/route.ts +++ b/caloreazi/app/api/measurements/route.ts @@ -5,6 +5,7 @@ export const runtime = "nodejs"; export async function POST(request: Request) { const initial = await readState(); const session = requireUser(initial, request); if (!session) return Response.json({ error: "יש להתחבר" }, { status: 401 }); + if (!ensureUserData(initial, session.userId).profile) return Response.json({ error: "יש להשלים פרופיל לפני הוספת מדידה" }, { status: 409 }); const body = await request.json(); const weight = Number(body.weight); if (!(weight >= 25 && weight <= 350)) return Response.json({ error: "יש להזין משקל תקין" }, { status: 400 }); const date = String(body.date || new Date().toISOString().slice(0, 10)); diff --git a/caloreazi/app/api/water/route.ts b/caloreazi/app/api/water/route.ts index dd60505..e27feea 100644 --- a/caloreazi/app/api/water/route.ts +++ b/caloreazi/app/api/water/route.ts @@ -10,13 +10,14 @@ export async function POST(request: Request) { const mutationKey = String(request.headers.get("idempotency-key") || "").slice(0, 120); const receipt = `${session.userId}:water:post:${mutationKey}`; if (mutationKey && (initial.systemSettings?.mutationReceipts || []).includes(receipt)) return Response.json(userView(initial, session.userId, session.role === "admin")); const { amount = 250, recordedAt, localDate, beverageId = "water" } = await request.json(); const eventTime = Number.isFinite(Date.parse(String(recordedAt || ""))) ? new Date(recordedAt).toISOString() : new Date().toISOString(); - const state = await updateState((latest) => { const data = ensureUserData(latest, session.userId); const custom = data.profile.customHydrationBeverages || []; const beverage = hydrationBeverage(String(beverageId), custom); const eventDate = /^\d{4}-\d{2}-\d{2}$/.test(String(localDate || "")) ? String(localDate) : entryDateFor(data, eventTime); let day = data.today.date === eventDate ? data.today : data.history.find((item) => item.date === eventDate); if (!day) { day = { date: eventDate, waterMl: 0, waterEvents: [], meals: [] }; data.history.push(day); data.history.sort((a, b) => a.date.localeCompare(b.date)); } const delta = Math.round(Number(amount || 0)); day.waterEvents = Array.isArray(day.waterEvents) ? day.waterEvents : []; day.meals = Array.isArray(day.meals) ? day.meals : []; if (delta > 0) { const eventId = crypto.randomUUID(); const nutrition = beverageNutrition(delta, beverage.id, custom); const mealId = nutrition.kcal > 0 ? crypto.randomUUID() : null; day.waterEvents.push({ id: eventId, amount: delta, hydrationMl: hydrationContribution(delta, beverage.id, custom), beverageId: beverage.id, beverageName: beverage.name, icon: beverage.icon, source: "hydration-control", ...(mealId ? { mealId } : {}), time: eventTime }); if (mealId) day.meals.push({ id: mealId, hydrationEventId: eventId, beverageEntry: true, name: beverage.name, period: "snack", ...nutrition, sugar: 0, items: [{ name: beverage.name, grams: delta, quantity: 1, kcalPer100: beverage.kcalPer100, proteinPer100: beverage.proteinPer100, carbsPer100: beverage.carbsPer100, fatPer100: beverage.fatPer100, nutritionSource: { sourceId: `hydration:${beverage.id}`, source: "CALOREAZI beverage reference" } }], source: "manual", confidence: .9, time: eventTime, logicalDate: eventDate }); } else if (delta < 0) removeLatestBeverageServing(day, beverage.id, Math.abs(delta), custom); day.meals.sort((a, b) => String(a.time).localeCompare(String(b.time))); day.waterMl = hydrationTotal(day.waterEvents); if (mutationKey) { latest.systemSettings.mutationReceipts = [...(latest.systemSettings.mutationReceipts || []), receipt].slice(-2000); } return latest; }); + const state = await updateState((latest) => { if (mutationKey && (latest.systemSettings?.mutationReceipts || []).includes(receipt)) return latest; const data = ensureUserData(latest, session.userId); const custom = data.profile?.customHydrationBeverages || []; const beverage = hydrationBeverage(String(beverageId), custom); const eventDate = /^\d{4}-\d{2}-\d{2}$/.test(String(localDate || "")) ? String(localDate) : entryDateFor(data, eventTime); let day = data.today.date === eventDate ? data.today : data.history.find((item) => item.date === eventDate); if (!day) { day = { date: eventDate, waterMl: 0, waterEvents: [], meals: [] }; data.history.push(day); data.history.sort((a, b) => a.date.localeCompare(b.date)); } const delta = Math.round(Number(amount || 0)); day.waterEvents = Array.isArray(day.waterEvents) ? day.waterEvents : []; day.meals = Array.isArray(day.meals) ? day.meals : []; if (delta > 0) { const eventId = crypto.randomUUID(); const nutrition = beverageNutrition(delta, beverage.id, custom); const mealId = nutrition.kcal > 0 ? crypto.randomUUID() : null; day.waterEvents.push({ id: eventId, amount: delta, hydrationMl: hydrationContribution(delta, beverage.id, custom), beverageId: beverage.id, beverageName: beverage.name, icon: beverage.icon, source: "hydration-control", ...(mealId ? { mealId } : {}), time: eventTime }); if (mealId) day.meals.push({ id: mealId, hydrationEventId: eventId, beverageEntry: true, name: beverage.name, period: "snack", ...nutrition, sugar: 0, items: [{ name: beverage.name, grams: delta, quantity: 1, kcalPer100: beverage.kcalPer100, proteinPer100: beverage.proteinPer100, carbsPer100: beverage.carbsPer100, fatPer100: beverage.fatPer100, nutritionSource: { sourceId: `hydration:${beverage.id}`, source: "CALOREAZI beverage reference" } }], source: "manual", confidence: .9, time: eventTime, logicalDate: eventDate }); } else if (delta < 0) removeLatestBeverageServing(day, beverage.id, Math.abs(delta), custom); day.meals.sort((a, b) => String(a.time).localeCompare(String(b.time))); day.waterMl = hydrationTotal(day.waterEvents); if (mutationKey) { latest.systemSettings.mutationReceipts = [...(latest.systemSettings.mutationReceipts || []), receipt].slice(-2000); } return latest; }); return Response.json(userView(state, session.userId, session.role === "admin")); } export async function PUT(request: Request) { const initial = await readState(); const session = requireUser(initial, request); if (!session) return Response.json({ error: "יש להתחבר" }, { status: 401 }); + if (!ensureUserData(initial, session.userId).profile) return Response.json({ error: "יש להשלים פרופיל לפני שינוי יעד השתייה" }, { status: 409 }); const { amount, targetWaterMl, localDate, beverages, customBeverages } = await request.json(); const hasManualAmount = amount !== undefined && amount !== null && Number.isFinite(Number(amount)); const waterMl = Math.max(0, Math.min(20_000, Math.round(Number(amount) || 0))); const dailyTarget = Math.max(1500, Math.min(2750, Math.round(Number(targetWaterMl) || 2000))); - const state = await updateState((latest) => { const data = ensureUserData(latest, session.userId); const editDate = /^\d{4}-\d{2}-\d{2}$/.test(String(localDate || "")) ? String(localDate) : data.today.date; const day = data.today.date === editDate ? data.today : data.history.find((item) => item.date === editDate); if (day) { if (hasManualAmount) day.waterMl = waterMl; day.waterEvents = Array.isArray(day.waterEvents) ? day.waterEvents : []; } data.profile.waterMl = dailyTarget; if (Array.isArray(customBeverages)) data.profile.customHydrationBeverages = customBeverages.slice(0, 10).map(normalizeCustomBeverage).filter((item) => item.name); const custom = data.profile.customHydrationBeverages || []; if (Array.isArray(beverages)) data.profile.hydrationBeverages = [...new Set(beverages.map(String).filter((id) => id !== "water" && hydrationBeverage(id, custom).id === id))].slice(0, 18); return latest; }); + const state = await updateState((latest) => { const data = ensureUserData(latest, session.userId); const editDate = /^\d{4}-\d{2}-\d{2}$/.test(String(localDate || "")) ? String(localDate) : data.today.date; const day = data.today.date === editDate ? data.today : data.history.find((item) => item.date === editDate); if (day) { if (hasManualAmount) day.waterMl = waterMl; day.waterEvents = Array.isArray(day.waterEvents) ? day.waterEvents : []; } data.profile.waterMl = dailyTarget; if (Array.isArray(customBeverages)) data.profile.customHydrationBeverages = customBeverages.slice(0, 10).map(normalizeCustomBeverage).filter((item) => item.name); const custom = data.profile?.customHydrationBeverages || []; if (Array.isArray(beverages)) data.profile.hydrationBeverages = [...new Set(beverages.map(String).filter((id) => id !== "water" && hydrationBeverage(id, custom).id === id))].slice(0, 18); return latest; }); return Response.json(userView(state, session.userId, session.role === "admin")); } diff --git a/caloreazi/app/globals.css b/caloreazi/app/globals.css index 32a7cd0..5fb45d6 100644 --- a/caloreazi/app/globals.css +++ b/caloreazi/app/globals.css @@ -199,7 +199,7 @@ body{font-size:16px}h1{font-size:34px;line-height:1.18;letter-spacing:0!importan /* Mobile PWA is a fixed-width application surface: vertical scrolling only. */ html,body{width:100%;max-width:100%;overflow-x:hidden;font-family:var(--font-ui);overscroll-behavior-x:none} body{touch-action:pan-y}.app-shell{width:100%;max-width:100%;overflow-x:clip} -@media(max-width:760px){html,body{width:100%!important;max-width:100%!important;overflow-x:hidden!important;touch-action:pan-y!important}.app-shell{width:100%!important;max-width:100%!important;overflow-x:clip!important}.app-shell main,.app-shell section,.app-shell article,.app-shell header,.app-shell footer,.app-shell div{min-width:0;max-width:100%}.app-shell img,.app-shell svg,.app-shell video,.app-shell canvas{max-width:100%;height:auto}.suggestion-periods{overflow:hidden!important;grid-template-columns:repeat(4,minmax(0,1fr))!important}.suggestion-periods button{min-width:0!important;padding-inline:4px}.meal-suggestions-panel>header{flex-wrap:wrap}.suggestion-actions{max-width:100%;flex-wrap:wrap}.meal-suggestion-list article{grid-template-columns:minmax(0,1fr) auto}.meal-suggestion-list article strong,.meal-suggestion-list article small,.meal-suggestion-list article footer{overflow-wrap:anywhere}.today-focus-card{width:100%;max-width:100%;grid-template-columns:minmax(0,1fr) auto}.today-focus-card strong{overflow-wrap:anywhere}} +@media(max-width:760px){html,body{width:100%!important;max-width:100%!important;overflow-x:hidden!important;touch-action:pan-y pinch-zoom!important}.app-shell{width:100%!important;max-width:100%!important;overflow-x:clip!important}.app-shell main,.app-shell section,.app-shell article,.app-shell header,.app-shell footer,.app-shell div{min-width:0;max-width:100%}.app-shell img,.app-shell svg,.app-shell video,.app-shell canvas{max-width:100%;height:auto}.suggestion-periods{overflow:hidden!important;grid-template-columns:repeat(4,minmax(0,1fr))!important}.suggestion-periods button{min-width:0!important;padding-inline:4px}.meal-suggestions-panel>header{flex-wrap:wrap}.suggestion-actions{max-width:100%;flex-wrap:wrap}.meal-suggestion-list article{grid-template-columns:minmax(0,1fr) auto}.meal-suggestion-list article strong,.meal-suggestion-list article small,.meal-suggestion-list article footer{overflow-wrap:anywhere}.today-focus-card{width:100%;max-width:100%;grid-template-columns:minmax(0,1fr) auto}.today-focus-card strong{overflow-wrap:anywhere}} @media(max-width:760px){.app-shell svg{height:revert}} /* Compact, single-screen meal source launcher. */ @@ -218,3 +218,8 @@ body{touch-action:pan-y}.app-shell{width:100%;max-width:100%;overflow-x:clip} /* Transparent nutrition audit: source, formula and confidence travel with every ingredient. */ .meal-reliability{margin:14px 0;padding:0!important;overflow:hidden;border:1px solid var(--line);border-radius:18px;background:var(--surface)}.meal-reliability.high{border-color:color-mix(in srgb,var(--green) 52%,var(--line))}.meal-reliability.medium{border-color:color-mix(in srgb,var(--carbs) 58%,var(--line))}.meal-reliability.low{border-color:color-mix(in srgb,#e35b55 65%,var(--line))}.meal-reliability>summary{min-height:66px;padding:13px 16px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;list-style:none}.meal-reliability>summary::-webkit-details-marker{display:none}.meal-reliability>summary>span{display:grid;grid-template-columns:34px 1fr;column-gap:10px;align-items:center}.meal-reliability>summary svg{grid-row:1/3;width:24px;height:24px;color:var(--green)}.meal-reliability.medium>summary svg{color:var(--carbs)}.meal-reliability.low>summary svg{color:#e35b55}.meal-reliability>summary b{font-size:17px}.meal-reliability>summary small{color:var(--muted);font-size:13px}.meal-reliability>summary>i{width:10px;height:10px;border-inline-end:2px solid var(--muted);border-bottom:2px solid var(--muted);transform:rotate(45deg);transition:transform .2s}.meal-reliability[open]>summary>i{transform:rotate(225deg)}.meal-reliability>div{padding:0 14px 14px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.meal-reliability article{padding:12px;border:1px solid var(--line);border-radius:13px;background:var(--canvas)}.meal-reliability article header{display:flex;justify-content:space-between;gap:10px}.meal-reliability article header b{color:var(--green);font-variant-numeric:tabular-nums}.meal-reliability article p{margin:5px 0;color:var(--muted);font-size:13px}.meal-reliability article small{display:block;color:var(--ink);font-size:13px;direction:rtl}.meal-reliability>ul{margin:0 16px 15px;padding-inline-start:18px;color:#e35b55}.meal-reliability>ul li{margin-top:5px} @media(max-width:760px){.meal-reliability>summary{min-height:72px;padding:14px}.meal-reliability>summary b{font-size:17px}.meal-reliability>summary small{font-size:13px!important}.meal-reliability>div{grid-template-columns:1fr}.meal-reliability article strong{font-size:16px}.meal-reliability article p,.meal-reliability article small{font-size:14px!important}} + +/* Focused meal launcher: primary actions and optional tools. */ +.quick-add-launcher .quick-source-grid.quick-primary-actions{grid-template-columns:repeat(2,minmax(0,1fr))!important;grid-template-rows:auto!important;grid-auto-rows:minmax(88px,auto);height:auto!important;flex:none!important} +.quick-add-launcher .quick-source-grid.quick-additional-actions{grid-template-columns:repeat(2,minmax(0,1fr))!important;grid-template-rows:auto!important;grid-auto-rows:minmax(72px,auto);height:auto!important;flex:none!important} +.quick-add-launcher{overflow-y:auto!important}.quick-source:focus-visible{outline:3px solid var(--accent,#72965c);outline-offset:3px} diff --git a/caloreazi/app/health/route.ts b/caloreazi/app/health/route.ts index 5de80d9..ac9cff0 100644 --- a/caloreazi/app/health/route.ts +++ b/caloreazi/app/health/route.ts @@ -1,3 +1,3 @@ export function GET() { - return Response.json({ status: "ok", service: "caloreazi", version: "1.23.3", build: process.env.CALOREAZI_BUILD_COMMIT || "development" }); + return Response.json({ status: "ok", service: "caloreazi", version: "1.24.0", build: process.env.CALOREAZI_BUILD_COMMIT || "development" }); } diff --git a/caloreazi/app/layout.tsx b/caloreazi/app/layout.tsx index 51ebcc9..f772380 100644 --- a/caloreazi/app/layout.tsx +++ b/caloreazi/app/layout.tsx @@ -23,8 +23,6 @@ export const metadata: Metadata = { export const viewport: Viewport = { width: "device-width", initialScale: 1, - maximumScale: 1, - userScalable: false, viewportFit: "cover", themeColor: [ { media: "(prefers-color-scheme: light)", color: "#F7F5F0" }, diff --git a/caloreazi/app/offline-queue.ts b/caloreazi/app/offline-queue.ts index 047801a..a97c8ac 100644 --- a/caloreazi/app/offline-queue.ts +++ b/caloreazi/app/offline-queue.ts @@ -1,22 +1,71 @@ -export type OfflineCapture = { clientId: string; imageDataUrl: string; createdAt: string; attempts?: number; lastError?: string }; -export type OfflineMutation = { id: string; url: string; method: string; body: string; createdAt: string; attempts: number; lastError?: string }; -export type OfflineQueueItem = { id: string; kind: "capture" | "mutation"; label: string; createdAt: string; attempts: number; lastError?: string }; +export type OfflineCapture = { clientId: string; imageDataUrl: string; createdAt: string; attempts?: number; lastError?: string; userId?: string; analysisJobId?: string; analysis?: Record }; +export type OfflineMutation = { id: string; url: string; method: string; body: string; createdAt: string; attempts: number; lastError?: string; userId?: string }; +export type OfflineQueueItem = { id: string; kind: "capture" | "mutation"; label: string; createdAt: string; attempts: number; lastError?: string; ready?: boolean }; const DB = "caloreazi-offline"; const CAPTURES = "meal-captures"; const MUTATIONS = "mutations"; - +let activeUser = ""; +export function setOfflineUser(userId: string) { activeUser = userId || ""; } +function owner() { if (!activeUser) throw new Error("יש להתחבר כדי לשמור במכשיר"); return activeUser; } function database(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB, 2); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(CAPTURES)) db.createObjectStore(CAPTURES, { keyPath: "clientId" }); if (!db.objectStoreNames.contains(MUTATIONS)) db.createObjectStore(MUTATIONS, { keyPath: "id" }); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } -async function put(store: string, value: unknown) { const db = await database(); await new Promise((resolve, reject) => { const request = db.transaction(store, "readwrite").objectStore(store).put(value); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); db.close(); } -async function remove(store: string, key: string) { const db = await database(); await new Promise((resolve, reject) => { const request = db.transaction(store, "readwrite").objectStore(store).delete(key); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); db.close(); } -async function all(store: string) { const db = await database(); const values = await new Promise((resolve, reject) => { const request = db.transaction(store).objectStore(store).getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); db.close(); return values; } -async function count(store: string) { const db = await database(); const value = await new Promise((resolve, reject) => { const request = db.transaction(store).objectStore(store).count(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); db.close(); return value; } - +async function write(store: string, value: unknown, key?: string) { + const db = await database(); + try { await new Promise((resolve, reject) => { const tx = db.transaction(store, "readwrite"); if (key !== undefined) tx.objectStore(store).delete(key); else tx.objectStore(store).put(value); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error || new Error("השמירה במכשיר נכשלה")); }); } finally { db.close(); } +} +async function all(store: string, userId = activeUser): Promise { + if (!userId) return []; + const db = await database(); + try { return await new Promise((resolve, reject) => { const request = db.transaction(store).objectStore(store).getAll(); request.onsuccess = () => resolve(request.result.filter((item: T) => item.userId === userId)); request.onerror = () => reject(request.error); }); } finally { db.close(); } +} +// Legacy records without an owner remain stored, but are never assigned to another account. function mutationLabel(url: string) { if (url.includes("/meals")) return "ארוחה"; if (url.includes("/water")) return "שתייה"; if (url.includes("/measurements")) return "מדידת משקל"; if (url.includes("/activity")) return "פעילות"; if (url.includes("/profile")) return "פרטים אישיים"; return "עדכון"; } - -export async function queueOfflineCapture(capture: OfflineCapture) { await put(CAPTURES, { ...capture, attempts: capture.attempts || 0 }); } -export async function queueOfflineMutation(mutation: Omit) { await put(MUTATIONS, { ...mutation, attempts: 0 }); } -export async function offlineCaptureCount() { return count(CAPTURES); } -export async function offlineMutationCount() { return count(MUTATIONS); } +export async function queueOfflineCapture(capture: OfflineCapture) { await write(CAPTURES, { ...capture, userId: owner(), attempts: capture.attempts || 0 }); } +export async function queueOfflineMutation(mutation: Omit) { await write(MUTATIONS, { ...mutation, userId: owner(), attempts: 0 }); } +export async function offlineCaptureCount() { return (await all(CAPTURES)).length; } +export async function offlineMutationCount() { return (await all(MUTATIONS)).length; } export async function offlinePendingCount() { return (await offlineCaptureCount()) + (await offlineMutationCount()); } -export async function listOfflineQueue(): Promise { const [captures, mutations] = await Promise.all([all(CAPTURES), all(MUTATIONS)]); return [...captures.map((item) => ({ id: item.clientId, kind: "capture" as const, label: "צילום ארוחה", createdAt: item.createdAt, attempts: item.attempts || 0, lastError: item.lastError })), ...mutations.map((item) => ({ id: item.id, kind: "mutation" as const, label: mutationLabel(item.url), createdAt: item.createdAt, attempts: item.attempts || 0, lastError: item.lastError }))].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } -export async function retryOfflineItem(item: OfflineQueueItem) { if (item.kind === "capture") { const record = (await all(CAPTURES)).find((entry) => entry.clientId === item.id); if (record) await put(CAPTURES, { ...record, attempts: 0, lastError: undefined }); return; } const record = (await all(MUTATIONS)).find((entry) => entry.id === item.id); if (record) await put(MUTATIONS, { ...record, attempts: 0, lastError: undefined }); } -export async function flushOfflineCaptures(send: (capture: OfflineCapture) => Promise) { const captures = (await all(CAPTURES)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); let sent = 0; for (const capture of captures) { try { await send(capture); await remove(CAPTURES, capture.clientId); sent += 1; } catch (error) { await put(CAPTURES, { ...capture, attempts: (capture.attempts || 0) + 1, lastError: error instanceof Error ? error.message : "sync failed" }); break; } } return sent; } -export async function flushOfflineMutations(send: (mutation: OfflineMutation) => Promise) { const mutations = (await all(MUTATIONS)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); let sent = 0; for (const mutation of mutations) { try { await send(mutation); await remove(MUTATIONS, mutation.id); sent += 1; } catch (error) { await put(MUTATIONS, { ...mutation, attempts: mutation.attempts + 1, lastError: error instanceof Error ? error.message : "sync failed" }); break; } } return sent; } +export async function listOfflineQueue(): Promise { + const userId = activeUser; + const [captures, mutations] = await Promise.all([all(CAPTURES, userId), all(MUTATIONS, userId)]); + return [...captures.map(item => ({ id: item.clientId, kind: "capture" as const, label: item.analysis ? "צילום מוכן לאישור" : "צילום ארוחה", createdAt: item.createdAt, attempts: item.attempts || 0, lastError: item.lastError, ready: Boolean(item.analysis) })), ...mutations.map(item => ({ id: item.id, kind: "mutation" as const, label: mutationLabel(item.url), createdAt: item.createdAt, attempts: item.attempts || 0, lastError: item.lastError }))].sort((a,b) => a.createdAt.localeCompare(b.createdAt)); +} +export async function getOfflineCapture(id: string) { return (await all(CAPTURES)).find(item => item.clientId === id); } +export async function discardOfflineItem(item: Pick) { + const store = item.kind === "capture" ? CAPTURES : MUTATIONS; + const records = await all(store); + if (records.some(record => (item.kind === "capture" ? record.clientId : record.id) === item.id)) await write(store, null, item.id); +} +export async function retryOfflineItem(item: OfflineQueueItem) { + const store = item.kind === "capture" ? CAPTURES : MUTATIONS; + const record = (await all(store)).find(entry => (item.kind === "capture" ? entry.clientId : entry.id) === item.id); + if (record) await write(store, { ...record, analysisJobId: undefined, attempts: 0, lastError: undefined }); +} +const flushing = new Set(); +async function flush(store: string, send: (record: T) => Promise) { + const userId = activeUser; const key = store + ":" + userId; + if (!userId || flushing.has(key)) return 0; + const run = async () => { + flushing.add(key); let sent = 0; + try { + const records = (await all(store, userId)).sort((a,b) => a.createdAt.localeCompare(b.createdAt)); + for (const record of records) { + if (activeUser !== userId) break; + if (record.analysis || (record.attempts || 0) >= 3) continue; + try { + const result = await send(record); + if (store === CAPTURES) { + if (!result) throw new Error("הצילום עדיין ממתין לניתוח"); + if ((result as Record).pendingJobId) { await write(store, { ...record, analysisJobId: (result as Record).pendingJobId }); continue; } + await write(store, { ...record, analysis: result, attempts: 0, lastError: undefined }); + } else await write(store, null, record.id); + sent++; + } catch (error) { + await write(store, { ...record, attempts: (record.attempts || 0) + 1, lastError: error instanceof Error ? error.message : "sync failed" }); + break; + } + } + return sent; + } finally { flushing.delete(key); } + }; + return typeof navigator !== "undefined" && navigator.locks ? navigator.locks.request("caloreazi:" + key, run) : run(); +} +export async function flushOfflineCaptures(send: (capture: OfflineCapture) => Promise>) { return flush(CAPTURES, send); } +export async function flushOfflineMutations(send: (mutation: OfflineMutation) => Promise) { return flush(MUTATIONS, send); } diff --git a/caloreazi/app/page.tsx b/caloreazi/app/page.tsx index bccb379..d6f7bed 100644 --- a/caloreazi/app/page.tsx +++ b/caloreazi/app/page.tsx @@ -10,7 +10,8 @@ import { useRef, useState, } from "react"; -import { flushOfflineCaptures, flushOfflineMutations, listOfflineQueue, offlinePendingCount, queueOfflineCapture, queueOfflineMutation, retryOfflineItem, type OfflineQueueItem } from "./offline-queue"; +import { setOfflineUser, getOfflineCapture, discardOfflineItem, flushOfflineCaptures, flushOfflineMutations, listOfflineQueue, offlinePendingCount, queueOfflineCapture, queueOfflineMutation, retryOfflineItem, type OfflineQueueItem } from "./offline-queue"; +import { useModalAccessibility } from "./use-modal-accessibility"; import { AppIcon } from "./components/AppIcon"; import { assessMealReliability } from "../server/meal-reliability.js"; import { HYDRATION_BEVERAGES, beverageNutrition, hydrationBeverage, hydrationContribution, hydrationTotal, normalizeCustomBeverage, removeLatestBeverageServing } from "../server/hydration.js"; @@ -525,6 +526,7 @@ function withTimeout(promise: Promise, milliseconds: number, message: stri } export default function Home() { + useModalAccessibility(); const [state, setState] = useState(null); const [error, setError] = useState(""); const [dark, setDark] = useState(false); @@ -653,6 +655,7 @@ export default function Home() { const [weightDate, setWeightDate] = useState(""); const [weightFeedback, setWeightFeedback] = useState(""); const [quickAddOpen, setQuickAddOpen] = useState(false); + const [moreMealActions, setMoreMealActions] = useState(false); const [forgottenOpen, setForgottenOpen] = useState(false); const [forgottenMeals, setForgottenMeals] = useState([]); const [forgottenStatus, setForgottenStatus] = useState(""); @@ -691,8 +694,9 @@ export default function Home() { const [syncCenterOpen, setSyncCenterOpen] = useState(false); const [syncStatus, setSyncStatus] = useState<"offline" | "idle" | "syncing" | "success" | "attention">("idle"); const [syncRequested, setSyncRequested] = useState(0); + const [offlineReviewId, setOfflineReviewId] = useState(""); const [waterOpen, setWaterOpen] = useState(false); - const [waterValue, setWaterValue] = useState(0); + const [, setWaterValue] = useState(0); const [waterTargetValue, setWaterTargetValue] = useState(2000); const [selectedHydrationBeverages, setSelectedHydrationBeverages] = useState([]); const [customHydrationBeverages, setCustomHydrationBeverages] = useState([]); @@ -864,24 +868,30 @@ export default function Home() { return () => window.clearInterval(timer); }, []); useEffect(() => { + const userId = state?.currentUser?.id || ""; + setOfflineUser(userId); + if (!userId) { setOfflineQueueItems([]); setOfflineQueueCount(0); return; } + let cancelled = false; setOnline(navigator.onLine); let syncing = false; const refreshQueue = async () => { const items = await listOfflineQueue(); setOfflineQueueItems(items); setOfflineQueueCount(items.length); if (items.some((item) => item.attempts >= 3)) setSyncStatus("attention"); return items; }; const update = async () => { - setOnline(navigator.onLine); if (syncing) return; + setOnline(navigator.onLine); if (syncing || cancelled) return; if (!navigator.onLine) { setSyncStatus("offline"); refreshQueue().catch(() => undefined); return; } syncing = true; setSyncStatus("syncing"); try { - const mutationsSent = await flushOfflineMutations(async (mutation) => { await api(mutation.url, { method: mutation.method, headers: { "Idempotency-Key": mutation.id }, body: mutation.body }); }); + const mutationsSent = await flushOfflineMutations(async (mutation) => { await api(mutation.url, { method: mutation.method, headers: { "Idempotency-Key": mutation.id, "X-Caloreazi-User": userId }, body: mutation.body }); }); const capturesSent = await flushOfflineCaptures(async (capture) => { - let result = await api("/api/ai/analyze-meal", { method: "POST", headers: { "Idempotency-Key": capture.clientId }, body: JSON.stringify(capture) }); - if (!result.items && result.jobId) result = await api(`/api/ai/analyze-meal?id=${encodeURIComponent(result.jobId)}`); - result = result.result ? { ...result.result, jobId: result.jobId } : result; - if (!result.items) throw new Error("הצילום עדיין ממתין לניתוח"); - setPhotoPreview(capture.imageDataUrl); setMealSource("photo"); setMealForm({ name: result.name, kcal: 0, protein: 0, carbs: 0, fat: 0 }); setMealItems(result.items); setAiOriginalItems(structuredClone(result.items)); setMealConfidence(result.confidence || "low"); setMealReviewReady(true); setPhotoStatus("הצילום שסונכרן נותח ומוכן לבדיקה ולאישור."); setMealOpen(true); + const headers = { "Idempotency-Key": capture.clientId, "X-Caloreazi-User": userId }; + let result = capture.analysisJobId ? await api("/api/ai/analyze-meal?id=" + encodeURIComponent(capture.analysisJobId), { headers }) : await api("/api/ai/analyze-meal", { method: "POST", headers, body: JSON.stringify(capture) }); + if (result.status === "failed" || result.status === "cancelled") throw new Error(result.errorMessage || "הניתוח נכשל; אפשר לנסות שוב"); + const jobId = result.jobId || result.id || capture.analysisJobId; + result = result.result ? { ...result.result, jobId } : { ...result, jobId }; + if (!result.items) return { pendingJobId: jobId }; + return result; }); - const latest = await api("/api/state"); setState(latest); + const latest = await api("/api/state"); if (cancelled) return; setState(latest); const remaining = await refreshQueue(); setSyncStatus(remaining.some((item) => item.attempts >= 3) ? "attention" : remaining.length ? "idle" : mutationsSent + capturesSent > 0 ? "success" : "idle"); } catch { const remaining = await refreshQueue().catch(() => []); setSyncStatus(remaining.some((item) => item.attempts >= 3) ? "attention" : "idle"); } @@ -896,12 +906,13 @@ export default function Home() { navigator.serviceWorker.register("./sw.js").catch(() => undefined); void update(); return () => { + cancelled = true; setOfflineUser(""); window.removeEventListener("online", update); window.removeEventListener("offline", update); document.removeEventListener("visibilitychange", visibility); window.clearInterval(retryTimer); }; - }, [syncRequested]); + }, [syncRequested, state?.currentUser?.id]); useEffect(() => { const saved = window.localStorage.getItem("caloreazi-theme"); if (saved) setDark(saved === "dark"); @@ -1529,6 +1540,7 @@ export default function Home() { setMacroDetail(""); setMealPreview(null); setPendingQuickFood(null); setPendingFavorite(null); setEditingFood(null); } function openMealLauncher() { + setMoreMealActions(false); setOfflineReviewId(""); closeOpenScreens(); setQuickCategory(""); setQuickSearch(""); @@ -1795,6 +1807,7 @@ export default function Home() { if (!catalogOnly && savedMealId && !photoPreview) void api("/api/meals/image", { method: "POST", body: JSON.stringify({ id: savedMealId, allowGenerate: state?.ai?.autoGenerateMealImages === true && state?.ai?.economyMode === false }) }).then((imageState) => { setState(imageState); if (imageState.imageCompleted) setMealResult((current: any) => current ? { ...current, imageCompleted: true } : current); }).catch(() => undefined); if (!catalogOnly && savedLocalDate && savedLocalDate !== latest.today?.date) setError(`הארוחה נשמרה בהיסטוריה בתאריך ${savedLocalDate}, בהתאם לשעה שנבחרה.`); + if (!catalogOnly && offlineReviewId) { await discardOfflineItem({ id: offlineReviewId, kind: "capture" }); setOfflineReviewId(""); setOfflineQueueItems(await listOfflineQueue()); setOfflineQueueCount(await offlinePendingCount()); } setMealOpen(false); mealSaveRequestId.current = ""; setEditingMealId(""); @@ -2032,7 +2045,7 @@ export default function Home() { if (weightValue && Number(weightValue) !== Number(latestWeight)) await queueMutation("/api/measurements", "POST", JSON.stringify({ weight: weightValue, date: state.today.date })); setState((current: any) => ({ ...current, profile: { ...current.profile, ...offlineProfile, age: exactAge(offlineProfile.birthDate) ?? current.profile.age, weight: weightValue || current.profile.weight } })); setCoachVoice(offlineProfile.coachVoice === "female" ? "female" : "male"); setCoachVoiceStyle(offlineProfile.coachVoiceStyle === "clear" ? "clear" : "warm"); setCoachVoiceProvider(offlineProfile.coachVoiceProvider === "device" ? "device" : "cloud"); - setOfflineQueueCount(await offlinePendingCount()); setProfileOpen(false); setMealResult({ name: "הפרטים נשמרו במכשיר וממתינים לסנכרון", kcal: 0, protein: 0, carbs: 0, fat: 0 }); return; + setOfflineQueueCount(await offlinePendingCount()); setProfileOpen(false); setMealResult({ name: "הפרטים נשמרו במכשיר וממתינים לטיפול", kcal: 0, protein: 0, carbs: 0, fat: 0 }); return; } let latest = await api("/api/profile", { method: "PUT", @@ -2131,6 +2144,7 @@ export default function Home() { } } async function switchUser() { + setOfflineUser(""); await api("/api/auth/session", { method: "DELETE" }); window.location.reload(); } @@ -2184,7 +2198,19 @@ export default function Home() { setError((e as Error).message); } } + async function openOfflineReview(id: string) { + const capture = await getOfflineCapture(id); if (!capture?.analysis) return; + openManualMeal(); + const result: any = capture.analysis; + setOfflineReviewId(id); mealSaveRequestId.current = capture.clientId; + setPhotoPreview(capture.imageDataUrl); setMealSource("photo"); setMealDateTime(localDateTimeInput(new Date(capture.createdAt))); + setAnalysisJobId(result.jobId || result.id || ""); setMealForm({ name: result.name, kcal: 0, protein: 0, carbs: 0, fat: 0 }); + setMealItems(result.items); setAiOriginalItems(structuredClone(result.items)); setMealConfidence(result.confidence || "low"); setMealReviewReady(true); + setPhotoStatus("הצילום שמור במכשיר עד לאישור והוספה."); setSyncCenterOpen(false); + } + function openManualMeal(category = "meals") { + setOfflineReviewId(""); if (category !== "meals") { setFoodCategory(category); openCustomFood(); @@ -2217,6 +2243,7 @@ export default function Home() { setMealOpen(true); } function openForgottenMeals() { + setOfflineReviewId(""); setQuickAddOpen(false); setForgottenMeals([newForgottenMeal()]); setForgottenStatus(""); @@ -2259,6 +2286,7 @@ export default function Home() { finally { setBusy(false); } } function openCustomFood() { + setOfflineReviewId(""); setCustomFoodName(""); setCustomFoodDraft(null); setCustomFoodStatus(""); @@ -2325,6 +2353,7 @@ export default function Home() { } } function selectQuickFood(item: any) { + setOfflineReviewId(""); setMealPeriod("snack"); setQuickFoodWeight(Number(item.defaultWeight || 100)); setPendingQuickFood(item); @@ -2575,6 +2604,7 @@ export default function Home() { return canvas.toDataURL("image/jpeg", quality); } async function analyzePhotoFile(file: File, recognitionHint = "") { + setOfflineReviewId(""); setManualAiMode(false); setMealSource("photo"); setMealItems([]); @@ -2982,6 +3012,7 @@ export default function Home() { setCameraStatus(""); } async function openInAppCamera() { + setOfflineReviewId(""); setQuickAddOpen(false); setCameraHint(""); setCameraStatus("פותח מצלמה…"); @@ -3291,8 +3322,8 @@ export default function Home() { {error} × )} - {offlineQueueCount > 0 && !syncCenterOpen && } - {syncCenterOpen &&
{online ? "מחובר" : "Offline"}{offlineQueueItems.length}פעולות ממתינות
{offlineQueueItems.map((item) =>
= 3 ? "failed" : ""} key={`${item.kind}-${item.id}`}>
{item.label}נשמר {new Date(item.createdAt).toLocaleString("he-IL")}{item.attempts >= 3 && <>לא הצלחנו לסנכרן אחרי {item.attempts} ניסיונות{item.lastError && {item.lastError}}}
{item.attempts >= 3 ? : {syncStatus === "syncing" ? "מסנכרן" : "ממתין"}}
)}{!offlineQueueItems.length &&
✓הכול מסונכרןאין פעולות שממתינות לשליחה.
}
} + {offlineQueueCount > 0 && !syncCenterOpen && } + {syncCenterOpen &&
{online ? "מחובר" : "Offline"}{offlineQueueItems.length}פעולות ממתינות
{offlineQueueItems.map((item) =>
= 3 ? "failed" : ""} key={`${item.kind}-${item.id}`}>
{item.label}נשמר {new Date(item.createdAt).toLocaleString("he-IL")}{item.attempts >= 3 && <>לא הצלחנו לסנכרן אחרי {item.attempts} ניסיונות{item.lastError && {item.lastError}}}
{item.ready ? : item.attempts >= 3 ? : {syncStatus === "syncing" ? "מסנכרן" : "ממתין"}}{item.kind === "capture" && }
)}{!offlineQueueItems.length &&
✓הכול מסונכרןאין פעולות שממתינות לשליחה.
}
} {mealResult && } {undoMeal && (