feat: time-first recipe entry (kiwi#52)
- Add max_total_min to RecipeRequest schema and TypeScript interface - Add _within_time() helper to recipe_engine using parse_time_effort() with graceful degradation (empty directions or no signals -> pass) - Wire max_total_min filter into suggest() loop after max_time_min - Add time_first_layout to allowed settings keys - Add timeFirstLayout ref to settings store (preserves sensoryPreferences) - Add maxTotalMin ref to recipes store, wired into _buildRequest() - Add time bucket selector UI (15/30/45/60/90 min) in RecipesView Find tab, gated by timeFirstLayout != 'normal' - Add time-first layout selector section in SettingsView - Add 5 _within_time unit tests and 2 settings key tests
This commit is contained in:
parent
521cb419bc
commit
c3e7dc1ea4
10 changed files with 209 additions and 2 deletions
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences"})
|
||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"})
|
||||
|
||||
|
||||
class SettingBody(BaseModel):
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ class RecipeRequest(BaseModel):
|
|||
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
||||
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
||||
max_total_min: int | None = None # filter by parsed total time from recipe directions
|
||||
unit_system: str = "metric" # "metric" | "imperial"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from app.services.recipe.element_classifier import ElementClassifier
|
|||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
||||
from app.services.recipe.substitution_engine import SubstitutionEngine
|
||||
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
|
||||
_LEFTOVER_DAILY_MAX_FREE = 5
|
||||
|
||||
|
|
@ -613,6 +614,21 @@ def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
|||
return max(10, 20 + steps * 4) # moderate
|
||||
|
||||
|
||||
def _within_time(directions: list[str], max_total_min: int) -> bool:
|
||||
"""Return True if parsed total time (active + passive) is within max_total_min.
|
||||
|
||||
Graceful degradation:
|
||||
- Empty directions -> True (no data, don't hide)
|
||||
- total_min == 0 (no time signals found) -> True (unparseable, don't hide)
|
||||
"""
|
||||
if not directions:
|
||||
return True
|
||||
profile = parse_time_effort(directions)
|
||||
if profile.total_min == 0:
|
||||
return True
|
||||
return profile.total_min <= max_total_min
|
||||
|
||||
|
||||
def _classify_method_complexity(
|
||||
directions: list[str],
|
||||
available_equipment: list[str] | None = None,
|
||||
|
|
@ -807,6 +823,10 @@ class RecipeEngine:
|
|||
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
||||
continue
|
||||
|
||||
# Total time filter (kiwi#52) — uses parsed time from directions
|
||||
if req.max_total_min is not None and not _within_time(directions, req.max_total_min):
|
||||
continue
|
||||
|
||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||
if req.level == 2 and req.constraints:
|
||||
for ing in ingredient_names:
|
||||
|
|
|
|||
|
|
@ -102,6 +102,28 @@
|
|||
Tap "Find recipes" again to apply.
|
||||
</p>
|
||||
|
||||
<!-- Time Budget selector (kiwi#52) -->
|
||||
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
|
||||
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
|
||||
<label class="form-label">How much time do you have?</label>
|
||||
<div class="flex flex-wrap gap-sm">
|
||||
<button
|
||||
v-for="bucket in timeBuckets"
|
||||
:key="bucket.label"
|
||||
:class="['btn', 'btn-sm', 'time-bucket-btn',
|
||||
recipesStore.maxTotalMin === bucket.value ? 'time-bucket-active' : 'btn-secondary']"
|
||||
@click="recipesStore.maxTotalMin = recipesStore.maxTotalMin === bucket.value ? null : bucket.value"
|
||||
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
|
||||
>
|
||||
{{ bucket.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-hint">
|
||||
Filters by time found in recipe steps.
|
||||
<span v-if="!recipesStore.maxTotalMin">No time limit set.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dietary Preferences (collapsible) -->
|
||||
<details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open">
|
||||
<summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen">
|
||||
|
|
@ -696,6 +718,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useInventoryStore } from '../stores/inventory'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
||||
|
|
@ -710,6 +733,7 @@ import { recipesAPI } from '../services/api'
|
|||
|
||||
const recipesStore = useRecipesStore()
|
||||
const inventoryStore = useInventoryStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Tab state
|
||||
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
|
||||
|
|
@ -962,6 +986,15 @@ const activeNutritionFilterCount = computed(() =>
|
|||
|
||||
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
|
||||
|
||||
// Time budget buckets for the time-first entry selector (kiwi#52)
|
||||
const timeBuckets = [
|
||||
{ label: '15 min', value: 15 },
|
||||
{ label: '30 min', value: 30 },
|
||||
{ label: '45 min', value: 45 },
|
||||
{ label: '1 hour', value: 60 },
|
||||
{ label: '90 min', value: 90 },
|
||||
]
|
||||
|
||||
const cuisineStyles = [
|
||||
{ id: 'italian', label: 'Italian' },
|
||||
{ id: 'mediterranean', label: 'Mediterranean' },
|
||||
|
|
@ -1477,6 +1510,23 @@ details[open] .collapsible-summary::before {
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Time bucket selector (kiwi#52) */
|
||||
.time-bucket-group {
|
||||
margin-top: var(--spacing-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.time-bucket-btn {
|
||||
min-width: 4.5rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-bucket-active {
|
||||
background: var(--color-primary, #1a6b4a);
|
||||
color: white;
|
||||
border-color: var(--color-primary, #1a6b4a);
|
||||
}
|
||||
|
||||
/* Preset grid — auto-fill 2+ columns */
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -233,6 +233,44 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Time-First Layout -->
|
||||
<section class="mt-md">
|
||||
<h3 class="text-lg font-semibold mb-xs">Recipe Search Layout</h3>
|
||||
<p class="text-sm text-secondary mb-sm">
|
||||
Choose how the Find tab looks when you search for recipes.
|
||||
</p>
|
||||
<div class="flex flex-col gap-xs" role="radiogroup" aria-label="Recipe search layout">
|
||||
<label
|
||||
v-for="opt in timeFirstLayoutOptions"
|
||||
:key="opt.value"
|
||||
class="flex-start gap-sm time-layout-option"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="time_first_layout"
|
||||
:value="opt.value"
|
||||
:checked="settingsStore.timeFirstLayout === opt.value"
|
||||
@change="settingsStore.timeFirstLayout = opt.value"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ opt.label }}</strong>
|
||||
<span class="text-xs text-muted ml-xs">{{ opt.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-start gap-sm mt-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>
|
||||
|
|
@ -345,12 +383,19 @@ import { useSettingsStore } from '../stores/settings'
|
|||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { householdAPI, type HouseholdStatus } from '../services/api'
|
||||
import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api'
|
||||
import type { TimeFirstLayout } from '../stores/settings'
|
||||
import { useOrchUsage } from '../composables/useOrchUsage'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const recipesStore = useRecipesStore()
|
||||
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage()
|
||||
|
||||
const timeFirstLayoutOptions: Array<{ value: TimeFirstLayout; label: string; description: string }> = [
|
||||
{ value: 'auto', label: 'Auto', description: 'Shows a time selector when recipes are available.' },
|
||||
{ value: 'time_first', label: 'Time First', description: 'Always show the time bucket selector at the top.' },
|
||||
{ value: 'normal', label: 'Normal', description: 'Standard layout — no time selector shown.' },
|
||||
]
|
||||
|
||||
const sortedCookLog = computed(() =>
|
||||
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
||||
)
|
||||
|
|
@ -730,6 +775,20 @@ function getNoiseClass(value: NoiseLevel, idx: number): string {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Time-first layout option ────────────────────────────────────────────── */
|
||||
|
||||
.time-layout-option {
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs, 0.25rem) 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.time-layout-option input[type="radio"] {
|
||||
accent-color: var(--color-primary);
|
||||
margin-top: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Sensory pills ───────────────────────────────────────────────────────── */
|
||||
|
||||
.sensory-pill {
|
||||
|
|
|
|||
|
|
@ -553,6 +553,7 @@ export interface RecipeRequest {
|
|||
pantry_match_only: boolean
|
||||
complexity_filter: string | null
|
||||
max_time_min: number | null
|
||||
max_total_min: number | null
|
||||
}
|
||||
|
||||
export interface Staple {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const pantryMatchOnly = ref(false)
|
||||
const complexityFilter = ref<string | null>(null)
|
||||
const maxTimeMin = ref<number | null>(null)
|
||||
const maxTotalMin = ref<number | null>(null)
|
||||
const nutritionFilters = ref<NutritionFilters>({
|
||||
max_calories: null,
|
||||
max_sugar_g: null,
|
||||
|
|
@ -205,6 +206,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
pantry_match_only: pantryMatchOnly.value,
|
||||
complexity_filter: complexityFilter.value,
|
||||
max_time_min: maxTimeMin.value,
|
||||
max_total_min: maxTotalMin.value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -384,6 +386,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
pantryMatchOnly,
|
||||
complexityFilter,
|
||||
maxTimeMin,
|
||||
maxTotalMin,
|
||||
nutritionFilters,
|
||||
dismissedIds,
|
||||
dismissedCount,
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ import type { UnitSystem } from '../utils/units'
|
|||
import type { SensoryPreferences } from '../services/api'
|
||||
import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
|
||||
|
||||
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// State
|
||||
const cookingEquipment = ref<string[]>([])
|
||||
const unitSystem = ref<UnitSystem>('metric')
|
||||
const shoppingLocale = ref<string>('us')
|
||||
const sensoryPreferences = ref<SensoryPreferences>({ ...DEFAULT_SENSORY_PREFERENCES })
|
||||
const timeFirstLayout = ref<TimeFirstLayout>('auto')
|
||||
const loading = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
|
|
@ -24,11 +27,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rawEquipment, rawUnits, rawLocale, rawSensory] = await Promise.allSettled([
|
||||
const [rawEquipment, rawUnits, rawLocale, rawSensory, rawTimeFirst] = await Promise.allSettled([
|
||||
settingsAPI.getSetting('cooking_equipment'),
|
||||
settingsAPI.getSetting('unit_system'),
|
||||
settingsAPI.getSetting('shopping_locale'),
|
||||
settingsAPI.getSetting('sensory_preferences'),
|
||||
settingsAPI.getSetting('time_first_layout'),
|
||||
])
|
||||
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
|
||||
cookingEquipment.value = JSON.parse(rawEquipment.value)
|
||||
|
|
@ -46,6 +50,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
sensoryPreferences.value = { ...DEFAULT_SENSORY_PREFERENCES }
|
||||
}
|
||||
}
|
||||
if (rawTimeFirst.status === 'fulfilled' && rawTimeFirst.value) {
|
||||
timeFirstLayout.value = rawTimeFirst.value as TimeFirstLayout
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load settings:', err)
|
||||
} finally {
|
||||
|
|
@ -61,6 +68,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
settingsAPI.setSetting('unit_system', unitSystem.value),
|
||||
settingsAPI.setSetting('shopping_locale', shoppingLocale.value),
|
||||
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
||||
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
||||
])
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
|
|
@ -95,6 +103,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
unitSystem,
|
||||
shoppingLocale,
|
||||
sensoryPreferences,
|
||||
timeFirstLayout,
|
||||
loading,
|
||||
saved,
|
||||
|
||||
|
|
|
|||
|
|
@ -139,3 +139,31 @@ def test_sensory_preferences_unknown_key_still_422(tmp_store: MagicMock) -> None
|
|||
json={"value": "{}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_set_and_get_time_first_layout(tmp_store: MagicMock) -> None:
|
||||
"""PUT then GET round-trips the time_first_layout value."""
|
||||
layout_value = "time_first"
|
||||
|
||||
put_resp = client.put(
|
||||
"/api/v1/settings/time_first_layout",
|
||||
json={"value": layout_value},
|
||||
)
|
||||
assert put_resp.status_code == 200
|
||||
assert put_resp.json()["key"] == "time_first_layout"
|
||||
assert put_resp.json()["value"] == layout_value
|
||||
tmp_store.set_setting.assert_called_with("time_first_layout", layout_value)
|
||||
|
||||
tmp_store.get_setting.return_value = layout_value
|
||||
get_resp = client.get("/api/v1/settings/time_first_layout")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["value"] == layout_value
|
||||
|
||||
|
||||
def test_time_first_layout_unknown_key_still_422(tmp_store: MagicMock) -> None:
|
||||
"""Confirm unknown keys still 422 after adding time_first_layout."""
|
||||
resp = client.put(
|
||||
"/api/v1/settings/time_first_mode",
|
||||
json={"value": "time_first"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
|
|
|||
|
|
@ -134,3 +134,39 @@ def test_suggest_returns_no_assembly_results(store_with_recipes):
|
|||
result = engine.suggest(req)
|
||||
assembly_ids = [s.id for s in result.suggestions if s.id < 0]
|
||||
assert assembly_ids == [], f"Found assembly results in suggest(): {assembly_ids}"
|
||||
|
||||
|
||||
# ── _within_time tests (kiwi#52) ──────────────────────────────────────────────
|
||||
|
||||
def test_within_time_no_directions_passes():
|
||||
"""Empty directions -> True (don't hide recipes with no data)."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
assert _within_time([], max_total_min=10) is True
|
||||
|
||||
|
||||
def test_within_time_no_time_signals_passes():
|
||||
"""Directions with no time signals -> total_min == 0 -> True."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["mix together", "pour over ice", "serve immediately"]
|
||||
assert _within_time(steps, max_total_min=5) is True
|
||||
|
||||
|
||||
def test_within_time_under_limit_passes():
|
||||
"""Recipe with 10 min total and limit of 15 -> passes."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["cook for 10 minutes", "serve"]
|
||||
assert _within_time(steps, max_total_min=15) is True
|
||||
|
||||
|
||||
def test_within_time_at_limit_passes():
|
||||
"""Recipe exactly at limit -> passes (inclusive boundary)."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["simmer for 10 minutes"]
|
||||
assert _within_time(steps, max_total_min=10) is True
|
||||
|
||||
|
||||
def test_within_time_over_limit_fails():
|
||||
"""Recipe with 45 min total and limit of 30 -> fails."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["brown onions for 15 minutes", "simmer for 30 minutes"]
|
||||
assert _within_time(steps, max_total_min=30) is False
|
||||
|
|
|
|||
Loading…
Reference in a new issue