planmymeal/app/lib/display.ts
Cayden Schwechten df5dd3a70e Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00

58 lines
2.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Recipe, RecipeIngredient } from "@/app/types";
import { CATALOG_BY_ID } from "./catalog";
// ---------------------------------------------------------------------------
// Human-friendly ingredient lines, scaled to a serving count.
//
// ing("white_rice", 1.5, "cup") for 4 → shown for 2 as "¾ cup Long Grain White Rice"
// ing("black_beans", 1, "each") → "1 × Black Beans, canned (15 oz can)"
// ---------------------------------------------------------------------------
const FRACTIONS: [number, string][] = [
[0, ""],
[1 / 8, "⅛"],
[1 / 4, "¼"],
[1 / 3, "⅓"],
[1 / 2, "½"],
[2 / 3, "⅔"],
[3 / 4, "¾"],
[1, ""],
];
/** 0.75 → "¾", 1.5 → "1½", 2.33 → "2⅓", 13.2 → "13". */
function formatQuantity(value: number): string {
if (value >= 10) return String(Math.round(value));
const whole = Math.floor(value);
const fraction = value - whole;
const [nearest, glyph] = FRACTIONS.reduce((best, f) => (Math.abs(f[0] - fraction) < Math.abs(best[0] - fraction) ? f : best));
const total = whole + (nearest === 1 ? 1 : 0);
if (total === 0 && glyph === "") return "⅛"; // never show "0" for a real ingredient
return `${total > 0 ? total : ""}${glyph}` || "0";
}
interface IngredientLine {
quantity: string;
/** e.g. "cup", "lb", or "×" for counted items. */
unit: string;
name: string;
/** Package hint for counted items, e.g. "15 oz can". */
detail?: string;
}
function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
const item = CATALOG_BY_ID.get(ingredient.itemId);
const name = item?.name ?? ingredient.itemId;
const quantity = formatQuantity(ingredient.amount * scale);
if (ingredient.unit === "each") {
// Single-unit packages (a can, a jar) read better with the package size.
const detail = item && item.packageSize === 1 && item.packageLabel !== "each" ? item.packageLabel : undefined;
return { quantity, unit: "×", name, detail };
}
return { quantity, unit: ingredient.unit, name };
}
export function ingredientLines(recipe: Recipe, servings: number): IngredientLine[] {
const scale = servings / recipe.servings;
return recipe.ingredients.map((i) => ingredientLine(i, scale));
}