feat(recipes): orbital cadence — last-cooked chip and sort on saved recipes (#120)
This commit is contained in:
parent
95e76edaea
commit
e05bfe86f5
3 changed files with 49 additions and 21 deletions
|
|
@ -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">A–Z</option>
|
<option value="title">A–Z</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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue