feat(recipes): orbital cadence — last-cooked chip and sort on saved recipes (#120)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

This commit is contained in:
pyr0ball 2026-04-26 09:09:27 -07:00
parent 95e76edaea
commit e05bfe86f5
3 changed files with 49 additions and 21 deletions

View file

@ -32,6 +32,7 @@
<option value="saved_at">Recently saved</option> <option value="saved_at">Recently saved</option>
<option value="rating">Highest rated</option> <option value="rating">Highest rated</option>
<option value="title">AZ</option> <option value="title">AZ</option>
<option value="last_cooked">Last cooked</option>
</select> </select>
</div> </div>
@ -46,7 +47,7 @@
<!-- Recipe cards --> <!-- Recipe cards -->
<div class="saved-list flex-col gap-sm"> <div class="saved-list flex-col gap-sm">
<div <div
v-for="recipe in store.saved" v-for="recipe in sortedSaved"
:key="recipe.id" :key="recipe.id"
class="card-sm saved-card" class="card-sm saved-card"
:class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }" :class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }"
@ -79,8 +80,8 @@
>{{ tag }}</span> >{{ tag }}</span>
</div> </div>
<!-- Last cooked hint --> <!-- Last cooked chip (orbital cadence: neutral, no urgency) -->
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-hint text-xs text-muted mt-xs"> <div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-chip text-xs mt-xs">
{{ lastCookedLabel(recipe.recipe_id) }} {{ lastCookedLabel(recipe.recipe_id) }}
</div> </div>
@ -165,20 +166,32 @@ const recipesStore = useRecipesStore()
const editingRecipe = ref<SavedRecipe | null>(null) const editingRecipe = ref<SavedRecipe | null>(null)
function lastCookedLabel(recipeId: number): string | null { function lastCookedLabel(recipeId: number): string | null {
const entries = recipesStore.cookLog.filter((e) => e.id === recipeId) const days = recipesStore.lastCookedDaysAgo(recipeId)
if (entries.length === 0) return null if (days === null) return null
const latestMs = Math.max(...entries.map((e) => e.cookedAt)) if (days === 0) return 'made today'
const diffMs = Date.now() - latestMs if (days === 1) return 'made yesterday'
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) if (days < 7) return `made ${days} days ago`
if (diffDays === 0) return 'Last made: today' if (days < 14) return 'made 1 week ago'
if (diffDays === 1) return 'Last made: yesterday' const weeks = Math.floor(days / 7)
if (diffDays < 7) return `Last made: ${diffDays} days ago` if (days < 60) return `made ${weeks} weeks ago`
if (diffDays < 14) return 'Last made: 1 week ago' const months = Math.floor(days / 30)
const diffWeeks = Math.floor(diffDays / 7) return `made ${months} month${months !== 1 ? 's' : ''} ago`
if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago`
const diffMonths = Math.floor(diffDays / 30)
return `Last made: ${diffMonths} month${diffMonths !== 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) const showNewCollection = ref(false)
// #44: two-step remove confirmation // #44: two-step remove confirmation
@ -363,9 +376,14 @@ async function createCollection() {
padding: var(--spacing-xl); padding: var(--spacing-xl);
} }
.last-cooked-hint { .last-cooked-chip {
font-style: italic; display: inline-block;
opacity: 0.75; 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 { .modal-overlay {

View file

@ -318,6 +318,8 @@ export const useRecipesStore = defineStore('recipes', () => {
localStorage.removeItem(DISMISSED_KEY) 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) { function logCook(id: number, title: string) {
const entry: CookLogEntry = { id, title, cookedAt: Date.now() } const entry: CookLogEntry = { id, title, cookedAt: Date.now() }
cookLog.value = [...cookLog.value, entry] cookLog.value = [...cookLog.value, entry]
@ -329,6 +331,13 @@ export const useRecipesStore = defineStore('recipes', () => {
localStorage.removeItem(COOK_LOG_KEY) 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 { function isBookmarked(id: number): boolean {
return bookmarks.value.some((b) => b.id === id) return bookmarks.value.some((b) => b.id === id)
} }
@ -393,6 +402,7 @@ export const useRecipesStore = defineStore('recipes', () => {
cookLog, cookLog,
logCook, logCook,
clearCookLog, clearCookLog,
lastCookedDaysAgo,
bookmarks, bookmarks,
isBookmarked, isBookmarked,
toggleBookmark, toggleBookmark,

View file

@ -11,7 +11,7 @@ export const useSavedRecipesStore = defineStore('savedRecipes', () => {
const saved = ref<SavedRecipe[]>([]) const saved = ref<SavedRecipe[]>([])
const collections = ref<RecipeCollection[]>([]) const collections = ref<RecipeCollection[]>([])
const loading = ref(false) 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<number | null>(null) const activeCollectionId = ref<number | null>(null)
const savedIds = computed(() => new Set(saved.value.map((s) => s.recipe_id))) 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 // saved recipes from loading. Backend now returns [] for Free, but guard
// here too in case an older API version is deployed. // here too in case an older API version is deployed.
const [itemsResult, colsResult] = await Promise.allSettled([ 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(), savedRecipesAPI.listCollections(),
]) ])
if (itemsResult.status === 'fulfilled') saved.value = itemsResult.value if (itemsResult.status === 'fulfilled') saved.value = itemsResult.value