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
This commit is contained in:
parent
757f779030
commit
76516abd62
8 changed files with 147 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
73
frontend/src/utils/units.ts
Normal file
73
frontend/src/utils/units.ts
Normal 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}`
|
||||
}
|
||||
Loading…
Reference in a new issue