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 fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import (
|
from app.models.schemas.recipe import (
|
||||||
AssemblyTemplateOut,
|
AssemblyTemplateOut,
|
||||||
|
|
@ -54,9 +55,12 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
||||||
async def suggest_recipes(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
|
store: Store = Depends(get_store),
|
||||||
) -> RecipeResult:
|
) -> RecipeResult:
|
||||||
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
# 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:
|
if req.level == 4 and not req.wildcard_confirmed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
|
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
class SettingBody(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ class RecipeRequest(BaseModel):
|
||||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
excluded_ids: list[int] = Field(default_factory=list)
|
||||||
shopping_mode: bool = False
|
shopping_mode: bool = False
|
||||||
|
unit_system: str = "metric" # "metric" | "imperial"
|
||||||
|
|
||||||
|
|
||||||
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,13 @@ class LLMRecipeGenerator:
|
||||||
if template.aromatics:
|
if template.aromatics:
|
||||||
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
|
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 += [
|
lines += [
|
||||||
|
unit_line,
|
||||||
"",
|
"",
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
||||||
"Title: <name of the dish>",
|
"Title: <name of the dish>",
|
||||||
|
|
@ -118,8 +124,14 @@ class LLMRecipeGenerator:
|
||||||
if allergy_list:
|
if allergy_list:
|
||||||
lines.append(f"Must NOT contain: {', '.join(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 += [
|
lines += [
|
||||||
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
||||||
|
unit_line,
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
||||||
"Title: <name of the dish>",
|
"Title: <name of the dish>",
|
||||||
"Ingredients: <comma-separated list>",
|
"Ingredients: <comma-separated list>",
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@
|
||||||
|
|
||||||
<!-- Right side: qty + expiry + actions -->
|
<!-- Right side: qty + expiry + actions -->
|
||||||
<div class="inv-row-right">
|
<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
|
<span
|
||||||
v-if="item.expiration_date"
|
v-if="item.expiration_date"
|
||||||
|
|
@ -395,13 +395,16 @@
|
||||||
import { ref, computed, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { inventoryAPI } from '../services/api'
|
import { inventoryAPI } from '../services/api'
|
||||||
import type { InventoryItem } from '../services/api'
|
import type { InventoryItem } from '../services/api'
|
||||||
|
import { formatQuantity } from '../utils/units'
|
||||||
import EditItemModal from './EditItemModal.vue'
|
import EditItemModal from './EditItemModal.vue'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import ToastNotification from './ToastNotification.vue'
|
import ToastNotification from './ToastNotification.vue'
|
||||||
|
|
||||||
const store = useInventoryStore()
|
const store = useInventoryStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(store)
|
const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(store)
|
||||||
|
|
||||||
const filteredItems = computed(() => store.filteredItems)
|
const filteredItems = computed(() => store.filteredItems)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,41 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Display Preferences -->
|
||||||
<section class="mt-md">
|
<section class="mt-md">
|
||||||
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { settingsAPI } from '../services/api'
|
import { settingsAPI } from '../services/api'
|
||||||
|
import type { UnitSystem } from '../utils/units'
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// State
|
// State
|
||||||
const cookingEquipment = ref<string[]>([])
|
const cookingEquipment = ref<string[]>([])
|
||||||
|
const unitSystem = ref<UnitSystem>('metric')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
|
||||||
|
|
@ -18,9 +20,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const raw = await settingsAPI.getSetting('cooking_equipment')
|
const [rawEquipment, rawUnits] = await Promise.allSettled([
|
||||||
if (raw) {
|
settingsAPI.getSetting('cooking_equipment'),
|
||||||
cookingEquipment.value = JSON.parse(raw)
|
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) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load settings:', err)
|
console.error('Failed to load settings:', err)
|
||||||
|
|
@ -32,7 +40,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
async function save() {
|
async function save() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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
|
saved.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saved.value = false
|
saved.value = false
|
||||||
|
|
@ -47,6 +58,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
cookingEquipment,
|
cookingEquipment,
|
||||||
|
unitSystem,
|
||||||
loading,
|
loading,
|
||||||
saved,
|
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