* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -0,0 +1,40 @@
import { apiFetch, saveTokens } from '@/src/api/client';
import type { LoginRequest, RegisterRequest, TokenResponse, UserInfo } from '@/src/types/auth';
export async function login(payload: LoginRequest): Promise<TokenResponse> {
const tokens = await apiFetch<TokenResponse>('/auth/login', {
method: 'POST',
body: payload,
skipAuth: true,
});
await saveTokens(tokens);
return tokens;
}
export async function register(payload: RegisterRequest): Promise<TokenResponse> {
const tokens = await apiFetch<TokenResponse>('/auth/register', {
method: 'POST',
body: payload,
skipAuth: true,
});
await saveTokens(tokens);
return tokens;
}
export async function fetchCurrentUser(): Promise<UserInfo> {
return apiFetch<UserInfo>('/auth/me');
}
export async function logout(refresh: string | null): Promise<void> {
if (refresh) {
try {
await apiFetch('/auth/logout', {
method: 'POST',
body: { refreshToken: refresh },
skipAuth: true,
});
} catch {
// best effort
}
}
}

View File

@@ -0,0 +1,135 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getApiBaseUrl } from '@/src/utils/config';
import { ApiError } from '@/src/utils/apiError';
import type { TokenResponse } from '@/src/types/auth';
const ACCESS_KEY = 'dailymeals.access';
const REFRESH_KEY = 'dailymeals.refresh';
let accessToken: string | null = null;
let refreshToken: string | null = null;
let refreshPromise: Promise<string | null> | null = null;
export async function loadStoredTokens(): Promise<void> {
accessToken = await AsyncStorage.getItem(ACCESS_KEY);
refreshToken = await AsyncStorage.getItem(REFRESH_KEY);
}
export async function saveTokens(tokens: TokenResponse): Promise<void> {
accessToken = tokens.accessToken;
refreshToken = tokens.refreshToken;
await AsyncStorage.multiSet([
[ACCESS_KEY, tokens.accessToken],
[REFRESH_KEY, tokens.refreshToken],
]);
}
export async function clearTokens(): Promise<void> {
accessToken = null;
refreshToken = null;
await AsyncStorage.multiRemove([ACCESS_KEY, REFRESH_KEY]);
}
export function getAccessToken(): string | null {
return accessToken;
}
export async function getRefreshToken(): Promise<string | null> {
if (refreshToken) return refreshToken;
return AsyncStorage.getItem(REFRESH_KEY);
}
async function refreshAccessToken(): Promise<string | null> {
const storedRefresh = refreshToken ?? (await AsyncStorage.getItem(REFRESH_KEY));
if (!storedRefresh) return null;
refreshToken = storedRefresh;
const res = await fetch(`${getApiBaseUrl()}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: storedRefresh }),
});
if (!res.ok) return null;
const tokens = (await res.json()) as TokenResponse;
await saveTokens(tokens);
return tokens.accessToken;
}
async function performRefresh(): Promise<string | null> {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
skipAuth?: boolean;
retried?: boolean;
}
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const { body, skipAuth, retried, headers, ...rest } = options;
const url = `${getApiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
const requestHeaders: Record<string, string> = {
Accept: 'application/json',
...(headers as Record<string, string>),
};
if (!(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json';
}
if (!skipAuth && accessToken) {
requestHeaders.Authorization = `Bearer ${accessToken}`;
}
const res = await fetch(url, {
...rest,
headers: requestHeaders,
body: body instanceof FormData ? body : body !== undefined ? JSON.stringify(body) : undefined,
});
const isAuthPath =
path.includes('/auth/login') ||
path.includes('/auth/register') ||
path.includes('/auth/refresh');
if (res.status === 401 && !skipAuth && !retried && !isAuthPath) {
const newToken = await performRefresh();
if (newToken) {
return apiFetch<T>(path, { ...options, retried: true });
}
await clearTokens();
throw new ApiError('Session expired.', 401);
}
if (!res.ok) {
let message = `Request failed (${res.status})`;
try {
const data = (await res.json()) as { error?: string; title?: string };
const genericTitles = new Set(['An unexpected error occurred.', 'Internal Server Error']);
if (data.error) {
message = data.error;
} else if (data.title && !genericTitles.has(data.title)) {
message = data.title;
}
} catch {
// ignore parse errors
}
throw new ApiError(message, res.status);
}
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return (await res.blob()) as T;
}

View File

@@ -0,0 +1,89 @@
import { apiFetch } from '@/src/api/client';
import type {
CommitDietPlanResult,
DietGeneratorSession,
DietUserProfile,
MacroProposal,
} from '@/src/types/generator';
export async function fetchDietProfile(): Promise<DietUserProfile | null> {
const data = await apiFetch<DietUserProfile>('/api/diet-generator/profile');
return data.heightCm ? data : null;
}
export async function saveDietProfile(profile: DietUserProfile): Promise<DietUserProfile> {
return apiFetch<DietUserProfile>('/api/diet-generator/profile', {
method: 'PUT',
body: profile,
});
}
export async function createDietSession(input: {
planStartDate?: string;
planDayCount?: number;
calorieToleranceKcal?: number;
}): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>('/api/diet-generator/sessions', {
method: 'POST',
body: input,
});
}
export async function fetchDietSession(id: number): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${id}`);
}
export async function proposeDietMacros(sessionId: number): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/propose-macros`, {
method: 'POST',
});
}
export async function acceptDietMacros(
sessionId: number,
macros?: Partial<MacroProposal>,
): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/accept-macros`, {
method: 'POST',
body: {
calories: macros?.calories,
proteinG: macros?.proteinG,
fatG: macros?.fatG,
carbsG: macros?.carbsG,
},
});
}
export async function generateDietPlan(sessionId: number): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/generate-plan`, {
method: 'POST',
});
}
export async function regenerateDietMeal(
sessionId: number,
planDate: string,
mealCategory: number,
): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/regenerate-meal`, {
method: 'POST',
body: { planDate, mealCategory },
});
}
export async function toggleDietMealLock(
sessionId: number,
mealId: number,
isLocked: boolean,
): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/meals/${mealId}`, {
method: 'PATCH',
body: { isLocked },
});
}
export async function commitDietPlan(sessionId: number): Promise<CommitDietPlanResult> {
return apiFetch<CommitDietPlanResult>(`/api/diet-generator/sessions/${sessionId}/commit`, {
method: 'POST',
});
}

View File

@@ -0,0 +1,107 @@
import { apiFetch } from '@/src/api/client';
import type {
CopyMealsResult,
CreateDietReminderInput,
DailySummary,
DietReminder,
DietSuggestion,
DrinkCatalogItem,
DueReminder,
FavoriteCatalogItem,
FavoriteRecipe,
HistoryDay,
LogDrinkInput,
LogMealInput,
MealConsumption,
MealPreview,
RecentMeal,
UpdateDietReminderInput,
UserDailyGoals,
} from '@/src/types/diet';
import { todayIso } from '@/src/types/diet';
import { normalizeDailySummary } from '@/src/utils/diet-normalize';
export async function fetchDailySummary(date = todayIso()): Promise<DailySummary> {
const data = await apiFetch<unknown>(`/diet/summary?date=${date}`);
return normalizeDailySummary(data);
}
export async function fetchGoals(): Promise<UserDailyGoals> {
return apiFetch<UserDailyGoals>('/diet/goals');
}
export async function saveGoals(goals: UserDailyGoals): Promise<UserDailyGoals> {
return apiFetch<UserDailyGoals>('/diet/goals', { method: 'PUT', body: goals });
}
export async function logMeal(input: LogMealInput): Promise<MealConsumption> {
return apiFetch<MealConsumption>('/diet/meals', { method: 'POST', body: input });
}
export async function previewMeal(input: LogMealInput, date = todayIso()): Promise<MealPreview> {
return apiFetch<MealPreview>(`/diet/meals/preview?date=${date}`, { method: 'POST', body: input });
}
export async function fetchMealSuggestions(date = todayIso(), limit = 5): Promise<DietSuggestion[]> {
return apiFetch<DietSuggestion[]>(`/diet/suggestions?date=${date}&limit=${limit}`);
}
export async function deleteMeal(id: number): Promise<void> {
await apiFetch(`/diet/meals/${id}`, { method: 'DELETE' });
}
export async function fetchDrinkCatalog(): Promise<DrinkCatalogItem[]> {
return apiFetch<DrinkCatalogItem[]>('/diet/drinks/catalog');
}
export async function logDrink(input: LogDrinkInput): Promise<void> {
await apiFetch('/diet/drinks', { method: 'POST', body: input });
}
export async function deleteDrink(id: number): Promise<void> {
await apiFetch(`/diet/drinks/${id}`, { method: 'DELETE' });
}
export async function fetchReminders(): Promise<DietReminder[]> {
return apiFetch<DietReminder[]>('/diet/reminders');
}
export async function createReminder(input: CreateDietReminderInput): Promise<DietReminder> {
return apiFetch<DietReminder>('/diet/reminders', { method: 'POST', body: input });
}
export async function updateReminder(id: number, input: UpdateDietReminderInput): Promise<DietReminder> {
return apiFetch<DietReminder>(`/diet/reminders/${id}`, { method: 'PUT', body: input });
}
export async function deleteReminder(id: number): Promise<void> {
await apiFetch(`/diet/reminders/${id}`, { method: 'DELETE' });
}
export async function fetchDueReminders(date?: string, time?: string): Promise<DueReminder[]> {
const params = new URLSearchParams();
if (date) params.set('date', date);
if (time) params.set('time', time);
const qs = params.toString();
return apiFetch<DueReminder[]>(`/diet/reminders/due${qs ? `?${qs}` : ''}`);
}
export async function fetchHistory(from: string, to: string): Promise<HistoryDay[]> {
return apiFetch<HistoryDay[]>(`/diet/history?from=${from}&to=${to}`);
}
export async function fetchRecentMeals(limit = 10): Promise<RecentMeal[]> {
return apiFetch<RecentMeal[]>(`/diet/meals/recent?limit=${limit}`);
}
export async function copyYesterdayMeals(date = todayIso()): Promise<CopyMealsResult> {
return apiFetch<CopyMealsResult>(`/diet/meals/copy-yesterday?date=${date}`, { method: 'POST' });
}
export async function fetchFavoriteRecipes(): Promise<FavoriteRecipe[]> {
return apiFetch<FavoriteRecipe[]>('/diet/favorites/recipes');
}
export async function fetchFavoriteCatalogItems(): Promise<FavoriteCatalogItem[]> {
return apiFetch<FavoriteCatalogItem[]>('/diet/favorites/catalog');
}

View File

