Merge branch 'screen1'

This commit is contained in:
echo 2026-03-31 17:05:43 +02:00
commit 4c2e97b66d
7 changed files with 1083 additions and 472 deletions

Binary file not shown.

View File

@ -0,0 +1,156 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getUserMembershipContext } from "@/lib/membership/access";
import log from "@/lib/logger";
interface OpenFoodFactsProduct {
product_name?: string;
product_name_en?: string;
brands?: string;
image_url?: string;
image_front_url?: string;
serving_size?: string;
nutriments?: {
[key: string]: number | string | undefined;
"energy-kcal_serving"?: number;
"energy-kcal_100g"?: number;
proteins_serving?: number;
proteins_100g?: number;
carbohydrates_serving?: number;
carbohydrates_100g?: number;
fat_serving?: number;
fat_100g?: number;
};
}
interface OpenFoodFactsResponse {
status: number;
code: string;
product?: OpenFoodFactsProduct;
}
function normalizeBarcode(rawCode: string): string {
return rawCode.replace(/\D/g, "").trim();
}
function isSupportedBarcode(code: string): boolean {
return [8, 12, 13].includes(code.length);
}
function getNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function buildProductPayload(code: string, product: OpenFoodFactsProduct) {
const caloriesPerServing =
getNumber(product.nutriments?.["energy-kcal_serving"]) ??
getNumber(product.nutriments?.["energy-kcal_100g"]) ??
0;
const protein =
getNumber(product.nutriments?.proteins_serving) ??
getNumber(product.nutriments?.proteins_100g);
const carbs =
getNumber(product.nutriments?.carbohydrates_serving) ??
getNumber(product.nutriments?.carbohydrates_100g);
const fat =
getNumber(product.nutriments?.fat_serving) ??
getNumber(product.nutriments?.fat_100g);
return {
barcode: code,
name: product.product_name || product.product_name_en || "Unknown Product",
brand: product.brands || null,
imageUrl: product.image_url || product.image_front_url || null,
servingSize: product.serving_size || "1 serving",
caloriesPerServing: Math.max(0, Math.round(caloriesPerServing)),
macros: {
protein: protein ?? null,
carbs: carbs ?? null,
fat: fat ?? null,
},
source: "openfoodfacts" as const,
};
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ code: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Barcode food scan is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const { code: rawCode } = await params;
const code = normalizeBarcode(rawCode);
if (!isSupportedBarcode(code)) {
return NextResponse.json(
{ error: "Invalid barcode. Use EAN-8, UPC-A, or EAN-13 formats." },
{ status: 400 },
);
}
const response = await fetch(
`https://world.openfoodfacts.org/api/v2/product/${code}.json`,
{
headers: {
"User-Agent": "FitAI/1.0 (fitai.app)",
},
cache: "no-store",
},
);
if (!response.ok) {
log.warn("OpenFoodFacts lookup failed", {
status: response.status,
barcode: code,
});
return NextResponse.json(
{ error: "Food lookup service unavailable. Please try again." },
{ status: 503 },
);
}
const payload = (await response.json()) as OpenFoodFactsResponse;
if (payload.status !== 1 || !payload.product) {
return NextResponse.json(
{ error: "Product not found in OpenFoodFacts" },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
data: buildProductPayload(code, payload.product),
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed barcode food lookup", error);
return NextResponse.json(
{ error: "Failed to lookup food barcode" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,35 @@
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface ScannedFoodProduct {
barcode: string;
name: string;
brand: string | null;
imageUrl: string | null;
servingSize: string;
caloriesPerServing: number;
macros: {
protein: number | null;
carbs: number | null;
fat: number | null;
};
source: "openfoodfacts";
}
interface FoodLookupResponse {
success: boolean;
data: ScannedFoodProduct;
}
export async function lookupFoodByBarcode(
barcode: string,
token: string | null,
): Promise<ScannedFoodProduct> {
const normalized = barcode.replace(/\D/g, "");
const response = await apiClient.get<FoodLookupResponse>(
API_ENDPOINTS.FOOD.LOOKUP_BARCODE(normalized),
withAuth(token),
);
return response.data.data;
}

View File

@ -15,4 +15,5 @@ export * from "./hydration";
export * from "./client"; export * from "./client";
export * from "./helpers"; export * from "./helpers";
export * from "./membership"; export * from "./membership";
export * from "./food";
export * from "./gyms"; export * from "./gyms";

View File

@ -8,6 +8,7 @@ import {
Animated, Animated,
TouchableOpacity, TouchableOpacity,
Alert, Alert,
AppState,
} from "react-native"; } from "react-native";
import { useUser } from "@clerk/clerk-expo"; import { useUser } from "@clerk/clerk-expo";
import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { useState, useCallback, useEffect, useRef, useMemo } from "react";
@ -43,6 +44,22 @@ const CALORIE_GOAL = 2000;
const WATER_GOAL = 2000; const WATER_GOAL = 2000;
const WORKOUT_GOAL = 3; const WORKOUT_GOAL = 3;
const MOTIVATION_KEY_PREFIX = "home-motivation"; const MOTIVATION_KEY_PREFIX = "home-motivation";
const HOME_METRICS_KEY_PREFIX = "home-metrics";
const getLocalDateKey = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const getMillisecondsUntilNextMidnight = () => {
const now = new Date();
const nextMidnight = new Date(now);
nextMidnight.setHours(24, 0, 0, 0);
return Math.max(1000, nextMidnight.getTime() - now.getTime());
};
const getRandomMotivation = () => { const getRandomMotivation = () => {
const messages = [ const messages = [
@ -74,12 +91,102 @@ export default function HomeScreen() {
const caloriesBounce = useRef(new Animated.Value(1)).current; const caloriesBounce = useRef(new Animated.Value(1)).current;
const waterBounce = useRef(new Animated.Value(1)).current; const waterBounce = useRef(new Animated.Value(1)).current;
const caloriesRef = useRef(0);
const waterRef = useRef(0);
const currentDateRef = useRef(getLocalDateKey());
const midnightResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
caloriesRef.current = calories;
}, [calories]);
useEffect(() => {
waterRef.current = waterIntake;
}, [waterIntake]);
const getMetricsStorageKey = useCallback(
() => `${HOME_METRICS_KEY_PREFIX}_${user?.id || "guest"}`,
[user?.id],
);
const persistDailyMetrics = useCallback(
async (nextCalories: number, nextWaterIntake: number, dateKey?: string) => {
if (!user?.id) return;
const targetDate = dateKey || currentDateRef.current;
await AsyncStorage.setItem(
getMetricsStorageKey(),
JSON.stringify({
date: targetDate,
calories: nextCalories,
waterIntake: nextWaterIntake,
}),
);
},
[getMetricsStorageKey, user?.id],
);
const reconcileDailyMetrics = useCallback(async () => {
const today = getLocalDateKey();
currentDateRef.current = today;
if (!user?.id) {
setCalories(0);
setWaterIntake(0);
return;
}
const stored = await AsyncStorage.getItem(getMetricsStorageKey());
if (!stored) {
setCalories(0);
setWaterIntake(0);
await persistDailyMetrics(0, 0, today);
return;
}
try {
const parsed = JSON.parse(stored) as {
date?: string;
calories?: number;
waterIntake?: number;
};
if (parsed.date === today) {
const nextCalories = Number(parsed.calories) || 0;
const nextWater = Number(parsed.waterIntake) || 0;
setCalories(nextCalories);
setWaterIntake(nextWater);
} else {
setCalories(0);
setWaterIntake(0);
await persistDailyMetrics(0, 0, today);
}
} catch {
setCalories(0);
setWaterIntake(0);
await persistDailyMetrics(0, 0, today);
}
}, [getMetricsStorageKey, persistDailyMetrics, user?.id]);
const scheduleMidnightReset = useCallback(() => {
if (midnightResetTimerRef.current) {
clearTimeout(midnightResetTimerRef.current);
}
midnightResetTimerRef.current = setTimeout(() => {
void reconcileDailyMetrics();
scheduleMidnightReset();
}, getMillisecondsUntilNextMidnight() + 50);
}, [reconcileDailyMetrics]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void reconcileDailyMetrics();
refetchStatistics(); refetchStatistics();
refetchGoals(); refetchGoals();
}, [refetchStatistics, refetchGoals]), }, [reconcileDailyMetrics, refetchStatistics, refetchGoals]),
); );
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
@ -100,7 +207,11 @@ export default function HomeScreen() {
name: string; name: string;
calories: number; calories: number;
}) => { }) => {
setCalories((prev) => prev + meal.calories); setCalories((prev) => {
const next = prev + meal.calories;
void persistDailyMetrics(next, waterRef.current);
return next;
});
setTrackMealModalVisible(false); setTrackMealModalVisible(false);
Animated.sequence([ Animated.sequence([
Animated.timing(caloriesBounce, { Animated.timing(caloriesBounce, {
@ -117,7 +228,11 @@ export default function HomeScreen() {
}; };
const handleAddWater = (amount: number) => { const handleAddWater = (amount: number) => {
setWaterIntake((prev) => prev + amount); setWaterIntake((prev) => {
const next = prev + amount;
void persistDailyMetrics(caloriesRef.current, next);
return next;
});
setAddWaterModalVisible(false); setAddWaterModalVisible(false);
Animated.sequence([ Animated.sequence([
Animated.timing(waterBounce, { Animated.timing(waterBounce, {
@ -133,20 +248,23 @@ export default function HomeScreen() {
]).start(); ]).start();
}; };
const handleResetCalories = () => setCalories(0); const handleResetCalories = () => {
const handleResetWater = () => setWaterIntake(0); setCalories(0);
const handleAddScannedFood = (scannedCalories: number) => { void persistDailyMetrics(0, waterRef.current);
setCalories((prev) => prev + scannedCalories);
setScanFoodModalVisible(false);
}; };
const resetAllCounters = async () => { const handleResetWater = () => {
setCalories(0);
setWaterIntake(0); setWaterIntake(0);
const today = new Date().toDateString(); void persistDailyMetrics(caloriesRef.current, 0);
await AsyncStorage.setItem("lastResetDate", today); };
await AsyncStorage.removeItem(`calories_${today}`);
await AsyncStorage.removeItem(`water_${today}`); const handleAddScannedFood = (scannedCalories: number) => {
setCalories((prev) => {
const next = prev + scannedCalories;
void persistDailyMetrics(next, waterRef.current);
return next;
});
setScanFoodModalVisible(false);
}; };
useEffect(() => { useEffect(() => {
@ -183,42 +301,28 @@ export default function HomeScreen() {
}, [user?.id]); }, [user?.id]);
useEffect(() => { useEffect(() => {
const loadPersistedData = async () => { void reconcileDailyMetrics();
const today = new Date().toDateString(); }, [reconcileDailyMetrics]);
const storedCalories = await AsyncStorage.getItem(`calories_${today}`);
const storedWater = await AsyncStorage.getItem(`water_${today}`);
if (storedCalories) setCalories(parseInt(storedCalories, 10));
if (storedWater) setWaterIntake(parseInt(storedWater, 10));
};
loadPersistedData();
}, []);
useEffect(() => { useEffect(() => {
const persistCalories = async () => { const appStateSubscription = AppState.addEventListener(
const today = new Date().toDateString(); "change",
await AsyncStorage.setItem(`calories_${today}`, calories.toString()); (state) => {
}; if (state === "active") {
persistCalories(); void reconcileDailyMetrics();
}, [calories]); }
},
);
useEffect(() => { scheduleMidnightReset();
const persistWater = async () => {
const today = new Date().toDateString();
await AsyncStorage.setItem(`water_${today}`, waterIntake.toString());
};
persistWater();
}, [waterIntake]);
useEffect(() => { return () => {
const checkAndResetIfNeeded = async () => { appStateSubscription.remove();
const lastResetDate = await AsyncStorage.getItem("lastResetDate"); if (midnightResetTimerRef.current) {
const today = new Date().toDateString(); clearTimeout(midnightResetTimerRef.current);
if (lastResetDate !== today) {
await resetAllCounters();
} }
}; };
checkAndResetIfNeeded(); }, [reconcileDailyMetrics, scheduleMidnightReset]);
}, []);
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0; const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
const currentStreak = statistics?.attendance.currentStreak || 0; const currentStreak = statistics?.attendance.currentStreak || 0;

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,9 @@ export const API_ENDPOINTS = {
MEMBERSHIP: { MEMBERSHIP: {
FEATURES: "/api/membership/features", FEATURES: "/api/membership/features",
}, },
FOOD: {
LOOKUP_BARCODE: (code: string) => `/api/food/barcode/${code}`,
},
NUTRITION: { NUTRITION: {
BASE: "/api/nutrition", BASE: "/api/nutrition",
MEALS: "/api/nutrition/meals", MEALS: "/api/nutrition/meals",