281 lines
8.1 KiB
TypeScript
281 lines
8.1 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 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'
|
|
|
|
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)
|
|
|
|
// Request parameters
|
|
const level = ref(1)
|
|
const constraints = ref<string[]>([])
|
|
const allergies = ref<string[]>([])
|
|
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 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))
|
|
|
|
const dismissedCount = computed(() => dismissedIds.value.size)
|
|
|
|
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
|
|
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
|
return {
|
|
pantry_items: pantryItems,
|
|
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],
|
|
shopping_mode: shoppingMode.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[]) {
|
|
loading.value = true
|
|
error.value = null
|
|
seenIds.value = new Set()
|
|
|
|
try {
|
|
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
|
_trackSeen(result.value.suggestions)
|
|
} catch (err: unknown) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadMore(pantryItems: 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, [...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 clearDismissed() {
|
|
dismissedIds.value = new Set()
|
|
localStorage.removeItem(DISMISSED_KEY)
|
|
}
|
|
|
|
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 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 clearResult() {
|
|
result.value = null
|
|
error.value = null
|
|
wildcardConfirmed.value = false
|
|
}
|
|
|
|
return {
|
|
result,
|
|
loading,
|
|
error,
|
|
level,
|
|
constraints,
|
|
allergies,
|
|
hardDayMode,
|
|
maxMissing,
|
|
styleId,
|
|
category,
|
|
wildcardConfirmed,
|
|
shoppingMode,
|
|
nutritionFilters,
|
|
dismissedIds,
|
|
dismissedCount,
|
|
cookLog,
|
|
logCook,
|
|
clearCookLog,
|
|
bookmarks,
|
|
isBookmarked,
|
|
toggleBookmark,
|
|
clearBookmarks,
|
|
missingIngredientMode,
|
|
builderFilterMode,
|
|
suggest,
|
|
loadMore,
|
|
dismiss,
|
|
clearDismissed,
|
|
clearResult,
|
|
}
|
|
})
|