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:
pyr0ball 2026-04-24 10:15:58 -07:00
parent 521cb419bc
commit c3e7dc1ea4
10 changed files with 209 additions and 2 deletions

View file

@ -10,7 +10,7 @@ from app.db.store import Store
router = APIRouter() 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): class SettingBody(BaseModel):

View file

@ -105,6 +105,7 @@ class RecipeRequest(BaseModel):
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
max_time_min: int | None = None # filter by estimated cooking time ceiling 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" unit_system: str = "metric" # "metric" | "imperial"

View file

@ -25,6 +25,7 @@ from app.services.recipe.element_classifier import ElementClassifier
from app.services.recipe.grocery_links import GroceryLinkBuilder from app.services.recipe.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine 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.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
from app.services.recipe.time_effort import parse_time_effort
_LEFTOVER_DAILY_MAX_FREE = 5 _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 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( def _classify_method_complexity(
directions: list[str], directions: list[str],
available_equipment: list[str] | None = None, 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: if req.max_time_min is not None and row_time_min > req.max_time_min:
continue 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 # Level 2: also add dietary constraint swaps from substitution_pairs
if req.level == 2 and req.constraints: if req.level == 2 and req.constraints:
for ing in ingredient_names: for ing in ingredient_names:

View file

@ -102,6 +102,28 @@
Tap "Find recipes" again to apply. Tap "Find recipes" again to apply.
</p> </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) --> <!-- Dietary Preferences (collapsible) -->
<details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open"> <details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open">
<summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen"> <summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen">
@ -696,6 +718,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useSettingsStore } from '../stores/settings'
import { useInventoryStore } from '../stores/inventory' import { useInventoryStore } from '../stores/inventory'
import { useSavedRecipesStore } from '../stores/savedRecipes' import { useSavedRecipesStore } from '../stores/savedRecipes'
import RecipeDetailPanel from './RecipeDetailPanel.vue' import RecipeDetailPanel from './RecipeDetailPanel.vue'
@ -710,6 +733,7 @@ import { recipesAPI } from '../services/api'
const recipesStore = useRecipesStore() const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
const settingsStore = useSettingsStore()
// Tab state // Tab state
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build' type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
@ -962,6 +986,15 @@ const activeNutritionFilterCount = computed(() =>
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level)) 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 = [ const cuisineStyles = [
{ id: 'italian', label: 'Italian' }, { id: 'italian', label: 'Italian' },
{ id: 'mediterranean', label: 'Mediterranean' }, { id: 'mediterranean', label: 'Mediterranean' },
@ -1477,6 +1510,23 @@ details[open] .collapsible-summary::before {
margin-left: auto; 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 — auto-fill 2+ columns */
.preset-grid { .preset-grid {
display: grid; display: grid;

View file

@ -233,6 +233,44 @@
</div> </div>
</section> </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 --> <!-- 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>
@ -345,12 +383,19 @@ import { useSettingsStore } from '../stores/settings'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { householdAPI, type HouseholdStatus } from '../services/api' import { householdAPI, type HouseholdStatus } from '../services/api'
import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api' import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api'
import type { TimeFirstLayout } from '../stores/settings'
import { useOrchUsage } from '../composables/useOrchUsage' import { useOrchUsage } from '../composables/useOrchUsage'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const recipesStore = useRecipesStore() const recipesStore = useRecipesStore()
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage() 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(() => const sortedCookLog = computed(() =>
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt) [...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
) )
@ -730,6 +775,20 @@ function getNoiseClass(value: NoiseLevel, idx: number): string {
flex-shrink: 0; 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 pills ───────────────────────────────────────────────────────── */
.sensory-pill { .sensory-pill {

View file

@ -553,6 +553,7 @@ export interface RecipeRequest {
pantry_match_only: boolean pantry_match_only: boolean
complexity_filter: string | null complexity_filter: string | null
max_time_min: number | null max_time_min: number | null
max_total_min: number | null
} }
export interface Staple { export interface Staple {

View file

@ -151,6 +151,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const pantryMatchOnly = ref(false) const pantryMatchOnly = ref(false)
const complexityFilter = ref<string | null>(null) const complexityFilter = ref<string | null>(null)
const maxTimeMin = ref<number | null>(null) const maxTimeMin = ref<number | null>(null)
const maxTotalMin = ref<number | null>(null)
const nutritionFilters = ref<NutritionFilters>({ const nutritionFilters = ref<NutritionFilters>({
max_calories: null, max_calories: null,
max_sugar_g: null, max_sugar_g: null,
@ -205,6 +206,7 @@ export const useRecipesStore = defineStore('recipes', () => {
pantry_match_only: pantryMatchOnly.value, pantry_match_only: pantryMatchOnly.value,
complexity_filter: complexityFilter.value, complexity_filter: complexityFilter.value,
max_time_min: maxTimeMin.value, max_time_min: maxTimeMin.value,
max_total_min: maxTotalMin.value,
} }
} }
@ -384,6 +386,7 @@ export const useRecipesStore = defineStore('recipes', () => {
pantryMatchOnly, pantryMatchOnly,
complexityFilter, complexityFilter,
maxTimeMin, maxTimeMin,
maxTotalMin,
nutritionFilters, nutritionFilters,
dismissedIds, dismissedIds,
dismissedCount, dismissedCount,

View file

@ -11,12 +11,15 @@ import type { UnitSystem } from '../utils/units'
import type { SensoryPreferences } from '../services/api' import type { SensoryPreferences } from '../services/api'
import { DEFAULT_SENSORY_PREFERENCES } from '../services/api' import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
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 unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('us') const shoppingLocale = ref<string>('us')
const sensoryPreferences = ref<SensoryPreferences>({ ...DEFAULT_SENSORY_PREFERENCES }) const sensoryPreferences = ref<SensoryPreferences>({ ...DEFAULT_SENSORY_PREFERENCES })
const timeFirstLayout = ref<TimeFirstLayout>('auto')
const loading = ref(false) const loading = ref(false)
const saved = ref(false) const saved = ref(false)
@ -24,11 +27,12 @@ export const useSettingsStore = defineStore('settings', () => {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const [rawEquipment, rawUnits, rawLocale, rawSensory] = await Promise.allSettled([ const [rawEquipment, rawUnits, rawLocale, rawSensory, rawTimeFirst] = await Promise.allSettled([
settingsAPI.getSetting('cooking_equipment'), settingsAPI.getSetting('cooking_equipment'),
settingsAPI.getSetting('unit_system'), settingsAPI.getSetting('unit_system'),
settingsAPI.getSetting('shopping_locale'), settingsAPI.getSetting('shopping_locale'),
settingsAPI.getSetting('sensory_preferences'), settingsAPI.getSetting('sensory_preferences'),
settingsAPI.getSetting('time_first_layout'),
]) ])
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) { if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
cookingEquipment.value = JSON.parse(rawEquipment.value) cookingEquipment.value = JSON.parse(rawEquipment.value)
@ -46,6 +50,9 @@ export const useSettingsStore = defineStore('settings', () => {
sensoryPreferences.value = { ...DEFAULT_SENSORY_PREFERENCES } sensoryPreferences.value = { ...DEFAULT_SENSORY_PREFERENCES }
} }
} }
if (rawTimeFirst.status === 'fulfilled' && rawTimeFirst.value) {
timeFirstLayout.value = rawTimeFirst.value as TimeFirstLayout
}
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load settings:', err) console.error('Failed to load settings:', err)
} finally { } finally {
@ -61,6 +68,7 @@ export const useSettingsStore = defineStore('settings', () => {
settingsAPI.setSetting('unit_system', unitSystem.value), settingsAPI.setSetting('unit_system', unitSystem.value),
settingsAPI.setSetting('shopping_locale', shoppingLocale.value), settingsAPI.setSetting('shopping_locale', shoppingLocale.value),
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)), settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
]) ])
saved.value = true saved.value = true
setTimeout(() => { setTimeout(() => {
@ -95,6 +103,7 @@ export const useSettingsStore = defineStore('settings', () => {
unitSystem, unitSystem,
shoppingLocale, shoppingLocale,
sensoryPreferences, sensoryPreferences,
timeFirstLayout,
loading, loading,
saved, saved,

View file

@ -139,3 +139,31 @@ def test_sensory_preferences_unknown_key_still_422(tmp_store: MagicMock) -> None
json={"value": "{}"}, json={"value": "{}"},
) )
assert resp.status_code == 422 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

View file

@ -134,3 +134,39 @@ def test_suggest_returns_no_assembly_results(store_with_recipes):
result = engine.suggest(req) result = engine.suggest(req)
assembly_ids = [s.id for s in result.suggestions if s.id < 0] assembly_ids = [s.id for s in result.suggestions if s.id < 0]
assert assembly_ids == [], f"Found assembly results in suggest(): {assembly_ids}" 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