@@ -0,0 +1,44 @@
import { apiFetch } from '@/src/api/client';
import type {
IngredientCategory,
IngredientNutritionItem,
UpsertIngredientNutritionItemInput,
} from '@/src/types/ingredient-catalog';
export async function fetchIngredientCategories(): Promise<IngredientCategory[]> {
return apiFetch<IngredientCategory[]>('/ingredient-catalog/categories');
}
export async function fetchIngredientCatalogItems(params?: {
category?: string;
search?: string;
}): Promise<IngredientNutritionItem[]> {
const query = new URLSearchParams();
if (params?.category) query.set('category', params.category);
if (params?.search) query.set('search', params.search);
const suffix = query.toString() ? `?${query.toString()}` : '';
return apiFetch<IngredientNutritionItem[]>(`/ingredient-catalog${suffix}`);
}
export async function createIngredientCatalogItem(
input: UpsertIngredientNutritionItemInput,
): Promise<IngredientNutritionItem> {
return apiFetch<IngredientNutritionItem>('/ingredient-catalog', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function updateIngredientCatalogItem(
id: number,
input: UpsertIngredientNutritionItemInput,
): Promise<IngredientNutritionItem> {
return apiFetch<IngredientNutritionItem>(`/ingredient-catalog/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export async function deleteIngredientCatalogItem(id: number): Promise<void> {
await apiFetch<void>(`/ingredient-catalog/${id}`, { method: 'DELETE' });
}

View File

@@ -0,0 +1,22 @@
import { apiFetch } from '@/src/api/client';
import type {
MealPlanEntry,
ShoppingListItem,
UpsertMealPlanEntryInput,
} from '@/src/types/meal-plan';
export async function fetchMealPlanEntries(from: string, to: string): Promise<MealPlanEntry[]> {
return apiFetch<MealPlanEntry[]>(`/meal-plan?from=${from}&to=${to}`);
}
export async function upsertMealPlanEntry(input: UpsertMealPlanEntryInput): Promise<MealPlanEntry> {
return apiFetch<MealPlanEntry>('/meal-plan', { method: 'PUT', body: input });
}
export async function deleteMealPlanEntry(id: number): Promise<void> {
await apiFetch(`/meal-plan/${id}`, { method: 'DELETE' });
}
export async function fetchShoppingList(from: string, to: string): Promise<ShoppingListItem[]> {
return apiFetch<ShoppingListItem[]>(`/meal-plan/shopping-list?from=${from}&to=${to}`);
}

View File

@@ -0,0 +1,59 @@
import { apiFetch } from '@/src/api/client';
import type {
CommitRecipesResult,
GeneratedRecipeDraft,
RecipeGeneratorConstraints,
RecipeGeneratorSession,
} from '@/src/types/generator';
export async function createRecipeSession(
constraints: RecipeGeneratorConstraints,
): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>('/api/recipe-generator/sessions', {
method: 'POST',
body: constraints,
});
}
export async function generateRecipeDrafts(sessionId: number, count = 3): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/generate`, {
method: 'POST',
body: { count },
});
}
export async function regenerateRecipePart(
sessionId: number,
draftId: string,
mode: 'ingredients' | 'steps' | 'full',
instruction?: string,
): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/regenerate`, {
method: 'POST',
body: { draftId, mode, instruction },
});
}
export async function removeRecipeDraft(sessionId: number, draftId: string): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(
`/api/recipe-generator/sessions/${sessionId}/drafts/${encodeURIComponent(draftId)}`,
{ method: 'DELETE' },
);
}
export async function commitRecipes(sessionId: number, draftIds?: string[]): Promise<CommitRecipesResult> {
return apiFetch<CommitRecipesResult>(`/api/recipe-generator/sessions/${sessionId}/commit`, {
method: 'POST',
body: { draftIds: draftIds?.length ? draftIds : undefined },
});
}
export async function updateRecipeDraft(
sessionId: number,
draft: GeneratedRecipeDraft,
): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/draft`, {
method: 'PUT',
body: draft,
});
}

View File

@@ -0,0 +1,47 @@
import { apiFetch } from '@/src/api/client';
import type { CreateRecipeInput, RecipeDetail } from '@/src/types/recipe';
import type { RecipeImportResult } from '@/src/types/recipe-management';
function toPayload(input: CreateRecipeInput) {
return {
name: input.name,
mealCategory: input.mealCategory,
calories: input.calories,
protein: input.protein,
fat: input.fat,
carbs: input.carbs,
prepTimeMinutes: input.prepTimeMinutes,
ingredients: input.ingredients.map((i, index) => ({
name: i.name,
amountGrams: i.amountGrams,
unit: i.unit.trim() || null,
catalogItemId: i.catalogItemId ?? null,
sortOrder: i.sortOrder || index,
})),
steps: input.steps.map((s) => ({
stepNumber: s.stepNumber,
description: s.description,
})),
};
}
export async function updateRecipe(id: number, input: CreateRecipeInput): Promise<RecipeDetail> {
return apiFetch<RecipeDetail>(`/recipes/${id}`, { method: 'PUT', body: toPayload(input) });
}
export async function downloadImportTemplate(): Promise<Blob> {
return apiFetch<Blob>('/recipes/import/template');
}
export async function importRecipesFromExcel(uri: string, fileName: string): Promise<RecipeImportResult> {
const formData = new FormData();
formData.append('file', {
uri,
name: fileName,
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
} as unknown as Blob);
return apiFetch<RecipeImportResult>('/recipes/import/excel', {
method: 'POST',
body: formData,
});
}

View File

@@ -0,0 +1,52 @@
import { apiFetch } from '@/src/api/client';
import type {
CategoryCount,
CreateRecipeInput,
MealCategory,
RecipeDetail,
RecipeListItem,
} from '@/src/types/recipe';
export async function fetchRecipes(params?: {
category?: MealCategory;
search?: string;
ingredient?: string;
}): Promise<RecipeListItem[]> {
const query = new URLSearchParams();
if (params?.category != null) query.set('category', String(params.category));
if (params?.search) query.set('search', params.search);
if (params?.ingredient) query.set('ingredient', params.ingredient);
const qs = query.toString();
return apiFetch<RecipeListItem[]>(`/recipes${qs ? `?${qs}` : ''}`);
}
export async function fetchRecipeById(id: number): Promise<RecipeDetail> {
return apiFetch<RecipeDetail>(`/recipes/${id}`);
}
export async function fetchCategories(): Promise<CategoryCount[]> {
return apiFetch<CategoryCount[]>('/recipes/categories');
}
export async function createRecipe(input: CreateRecipeInput): Promise<RecipeDetail> {
return apiFetch<RecipeDetail>('/recipes', {
method: 'POST',
body: {
name: input.name,
mealCategory: input.mealCategory,
calories: input.calories,
protein: input.protein,
fat: input.fat,
carbs: input.carbs,
prepTimeMinutes: input.prepTimeMinutes,
ingredients: input.ingredients.map((i, index) => ({
name: i.name,
amountGrams: i.amountGrams,
unit: i.unit.trim() || null,
catalogItemId: i.catalogItemId ?? null,
sortOrder: i.sortOrder || index,
})),
steps: input.steps,
},
});
}

View File

@@ -0,0 +1,90 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
type PressableProps,
} from 'react-native';
import { useAppearance } from '@/src/context/AppearanceContext';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
interface ButtonProps extends Omit<PressableProps, 'style'> {
title: string;
loading?: boolean;
variant?: Variant;
style?: PressableProps['style'];
}
export function Button({
title,
loading,
variant = 'primary',
disabled,
style: externalStyle,
...rest
}: ButtonProps) {
const { colors } = useAppearance();
const isDisabled = disabled || loading;
const backgroundColor =
variant === 'primary'
? colors.brand
: variant === 'danger'
? colors.danger
: variant === 'secondary'
? colors.brandLight
: 'transparent';
const textColor =
variant === 'primary' || variant === 'danger'
? '#ffffff'
: variant === 'secondary'
? colors.brand
: colors.brand;
return (
<Pressable
accessibilityRole="button"
disabled={isDisabled}
style={(state) => {
const resolvedExternal =
typeof externalStyle === 'function' ? externalStyle(state) : externalStyle;
return [
styles.base,
{
backgroundColor,
borderColor: variant === 'ghost' ? colors.border : backgroundColor,
opacity: isDisabled ? 0.6 : state.pressed ? 0.85 : 1,
},
variant === 'ghost' && styles.ghost,
resolvedExternal,
];
}}
{...rest}
>
{loading ? (
<ActivityIndicator color={textColor} />
) : (
<Text style={[styles.label, { color: textColor }]}>{title}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
minHeight: 48,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
ghost: {
borderWidth: 1,
},
label: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,81 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
import { addDays, formatDisplayDate } from '@/src/utils/date';
import { todayIso } from '@/src/types/diet';
interface DateNavigatorProps {
date: string;
onChange: (date: string) => void;
}
export function DateNavigator({ date, onChange }: DateNavigatorProps) {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const isToday = date === todayIso();
return (
<View style={styles.row}>
<Pressable
onPress={() => onChange(addDays(date, -1))}
style={[styles.btn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontWeight: '600' }}></Text>
</Pressable>
<View style={[styles.center, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontWeight: '700' }}>
{formatDisplayDate(date, i18n.language)}
</Text>
{isToday ? (
<Text style={{ color: colors.brand, fontSize: 11, fontWeight: '600' }}>{t('diet.today')}</Text>
) : null}
</View>
<Pressable
onPress={() => onChange(addDays(date, 1))}
style={[styles.btn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontWeight: '600' }}></Text>
</Pressable>
{!isToday ? (
<Pressable
onPress={() => onChange(todayIso())}
style={[styles.todayBtn, { borderColor: colors.brand, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.brand, fontWeight: '600', fontSize: 12 }}>{t('diet.goToday')}</Text>
</Pressable>
) : null}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
},
btn: {
borderWidth: 1,
borderRadius: 10,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
center: {
flex: 1,
minWidth: 120,
borderWidth: 1,
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 12,
alignItems: 'center',
},
todayBtn: {
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
});

View File

@@ -0,0 +1,29 @@
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
import { Button } from '@/src/components/Button';
export function ErrorView({ message, onRetry }: { message: string; onRetry?: () => void }) {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<View style={[styles.wrap, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.message, { color: colors.danger }]}>{message}</Text>
{onRetry ? <Button title={t('common.tryAgain')} variant="secondary" onPress={onRetry} /> : null}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
borderWidth: 1,
borderRadius: 14,
padding: 16,
gap: 12,
},
message: {
fontSize: 15,
lineHeight: 22,
},
});

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Alert, StyleSheet, Text, View } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { downloadImportTemplate, importRecipesFromExcel } from '@/src/api/recipe-management.api';
import { Button } from '@/src/components/Button';
import { useAppearance } from '@/src/context/AppearanceContext';
import { extractApiError } from '@/src/utils/apiError';
interface ExcelImportPanelProps {
onImported?: () => void;
}
export function ExcelImportPanel({ onImported }: ExcelImportPanelProps) {
const { t } = useTranslation();
const { colors } = useAppearance();
const [selectedFile, setSelectedFile] = useState<{ uri: string; name: string } | null>(null);
const [downloading, setDownloading] = useState(false);
const importMutation = useMutation({
mutationFn: () => {
if (!selectedFile) throw new Error('No file');
return importRecipesFromExcel(selectedFile.uri, selectedFile.name);
},
onSuccess: (result) => {
setSelectedFile(null);
onImported?.();
Alert.alert(
t('manage.importComplete'),
`${t('manage.createdCount', { count: result.createdCount })} · ${t('manage.skippedCount', { count: result.skippedCount })}`,
);
},
});
async function handleDownloadTemplate() {
setDownloading(true);
try {
const blob = await downloadImportTemplate();
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
const dataUrl = reader.result as string;
resolve(dataUrl.split(',')[1] ?? '');
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const path = `${FileSystem.cacheDirectory}DailyMeals-recipe-import-template.xlsx`;
await FileSystem.writeAsStringAsync(path, base64, { encoding: FileSystem.EncodingType.Base64 });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path);
}
} catch (err) {
Alert.alert(t('errors.generic'), extractApiError(err, t('errors.saveRecipeFailed')));
} finally {
setDownloading(false);
}
}
async function handlePickFile() {
const result = await DocumentPicker.getDocumentAsync({
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
copyToCacheDirectory: true,
});
if (!result.canceled && result.assets[0]) {
setSelectedFile({ uri: result.assets[0].uri, name: result.assets[0].name });
}
}
return (
<View style={styles.container}>
<Text style={{ color: colors.text, fontWeight: '700', fontSize: 16 }}>{t('manage.importTitle')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>{t('manage.importDescription')}</Text>
<Button
title={t('manage.downloadTemplate')}
variant="secondary"
loading={downloading}
onPress={() => void handleDownloadTemplate()}
/>
<Button title={t('manage.uploadLabel')} variant="secondary" onPress={() => void handlePickFile()} />
{selectedFile ? (
<Text style={{ color: colors.text, fontSize: 13 }}>{selectedFile.name}</Text>
) : null}
{importMutation.isError ? (
<Text style={{ color: colors.danger }}>{extractApiError(importMutation.error, t('errors.saveRecipeFailed'))}</Text>
) : null}
<Button
title={importMutation.isPending ? t('manage.importing') : t('manage.importRecipes')}
loading={importMutation.isPending}
disabled={!selectedFile}
onPress={() => importMutation.mutate()}
/>
<View style={[styles.hint, { borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{t('manage.excelFormatTitle')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}> {t('manage.excelRecipesSheet')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}> {t('manage.excelIngredientsSheet')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}> {t('manage.excelStepsSheet')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13, marginTop: 4 }}>{t('manage.duplicateSkipped')}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 12 },
hint: { borderWidth: 1, borderStyle: 'dashed', borderRadius: 12, padding: 12, gap: 4 },
});

View File

@@ -0,0 +1,51 @@
import { StyleSheet, Text, TextInput, View, type TextInputProps } from 'react-native';
import { useAppearance } from '@/src/context/AppearanceContext';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
}
export function Input({ label, error, style, ...rest }: InputProps) {
const { colors } = useAppearance();
return (
<View style={styles.wrap}>
{label ? <Text style={[styles.label, { color: colors.text }]}>{label}</Text> : null}
<TextInput
placeholderTextColor={colors.textMuted}
style={[
styles.input,
{
backgroundColor: colors.surface,
borderColor: error ? colors.danger : colors.border,
color: colors.text,
},
style,
]}
{...rest}
/>
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
gap: 6,
},
label: {
fontSize: 14,
fontWeight: '600',
},
input: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
fontSize: 16,
},
error: {
fontSize: 13,
},
});

