* 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,29 @@
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function addDaysIso(iso: string, days: number): string {
const d = new Date(`${iso}T12:00:00`);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
export function startOfWeekIso(iso: string): string {
const d = new Date(`${iso}T12:00:00`);
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
return d.toISOString().slice(0, 10);
}
export function endOfWeekIso(iso: string): string {
return addDaysIso(startOfWeekIso(iso), 6);
}
export function formatDisplayDate(iso: string, locale: string): string {
return new Date(`${iso}T12:00:00`).toLocaleDateString(locale, {
weekday: "short",
month: "short",
day: "numeric",
});
}

View File

@@ -0,0 +1,77 @@
import type { DailySummary, DecimalMacroRemaining, MacroRemaining } from '@/types/diet.types';
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']) : [],
};
}

View File

@@ -0,0 +1,11 @@
import type { IngredientNutritionItem } from '@/types/ingredient-catalog.types';
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,116 @@
/** Mirrors MealPlan.Api IngredientScalingHelper — AmountGrams is grams, not piece count. */
const COUNT_AMOUNT_THRESHOLD = 10;
const NON_QUANTIFIED_UNITS = new Set([
"to taste",
"for serving",
"optional",
"do smaku",
"do podania",
"opcjonalnie",
"garnish",
"szczypta",
]);
function normalizeUnit(unit: string): string {
return unit.trim().toLowerCase().replace(/\.$/, "");
}
export function isNonQuantifiedUnit(unit: string | null | undefined): boolean {
if (!unit?.trim()) return false;
return NON_QUANTIFIED_UNITS.has(normalizeUnit(unit));
}
function isCountUnit(unit: string | null | undefined): boolean {
if (!unit?.trim()) return false;
const u = normalizeUnit(unit);
return (
u === "szt" ||
u === "sztuka" ||
u === "sztuki" ||
u === "ząbek" ||
u === "zabek" ||
u === "zabki" ||
u === "clove" ||
u === "cloves" ||
u === "łyżka" ||
u === "lyzka" ||
u === "łyżeczka" ||
u === "lyzeczka" ||
u === "jajko" ||
u === "jajka" ||
u === "egg" ||
u === "eggs"
);
}
function gramsPerCountUnit(unit: string): number {
const u = normalizeUnit(unit);
switch (u) {
case "ząbek":
case "zabek":
case "zabki":
case "clove":
case "cloves":
return 4;
case "łyżka":
case "lyzka":
return 15;
case "łyżeczka":
case "lyzeczka":
return 5;
default:
return 60;
}
}
function roundAmount(value: number): number {
return Math.round(value * 10) / 10;
}
/** Converts recipe ingredient rows to gram weights for aggregation / display. */
export function normalizeIngredientQuantity(
amountGrams: number | null | undefined,
unit: string | null | undefined,
): { amountGrams: number | null; unit: string | null } {
const unitRaw = unit?.trim() || null;
if (isNonQuantifiedUnit(unitRaw)) {
return { amountGrams: amountGrams ?? null, unit: unitRaw };
}
if (amountGrams == null || amountGrams <= 0) {
return { amountGrams: null, unit: unitRaw };
}
if (isCountUnit(unitRaw) && unitRaw) {
if (amountGrams <= COUNT_AMOUNT_THRESHOLD) {
return {
amountGrams: roundAmount(amountGrams * gramsPerCountUnit(unitRaw)),
unit: null,
};
}
return { amountGrams: roundAmount(amountGrams), unit: null };
}
return { amountGrams: roundAmount(amountGrams), unit: null };
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? value.toString() : value.toFixed(1).replace(/\.0$/, "");
}
/** Human-readable amount for recipe cards and PDF recipe pages. */
export function formatIngredientAmount(amountGrams: number | null, unit: string | null): string {
if (isNonQuantifiedUnit(unit)) {
return unit?.trim() ?? "";
}
const normalized = normalizeIngredientQuantity(amountGrams, unit);
if (normalized.amountGrams == null || normalized.amountGrams <= 0) {
return normalized.unit ?? "";
}
return `${formatNumber(normalized.amountGrams)} g`;
}

View File

