From 76516abd628ed2ef1a0d156db67a5fec30c65576 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 15 Apr 2026 23:04:29 -0700 Subject: [PATCH] feat: metric/imperial unit preference (#81) - Settings: add unit_system key (metric | imperial, default metric) - Recipe LLM prompts: inject unit instruction into L3 and L4 prompts so generated recipes use the user's preferred units throughout - Frontend: new utils/units.ts converter (mirrors Python units.py) - Inventory list: display quantities converted to preferred units - Settings view: metric/imperial toggle with save button - Settings store: load/save unit_system alongside cooking_equipment Closes #81 --- app/api/endpoints/recipes.py | 6 +- app/api/endpoints/settings.py | 2 +- app/models/schemas/recipe.py | 1 + app/services/recipe/llm_recipe.py | 12 ++++ frontend/src/components/InventoryList.vue | 5 +- frontend/src/components/SettingsView.vue | 35 +++++++++++ frontend/src/stores/settings.ts | 20 +++++-- frontend/src/utils/units.ts | 73 +++++++++++++++++++++++ 8 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 frontend/src/utils/units.ts diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 76a6e14..f20ec51 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -8,6 +8,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from app.cloud_session import CloudUser, get_session +from app.db.session import get_store from app.db.store import Store from app.models.schemas.recipe import ( AssemblyTemplateOut, @@ -54,9 +55,12 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult: async def suggest_recipes( req: RecipeRequest, session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), ) -> RecipeResult: # Inject session-authoritative tier/byok immediately — client-supplied values are ignored. - req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok}) + # Also read stored unit_system preference; default to metric if not set. + unit_system = store.get_setting("unit_system") or "metric" + req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok, "unit_system": unit_system}) if req.level == 4 and not req.wildcard_confirmed: raise HTTPException( status_code=400, diff --git a/app/api/endpoints/settings.py b/app/api/endpoints/settings.py index 1570cdc..9353874 100644 --- a/app/api/endpoints/settings.py +++ b/app/api/endpoints/settings.py @@ -10,7 +10,7 @@ from app.db.store import Store router = APIRouter() -_ALLOWED_KEYS = frozenset({"cooking_equipment"}) +_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"}) class SettingBody(BaseModel): diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index 80d2129..f3c1640 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -83,6 +83,7 @@ class RecipeRequest(BaseModel): nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters) excluded_ids: list[int] = Field(default_factory=list) shopping_mode: bool = False + unit_system: str = "metric" # "metric" | "imperial" # ── Build Your Own schemas ────────────────────────────────────────────────── diff --git a/app/services/recipe/llm_recipe.py b/app/services/recipe/llm_recipe.py index de63439..230e15f 100644 --- a/app/services/recipe/llm_recipe.py +++ b/app/services/recipe/llm_recipe.py @@ -84,7 +84,13 @@ class LLMRecipeGenerator: if template.aromatics: lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}") + unit_line = ( + "Use metric units (grams, ml, Celsius) for all quantities and temperatures." + if req.unit_system == "metric" + else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures." + ) lines += [ + unit_line, "", "Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:", "Title: ", @@ -118,8 +124,14 @@ class LLMRecipeGenerator: if allergy_list: lines.append(f"Must NOT contain: {', '.join(allergy_list)}") + unit_line = ( + "Use metric units (grams, ml, Celsius) for all quantities and temperatures." + if req.unit_system == "metric" + else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures." + ) lines += [ "Treat any mystery ingredient as a wildcard — use your imagination.", + unit_line, "Reply using EXACTLY this plain-text format — no markdown, no bold:", "Title: ", "Ingredients: ", diff --git a/frontend/src/components/InventoryList.vue b/frontend/src/components/InventoryList.vue index 64ee162..83f5718 100644 --- a/frontend/src/components/InventoryList.vue +++ b/frontend/src/components/InventoryList.vue @@ -321,7 +321,7 @@
- {{ item.quantity }} {{ item.unit }} + {{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }} store.filteredItems) diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue index 14c4a0e..42ad026 100644 --- a/frontend/src/components/SettingsView.vue +++ b/frontend/src/components/SettingsView.vue @@ -64,6 +64,41 @@
+ +
+

Units

+