View File

@@ -0,0 +1,80 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
import type { RecipeListItem } from '@/src/types/recipe';
import { categoryLabel } from '@/src/utils/recipe';
interface RecipeCardProps {
recipe: RecipeListItem;
onPress: () => void;
}
export function RecipeCard({ recipe, onPress }: RecipeCardProps) {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={[styles.name, { color: colors.text }]}>{recipe.name}</Text>
<Text style={[styles.meta, { color: colors.textMuted }]}>
{categoryLabel(recipe.mealCategory, t)}
{recipe.calories != null ? ` · ${t('recipes.kcal', { count: recipe.calories })}` : ''}
{recipe.prepTimeMinutes != null
? ` · ${t('recipes.minutes', { count: recipe.prepTimeMinutes })}`
: ''}
</Text>
</Pressable>
);
}
export function EmptyState({ title, description }: { title: string; description?: string }) {
const { colors } = useAppearance();
return (
<View style={[styles.empty, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.emptyTitle, { color: colors.text }]}>{title}</Text>
{description ? (
<Text style={[styles.emptyDesc, { color: colors.textMuted }]}>{description}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
gap: 4,
},
name: {
fontSize: 17,
fontWeight: '600',
},
meta: {
fontSize: 14,
},
empty: {
borderWidth: 1,
borderRadius: 14,
padding: 20,
gap: 6,
},
emptyTitle: {
fontSize: 17,
fontWeight: '600',
},
emptyDesc: {
fontSize: 14,
lineHeight: 20,
},
});

View File

@@ -0,0 +1,6 @@
import { useReminderNotifications } from '@/src/hooks/useReminderNotifications';
export function ReminderNotificationBridge() {
useReminderNotifications();
return null;
}

View File

@@ -0,0 +1,57 @@
import { ActivityIndicator, StyleSheet, Text, View, type ViewProps } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppearance } from '@/src/context/AppearanceContext';
interface ScreenProps extends ViewProps {
title?: string;
subtitle?: string;
loading?: boolean;
}
export function Screen({ title, subtitle, loading, children, style, ...rest }: ScreenProps) {
const { colors } = useAppearance();
return (
<SafeAreaView style={[styles.safe, { backgroundColor: colors.background }]} edges={['top']}>
<View style={[styles.container, style]} {...rest}>
{title ? <Text style={[styles.title, { color: colors.text }]}>{title}</Text> : null}
{subtitle ? (
<Text style={[styles.subtitle, { color: colors.textMuted }]}>{subtitle}</Text>
) : null}
{loading ? (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : (
children
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
container: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 8,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
},
subtitle: {
fontSize: 15,
lineHeight: 22,
marginBottom: 4,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -0,0 +1,49 @@
import { StyleSheet, Text, View } from 'react-native';
import { useAppearance } from '@/src/context/AppearanceContext';
interface BarChartItem {
label: string;
value: number;
}
interface SimpleBarChartProps {
data: BarChartItem[];
unit?: string;
maxValue?: number;
}
export function SimpleBarChart({ data, unit = '', maxValue }: SimpleBarChartProps) {
const { colors } = useAppearance();
const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
return (
<View style={styles.container}>
{data.map((item) => {
const pct = Math.min((item.value / max) * 100, 100);
return (
<View key={item.label} style={styles.row}>
<Text style={[styles.label, { color: colors.textMuted }]} numberOfLines={1}>
{item.label}
</Text>
<View style={[styles.track, { backgroundColor: colors.background }]}>
<View style={[styles.fill, { width: `${pct}%`, backgroundColor: colors.brand }]} />
</View>
<Text style={[styles.value, { color: colors.text }]}>
{item.value}
{unit}
</Text>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 10 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
label: { width: 52, fontSize: 11, fontWeight: '600' },
track: { flex: 1, height: 10, borderRadius: 999, overflow: 'hidden' },
fill: { height: '100%', borderRadius: 999 },
value: { width: 56, fontSize: 11, fontWeight: '600', textAlign: 'right' },
});

View File

@@ -0,0 +1,61 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
APPEARANCE_OPTIONS,
getThemeColors,
type AppearanceId,
type ThemeColors,
} from '@/src/theme/appearance';
const STORAGE_KEY = 'dailymeals.appearance';
interface AppearanceContextValue {
appearanceId: AppearanceId;
colors: ThemeColors;
setAppearance: (id: AppearanceId) => Promise<void>;
}
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export function AppearanceProvider({ children }: { children: ReactNode }) {
const [appearanceId, setAppearanceId] = useState<AppearanceId>('forest');
useEffect(() => {
(async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored && APPEARANCE_OPTIONS.some((o) => o.id === stored)) {
setAppearanceId(stored as AppearanceId);
}
})();
}, []);
const setAppearance = useCallback(async (id: AppearanceId) => {
setAppearanceId(id);
await AsyncStorage.setItem(STORAGE_KEY, id);
}, []);
const value = useMemo(
() => ({
appearanceId,
colors: getThemeColors(appearanceId),
setAppearance,
}),
[appearanceId, setAppearance],
);
return <AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>;
}
export function useAppearance() {
const ctx = useContext(AppearanceContext);
if (!ctx) throw new Error('useAppearance must be used within AppearanceProvider');
return ctx;
}

View File

@@ -0,0 +1,101 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
clearTokens,
getAccessToken,
getRefreshToken,
loadStoredTokens,
saveTokens,
} from '@/src/api/client';
import * as authApi from '@/src/api/auth.api';
import type { LoginRequest, RegisterRequest, UserInfo } from '@/src/types/auth';
interface AuthContextValue {
user: UserInfo | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (payload: LoginRequest) => Promise<void>;
register: (payload: RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshSession = useCallback(async () => {
await loadStoredTokens();
if (!getAccessToken()) {
setUser(null);
return;
}
const me = await authApi.fetchCurrentUser();
setUser(me);
}, []);
useEffect(() => {
(async () => {
try {
await refreshSession();
} catch {
await clearTokens();
setUser(null);
} finally {
setIsLoading(false);
}
})();
}, [refreshSession]);
const login = useCallback(async (payload: LoginRequest) => {
await authApi.login(payload);
const me = await authApi.fetchCurrentUser();
setUser(me);
}, []);
const register = useCallback(async (payload: RegisterRequest) => {
await authApi.register(payload);
const me = await authApi.fetchCurrentUser();
setUser(me);
}, []);
const logout = useCallback(async () => {
const refresh = await getRefreshToken();
await authApi.logout(refresh);
await clearTokens();
setUser(null);
}, []);
const value = useMemo(
() => ({
user,
isLoading,
isAuthenticated: Boolean(user && getAccessToken()),
login,
register,
logout,
refreshSession,
}),
[user, isLoading, login, register, logout, refreshSession],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
// Re-export for token refresh after external login
export { saveTokens };

View File

@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { useAuth } from '@/src/context/AuthContext';
import { fetchDueReminders } from '@/src/api/diet.api';
import { todayIso } from '@/src/types/diet';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
const POLL_MS = 60_000;
function currentTimeHHmm(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
export function useReminderNotifications() {
const { isAuthenticated } = useAuth();
const notifiedRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
let interval: ReturnType<typeof setInterval> | undefined;
const poll = async () => {
if (cancelled) return;
try {
const due = await fetchDueReminders(todayIso(), currentTimeHHmm());
for (const reminder of due) {
if (notifiedRef.current.has(reminder.id)) continue;
notifiedRef.current.add(reminder.id);
await Notifications.scheduleNotificationAsync({
content: {
title: reminder.title,
body: reminder.message ?? undefined,
},
trigger: null,
});
}
} catch {
// ignore polling errors
}
};
void (async () => {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted' || cancelled) return;
await poll();
interval = setInterval(() => void poll(), POLL_MS);
})();
return () => {
cancelled = true;
if (interval) clearInterval(interval);
notifiedRef.current.clear();
};
}, [isAuthenticated]);
}

View File

@@ -0,0 +1,26 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import AsyncStorage from '@react-native-async-storage/async-storage';
import en from '@/src/i18n/locales/en.json';
import pl from '@/src/i18n/locales/pl.json';
const LANG_KEY = 'dailymeals.lang';
export async function initI18n() {
const stored = await AsyncStorage.getItem(LANG_KEY);
const lng = stored === 'pl' ? 'pl' : 'en';
await i18n.use(initReactI18next).init({
resources: { en: { translation: en }, pl: { translation: pl } },
lng,
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
}
export async function setAppLanguage(code: 'en' | 'pl') {
await AsyncStorage.setItem(LANG_KEY, code);
await i18n.changeLanguage(code);
}
export default i18n;

View File

@@ -0,0 +1,385 @@
{
"app": { "name": "DailyMeals" },
"language": {
"label": "Language",
"en": "English",
"pl": "Polish"
},
"appearance": {
"label": "Appearance",
"forest": "Forest Classic",
"forestDesc": "Green palette",
"ocean": "Ocean Fresh",
"oceanDesc": "Blue palette",
"sunset": "Sunset Kitchen",
"sunsetDesc": "Warm amber palette",
"berry": "Berry Modern",
"berryDesc": "Violet palette"
},
"nav": {
"dashboard": "Dashboard",
"allMeals": "All Meals",
"manageMeals": "Manage Meals",
"dietTracker": "My Diet",
"planner": "Meal Plan",
"shopping": "Shopping",
"ingredientCatalog": "Ingredients",
"dietGenerator": "Diet Generator",
"recipeGenerator": "Recipe Generator",
"settings": "Settings",
"logout": "Logout"
},
"categories": {
"breakfast": "Breakfast",
"secondBreakfast": "Second Breakfast",
"lunch": "Lunch",
"dinner": "Dinner",
"unknown": "Unknown"
},
"common": {
"loading": "Loading...",
"tryAgain": "Try again",
"back": "Back",
"add": "Add",
"category": "Category",
"all": "All",
"search": "Search",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"optional": "optional",
"recipeCount_one": "{{count}} recipe",
"recipeCount_other": "{{count}} recipes"
},
"auth": {
"signInSubtitle": "Sign in to plan your meals",
"email": "Email",
"password": "Password",
"emailPlaceholder": "you@example.com",
"signIn": "Sign in",
"signingIn": "Signing in...",
"noAccount": "Don't have an account?",
"createOne": "Create one",
"createAccountTitle": "Create account",
"createAccountSubtitle": "Join DailyMeals to browse and plan meals",
"displayName": "Display name",
"displayNamePlaceholder": "Piotr",
"confirmPassword": "Confirm password",
"passwordHint": "At least 8 characters with uppercase, lowercase, and a number.",
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"hasAccount": "Already have an account?"
},
"validation": {
"emailRequired": "Email is required.",
"emailInvalid": "Enter a valid email address.",
"passwordRequired": "Password is required.",
"passwordMinLength": "Password must be at least 8 characters.",
"passwordUppercase": "Include at least one uppercase letter.",
"passwordLowercase": "Include at least one lowercase letter.",
"passwordDigit": "Include at least one digit.",
"confirmPasswordRequired": "Please confirm your password.",
"passwordsMismatch": "Passwords do not match.",
"recipeNameRequired": "Recipe name is required.",
"ingredientNameRequired": "Ingredient name is required.",
"stepDescriptionRequired": "Step description is required.",
"stepsMin": "Add at least one preparation step."
},
"errors": {
"generic": "Something went wrong.",
"signInFailed": "Unable to sign in.",
"registerFailed": "Unable to create account.",
"loadCategoriesFailed": "Failed to load categories.",
"loadRecipesFailed": "Failed to load recipes.",
"loadRecipeFailed": "Failed to load recipe.",
"saveRecipeFailed": "Failed to save recipe.",
"loadDietFailed": "Failed to load diet data.",
"loadHistoryFailed": "Failed to load diet history.",
"loadPlannerFailed": "Failed to load meal plan.",
"loadShoppingFailed": "Failed to load shopping list.",
"pdfGenerateFailed": "PDF generation failed.",
"pdfNothingToExport": "Nothing to export.",
"loadIngredientCatalogFailed": "Failed to load ingredient catalog.",
"saveIngredientFailed": "Failed to save ingredient."
},
"dashboard": {
"welcome": "Welcome back",
"welcomeNamed": "Welcome back, {{name}}",
"subtitle": "Browse recipes by meal category or explore everything at once.",
"viewRecipes": "View recipes"
},
"recipes": {
"allMealsTitle": "All Meals",
"allMealsSubtitle": "Search and browse every recipe.",
"searchRecipes": "Search recipes...",
"searchByIngredient": "Search by ingredient...",
"noMatchingTitle": "No matching recipes",
"noMatchingIngredient": "No recipes contain that ingredient.",
"noMatchingFilter": "Try adjusting your search or category filter.",
"emptyCategoryTitle": "No {{category}} recipes yet",
"emptyCategoryDescription": "There are currently no recipes in this category.",
"prepTime": "{{count}} min preparation",
"calories": "Calories",
"protein": "Protein",
"fat": "Fat",
"carbs": "Carbs",
"ingredients": "Ingredients",
"preparation": "Preparation",
"noIngredients": "No ingredients listed.",
"noSteps": "No steps listed.",
"kcal": "{{count}} kcal",
"minutes": "{{count}} min"
},
"manage": {
"title": "Manage Meals",
"subtitle": "Add recipes manually or import them from an Excel spreadsheet.",
"tabManual": "Add manually",
"tabImport": "Import Excel",
"editRecipe": "Edit recipe",
"updatedSuccess": "\"{{name}}\" updated successfully.",
"catalogItem": "Catalog item",
"savedSuccess": "\"{{name}}\" saved successfully.",
"basicInfo": "Basic info",
"recipeName": "Recipe name",
"recipeNamePlaceholder": "Grilled chicken salad",
"prepTimeMin": "Prep time (min)",
"caloriesKcal": "Calories (kcal)",
"proteinG": "Protein (g)",
"fatG": "Fat (g)",
"carbsG": "Carbs (g)",
"preparationSteps": "Preparation steps",
"addStep": "Add step",
"stepPlaceholder": "Describe this step...",
"ingredientName": "Name",
"ingredientGrams": "Grams",
"ingredientUnit": "Unit",
"removeIngredient": "Remove ingredient",
"removeStep": "Remove step",
"saveRecipe": "Save recipe",
"saving": "Saving...",
"importTitle": "Import from Excel",
"importDescription": "Use the template with three sheets: Recipes, Ingredients, and Steps.",
"downloadTemplate": "Download template",
"uploadLabel": "Upload filled .xlsx file",
"importComplete": "Import complete",
"createdCount": "Created: {{count}}",
"skippedCount": "Skipped: {{count}}",
"importRecipes": "Import recipes",
"importing": "Importing...",
"excelFormatTitle": "Excel format",
"excelRecipesSheet": "Recipes: Name, MealCategory, Calories, Protein, Fat, Carbs, PrepTimeMinutes",
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
"duplicateSkipped": "Duplicate recipe names are skipped."
},
"pdf": {
"generate": "Export PDF",
"generating": "Generating...",
"shoppingList": "Shopping List",
"generatedOn": "Generated {{date}} · {{count}} items",
"toTaste": "to taste"
},
"planner": {
"title": "Weekly planner",
"subtitle": "Assign recipes to each day and meal slot.",
"assignRecipe": "Assign recipe",
"removeEntry": "Remove planned meal",
"openShopping": "Open shopping list",
"dailyTarget": "Daily calorie target",
"daySummary": "{{logged}} / {{target}} kcal planned",
"remaining": "{{count}} kcal left",
"slotHint": "Suggested ~{{count}} kcal for this meal",
"suggestionsTitle": "Suggestions for this slot",
"noSuggestions": "Set a daily calorie target to see suggestions.",
"estimatedMeal": "This meal: ~{{count}} kcal",
"selectedRecipe": "Selected: {{name}}",
"clearSelection": "Clear selection",
"exportDay": "Export day plan (PDF)",
"includingSelection": "incl. current pick: {{count}} kcal"
},
"shopping": {
"title": "Shopping list",
"subtitle": "Ingredients aggregated from your weekly meal plan.",
"empty": "No items for this week. Add meals in the planner first."
},
"diet": {
"title": "My Diet",
"subtitle": "Track what you eat and drink, set daily targets, and stay on top of reminders.",
"tabToday": "Today",
"tabHistory": "History",
"tabGoals": "Goals",
"tabReminders": "Reminders",
"today": "Today",
"goToday": "Today",
"quickLog": "Quick log",
"copyYesterday": "Copy yesterday's meals",
"recents": "Recent meals",
"favoriteRecipes": "Favorite recipes",
"favoriteCatalog": "Favorite ingredients",
"historyHint": "Calories consumed over the last 14 days.",
"mealsShort": "meals",
"updateReminder": "Update reminder",
"calories": "Calories",
"water": "Water",
"snack": "Snack",
"logDrink": "Log a drink",
"logMeal": "Mark a meal as eaten",
"searchRecipe": "From a recipe",
"orCustomMeal": "Or enter a custom meal name",
"markEaten": "Mark as eaten",
"mealsToday": "Meals today",
"drinksToday": "Drinks today",
"nothingLogged": "Nothing logged yet.",
"noMacros": "No nutrition info",
"removeEntry": "Remove entry",
"dailyGoals": "Daily goals",
"goalsHint": "Set daily targets — the Today tab shows what is left.",
"calorieGoal": "Calorie goal (kcal)",
"waterGoal": "Water goal (ml)",
"saveGoals": "Save goals",
"addReminder": "Add reminder",
"reminderType": "Type",
"reminderWater": "Drink water",
"reminderMeal": "Meal time",
"reminderCustom": "Custom",
"reminderTitle": "Title",
"reminderTitlePlaceholder": "Drink a glass of water",
"reminderTime": "Time",
"saveReminder": "Save reminder",
"yourReminders": "Your reminders",
"noReminders": "No reminders yet.",
"remaining": "Left",
"portions": "Portions",
"fromCatalog": "From ingredient catalog",
"noCatalogItem": "None",
"grams": "Amount (g)",
"mealPreview": "This meal adds",
"remainingAfter": "After logging you will still have",
"suggestionsTitle": "What to eat next",
"suggestionsHint": "Based on what you still need today.",
"logSuggestion": "Log",
"suggestionReasons": {
"highProtein": "High protein",
"needCarbs": "Good carbs",
"needFat": "Healthy fats",
"needCalories": "Energy boost",
"balanced": "Balanced choice"
}
},
"ingredientCatalog": {
"title": "Ingredient catalog",
"subtitle": "Nutrition values per 100 g, grouped by food category.",
"allCategories": "All",
"searchPlaceholder": "Search ingredients…",
"noResultsTitle": "No ingredients found",
"noResultsDescription": "Try another category or search term.",
"per100gNote": "All values are per 100 g.",
"addIngredient": "Add ingredient",
"editIngredient": "Edit ingredient",
"selectCategory": "Select category",
"nameEn": "Name (English)",
"namePlLabel": "Name (Polish)",
"formInvalid": "Please fill in category and name.",
"deleteConfirm": "Remove this ingredient from the catalog?",
"columns": {
"name": "Ingredient",
"category": "Category",
"calories": "kcal",
"protein": "Protein (g)",
"fat": "Fat (g)",
"carbs": "Carbs (g)",
"fiber": "Fiber (g)"
},
"categories": {
"meat": "Meat",
"poultry": "Poultry",
"fish": "Fish",
"seafood": "Seafood",
"vegetables": "Vegetables",
"fruits": "Fruits",
"dairy": "Dairy",
"eggs": "Eggs",
"grains": "Grains",
"legumes": "Legumes",
"nuts": "Nuts & seeds",
"oils": "Oils & fats"
}
},
"settings": {
"title": "Settings",
"account": "Account",
"preferences": "Preferences"
},
"generators": {
"diet": {
"title": "Diet Generator",
"subtitle": "Build a weekly plan from your recipe catalog and save it to the meal planner.",
"profileTitle": "Your profile",
"heightCm": "Height (cm)",
"weightKg": "Weight (kg)",
"age": "Age",
"sex": "Sex",
"male": "Male",
"female": "Female",
"activity": "Activity level",
"sedentary": "Sedentary",
"light": "Light",
"moderate": "Moderate",
"active": "Active",
"veryActive": "Very active",
"goal": "Goal",
"loseWeight": "Lose weight",
"maintain": "Maintain",
"gainMuscle": "Gain muscle",
"recomp": "Recomposition",
"allergies": "Allergies / notes",
"continue": "Calculate targets",
"macrosTitle": "Daily targets",
"acceptAndGenerate": "Accept & generate 7-day plan",
"planHint": "Each day should be within ±10 kcal of your target. Lock meals you like, regenerate others.",
"lock": "Lock",
"unlock": "Unlock",
"viewRecipe": "Details",
"noAlternativeMeals": "No other recipes are available for this meal — all suitable options are already in your plan.",
"commit": "Save plan to calendar",
"done": "Plan saved to your meal planner and daily goals.",
"newPlan": "Create another plan",
"error": "Diet generator failed."
},
"recipe": {
"title": "Recipe Generator",
"subtitle": "Create several recipe drafts with AI, review them, and save the ones you like.",
"constraintsTitle": "What should we cook?",
"prompt": "Describe the meal",
"promptPlaceholder": "Light breakfast with oatmeal and fruit…",
"targetCalories": "Target calories (kcal)",
"targetCaloriesOptional": "e.g. 500",
"targetCaloriesHint": "Ingredient amounts will be scaled to match (±10 kcal).",
"caloriesWithTarget": "{{actual}} kcal (target: {{target}})",
"maxPrep": "Max prep time (min)",
"exclusions": "Exclude ingredients",
"generate": "Generate recipe",
"recipeCount": "How many recipes to generate",
"reviewHint": "Review {{count}} generated recipes. Uncheck or remove ones you don't want, then save the rest.",
"selectAll": "Select all",
"remove": "Remove",
"saveSelected": "Save selected ({{count}})",
"doneMultiple": "{{count}} recipes saved to your library.",
"generateMore": "Generate more recipes",
"regenIngredients": "Regenerate ingredients",
"regenSteps": "Regenerate steps",
"regenFull": "Regenerate all",
"save": "Save to library",
"done": "Recipe saved.",
"viewRecipe": "View recipe",
"unlinkedWarning": "{{count}} ingredient(s) have grams but no catalog link.",
"error": "Recipe generator failed."
}
},
"notFound": {
"title": "Page not found",
"description": "The page you're looking for doesn't exist.",
"backToDashboard": "Back to dashboard"
}
}

View File

@@ -0,0 +1,387 @@
{
"app": { "name": "DailyMeals" },
"language": {
"label": "Język",
"en": "Angielski",
"pl": "Polski"
},
"appearance": {
"label": "Wygląd",
"forest": "Leśna klasyka",
"forestDesc": "Zielona paleta",
"ocean": "Oceaniczna świeżość",
"oceanDesc": "Niebieska paleta",
"sunset": "Zachód słońca",
"sunsetDesc": "Ciepła bursztynowa paleta",
"berry": "Jagodowy modern",
"berryDesc": "Fioletowa paleta"
},
"nav": {
"dashboard": "Panel",
"allMeals": "Wszystkie posiłki",
"manageMeals": "Zarządzaj posiłkami",
"dietTracker": "Moja dieta",
"planner": "Plan posiłków",
"shopping": "Zakupy",
"ingredientCatalog": "Składniki",
"dietGenerator": "Generator diety",
"recipeGenerator": "Generator przepisów",
"settings": "Ustawienia",
"logout": "Wyloguj"
},
"categories": {
"breakfast": "Śniadanie",
"secondBreakfast": "Drugie śniadanie",
"lunch": "Obiad",
"dinner": "Kolacja",
"unknown": "Nieznana"
},
"common": {
"loading": "Ładowanie...",
"tryAgain": "Spróbuj ponownie",
"back": "Wstecz",
"add": "Dodaj",
"category": "Kategoria",
"all": "Wszystkie",
"search": "Szukaj",
"save": "Zapisz",
"cancel": "Anuluj",
"edit": "Edytuj",
"delete": "Usuń",
"optional": "opcjonalnie",
"recipeCount_one": "{{count}} przepis",
"recipeCount_few": "{{count}} przepisy",
"recipeCount_many": "{{count}} przepisów",
"recipeCount_other": "{{count}} przepisów"
},
"auth": {
"signInSubtitle": "Zaloguj się, aby planować posiłki",
"email": "E-mail",
"password": "Hasło",
"emailPlaceholder": "ty@example.com",
"signIn": "Zaloguj się",
"signingIn": "Logowanie...",
"noAccount": "Nie masz konta?",
"createOne": "Utwórz je",
"createAccountTitle": "Utwórz konto",
"createAccountSubtitle": "Dołącz do DailyMeals i przeglądaj przepisy",
"displayName": "Nazwa wyświetlana",
"displayNamePlaceholder": "Piotr",
"confirmPassword": "Potwierdź hasło",
"passwordHint": "Co najmniej 8 znaków z wielką literą, małą literą i cyfrą.",
"createAccount": "Utwórz konto",
"creatingAccount": "Tworzenie konta...",
"hasAccount": "Masz już konto?"
},
"validation": {
"emailRequired": "E-mail jest wymagany.",
"emailInvalid": "Podaj prawidłowy adres e-mail.",
"passwordRequired": "Hasło jest wymagane.",
"passwordMinLength": "Hasło musi mieć co najmniej 8 znaków.",
"passwordUppercase": "Dodaj co najmniej jedną wielką literę.",
"passwordLowercase": "Dodaj co najmniej jedną małą literę.",
"passwordDigit": "Dodaj co najmniej jedną cyfrę.",
"confirmPasswordRequired": "Potwierdź hasło.",
"passwordsMismatch": "Hasła nie są identyczne.",
"recipeNameRequired": "Nazwa przepisu jest wymagana.",
"ingredientNameRequired": "Nazwa składnika jest wymagana.",
"stepDescriptionRequired": "Opis kroku jest wymagany.",
"stepsMin": "Dodaj co najmniej jeden krok przygotowania."
},
"errors": {
"generic": "Coś poszło nie tak.",
"signInFailed": "Nie udało się zalogować.",
"registerFailed": "Nie udało się utworzyć konta.",
"loadCategoriesFailed": "Nie udało się załadować kategorii.",
"loadRecipesFailed": "Nie udało się załadować przepisów.",
"loadRecipeFailed": "Nie udało się załadować przepisu.",
"saveRecipeFailed": "Nie udało się zapisać przepisu.",
"loadDietFailed": "Nie udało się załadować danych diety.",
"loadHistoryFailed": "Nie udało się wczytać historii diety.",
"loadPlannerFailed": "Nie udało się wczytać planu posiłków.",
"loadShoppingFailed": "Nie udało się wczytać listy zakupów.",
"pdfGenerateFailed": "Generowanie PDF nie powiodło się.",
"pdfNothingToExport": "Brak danych do eksportu.",
"loadIngredientCatalogFailed": "Nie udało się załadować katalogu składników.",
"saveIngredientFailed": "Nie udało się zapisać składnika."
},
"dashboard": {
"welcome": "Witaj ponownie",
"welcomeNamed": "Witaj ponownie, {{name}}",
"subtitle": "Przeglądaj przepisy według kategorii posiłków lub wszystkie naraz.",
"viewRecipes": "Zobacz przepisy"
},
"recipes": {
"allMealsTitle": "Wszystkie posiłki",
"allMealsSubtitle": "Wyszukuj i przeglądaj wszystkie przepisy.",
"searchRecipes": "Szukaj przepisów...",
"searchByIngredient": "Szukaj po składniku...",
"noMatchingTitle": "Brak pasujących przepisów",
"noMatchingIngredient": "Żaden przepis nie zawiera tego składnika.",
"noMatchingFilter": "Spróbuj zmienić wyszukiwanie lub filtr kategorii.",
"emptyCategoryTitle": "Brak przepisów: {{category}}",
"emptyCategoryDescription": "W tej kategorii nie ma jeszcze przepisów.",
"prepTime": "{{count}} min przygotowania",
"calories": "Kalorie",
"protein": "Białko",
"fat": "Tłuszcz",
"carbs": "Węglowodany",
"ingredients": "Składniki",
"preparation": "Przygotowanie",
"noIngredients": "Brak składników.",
"noSteps": "Brak kroków.",
"kcal": "{{count}} kcal",
"minutes": "{{count}} min"
},
"manage": {
"title": "Zarządzaj posiłkami",
"subtitle": "Dodawaj przepisy ręcznie lub importuj z arkusza Excel.",
"tabManual": "Dodaj ręcznie",
"tabImport": "Import Excel",
"editRecipe": "Edytuj przepis",
"updatedSuccess": "Zaktualizowano „{{name}}”.",
"catalogItem": "Pozycja katalogu",
"savedSuccess": "„{{name}}” zapisano pomyślnie.",
"basicInfo": "Podstawowe informacje",
"recipeName": "Nazwa przepisu",
"recipeNamePlaceholder": "Sałatka z grillowanym kurczakiem",
"prepTimeMin": "Czas przygotowania (min)",
"caloriesKcal": "Kalorie (kcal)",
"proteinG": "Białko (g)",
"fatG": "Tłuszcz (g)",
"carbsG": "Węglowodany (g)",
"preparationSteps": "Kroki przygotowania",
"addStep": "Dodaj krok",
"stepPlaceholder": "Opisz ten krok...",
"ingredientName": "Nazwa",
"ingredientGrams": "Gramy",
"ingredientUnit": "Jednostka",
"removeIngredient": "Usuń składnik",
"removeStep": "Usuń krok",
"saveRecipe": "Zapisz przepis",
"saving": "Zapisywanie...",
"importTitle": "Import z Excela",
"importDescription": "Użyj szablonu z trzema arkuszami: Recipes, Ingredients i Steps.",
"downloadTemplate": "Pobierz szablon",
"uploadLabel": "Prześlij wypełniony plik .xlsx",
"importComplete": "Import zakończony",
"createdCount": "Utworzono: {{count}}",
"skippedCount": "Pominięto: {{count}}",
"importRecipes": "Importuj przepisy",
"importing": "Importowanie...",
"excelFormatTitle": "Format Excela",
"excelRecipesSheet": "Recipes: Name, MealCategory, Calories, Protein, Fat, Carbs, PrepTimeMinutes",
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
"duplicateSkipped": "Duplikaty nazw przepisów są pomijane."
},
"pdf": {
"generate": "Eksportuj PDF",
"generating": "Generowanie...",
"shoppingList": "Lista zakupów",
"generatedOn": "Wygenerowano {{date}} · {{count}} poz.",
"toTaste": "do smaku"
},
"planner": {
"title": "Plan tygodniowy",
"subtitle": "Przypisz przepisy do dni i posiłków.",
"assignRecipe": "Przypisz przepis",
"removeEntry": "Usuń zaplanowany posiłek",
"openShopping": "Otwórz listę zakupów",
"dailyTarget": "Cel kalorii na dzień",
"daySummary": "Zaplanowano {{logged}} / {{target}} kcal",
"remaining": "Pozostało {{count}} kcal",
"slotHint": "Sugerowane ~{{count}} kcal na ten posiłek",
"suggestionsTitle": "Propozycje na ten posiłek",
"noSuggestions": "Ustaw cel kalorii na dzień, aby zobaczyć propozycje.",
"estimatedMeal": "Ten posiłek: ~{{count}} kcal",
"selectedRecipe": "Wybrany: {{name}}",
"clearSelection": "Wyczyść wybór",
"exportDay": "Eksportuj plan dnia (PDF)",
"includingSelection": "w tym bieżący wybór: {{count}} kcal"
},
"shopping": {
"title": "Lista zakupów",
"subtitle": "Składniki zebrane z planu posiłków na tydzień.",
"empty": "Brak pozycji w tym tygodniu. Najpierw dodaj posiłki w planerze."
},
"diet": {
"title": "Moja dieta",
"subtitle": "Śledź posiłki i napoje, ustaw cele dzienne i przypomnienia.",
"tabToday": "Dziś",
"tabHistory": "Historia",
"tabGoals": "Cele",
"tabReminders": "Przypomnienia",
"today": "Dziś",
"goToday": "Dziś",
"quickLog": "Szybkie logowanie",
"copyYesterday": "Kopiuj wczorajsze posiłki",
"recents": "Ostatnie posiłki",
"favoriteRecipes": "Ulubione przepisy",
"favoriteCatalog": "Ulubione składniki",
"historyHint": "Spożyte kalorie z ostatnich 14 dni.",
"mealsShort": "posiłków",
"updateReminder": "Zaktualizuj przypomnienie",
"calories": "Kalorie",
"water": "Woda",
"snack": "Przekąska",
"logDrink": "Dodaj napój",
"logMeal": "Oznacz zjedzony posiłek",
"searchRecipe": "Z przepisu",
"orCustomMeal": "Lub wpisz własną nazwę posiłku",
"markEaten": "Oznacz jako zjedzone",
"mealsToday": "Posiłki dziś",
"drinksToday": "Napoje dziś",
"nothingLogged": "Nic jeszcze nie zalogowano.",
"noMacros": "Brak danych odżywczych",
"removeEntry": "Usuń wpis",
"dailyGoals": "Cele dzienne",
"goalsHint": "Ustaw cele dzienne — zakładka Dziś pokaże, co zostało.",
"calorieGoal": "Cel kalorii (kcal)",
"waterGoal": "Cel wody (ml)",
"saveGoals": "Zapisz cele",
"addReminder": "Dodaj przypomnienie",
"reminderType": "Typ",
"reminderWater": "Pij wodę",
"reminderMeal": "Pora posiłku",
"reminderCustom": "Własne",
"reminderTitle": "Tytuł",
"reminderTitlePlaceholder": "Wypij szklankę wody",
"reminderTime": "Godzina",
"saveReminder": "Zapisz przypomnienie",
"yourReminders": "Twoje przypomnienia",
"noReminders": "Brak przypomnień.",
"remaining": "Pozostało",
"portions": "Porcje",
"fromCatalog": "Z katalogu składników",
"noCatalogItem": "Brak",
"grams": "Ilość (g)",
"mealPreview": "Ten posiłek dodaje",
"remainingAfter": "Po zapisaniu zostanie Ci",
"suggestionsTitle": "Co warto zjeść dalej",
"suggestionsHint": "Na podstawie tego, czego jeszcze brakuje dziś.",
"logSuggestion": "Zapisz",
"suggestionReasons": {
"highProtein": "Dużo białka",
"needCarbs": "Dobre węglowodany",
"needFat": "Zdrowe tłuszcze",
"needCalories": "Energia",
"balanced": "Zbilansowany wybór"
}
},
"ingredientCatalog": {
"title": "Katalog składników",
"subtitle": "Wartości odżywcze na 100 g, pogrupowane według kategorii.",
"allCategories": "Wszystkie",
"searchPlaceholder": "Szukaj składników…",
"noResultsTitle": "Nie znaleziono składników",
"noResultsDescription": "Spróbuj innej kategorii lub frazy.",
"per100gNote": "Wszystkie wartości dotyczą 100 g.",
"addIngredient": "Dodaj składnik",
"editIngredient": "Edytuj składnik",
"selectCategory": "Wybierz kategorię",
"nameEn": "Nazwa (angielski)",
"namePlLabel": "Nazwa (polski)",
"formInvalid": "Wypełnij kategorię i nazwę.",
"deleteConfirm": "Usunąć ten składnik z katalogu?",
"columns": {
"name": "Składnik",
"category": "Kategoria",
"calories": "kcal",
"protein": "Białko (g)",
"fat": "Tłuszcz (g)",
"carbs": "Węglowodany (g)",
"fiber": "Błonnik (g)"
},
"categories": {
"meat": "Mięso",
"poultry": "Drób",
"fish": "Ryby",
"seafood": "Owoce morza",
"vegetables": "Warzywa",
"fruits": "Owoce",
"dairy": "Nabiał",
"eggs": "Jaja",
"grains": "Zboża",
"legumes": "Rośliny strączkowe",
"nuts": "Orzechy i nasiona",
"oils": "Oleje i tłuszcze"
}
},
"settings": {
"title": "Ustawienia",
"account": "Konto",
"preferences": "Preferencje"
},
"generators": {
"diet": {
"title": "Generator diety",
"subtitle": "Zbuduj tygodniowy plan z katalogu przepisów i zapisz w planerze.",
"profileTitle": "Twój profil",
"heightCm": "Wzrost (cm)",
"weightKg": "Waga (kg)",
"age": "Wiek",
"sex": "Płeć",
"male": "Mężczyzna",
"female": "Kobieta",
"activity": "Poziom aktywności",
"sedentary": "Siedzący",
"light": "Lekka",
"moderate": "Umiarkowana",
"active": "Aktywna",
"veryActive": "Bardzo aktywna",
"goal": "Cel",
"loseWeight": "Redukcja wagi",
"maintain": "Utrzymanie",
"gainMuscle": "Budowa mięśni",
"recomp": "Rekompozycja",
"allergies": "Alergie / uwagi",
"continue": "Oblicz cele",
"macrosTitle": "Dzienne cele",
"acceptAndGenerate": "Akceptuj i generuj plan 7 dni",
"planHint": "Każdy dzień powinien mieścić się w ±10 kcal od celu. Zablokuj lub wygeneruj ponownie posiłki.",
"lock": "Zablokuj",
"unlock": "Odblokuj",
"viewRecipe": "Szczegóły",
"noAlternativeMeals": "Brak innych przepisów na ten posiłek — wszystkie pasujące opcje są już w planie.",
"commit": "Zapisz plan w kalendarzu",
"done": "Plan zapisany w planerze i celach dziennych.",
"newPlan": "Utwórz kolejny plan",
"error": "Generator diety nie powiódł się."
},
"recipe": {
"title": "Generator przepisów",
"subtitle": "Wygeneruj kilka propozycji przepisów z AI, przejrzyj je i zapisz te, które Ci pasują.",
"constraintsTitle": "Co ugotować?",
"prompt": "Opisz posiłek",
"promptPlaceholder": "Lekkie śniadanie z owsianką i owocami…",
"targetCalories": "Docelowa kaloryczność (kcal)",
"targetCaloriesOptional": "np. 500",
"targetCaloriesHint": "Składniki zostaną dopasowane do tej wartości (±10 kcal).",
"caloriesWithTarget": "{{actual}} kcal (cel: {{target}})",
"maxPrep": "Maks. czas przygotowania (min)",
"exclusions": "Wyklucz składniki",
"generate": "Generuj przepis",
"recipeCount": "Ile przepisów wygenerować",
"reviewHint": "Przejrzyj {{count}} wygenerowanych przepisów. Odznacz lub usuń te, których nie chcesz, i zapisz resztę.",
"selectAll": "Zaznacz wszystkie",
"remove": "Usuń",
"saveSelected": "Zapisz zaznaczone ({{count}})",
"doneMultiple": "Zapisano {{count}} przepisów w bibliotece.",
"generateMore": "Generuj kolejne przepisy",
"regenIngredients": "Generuj składniki ponownie",
"regenSteps": "Generuj kroki ponownie",
"regenFull": "Generuj całość ponownie",
"save": "Zapisz w bibliotece",
"done": "Przepis zapisany.",
"viewRecipe": "Zobacz przepis",
"unlinkedWarning": "{{count}} składnik(ów) ma gramy, ale brak powiązania z katalogiem.",
"error": "Generator przepisów nie powiódł się."
}
},
"notFound": {
"title": "Strona nie znaleziona",
"description": "Szukana strona nie istnieje.",
"backToDashboard": "Wróć do panelu"
}
}

View File

@@ -0,0 +1,76 @@
export type AppearanceId = 'forest' | 'ocean' | 'sunset' | 'berry';
export interface ThemeColors {
brand: string;
brandMuted: string;
brandLight: string;
background: string;
surface: string;
text: string;
textMuted: string;
border: string;
danger: string;
}
const PALETTES: Record<AppearanceId, ThemeColors> = {
forest: {
brand: '#16774c',
brandMuted: '#1e9a61',
brandLight: '#e8f5ef',
background: '#f6f8f7',
surface: '#ffffff',
text: '#0f172a',
textMuted: '#64748b',
border: '#e2e8f0',
danger: '#dc2626',
},
ocean: {
brand: '#2563eb',
brandMuted: '#3b82f6',
brandLight: '#eff6ff',
background: '#f8fafc',
surface: '#ffffff',
text: '#0f172a',
textMuted: '#64748b',
border: '#e2e8f0',
danger: '#dc2626',
},
sunset: {
brand: '#d97706',
brandMuted: '#f59e0b',
brandLight: '#fffbeb',
background: '#fffaf5',
surface: '#ffffff',
text: '#1c1917',
textMuted: '#78716c',
border: '#e7e5e4',
danger: '#dc2626',
},
berry: {
brand: '#7c3aed',
brandMuted: '#8b5cf6',
brandLight: '#f5f3ff',
background: '#faf8ff',
surface: '#ffffff',
text: '#1e1b4b',
textMuted: '#6b7280',
border: '#e5e7eb',
danger: '#dc2626',
},
};
export const APPEARANCE_OPTIONS: Array<{
id: AppearanceId;
labelKey: string;
descriptionKey: string;
swatch: string;
}> = [
{ id: 'forest', labelKey: 'appearance.forest', descriptionKey: 'appearance.forestDesc', swatch: '#16774c' },
{ id: 'ocean', labelKey: 'appearance.ocean', descriptionKey: 'appearance.oceanDesc', swatch: '#2563eb' },
{ id: 'sunset', labelKey: 'appearance.sunset', descriptionKey: 'appearance.sunsetDesc', swatch: '#d97706' },
{ id: 'berry', labelKey: 'appearance.berry', descriptionKey: 'appearance.berryDesc', swatch: '#7c3aed' },
];
export function getThemeColors(id: AppearanceId): ThemeColors {
return PALETTES[id];
}

View File

@@ -0,0 +1,23 @@
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresInSeconds: number;
tokenType: string;
}
export interface UserInfo {
id: string;
userName: string | null;
email: string | null;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
userName?: string;
}

View File

@@ -0,0 +1,219 @@
export interface UserDailyGoals {
calorieGoal?: number | null;
proteinGoalG?: number | null;
fatGoalG?: number | null;
carbsGoalG?: number | null;
waterGoalMl?: number | null;
}
export interface MacroRemaining {
goal?: number | null;
consumed: number;
remaining?: number | null;
progressPercent?: number | null;
}
export interface DecimalMacroRemaining {
goal?: number | null;
consumed: number;
remaining?: number | null;
progressPercent?: number | null;
}
export interface MealConsumption {
id: number;
recipeId?: number | null;
mealCategory: number;
logDate: string;
consumedAt: string;
name: string;
portions: number;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
notes?: string | null;
}
export interface DrinkCatalogItem {
id: number;
code: string;
name: string;
defaultVolumeMl: number;
caloriesPer100Ml?: number | null;
iconEmoji?: string | null;
}
export interface DrinkConsumption {
id: number;
drinkCatalogId?: number | null;
logDate: string;
consumedAt: string;
name: string;
volumeMl: number;
calories?: number | null;
}
export interface DailySummary {
date: string;
goals?: UserDailyGoals | null;
calories: MacroRemaining;
proteinG: DecimalMacroRemaining;
fatG: DecimalMacroRemaining;
carbsG: DecimalMacroRemaining;
waterMl: MacroRemaining;
meals: MealConsumption[];
drinks: DrinkConsumption[];
}
export interface LogMealInput {
recipeId?: number;
catalogItemId?: number;
grams?: number;
name?: string;
mealCategory: number;
logDate?: string;
portions?: number;
calories?: number;
proteinG?: number;
fatG?: number;
carbsG?: number;
notes?: string;
}
export interface LogDrinkInput {
drinkCatalogId?: number;
name?: string;
volumeMl: number;
logDate?: string;
calories?: number;
}
export interface DietReminder {
id: number;
reminderType: number;
title: string;
message?: string | null;
timeOfDay: string;
daysOfWeekMask: number;
mealCategory?: number | null;
isEnabled: boolean;
}
export interface CreateDietReminderInput {
reminderType: number;
title: string;
message?: string;
timeOfDay: string;
daysOfWeekMask?: number;
mealCategory?: number;
isEnabled?: boolean;
}
export type UpdateDietReminderInput = CreateDietReminderInput;
export interface DueReminder {
id: number;
reminderType: number;
title: string;
message?: string | null;
timeOfDay: string;
mealCategory?: number | null;
}
export interface HistoryDay {
date: string;
calories: number;
proteinG: number;
fatG: number;
carbsG: number;
waterMl: number;
mealCount: number;
}
export interface RecentMeal {
recipeId?: number | null;
catalogItemId?: number | null;
name: string;
mealCategory: number;
portions: number;
grams?: number | null;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
lastLoggedAt: string;
}
export interface FavoriteRecipe {
recipeId: number;
name: string;
mealCategory: number;
calories: number | null;
}
export interface FavoriteCatalogItem {
catalogItemId: number;
name: string;
namePl?: string | null;
categoryCode: string;
}
export interface CopyMealsResult {
sourceDate: string;
targetDate: string;
copiedCount: number;
}
export enum DietReminderType {
Water = 0,
Meal = 1,
Snack = 2,
Custom = 3,
}
export const CONSUMPTION_CATEGORIES = [
{ value: 0, labelKey: 'categories.breakfast' },
{ value: 1, labelKey: 'categories.secondBreakfast' },
{ value: 2, labelKey: 'categories.lunch' },
{ value: 3, labelKey: 'categories.dinner' },
{ value: 4, labelKey: 'diet.snack' },
] as const;
export const REMINDER_DAYS_MASK_ALL = 127;
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export interface MealMacroSnapshot {
name: string;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
}
export interface RemainingAfterMeal {
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
}
export interface MealPreview {
meal: MealMacroSnapshot;
remainingAfter: RemainingAfterMeal;
}
export interface DietSuggestion {
catalogItemId: number;
name: string;
namePl?: string | null;
suggestedGrams: number;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
reasonKey: string;
}

View File

@@ -0,0 +1,149 @@
export interface DietUserProfile {
heightCm: number;
weightKg: number;
age: number;
sex: 'male' | 'female';
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active';
goal: 'lose_weight' | 'maintain' | 'gain_muscle' | 'recomp';
allergiesNotes?: string | null;
dislikedFoods?: string | null;
mealsPerDayMask: number;
}
export interface MacroProposal {
calories: number;
proteinG: number;
fatG: number;
carbsG: number;
rationale?: string | null;
}
export interface DietGeneratorDraftMealIngredient {
id: number;
name: string;
amountGrams?: number | null;
unit?: string | null;
sortOrder: number;
sourceIngredientId?: number | null;
}
export interface DietGeneratorDraftMealStep {
id: number;
stepNumber: number;
description: string;
}
export interface DietGeneratorDraftMeal {
id: number;
planDate: string;
mealCategory: number;
recipeId: number;
recipeName: string;
portions: number;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
isLocked: boolean;
prepTimeMinutes?: number | null;
ingredients: DietGeneratorDraftMealIngredient[];
steps: DietGeneratorDraftMealStep[];
}
export interface DietGeneratorDaySummary {
planDate: string;
totalCalories: number;
targetCalories: number;
withinTolerance: boolean;
}
export interface DietGeneratorSession {
id: number;
status: string;
proposedMacros?: MacroProposal | null;
planStartDate?: string | null;
planDayCount: number;
calorieToleranceKcal: number;
draftMeals: DietGeneratorDraftMeal[];
daySummaries: DietGeneratorDaySummary[];
}
export interface CommitDietPlanResult {
sessionId: number;
planStartDate: string;
planEndDate: string;
mealsWritten: number;
}
export interface RecipeGeneratorConstraints {
prompt: string;
mealCategory: number;
targetCalories?: number | null;
calorieToleranceKcal?: number;
maxPrepTimeMinutes?: number | null;
cuisineStyle?: string | null;
dietStyle?: string | null;
exclusions?: string | null;
}
export interface GeneratedIngredient {
name: string;
amountGrams?: number | null;
unit?: string | null;
catalogItemId?: number | null;
catalogName?: string | null;
isLinked: boolean;
}
export interface GeneratedStep {
stepNumber: number;
description: string;
}
export interface GeneratedRecipeDraft {
draftId: string;
name: string;
mealCategory: number;
prepTimeMinutes?: number | null;
calories?: number | null;
protein?: number | null;
fat?: number | null;
carbs?: number | null;
ingredients: GeneratedIngredient[];
steps: GeneratedStep[];
unlinkedIngredientCount: number;
}
export interface RecipeGeneratorSession {
id: number;
status: string;
constraints: RecipeGeneratorConstraints;
draft?: GeneratedRecipeDraft | null;
drafts: GeneratedRecipeDraft[];
savedRecipeId?: number | null;
generationVersion: number;
}
export interface CommitRecipeResult {
sessionId: number;
recipeId: number;
recipeName: string;
draftId: string;
}
export interface CommitRecipesResult {
sessionId: number;
saved: CommitRecipeResult[];
}
export const DEFAULT_DIET_PROFILE: DietUserProfile = {
heightCm: 175,
weightKg: 75,
age: 30,
sex: 'male',
activityLevel: 'moderate',
goal: 'maintain',
mealsPerDayMask: 15,
allergiesNotes: '',
dislikedFoods: '',
};

View File

@@ -0,0 +1,33 @@
export interface IngredientCategory {
id: number;
code: string;
name: string;
itemCount: number;
}
export interface IngredientNutritionItem {
id: number;
categoryId: number;
categoryCode: string;
categoryName: string;
name: string;
namePl?: string | null;
caloriesPer100G: number;
proteinGPer100G: number;
fatGPer100G: number;
carbsGPer100G: number;
fiberGPer100G: number | null;
}
export interface UpsertIngredientNutritionItemInput {
categoryId: number;
name: string;
namePl?: string | null;
caloriesPer100G: number;
proteinGPer100G: number;
fatGPer100G: number;
carbsGPer100G: number;
fiberGPer100G?: number | null;
sortOrder?: number;
isActive?: boolean;
}

View File

@@ -0,0 +1,24 @@
export interface MealPlanEntry {
id: number;
planDate: string;
mealCategory: number;
recipeId: number;
recipeName: string;
portions: number;
calories: number | null;
}
export interface UpsertMealPlanEntryInput {
planDate: string;
mealCategory: number;
recipeId: number;
portions?: number;
}
export interface ShoppingListItem {
name: string;
unit: string | null;
totalGrams: number | null;
totalAmount: number | null;
breakdown: string[];
}

View File

@@ -0,0 +1,6 @@
export interface RecipeImportResult {
createdCount: number;
skippedCount: number;
errors: string[];
createdRecipeIds: number[];
}

View File

@@ -0,0 +1,66 @@
export enum MealCategory {
Breakfast = 0,
SecondBreakfast = 1,
Lunch = 2,
Dinner = 3,
}
export interface RecipeListItem {
id: number;
name: string;
mealCategory: MealCategory;
calories: number | null;
prepTimeMinutes: number | null;
}
export interface Ingredient {
id: number;
name: string;
amountGrams: number | null;
unit: string | null;
catalogItemId?: number | null;
sortOrder: number;
}
export interface RecipeStep {
id: number;
stepNumber: number;
description: string;
}
export interface RecipeDetail {
id: number;
name: string;
mealCategory: MealCategory;
calories: number | null;
protein: number | null;
fat: number | null;
carbs: number | null;
prepTimeMinutes: number | null;
ingredients: Ingredient[];
steps: RecipeStep[];
}
export interface CategoryCount {
category: MealCategory;
name: string;
count: number;
}
export interface CreateRecipeInput {
name: string;
mealCategory: MealCategory;
calories: number | null;
protein: number | null;
fat: number | null;
carbs: number | null;
prepTimeMinutes: number | null;
ingredients: Array<{
name: string;
amountGrams: number | null;
unit: string;
catalogItemId?: number | null;
sortOrder: number;
}>;
steps: Array<{ stepNumber: number; description: string }>;
}

View File

@@ -0,0 +1,19 @@
export function extractApiError(error: unknown, fallback = 'Something went wrong.'): string {
if (error instanceof ApiError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'ApiError';
}
}

View File

@@ -0,0 +1,18 @@
import Constants from 'expo-constants';
import { Platform } from 'react-native';
export function getApiBaseUrl(): string {
const configured = Constants.expoConfig?.extra?.apiUrl as string | undefined;
if (configured && !configured.includes('localhost')) {
return configured.replace(/\/$/, '');
}
if (__DEV__) {
if (Platform.OS === 'android') {
return 'http://10.0.2.2:5000/api';
}
return 'http://localhost:5000/api';
}
return (configured ?? 'https://localhost/api').replace(/\/$/, '');
}

View File

@@ -0,0 +1,39 @@
import { todayIso } from '@/src/types/diet';
export function addDays(isoDate: string, days: number): string {
const d = new Date(`${isoDate}T12:00:00`);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
export function formatDisplayDate(isoDate: string, locale: string): string {
const d = new Date(`${isoDate}T12:00:00`);
return d.toLocaleDateString(locale.startsWith('pl') ? 'pl-PL' : 'en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
export function weekRange(anchorDate = todayIso()): { from: string; to: string } {
const d = new Date(`${anchorDate}T12:00:00`);
const day = d.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diffToMonday);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return {
from: monday.toISOString().slice(0, 10),
to: sunday.toISOString().slice(0, 10),
};
}
export function weekDays(from: string): string[] {
return Array.from({ length: 7 }, (_, i) => addDays(from, i));
}
export function historyRange(days = 14): { from: string; to: string } {
const to = todayIso();
return { from: addDays(to, -(days - 1)), to };
}

View File

@@ -0,0 +1,82 @@
import type { DailySummary, DecimalMacroRemaining, MacroRemaining } from '@/src/types/diet';
const EMPTY_INT_MACRO: MacroRemaining = {
consumed: 0,
goal: null,
remaining: null,
progressPercent: null,
};
const EMPTY_DECIMAL_MACRO: DecimalMacroRemaining = {
consumed: 0,
goal: null,
remaining: null,
progressPercent: null,
};
function pick<T>(raw: Record<string, unknown>, camel: string, pascal: string): T | undefined {
return (raw[camel] ?? raw[pascal]) as T | undefined;
}
function normalizeIntMacro(raw: unknown): MacroRemaining {
if (!raw || typeof raw !== 'object') return { ...EMPTY_INT_MACRO };
const obj = raw as Record<string, unknown>;
return {
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
| number
| null,
};
}
function normalizeDecimalMacro(raw: unknown): DecimalMacroRemaining {
if (!raw || typeof raw !== 'object') return { ...EMPTY_DECIMAL_MACRO };
const obj = raw as Record<string, unknown>;
return {
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
| number
| null,
};
}
export function normalizeDailySummary(raw: unknown): DailySummary {
if (!raw || typeof raw !== 'object') {
return {
date: '',
goals: null,
calories: { ...EMPTY_INT_MACRO },
proteinG: { ...EMPTY_DECIMAL_MACRO },
fatG: { ...EMPTY_DECIMAL_MACRO },
carbsG: { ...EMPTY_DECIMAL_MACRO },
waterMl: { ...EMPTY_INT_MACRO },
meals: [],
drinks: [],
};
}
const obj = raw as Record<string, unknown>;
const meals = pick<unknown[]>(obj, 'meals', 'Meals');
const drinks = pick<unknown[]>(obj, 'drinks', 'Drinks');
return {
date: String(pick<string>(obj, 'date', 'Date') ?? ''),
goals: (pick(obj, 'goals', 'Goals') as DailySummary['goals']) ?? null,
calories: normalizeIntMacro(pick(obj, 'calories', 'Calories')),
proteinG: normalizeDecimalMacro(pick(obj, 'proteinG', 'ProteinG')),
fatG: normalizeDecimalMacro(pick(obj, 'fatG', 'FatG')),
carbsG: normalizeDecimalMacro(pick(obj, 'carbsG', 'CarbsG')),
waterMl: normalizeIntMacro(pick(obj, 'waterMl', 'WaterMl')),
meals: Array.isArray(meals) ? (meals as DailySummary['meals']) : [],
drinks: Array.isArray(drinks) ? (drinks as DailySummary['drinks']) : [],
};
}
export function formatReminderTime(value: string | null | undefined): string {
if (!value) return '';
return value.length >= 5 ? value.slice(0, 5) : value;
}

View File

@@ -0,0 +1,11 @@
import type { IngredientNutritionItem } from '@/src/types/ingredient-catalog';
export function ingredientDisplayName(
item: Pick<IngredientNutritionItem, 'name' | 'namePl'>,
language: string,
): string {
if (language.startsWith('pl') && item.namePl) {
return item.namePl;
}
return item.name;
}

View File

@@ -0,0 +1,98 @@
import type { MealPlanEntry } from '@/src/types/meal-plan';
import { MealCategory, type RecipeListItem } from '@/src/types/recipe';
export const PLANNER_MEAL_CATEGORIES = [
MealCategory.Breakfast,
MealCategory.SecondBreakfast,
MealCategory.Lunch,
MealCategory.Dinner,
] as const;
export interface DayMealPlanning {
dailyTarget: number;
logged: number;
remaining: number;
slotTarget: number;
openSlots: number;
pendingCalories: number;
}
export interface PendingMealSelection {
category: number;
calories: number;
}
export function entryCaloriesTotal(entry: MealPlanEntry): number {
if (entry.calories == null) return 0;
return Math.round(entry.calories * entry.portions);
}
export function computeDayMealPlanning(
date: string,
category: number,
entries: MealPlanEntry[],
dailyTarget: number,
pending?: PendingMealSelection | null,
): DayMealPlanning {
const dayEntries = entries.filter((e) => e.planDate === date);
const categoriesWithSaved = new Set(dayEntries.map((e) => e.mealCategory));
const existingInSlot = dayEntries.find((e) => e.mealCategory === category);
let logged = dayEntries.reduce((sum, e) => sum + entryCaloriesTotal(e), 0);
const pendingCalories =
pending && pending.category === category && pending.calories > 0 ? pending.calories : 0;
if (pendingCalories > 0) {
if (existingInSlot) {
logged = logged - entryCaloriesTotal(existingInSlot) + pendingCalories;
} else {
logged += pendingCalories;
}
}
const remaining = dailyTarget - logged;
const emptyCategories = PLANNER_MEAL_CATEGORIES.filter((cat) => !categoriesWithSaved.has(cat));
const currentSlotSaved = categoriesWithSaved.has(category as MealCategory);
let unfilled = [...emptyCategories];
if (pendingCalories > 0 && !currentSlotSaved) {
unfilled = unfilled.filter((cat) => cat !== category);
}
let openSlots: number;
if (currentSlotSaved) {
openSlots = Math.max(1, unfilled.length + 1);
} else if (pendingCalories > 0) {
openSlots = Math.max(1, unfilled.length);
} else if (unfilled.includes(category as MealCategory)) {
openSlots = Math.max(1, unfilled.length);
} else {
openSlots = Math.max(1, unfilled.length + 1);
}
const slotTarget = dailyTarget > 0 ? Math.round(Math.max(0, remaining) / openSlots) : 0;
return { dailyTarget, logged, remaining, slotTarget, openSlots, pendingCalories };
}
export function rankRecipeSuggestions(
recipes: RecipeListItem[],
category: number,
slotTarget: number,
limit = 6,
): { recipe: RecipeListItem; diff: number }[] {
if (slotTarget <= 0) return [];
return recipes
.filter((r) => r.mealCategory === category && r.calories != null && r.calories > 0)
.map((recipe) => ({ recipe, diff: Math.abs(recipe.calories! - slotTarget) }))
.sort((a, b) => a.diff - b.diff || a.recipe.name.localeCompare(b.recipe.name))
.slice(0, limit);
}
export function estimateEntryCalories(recipe: RecipeListItem | null, portions: number): number | null {
if (!recipe?.calories) return null;
return Math.round(recipe.calories * portions);
}

View File

@@ -0,0 +1,55 @@
import type { ShoppingListItem } from '@/src/types/meal-plan';
function formatNumber(value: number): string {
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '');
}
export function formatShoppingQuantity(item: ShoppingListItem, toTasteLabel: string): string {
if (item.unit && !item.totalGrams && !item.totalAmount) {
return item.unit;
}
if (item.totalGrams != null) {
return `${formatNumber(item.totalGrams)} g`;
}
if (item.totalAmount != null && item.unit) {
return `${formatNumber(item.totalAmount)} ${item.unit}`;
}
return toTasteLabel;
}
export function buildShoppingListHtml(
items: ShoppingListItem[],
title: string,
generatedLabel: string,
toTasteLabel: string,
): string {
const rows = items
.map((item) => {
const qty = formatShoppingQuantity(item, toTasteLabel);
const breakdown =
item.breakdown.length > 0
? `<div style="font-size:11px;color:#64748b;margin-top:2px">${item.breakdown.join(' · ')}</div>`
: '';
return `<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">${item.name}${breakdown}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right;white-space:nowrap">${qty}</td>
</tr>`;
})
.join('');
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${title}</title></head>
<body style="font-family:system-ui,sans-serif;padding:32px;color:#0f172a">
<h1 style="font-size:24px;margin:0 0 4px">${title}</h1>
<p style="color:#64748b;font-size:13px;margin:0 0 24px">${generatedLabel}</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead>
<tr style="background:#f1f5f9">
<th style="padding:8px 12px;text-align:left">Item</th>
<th style="padding:8px 12px;text-align:right">Qty</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</body></html>`;
}

View File

@@ -0,0 +1,39 @@
import { MealCategory } from '@/src/types/recipe';
import type { TFunction } from 'i18next';
export function categoryLabel(category: MealCategory, t: TFunction): string {
switch (category) {
case MealCategory.Breakfast:
return t('categories.breakfast');
case MealCategory.SecondBreakfast:
return t('categories.secondBreakfast');
case MealCategory.Lunch:
return t('categories.lunch');
case MealCategory.Dinner:
return t('categories.dinner');
default:
return t('categories.unknown');
}
}
export const ALL_CATEGORIES = [
MealCategory.Breakfast,
MealCategory.SecondBreakfast,
MealCategory.Lunch,
MealCategory.Dinner,
];
export function parseCategoryParam(value: string): MealCategory | null {
const num = Number(value);
if (Number.isInteger(num) && num >= 0 && num <= 3) {
return num as MealCategory;
}
return null;
}
export function parseOptionalNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const num = Number(trimmed);
return Number.isFinite(num) ? num : null;
}

View File

@@ -0,0 +1,53 @@
export type SortDirection = 'asc' | 'desc';
export function nextSort<T extends string>(
current: { column: T; direction: SortDirection } | null,
column: T,
): { column: T; direction: SortDirection } {
if (current?.column === column) {
return { column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
}
return { column, direction: 'asc' };
}
export function sortIndicator(
current: { column: string; direction: SortDirection } | null,
column: string,
): string {
if (current?.column !== column) return '';
return current.direction === 'asc' ? ' ↑' : ' ↓';
}
export function compareText(a: string, b: string, direction: SortDirection): number {
const result = a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
return direction === 'asc' ? result : -result;
}
export function compareNumber(
a: number | null | undefined,
b: number | null | undefined,
direction: SortDirection,
): number {
const aMissing = a == null || Number.isNaN(a);
const bMissing = b == null || Number.isNaN(b);
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
const result = a - b;
return direction === 'asc' ? result : -result;
}
export function sortBy<T>(
items: readonly T[],
direction: SortDirection,
getValue: (item: T) => string | number | null | undefined,
): T[] {
return [...items].sort((left, right) => {
const a = getValue(left);
const b = getValue(right);
if (typeof a === 'number' || typeof b === 'number') {
return compareNumber(typeof a === 'number' ? a : null, typeof b === 'number' ? b : null, direction);
}
return compareText(String(a ?? ''), String(b ?? ''), direction);
});
}