@@ -0,0 +1,103 @@
import type { MealPlanEntry } from "@/types/meal-plan.types";
import { MealCategory, type RecipeListItem } from "@/types/recipe.types";
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 interface RankedRecipeSuggestion {
recipe: RecipeListItem;
diff: number;
}
export function rankRecipeSuggestions(
recipes: RecipeListItem[],
category: number,
slotTarget: number,
limit = 6,
): RankedRecipeSuggestion[] {
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

@@ -1,13 +1,9 @@
import jsPDF from "jspdf";
import html2canvas from "html2canvas";
import i18n from "@/i18n";
import type { RecipeDetail } from "@/types/recipe.types";
import { isNonQuantifiedUnit, normalizeIngredientQuantity } from "@/utils/ingredientQuantity.util";
/**
* A single aggregated shopping-list row.
* - `unit === null` means the quantity is expressed in grams (`totalGrams`).
* - quantified non-gram units (pcs, tbsp, ...) use `totalAmount` + `unit`.
* - non-quantified items ("to taste", "for serving", "optional") have `quantified === false`.
*/
export interface ShoppingItem {
name: string;
totalGrams: number | null;
@@ -15,26 +11,9 @@ export interface ShoppingItem {
unit: string | null;
quantified: boolean;
sources: string[];
/** Per-source contributing amounts, used to render the parenthetical breakdown. */
contributions: Array<{ recipe: string; amount: number | null }>;
}
// Unit labels that represent "no concrete quantity" — listed once, never summed.
const NON_QUANTIFIED_UNITS = new Set([
"to taste",
"for serving",
"optional",
"do smaku",
"do podania",
"opcjonalnie",
"garnish",
]);
function isNonQuantifiedUnit(unit: string | null): boolean {
if (!unit) return false;
return NON_QUANTIFIED_UNITS.has(unit.trim().toLowerCase());
}
/**
* Aggregates ingredients across the selected recipes.
* Grouping key normalizes name (trim + lowercase); display keeps original capitalization.
@@ -46,18 +25,12 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
for (const ing of recipe.ingredients) {
const displayName = ing.name.trim();
const normName = displayName.toLowerCase();
const unitRaw = ing.unit?.trim() || null;
const nonQuantified = isNonQuantifiedUnit(unitRaw) ||
(unitRaw === null && ing.amountGrams === null);
const normalized = normalizeIngredientQuantity(ing.amountGrams, ing.unit);
const unitRaw = normalized.unit;
const nonQuantified =
isNonQuantifiedUnit(unitRaw) || (unitRaw === null && normalized.amountGrams === null);
let key: string;
if (nonQuantified) {
key = `nq:${normName}`;
} else if (unitRaw === null) {
key = `g:${normName}`; // grams
} else {
key = `u:${normName}:${unitRaw.toLowerCase()}`;
}
const key = nonQuantified ? `nq:${normName}` : `g:${normName}`;
let bucket = buckets.get(key);
if (!bucket) {
@@ -65,7 +38,7 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
name: displayName,
totalGrams: null,
totalAmount: null,
unit: unitRaw,
unit: nonQuantified ? unitRaw : null,
quantified: !nonQuantified,
sources: [],
contributions: [],
@@ -78,17 +51,16 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
}
if (nonQuantified) {
continue; // no summing, no quantity
continue;
}
const amount = normalized.amountGrams;
if (amount == null || amount <= 0) {
continue;
}
const amount = ing.amountGrams ?? null;
bucket.contributions.push({ recipe: recipe.name, amount });
if (unitRaw === null) {
bucket.totalGrams = (bucket.totalGrams ?? 0) + (amount ?? 0);
} else {
bucket.totalAmount = (bucket.totalAmount ?? 0) + (amount ?? 0);
}
bucket.totalGrams = (bucket.totalGrams ?? 0) + amount;
}
}
@@ -97,6 +69,25 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
);
}
function roundMacro(value: number): number {
return Math.round(value * 10) / 10;
}
/** Scales a recipe's macros and ingredient amounts for the planned portion count. */
export function scaleRecipeDetail(recipe: RecipeDetail, portions: number): RecipeDetail {
return {
...recipe,
calories: recipe.calories != null ? Math.round(recipe.calories * portions) : null,
protein: recipe.protein != null ? roundMacro(recipe.protein * portions) : null,
fat: recipe.fat != null ? roundMacro(recipe.fat * portions) : null,
carbs: recipe.carbs != null ? roundMacro(recipe.carbs * portions) : null,
ingredients: recipe.ingredients.map((ing) => ({
...ing,
amountGrams: ing.amountGrams != null ? roundMacro(ing.amountGrams * portions) : null,
})),
};
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, "");
}
@@ -104,13 +95,9 @@ function formatNumber(value: number): string {
/** Right-hand main quantity, e.g. "540 g", "7 pcs", "to taste". */
export function formatQuantity(item: ShoppingItem): string {
if (!item.quantified) {
return item.unit ? item.unit : "to taste";
return item.unit ? item.unit : i18n.t("pdf.toTaste");
}
if (item.unit === null) {
return `${formatNumber(item.totalGrams ?? 0)} g`;
}
const amount = item.totalAmount;
return amount === null ? item.unit : `${formatNumber(amount)} ${item.unit}`;
return `${formatNumber(item.totalGrams ?? 0)} g`;
}
/** Parenthetical breakdown shown when the ingredient comes from more than one recipe. */
@@ -124,7 +111,7 @@ export function formatBreakdown(item: ShoppingItem): string | null {
return null;
}
const unitLabel = item.unit === null ? "g" : item.unit;
const unitLabel = "g";
const allEqual = amounts.every((a) => a === amounts[0]);
if (allEqual && amounts.length > 1) {
@@ -138,15 +125,18 @@ export function formatBreakdown(item: ShoppingItem): string | null {
* Renders every `.pdf-page` element inside #pdf-render-area into an A4 portrait PDF,
* one source element per page, then triggers a download.
*/
export async function generatePdf(fileName = "meal-plan.pdf"): Promise<void> {
const container = document.getElementById("pdf-render-area");
export async function generatePdf(
fileName = "meal-plan.pdf",
containerId = "pdf-render-area",
): Promise<void> {
const container = document.getElementById(containerId);
if (!container) {
throw new Error("PDF render area not found.");
throw new Error(i18n.t("errors.pdfRenderAreaMissing"));
}
const pages = Array.from(container.querySelectorAll<HTMLElement>(".pdf-page"));
if (pages.length === 0) {
throw new Error("Nothing to export.");
throw new Error(i18n.t("errors.pdfNothingToExport"));
}
const pdf = new jsPDF({ unit: "pt", format: "a4", orientation: "portrait" });

View File

@@ -0,0 +1,65 @@
import type { IngredientNutritionItem } from "@/types/ingredient-catalog.types";
export interface MacroTotals {
calories: number;
protein: number;
fat: number;
carbs: number;
}
export interface MacroIngredientInput {
catalogItemId?: number | null;
amountGrams?: number | null;
unit?: string | null;
name?: string;
}
export function hasNonMacroUnit(unit?: string | null): boolean {
if (!unit?.trim()) return false;
const u = unit.trim().toLowerCase();
return u === "do smaku" || u === "optional" || u === "opcjonalnie" || u === "szczypta";
}
export function shouldIncludeIngredientInMacroSum(ing: MacroIngredientInput): boolean {
return Boolean(
ing.catalogItemId && ing.amountGrams != null && ing.amountGrams > 0 && !hasNonMacroUnit(ing.unit),
);
}
export function isUnlinkedMacroIngredient(ing: MacroIngredientInput): boolean {
if (!ing.name?.trim()) return false;
if (hasNonMacroUnit(ing.unit)) return false;
return Boolean(ing.amountGrams != null && ing.amountGrams > 0 && !ing.catalogItemId);
}
export function countUnlinkedMacroIngredients(ingredients: MacroIngredientInput[]): number {
return ingredients.filter(isUnlinkedMacroIngredient).length;
}
export function estimateMacrosFromCatalog(
ingredients: MacroIngredientInput[],
catalogItems: Pick<
IngredientNutritionItem,
"id" | "caloriesPer100G" | "proteinGPer100G" | "fatGPer100G" | "carbsGPer100G"
>[],
): MacroTotals | null {
let calories = 0;
let protein = 0;
let fat = 0;
let carbs = 0;
let hasAny = false;
for (const ing of ingredients) {
if (!shouldIncludeIngredientInMacroSum(ing)) continue;
const cat = catalogItems.find((c) => c.id === ing.catalogItemId);
if (!cat) continue;
const scale = Number(ing.amountGrams) / 100;
calories += Math.round(cat.caloriesPer100G * scale);
protein += cat.proteinGPer100G * scale;
fat += cat.fatGPer100G * scale;
carbs += cat.carbsGPer100G * scale;
hasAny = true;
}
return hasAny ? { calories, protein, fat, carbs } : 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);
});
}