+ Choose how quantities and temperatures are displayed in your pantry and recipes. +

+
+ + +
+
+ +
+
+

Display

diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 8daeb86..45c114f 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -7,10 +7,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { settingsAPI } from '../services/api' +import type { UnitSystem } from '../utils/units' export const useSettingsStore = defineStore('settings', () => { // State const cookingEquipment = ref([]) + const unitSystem = ref('metric') const loading = ref(false) const saved = ref(false) @@ -18,9 +20,15 @@ export const useSettingsStore = defineStore('settings', () => { async function load() { loading.value = true try { - const raw = await settingsAPI.getSetting('cooking_equipment') - if (raw) { - cookingEquipment.value = JSON.parse(raw) + const [rawEquipment, rawUnits] = await Promise.allSettled([ + settingsAPI.getSetting('cooking_equipment'), + settingsAPI.getSetting('unit_system'), + ]) + if (rawEquipment.status === 'fulfilled' && rawEquipment.value) { + cookingEquipment.value = JSON.parse(rawEquipment.value) + } + if (rawUnits.status === 'fulfilled' && rawUnits.value) { + unitSystem.value = rawUnits.value as UnitSystem } } catch (err: unknown) { console.error('Failed to load settings:', err) @@ -32,7 +40,10 @@ export const useSettingsStore = defineStore('settings', () => { async function save() { loading.value = true try { - await settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)) + await Promise.all([ + settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)), + settingsAPI.setSetting('unit_system', unitSystem.value), + ]) saved.value = true setTimeout(() => { saved.value = false @@ -47,6 +58,7 @@ export const useSettingsStore = defineStore('settings', () => { return { // State cookingEquipment, + unitSystem, loading, saved, diff --git a/frontend/src/utils/units.ts b/frontend/src/utils/units.ts new file mode 100644 index 0000000..5acfbb4 --- /dev/null +++ b/frontend/src/utils/units.ts @@ -0,0 +1,73 @@ +/** + * Unit conversion utilities — mirrors app/utils/units.py. + * Source of truth: metric (g, ml). Display conversion happens here. + */ + +export type UnitSystem = 'metric' | 'imperial' + +// ── Conversion thresholds ───────────────────────────────────────────────── + +const IMPERIAL_MASS: [number, string, number][] = [ + [453.592, 'lb', 453.592], + [0, 'oz', 28.3495], +] + +const METRIC_MASS: [number, string, number][] = [ + [1000, 'kg', 1000], + [0, 'g', 1], +] + +const IMPERIAL_VOLUME: [number, string, number][] = [ + [3785.41, 'gal', 3785.41], + [946.353, 'qt', 946.353], + [473.176, 'pt', 473.176], + [236.588, 'cup', 236.588], + [0, 'fl oz', 29.5735], +] + +const METRIC_VOLUME: [number, string, number][] = [ + [1000, 'l', 1000], + [0, 'ml', 1], +] + +// ── Public API ──────────────────────────────────────────────────────────── + +/** + * Convert a stored metric quantity to a display quantity + unit. + * baseUnit must be 'g', 'ml', or 'each'. + */ +export function convertFromMetric( + quantity: number, + baseUnit: string, + preferred: UnitSystem = 'metric', +): [number, string] { + if (baseUnit === 'each') return [quantity, 'each'] + + const thresholds = + baseUnit === 'g' + ? preferred === 'imperial' ? IMPERIAL_MASS : METRIC_MASS + : baseUnit === 'ml' + ? preferred === 'imperial' ? IMPERIAL_VOLUME : METRIC_VOLUME + : null + + if (!thresholds) return [Math.round(quantity * 100) / 100, baseUnit] + + for (const [min, unit, factor] of thresholds) { + if (quantity >= min) { + return [Math.round((quantity / factor) * 100) / 100, unit] + } + } + + return [Math.round(quantity * 100) / 100, baseUnit] +} + +/** Format a quantity + unit for display, e.g. "1.5 kg" or "3.2 oz". */ +export function formatQuantity( + quantity: number, + baseUnit: string, + preferred: UnitSystem = 'metric', +): string { + const [qty, unit] = convertFromMetric(quantity, baseUnit, preferred) + if (unit === 'each') return `${qty}` + return `${qty} ${unit}` +}