kiwi/frontend/src/stores/recipes.ts
pyr0ball 7498995092
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
feat(filters): split time filter into hands-on and total time (kiwi#52)
Adds max_active_min request field and backend filter. Active time uses
parse_time_effort().active_min (passive waits excluded). Recipes with
no parsed active time signal are not excluded (avoid hiding unlabelled
results). Total and active limits are AND'd when both set.

UI: two pill rows — "Hands-on time" (15/30/45/1hr) and "Total time"
(30m/1hr/90m/2hr/3hr/4+hr). Replaces single row capped at 90 min.
2026-04-27 16:03:27 -07:00

425 lines
13 KiB
TypeScript

/**
* Recipes Store
*
* Manages recipe suggestion state and request parameters using Pinia.
* Dismissed recipe IDs are persisted to localStorage with a 7-day TTL.
*/
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type RecipeJobStatusValue, type NutritionFilters } from '../services/api'
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
const COOK_LOG_KEY = 'kiwi:cook_log'
const COOK_LOG_MAX = 200
const BOOKMARKS_KEY = 'kiwi:bookmarks'
const BOOKMARKS_MAX = 50
const MISSING_MODE_KEY = 'kiwi:builder_missing_mode'
const FILTER_MODE_KEY = 'kiwi:builder_filter_mode'
const CONSTRAINTS_KEY = 'kiwi:constraints'
const ALLERGIES_KEY = 'kiwi:allergies'
const EXCLUDE_INGREDIENTS_KEY = 'kiwi:exclude_ingredients'
function loadConstraints(): string[] {
try {
const raw = localStorage.getItem(CONSTRAINTS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveConstraints(vals: string[]) {
localStorage.setItem(CONSTRAINTS_KEY, JSON.stringify(vals))
}
function loadAllergies(): string[] {
try {
const raw = localStorage.getItem(ALLERGIES_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveAllergies(vals: string[]) {
localStorage.setItem(ALLERGIES_KEY, JSON.stringify(vals))
}
function loadExcludeIngredients(): string[] {
try {
const raw = localStorage.getItem(EXCLUDE_INGREDIENTS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveExcludeIngredients(vals: string[]) {
localStorage.setItem(EXCLUDE_INGREDIENTS_KEY, JSON.stringify(vals))
}
type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart'
type BuilderFilterMode = 'text' | 'tags'
// [id, dismissedAtMs]
type DismissEntry = [number, number]
export interface CookLogEntry {
id: number
title: string
cookedAt: number // unix ms
}
function loadDismissed(): Set<number> {
try {
const raw = localStorage.getItem(DISMISSED_KEY)
if (!raw) return new Set()
const entries: DismissEntry[] = JSON.parse(raw)
const cutoff = Date.now() - DISMISS_TTL_MS
return new Set(entries.filter(([, ts]) => ts > cutoff).map(([id]) => id))
} catch {
return new Set()
}
}
function saveDismissed(ids: Set<number>) {
const now = Date.now()
const entries: DismissEntry[] = [...ids].map((id) => [id, now])
localStorage.setItem(DISMISSED_KEY, JSON.stringify(entries))
}
function loadCookLog(): CookLogEntry[] {
try {
const raw = localStorage.getItem(COOK_LOG_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveCookLog(log: CookLogEntry[]) {
localStorage.setItem(COOK_LOG_KEY, JSON.stringify(log.slice(-COOK_LOG_MAX)))
}
function loadBookmarks(): RecipeSuggestion[] {
try {
const raw = localStorage.getItem(BOOKMARKS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveBookmarks(bookmarks: RecipeSuggestion[]) {
localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks.slice(0, BOOKMARKS_MAX)))
}
function loadMissingMode(): MissingIngredientMode {
const raw = localStorage.getItem(MISSING_MODE_KEY)
if (raw === 'hidden' || raw === 'greyed' || raw === 'add-to-cart') return raw
return 'greyed'
}
function loadFilterMode(): BuilderFilterMode {
return localStorage.getItem(FILTER_MODE_KEY) === 'tags' ? 'tags' : 'text'
}
export const useRecipesStore = defineStore('recipes', () => {
// Suggestion result state
const result = ref<RecipeResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const jobStatus = ref<RecipeJobStatusValue | null>(null)
// Request parameters
const level = ref(1)
const constraints = ref<string[]>(loadConstraints())
const allergies = ref<string[]>(loadAllergies())
const excludeIngredients = ref<string[]>(loadExcludeIngredients())
const hardDayMode = ref(false)
const maxMissing = ref<number | null>(null)
const styleId = ref<string | null>(null)
const category = ref<string | null>(null)
const wildcardConfirmed = ref(false)
const shoppingMode = ref(false)
const pantryMatchOnly = ref(false)
const complexityFilter = ref<string | null>(null)
const maxTimeMin = ref<number | null>(null)
const maxTotalMin = ref<number | null>(null)
const maxActiveMin = ref<number | null>(null)
const nutritionFilters = ref<NutritionFilters>({
max_calories: null,
max_sugar_g: null,
max_carbs_g: null,
max_sodium_mg: null,
})
// Dismissed IDs: persisted to localStorage, 7-day TTL
const dismissedIds = ref<Set<number>>(loadDismissed())
// Seen IDs: session-only, used by Load More to avoid repeating results
const seenIds = ref<Set<number>>(new Set())
// Cook log: persisted to localStorage, max COOK_LOG_MAX entries
const cookLog = ref<CookLogEntry[]>(loadCookLog())
// Bookmarks: full RecipeSuggestion snapshots, max BOOKMARKS_MAX
const bookmarks = ref<RecipeSuggestion[]>(loadBookmarks())
// Build Your Own wizard preferences -- persisted across sessions
const missingIngredientMode = ref<MissingIngredientMode>(loadMissingMode())
const builderFilterMode = ref<BuilderFilterMode>(loadFilterMode())
// Persist wizard prefs on change
watch(missingIngredientMode, (val) => localStorage.setItem(MISSING_MODE_KEY, val))
watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val))
watch(constraints, (val) => saveConstraints(val), { deep: true })
watch(allergies, (val) => saveAllergies(val), { deep: true })
watch(excludeIngredients, (val) => saveExcludeIngredients(val), { deep: true })
const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(
pantryItems: string[],
secondaryPantryItems: Record<string, string> = {},
extraExcluded: number[] = [],
): RecipeRequest {
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
return {
pantry_items: pantryItems,
secondary_pantry_items: secondaryPantryItems,
level: level.value,
constraints: constraints.value,
allergies: allergies.value,
expiry_first: true,
hard_day_mode: hardDayMode.value,
max_missing: maxMissing.value,
style_id: styleId.value,
category: category.value,
wildcard_confirmed: wildcardConfirmed.value,
nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded],
exclude_ingredients: excludeIngredients.value,
shopping_mode: shoppingMode.value,
pantry_match_only: pantryMatchOnly.value,
complexity_filter: complexityFilter.value,
max_time_min: maxTimeMin.value,
max_total_min: maxTotalMin.value,
max_active_min: maxActiveMin.value,
}
}
function _trackSeen(suggestions: RecipeSuggestion[]) {
for (const s of suggestions) {
if (s.id) seenIds.value = new Set([...seenIds.value, s.id])
}
}
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
loading.value = true
error.value = null
jobStatus.value = null
seenIds.value = new Set()
try {
if (level.value >= 3) {
await _suggestAsync(pantryItems, secondaryPantryItems)
} else {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
_trackSeen(result.value.suggestions)
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
} finally {
loading.value = false
jobStatus.value = null
}
}
async function _suggestAsync(pantryItems: string[], secondaryPantryItems: Record<string, string>) {
const queued = await recipesAPI.suggestAsync(_buildRequest(pantryItems, secondaryPantryItems))
// CLOUD_MODE or future sync fallback: server returned result directly (status 200)
if ('suggestions' in queued) {
result.value = queued as unknown as RecipeResult
_trackSeen(result.value.suggestions)
return
}
jobStatus.value = 'queued'
const { job_id } = queued
const deadline = Date.now() + 90_000
const POLL_MS = 2_500
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
const poll = await recipesAPI.pollJob(job_id)
jobStatus.value = poll.status
if (poll.status === 'done') {
result.value = poll.result
if (result.value) _trackSeen(result.value.suggestions)
return
}
if (poll.status === 'failed') {
throw new Error(poll.error ?? 'Recipe generation failed')
}
}
throw new Error('Recipe generation timed out — the model may be busy. Try again.')
}
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
if (!result.value || loading.value) return
loading.value = true
error.value = null
try {
// Exclude everything already shown (dismissed + all seen this session)
const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value]))
if (more.suggestions.length === 0) {
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
} else {
result.value = {
...result.value,
suggestions: [...result.value.suggestions, ...more.suggestions],
grocery_list: [...new Set([...result.value.grocery_list, ...more.grocery_list])],
grocery_links: [...result.value.grocery_links, ...more.grocery_links],
}
_trackSeen(more.suggestions)
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load more recipes'
} finally {
loading.value = false
}
}
function dismiss(id: number) {
dismissedIds.value = new Set([...dismissedIds.value, id])
saveDismissed(dismissedIds.value)
// Remove from current results immediately
if (result.value) {
result.value = {
...result.value,
suggestions: result.value.suggestions.filter((s) => s.id !== id),
}
}
}
function undismiss(id: number) {
dismissedIds.value = new Set([...dismissedIds.value].filter((d) => d !== id))
saveDismissed(dismissedIds.value)
}
function clearDismissed() {
dismissedIds.value = new Set()
localStorage.removeItem(DISMISSED_KEY)
}
// Orbital cadence: cookedAt anchors to completion, not to a schedule.
// Days-since display measures from this timestamp — no debt accumulates.
function logCook(id: number, title: string) {
const entry: CookLogEntry = { id, title, cookedAt: Date.now() }
cookLog.value = [...cookLog.value, entry]
saveCookLog(cookLog.value)
}
function clearCookLog() {
cookLog.value = []
localStorage.removeItem(COOK_LOG_KEY)
}
function lastCookedDaysAgo(recipeId: number): number | null {
const entries = cookLog.value.filter((e) => e.id === recipeId)
if (entries.length === 0) return null
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
return Math.floor((Date.now() - latestMs) / 86_400_000)
}
function isBookmarked(id: number): boolean {
return bookmarks.value.some((b) => b.id === id)
}
function toggleBookmark(recipe: RecipeSuggestion) {
if (isBookmarked(recipe.id)) {
bookmarks.value = bookmarks.value.filter((b) => b.id !== recipe.id)
} else {
bookmarks.value = [recipe, ...bookmarks.value]
}
saveBookmarks(bookmarks.value)
}
function clearBookmarks() {
bookmarks.value = []
localStorage.removeItem(BOOKMARKS_KEY)
}
function clearConstraints() {
constraints.value = []
localStorage.removeItem(CONSTRAINTS_KEY)
}
function clearAllergies() {
allergies.value = []
localStorage.removeItem(ALLERGIES_KEY)
}
function clearExcludeIngredients() {
excludeIngredients.value = []
localStorage.removeItem(EXCLUDE_INGREDIENTS_KEY)
}
function clearResult() {
result.value = null
error.value = null
wildcardConfirmed.value = false
}
return {
result,
loading,
error,
jobStatus,
level,
constraints,
allergies,
excludeIngredients,
hardDayMode,
maxMissing,
styleId,
category,
wildcardConfirmed,
shoppingMode,
pantryMatchOnly,
complexityFilter,
maxTimeMin,
maxTotalMin,
maxActiveMin,
nutritionFilters,
dismissedIds,
dismissedCount,
cookLog,
logCook,
clearCookLog,
lastCookedDaysAgo,
bookmarks,
isBookmarked,
toggleBookmark,
clearBookmarks,
clearConstraints,
clearAllergies,
clearExcludeIngredients,
missingIngredientMode,
builderFilterMode,
suggest,
loadMore,
dismiss,
undismiss,
clearDismissed,
clearResult,
}
})