feat(find): invert flow — auto-suggest on tab open, collapsible Refine panel (closes #132)
Auto-suggest (L1/L2 only): When the Find tab is activated with a non-empty pantry and no existing results, suggestion fires immediately without user action. L3/L4 are excluded to avoid unintended VRAM allocation and AI quota charges. After the first auto-suggest completes, the Refine panel collapses so the results are the first thing the user sees. Live re-suggest (L1/L2 only): A single filterKey computed wraps all filter state as JSON. Any filter change while on the Find tab with existing results triggers a debounced (1.2s) re-suggest, keeping the result list live without button clicks. Refine collapsible: Time budget, Dietary preferences, and Nutrition/Advanced filters are wrapped in a v-show panel controlled by filtersOpen (persisted to localStorage under kiwi:find_filters_open, default open). Level selector, Hard Day Mode, and the Suggest button remain always visible. Toggle button shows active filter count badge when any filter is set.
This commit is contained in:
parent
ac4eda2047
commit
4e50661483
1 changed files with 112 additions and 0 deletions
|
|
@ -147,6 +147,19 @@
|
||||||
Tap "Find recipes" again to apply.
|
Tap "Find recipes" again to apply.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Refine panel toggle — wraps time budget + dietary + nutrition filters -->
|
||||||
|
<button
|
||||||
|
class="refine-toggle btn btn-secondary btn-sm"
|
||||||
|
@click="toggleFilters"
|
||||||
|
:aria-expanded="filtersOpen"
|
||||||
|
aria-controls="refine-panel"
|
||||||
|
>
|
||||||
|
{{ filtersOpen ? '▲' : '▼' }} Refine
|
||||||
|
<span v-if="activeFilterCount > 0" class="refine-count">{{ activeFilterCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="refine-panel" v-show="filtersOpen" class="refine-body">
|
||||||
|
|
||||||
<!-- Time Budget selector — always visible; closes #131 -->
|
<!-- Time Budget selector — always visible; closes #131 -->
|
||||||
<div class="form-group time-bucket-group">
|
<div class="form-group time-bucket-group">
|
||||||
<!-- Hands-on / active time row -->
|
<!-- Hands-on / active time row -->
|
||||||
|
|
@ -400,6 +413,8 @@
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
</div><!-- end #refine-panel -->
|
||||||
|
|
||||||
<!-- Suggest Button -->
|
<!-- Suggest Button -->
|
||||||
<div class="suggest-row">
|
<div class="suggest-row">
|
||||||
<button
|
<button
|
||||||
|
|
@ -985,6 +1000,14 @@ const advancedActive = computed(() =>
|
||||||
!!recipesStore.styleId
|
!!recipesStore.styleId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const FILTERS_OPEN_KEY = 'kiwi:find_filters_open'
|
||||||
|
const filtersOpen = ref(localStorage.getItem(FILTERS_OPEN_KEY) !== 'false')
|
||||||
|
|
||||||
|
function toggleFilters() {
|
||||||
|
filtersOpen.value = !filtersOpen.value
|
||||||
|
localStorage.setItem(FILTERS_OPEN_KEY, String(filtersOpen.value))
|
||||||
|
}
|
||||||
|
|
||||||
const activeFilterCount = computed(() => {
|
const activeFilterCount = computed(() => {
|
||||||
let n = 0
|
let n = 0
|
||||||
if (recipesStore.constraints.length > 0) n++
|
if (recipesStore.constraints.length > 0) n++
|
||||||
|
|
@ -1021,6 +1044,66 @@ function clearAllFindFilters() {
|
||||||
categoryInput.value = ''
|
categoryInput.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inverted flow: auto-suggest + live re-suggest (closes #132) ─────────────
|
||||||
|
|
||||||
|
function _debounce(fn: () => void, ms: number): () => void {
|
||||||
|
let t: ReturnType<typeof setTimeout>
|
||||||
|
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable key over all filter state — one watcher instead of a dozen.
|
||||||
|
const filterKey = computed(() => JSON.stringify({
|
||||||
|
constraints: recipesStore.constraints,
|
||||||
|
allergies: recipesStore.allergies,
|
||||||
|
excludeIngredients: recipesStore.excludeIngredients,
|
||||||
|
shoppingMode: recipesStore.shoppingMode,
|
||||||
|
pantryMatchOnly: recipesStore.pantryMatchOnly,
|
||||||
|
hardDayMode: recipesStore.hardDayMode,
|
||||||
|
maxActiveMin: recipesStore.maxActiveMin,
|
||||||
|
maxTotalMin: recipesStore.maxTotalMin,
|
||||||
|
maxMissing: recipesStore.maxMissing,
|
||||||
|
styleId: recipesStore.styleId,
|
||||||
|
category: recipesStore.category,
|
||||||
|
nutritionFilters: recipesStore.nutritionFilters,
|
||||||
|
level: recipesStore.level,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Flag prevents double-firing on initial pantry load + tab switch within same render cycle.
|
||||||
|
let _autoSuggestFired = false
|
||||||
|
|
||||||
|
// Auto-suggest on Find tab activation (L1/L2 only — L3/L4 require explicit user intent).
|
||||||
|
watch(
|
||||||
|
[activeTab, pantryItems] as const,
|
||||||
|
([tab, items]) => {
|
||||||
|
if (tab !== 'find') return
|
||||||
|
if (recipesStore.level > 2) return
|
||||||
|
if (items.length === 0) return
|
||||||
|
if (recipesStore.result || recipesStore.loading) return
|
||||||
|
if (_autoSuggestFired) return
|
||||||
|
_autoSuggestFired = true
|
||||||
|
handleSuggest().then(() => {
|
||||||
|
filtersOpen.value = false
|
||||||
|
localStorage.setItem(FILTERS_OPEN_KEY, 'false')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Live re-suggest when any filter changes while on Find tab with existing results (L1/L2).
|
||||||
|
const _debouncedResuggest = _debounce(async () => {
|
||||||
|
if (activeTab.value !== 'find') return
|
||||||
|
if (recipesStore.level > 2) return
|
||||||
|
if (pantryItems.value.length === 0) return
|
||||||
|
if (!recipesStore.result) return
|
||||||
|
await handleSuggest()
|
||||||
|
}, 1200)
|
||||||
|
|
||||||
|
watch(filterKey, () => {
|
||||||
|
if (activeTab.value !== 'find') return
|
||||||
|
if (recipesStore.level > 2) return
|
||||||
|
if (!recipesStore.result) return
|
||||||
|
_debouncedResuggest()
|
||||||
|
})
|
||||||
|
|
||||||
// #46 — count of active nutrition filters so the summary is informative when collapsed
|
// #46 — count of active nutrition filters so the summary is informative when collapsed
|
||||||
const activeNutritionFilterCount = computed(() =>
|
const activeNutritionFilterCount = computed(() =>
|
||||||
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
|
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
|
||||||
|
|
@ -1452,6 +1535,35 @@ watch(
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Refine collapsible ──────────────────────────────────────────────────── */
|
||||||
|
.refine-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refine-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: var(--font-size-xs, 0.72rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refine-body {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue