feat: metric/imperial unit preference (#81)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

- 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
This commit is contained in:
pyr0ball 2026-04-15 23:04:29 -07:00
parent 757f779030
commit 76516abd62
8 changed files with 147 additions and 7 deletions

View file

@ -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,

View file

@ -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):

View file

@ -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 ──────────────────────────────────────────────────

View file

@ -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: <name of the dish>",
@ -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: <name of the dish>",
"Ingredients: <comma-separated list>",

View file

@ -321,7 +321,7 @@
<!-- Right side: qty + expiry + actions -->
<div class="inv-row-right">
<span class="inv-qty">{{ item.quantity }}<span class="inv-unit"> {{ item.unit }}</span></span>
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<span
v-if="item.expiration_date"
@ -395,13 +395,16 @@
import { ref, computed, onMounted, reactive } from 'vue'
import { storeToRefs } from 'pinia'
import { useInventoryStore } from '../stores/inventory'
import { useSettingsStore } from '../stores/settings'
import { inventoryAPI } from '../services/api'
import type { InventoryItem } from '../services/api'
import { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import ToastNotification from './ToastNotification.vue'
const store = useInventoryStore()
const settingsStore = useSettingsStore()
const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(store)
const filteredItems = computed(() => store.filteredItems)

View file

@ -64,6 +64,41 @@
</div>
</section>
<!-- Units -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Units</h3>
<p class="text-sm text-secondary mb-sm">
Choose how quantities and temperatures are displayed in your pantry and recipes.
</p>
<div class="flex-start gap-sm mb-sm" role="group" aria-label="Unit system">
<button
:class="['btn', 'btn-sm', settingsStore.unitSystem === 'metric' ? 'btn-primary' : 'btn-secondary']"
:aria-pressed="settingsStore.unitSystem === 'metric'"
@click="settingsStore.unitSystem = 'metric'"
>
Metric (g, ml, °C)
</button>
<button
:class="['btn', 'btn-sm', settingsStore.unitSystem === 'imperial' ? 'btn-primary' : 'btn-secondary']"
:aria-pressed="settingsStore.unitSystem === 'imperial'"
@click="settingsStore.unitSystem = 'imperial'"
>
Imperial (oz, cups, °F)
</button>
</div>
<div class="flex-start gap-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Display Preferences -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Display</h3>

View file

@ -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<string[]>([])
const unitSystem = ref<UnitSystem>('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,

View file

@ -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}`
}