diff --git a/stacks/recipe/js/data/catalog.js b/stacks/recipe/js/data/catalog.js index 9bd076f..8162b04 100644 --- a/stacks/recipe/js/data/catalog.js +++ b/stacks/recipe/js/data/catalog.js @@ -1,6 +1,7 @@ /** * Katalog składników i przepisów — odpowiednik tabel w DB (edycja poza aplikacją). * pantryUnit: jednostka magazynowa / sumowania na liście zakupów (g, ml, szt.). + * purchasePack: minimalna „sztuka” ze sklepu w tej samej jednostce co pantryUnit (np. 200 g). * nutritionPer100g — wartości szacunkowe na 100 g (dla płynów: traktuj ml≈g przy wodzie). */ @@ -15,7 +16,13 @@ export const CATEGORY_LABELS = { inne: 'Inne', }; -/** @type {Record} */ +/** + * @typedef {{ kcal: number, protein: number, fat: number, carbs: number }} NutritionPer100 + * @typedef {{ amount: number, label?: string }} PurchasePack + * @typedef {{ id: string, name: string, category: keyof typeof CATEGORY_LABELS, pantryUnit: 'g'|'ml'|'szt', purchasePack?: PurchasePack, nutritionPer100g?: NutritionPer100 }} IngredientDef + */ + +/** @type {Record} */ export const INGREDIENTS = { maka_pszenna: { id: 'maka_pszenna', @@ -29,6 +36,7 @@ export const INGREDIENTS = { name: 'Mleko', category: 'nabial', pantryUnit: 'ml', + purchasePack: { amount: 1000, label: 'butelka 1 l' }, nutritionPer100g: { kcal: 42, protein: 3.4, fat: 1, carbs: 5 }, }, jajko: { @@ -169,6 +177,7 @@ export const INGREDIENTS = { name: 'Serek wiejski', category: 'nabial', pantryUnit: 'g', + purchasePack: { amount: 200, label: 'opakowanie 200 g' }, nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 3 }, }, orzechy_wloskie: { @@ -316,3 +325,47 @@ export const RECIPES = { ], }, }; + +/** + * Krok +/- w spiżarni: całe opakowanie albo domyślny krok (10 g/ml lub 1 szt.). + * @param {string} ingredientId + * @returns {number} + */ +export function pantryQtyStep(ingredientId) { + const d = INGREDIENTS[ingredientId]; + if (!d) return 10; + if (d.purchasePack && Number.isFinite(d.purchasePack.amount) && d.purchasePack.amount > 0) { + return d.purchasePack.amount; + } + return d.pantryUnit === 'szt' ? 1 : 10; +} + +/** + * @param {IngredientDef} def + * @param {number} stockQty — w pantryUnit + */ +export function nutritionForStock(def, stockQty) { + const n = def.nutritionPer100g; + if (!n || !Number.isFinite(stockQty) || stockQty <= 0) return null; + const f = stockQty / 100; + return { + kcal: Math.round(n.kcal * f), + protein: Math.round(n.protein * f * 10) / 10, + fat: Math.round(n.fat * f * 10) / 10, + carbs: Math.round(n.carbs * f * 10) / 10, + }; +} + +/** + * Pełne opakowania + reszta (np. 450 g / 200 → 2 + 50 g). + * @param {IngredientDef} def + * @param {number} stockQty + * @returns {{ fullPacks: number, remainder: number } | null} + */ +export function splitStockIntoPacks(def, stockQty) { + const size = def.purchasePack?.amount; + if (!size || !Number.isFinite(size) || size <= 0 || !Number.isFinite(stockQty)) return null; + const fullPacks = Math.floor(stockQty / size); + const remainder = Math.round((stockQty - fullPacks * size) * 10) / 10; + return { fullPacks, remainder }; +} diff --git a/stacks/recipe/js/services/pantryShopping.js b/stacks/recipe/js/services/pantryShopping.js index aebb3b1..52eab32 100644 --- a/stacks/recipe/js/services/pantryShopping.js +++ b/stacks/recipe/js/services/pantryShopping.js @@ -248,9 +248,10 @@ export function addOrMergeShoppingLines(lines, listId = KITCHEN_LIST_ID) { } /** - * Jedna sztuka / domyślna jednostka magazynowa — na listę kuchenną ze spiżarni. + * Na listę kuchenną ze spiżarni (amount w pantryUnit: g, ml lub szt.). + * @param {string} [sourceNote] — nadpisuje domyślne „Ze spiżarni” */ -export function addIngredientToKitchenList(ingredientId, amount = 1) { +export function addIngredientToKitchenList(ingredientId, amount = 1, sourceNote) { const def = INGREDIENTS[ingredientId]; if (!def) return; const unit = displayUnit(def.pantryUnit); @@ -260,7 +261,7 @@ export function addIngredientToKitchenList(ingredientId, amount = 1) { unit, name: def.name, category: def.category, - sourceNote: 'Ze spiżarni', + sourceNote: sourceNote ?? 'Ze spiżarni', }], KITCHEN_LIST_ID); } diff --git a/stacks/recipe/js/views/Pantry.js b/stacks/recipe/js/views/Pantry.js index b9ed79f..4700971 100644 --- a/stacks/recipe/js/views/Pantry.js +++ b/stacks/recipe/js/views/Pantry.js @@ -1,4 +1,9 @@ -import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js'; +import { + INGREDIENTS, + CATEGORY_LABELS, + pantryQtyStep, + splitStockIntoPacks, +} from '../data/catalog.js'; import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js'; import { showAppToast } from '../ui/toast.js'; @@ -26,6 +31,8 @@ const PANTRY_SHOP_OFF = `translateY(calc(100% + ${PANTRY_SHOP_BOTTOM}))`; let shopPickerIngredientId = null; /** @type {number} */ let shopPickerStep = 1; +/** Czy licznik w arkuszu to liczba opakowań (vs. jednostki magazynowe). */ +let shopPickerUsesPacks = false; export function getPantryHTML() { return ` @@ -66,6 +73,31 @@ let pantryFilterCategory = ''; let pantryAccordionHaveOpen = true; let pantryAccordionCatalogOpen = false; +/** Po zmianie ilości przez próg 0 ↔ zapas karta zostaje wizualnie w tej samej sekcji przez chwilę. */ +const PANTRY_SECTION_PIN_MS = 1400; + +/** @type {Record} */ +const pantrySectionPins = {}; +/** @type {Record>} */ +const pantryPinTimers = {}; + +/** + * @param {string} id + * @param {'have'|'catalog'} section + */ +function pinPantrySection(id, section) { + pantrySectionPins[id] = { section, until: Date.now() + PANTRY_SECTION_PIN_MS }; + if (pantryPinTimers[id]) { + clearTimeout(pantryPinTimers[id]); + delete pantryPinTimers[id]; + } + pantryPinTimers[id] = setTimeout(() => { + delete pantrySectionPins[id]; + delete pantryPinTimers[id]; + renderPantryResults(); + }, PANTRY_SECTION_PIN_MS); +} + function allCategoryKeys() { const s = new Set(); Object.values(INGREDIENTS).forEach((d) => s.add(d.category)); @@ -113,15 +145,93 @@ function filterIds(searchRaw) { .sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl')); } -/** Krok +/-: tylko liczby całkowite (szt. ±1, g/ml ±10). */ -function qtyStepForIngredient(id) { - const u = INGREDIENTS[id]?.pantryUnit; - return u === 'szt' ? 1 : 10; +function packStockCaption(def, stockQty) { + const split = splitStockIntoPacks(def, stockQty); + if (!split || !def.purchasePack) return ''; + const u = pantryUnitLabel(def.pantryUnit); + const hint = def.purchasePack.label || `${def.purchasePack.amount} ${u}`; + const { fullPacks, remainder } = split; + if (fullPacks <= 0 && remainder <= 0) { + return `Kupujesz w: ${escapeHtml(hint)}`; + } + const bits = []; + if (fullPacks > 0) bits.push(`${fullPacks}× opak.`); + if (remainder > 0) bits.push(`+ ${remainder} ${u}`); + return `${escapeHtml(bits.join(' '))} (${escapeHtml(hint)})`; +} + +/** Mała ikona w prawym górnym rogu karty — rozwija panel w dół. */ +function nutritionCornerToggle(ingredientId) { + const panelId = `pantry-nut-${ingredientId}`; + return ` + `; +} + +function nutritionListRow(label, valueHtml) { + return `
  • + ${escapeHtml(label)} + ${valueHtml} +
  • `; +} + +function nutritionPanelHtml(def, ingredientId) { + const n = def.nutritionPer100g; + if (!n) return ''; + const panelId = `pantry-nut-${ingredientId}`; + const refLabel = def.pantryUnit === 'ml' ? '100 ml produktu' : '100 g produktu'; + + const refList = ` +
      + ${nutritionListRow('Energia', `${n.kcal} kcal`)} + ${nutritionListRow('Białko', `${n.protein} g`)} + ${nutritionListRow('Tłuszcz', `${n.fat} g`)} + ${nutritionListRow('Węglowodany', `${n.carbs} g`)} +
    `; + + return ` + `; } function splitHaveAndCatalog(ids, pantry) { - const have = ids.filter((id) => (Number(pantry[id]) || 0) > 0); - const catalogOnly = ids.filter((id) => !pantry[id] || Number(pantry[id]) <= 0); + const now = Date.now(); + /** @type {string[]} */ + const have = []; + /** @type {string[]} */ + const catalogOnly = []; + + for (const id of ids) { + const pin = pantrySectionPins[id]; + if (pin && pin.until > now) { + if (pin.section === 'have') have.push(id); + else catalogOnly.push(id); + continue; + } + if (pin && pin.until <= now) { + delete pantrySectionPins[id]; + if (pantryPinTimers[id]) { + clearTimeout(pantryPinTimers[id]); + delete pantryPinTimers[id]; + } + } + const qty = Number(pantry[id]) || 0; + if (qty > 0) have.push(id); + else catalogOnly.push(id); + } return { have, catalogOnly }; } @@ -176,28 +286,45 @@ function pantryCardHtml(id, pantry, variant) { const unit = pantryUnitLabel(def.pantryUnit); const qty = Number(pantry[id]) || 0; const val = qty > 0 ? String(Math.round(qty)) : ''; - const step = qtyStepForIngredient(id); + const step = pantryQtyStep(id); + const pack = def.purchasePack; + const packPill = pack + ? `${escapeHtml(pack.label || `${pack.amount} ${unit}`)}` + : ''; + + const stepHint = pack + ? `+/−: ${step} ${unit} (1 opak.)` + : `+/−: ${step} ${unit}`; const shell = variant === 'have' ? 'rounded-xl border border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-white p-3 shadow-sm ring-1 ring-emerald-100/80' : 'rounded-xl border border-dashed border-gray-200 bg-gray-50/90 p-3 shadow-sm'; + const hasNutrition = Boolean(def.nutritionPer100g); + return `
    -
    -
    -

    ${escapeHtml(def.name)}

    -

    ${escapeHtml(categoryLabel(def.category))} · magazyn: ${unit}

    +
    +
    +
    +

    ${escapeHtml(def.name)}

    + ${packPill} +
    +

    ${escapeHtml(categoryLabel(def.category))} · stan w ${unit}

    + ${packStockCaption(def, qty)}
    + ${hasNutrition ? nutritionCornerToggle(id) : ''}
    -
    + ${hasNutrition ? nutritionPanelHtml(def, id) : ''} +
    - +
    + ${escapeHtml(stepHint)}