* Extended functionalities
* Added different UIs
This commit is contained in:
29
meal-plan-frontend/src/utils/date.util.ts
Normal file
29
meal-plan-frontend/src/utils/date.util.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
77
meal-plan-frontend/src/utils/diet-normalize.ts
Normal file
77
meal-plan-frontend/src/utils/diet-normalize.ts
Normal 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']) : [],
|
||||
};
|
||||
}
|
||||
11
meal-plan-frontend/src/utils/ingredient-catalog.util.ts
Normal file
11
meal-plan-frontend/src/utils/ingredient-catalog.util.ts
Normal 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;
|
||||
}
|
||||
116
meal-plan-frontend/src/utils/ingredientQuantity.util.ts
Normal file
116
meal-plan-frontend/src/utils/ingredientQuantity.util.ts
Normal 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`;
|
||||
}
|
||||
103
meal-plan-frontend/src/utils/mealPlannerSuggestions.util.ts
Normal file
103
meal-plan-frontend/src/utils/mealPlannerSuggestions.util.ts
Normal 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);
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
65
meal-plan-frontend/src/utils/recipe-macros.util.ts
Normal file
65
meal-plan-frontend/src/utils/recipe-macros.util.ts
Normal 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;
|
||||
}
|
||||
53
meal-plan-frontend/src/utils/sort.util.ts
Normal file
53
meal-plan-frontend/src/utils/sort.util.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user