From e05bfe86f53ce2e10d194a9e3544bf8411bd1486 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 26 Apr 2026 09:09:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20orbital=20cadence=20=E2=80=94?= =?UTF-8?q?=20last-cooked=20chip=20and=20sort=20on=20saved=20recipes=20(#1?= =?UTF-8?q?20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/SavedRecipesPanel.vue | 56 ++++++++++++------- frontend/src/stores/recipes.ts | 10 ++++ frontend/src/stores/savedRecipes.ts | 4 +- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/SavedRecipesPanel.vue b/frontend/src/components/SavedRecipesPanel.vue index 3928be3..4a1c182 100644 --- a/frontend/src/components/SavedRecipesPanel.vue +++ b/frontend/src/components/SavedRecipesPanel.vue @@ -32,6 +32,7 @@ + @@ -46,7 +47,7 @@
{{ tag }}
- -
+ +
{{ lastCookedLabel(recipe.recipe_id) }}
@@ -165,20 +166,32 @@ const recipesStore = useRecipesStore() const editingRecipe = ref(null) function lastCookedLabel(recipeId: number): string | null { - const entries = recipesStore.cookLog.filter((e) => e.id === recipeId) - if (entries.length === 0) return null - const latestMs = Math.max(...entries.map((e) => e.cookedAt)) - const diffMs = Date.now() - latestMs - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) - if (diffDays === 0) return 'Last made: today' - if (diffDays === 1) return 'Last made: yesterday' - if (diffDays < 7) return `Last made: ${diffDays} days ago` - if (diffDays < 14) return 'Last made: 1 week ago' - const diffWeeks = Math.floor(diffDays / 7) - if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago` - const diffMonths = Math.floor(diffDays / 30) - return `Last made: ${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago` + const days = recipesStore.lastCookedDaysAgo(recipeId) + if (days === null) return null + if (days === 0) return 'made today' + if (days === 1) return 'made yesterday' + if (days < 7) return `made ${days} days ago` + if (days < 14) return 'made 1 week ago' + const weeks = Math.floor(days / 7) + if (days < 60) return `made ${weeks} weeks ago` + const months = Math.floor(days / 30) + return `made ${months} month${months !== 1 ? 's' : ''} ago` } + +// Client-side last_cooked sort — resolves from localStorage cook log so no API change needed. +// Recipes with a cook date surface oldest-first (natural "due for a revisit" order without +// framing it that way). Recipes never cooked sort to the end. +const sortedSaved = computed(() => { + if (store.sortBy !== 'last_cooked') return store.saved + return [...store.saved].sort((a, b) => { + const daysA = recipesStore.lastCookedDaysAgo(a.recipe_id) + const daysB = recipesStore.lastCookedDaysAgo(b.recipe_id) + if (daysA === null && daysB === null) return 0 + if (daysA === null) return 1 // never cooked → end + if (daysB === null) return -1 // never cooked → end + return daysB - daysA // oldest cooked first (largest days value first) + }) +}) const showNewCollection = ref(false) // #44: two-step remove confirmation @@ -363,9 +376,14 @@ async function createCollection() { padding: var(--spacing-xl); } -.last-cooked-hint { - font-style: italic; - opacity: 0.75; +.last-cooked-chip { + display: inline-block; + color: var(--color-text-muted, var(--color-secondary, #888)); + background: var(--color-surface-subtle, transparent); + border-radius: var(--radius-sm, 4px); + padding: 0 var(--spacing-xs, 4px); + font-style: normal; + opacity: 0.8; } .modal-overlay { diff --git a/frontend/src/stores/recipes.ts b/frontend/src/stores/recipes.ts index 5046b34..f85df6b 100644 --- a/frontend/src/stores/recipes.ts +++ b/frontend/src/stores/recipes.ts @@ -318,6 +318,8 @@ export const useRecipesStore = defineStore('recipes', () => { 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] @@ -329,6 +331,13 @@ export const useRecipesStore = defineStore('recipes', () => { 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) } @@ -393,6 +402,7 @@ export const useRecipesStore = defineStore('recipes', () => { cookLog, logCook, clearCookLog, + lastCookedDaysAgo, bookmarks, isBookmarked, toggleBookmark, diff --git a/frontend/src/stores/savedRecipes.ts b/frontend/src/stores/savedRecipes.ts index af7f2e5..56acbb6 100644 --- a/frontend/src/stores/savedRecipes.ts +++ b/frontend/src/stores/savedRecipes.ts @@ -11,7 +11,7 @@ export const useSavedRecipesStore = defineStore('savedRecipes', () => { const saved = ref([]) const collections = ref([]) const loading = ref(false) - const sortBy = ref<'saved_at' | 'rating' | 'title'>('saved_at') + const sortBy = ref<'saved_at' | 'rating' | 'title' | 'last_cooked'>('saved_at') const activeCollectionId = ref(null) const savedIds = computed(() => new Set(saved.value.map((s) => s.recipe_id))) @@ -31,7 +31,7 @@ export const useSavedRecipesStore = defineStore('savedRecipes', () => { // saved recipes from loading. Backend now returns [] for Free, but guard // here too in case an older API version is deployed. const [itemsResult, colsResult] = await Promise.allSettled([ - savedRecipesAPI.list({ sort_by: sortBy.value, collection_id: activeCollectionId.value ?? undefined }), + savedRecipesAPI.list({ sort_by: sortBy.value === 'last_cooked' ? 'saved_at' : sortBy.value, collection_id: activeCollectionId.value ?? undefined }), savedRecipesAPI.listCollections(), ]) if (itemsResult.status === 'fulfilled') saved.value = itemsResult.value