From 096d6e286812972dd00c81fbc21a5b98d53b9f4a Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 2 May 2026 17:53:27 +0200 Subject: [PATCH] feat(rezepte)!: liturgical-aware seasonality via date ranges Replace season: number[] (months 1-12) on Recipe with seasonRanges, a list of date ranges where each endpoint is either a fixed MM-DD or a movable liturgical anchor (Easter, Ash Wednesday, Palm Sunday, Pentecost, Advent I) plus a day offset. The old month list couldn't express liturgical seasons whose boundaries shift each year (Advent, Lent, Easter Octave, Christmas Octave) nor sub-month windows. The shared evaluator resolves anchors against [Y-1, Y, Y+1] so spans that wrap the calendar year boundary (e.g. christmas + 0 to christmas + 7) match correctly on both sides. SeasonSelect was rewritten as a controlled bind:ranges editor with a fixed/liturgical kind toggle, anchor + offset inputs, per-row resolved-this-year preview, and preset chips. Run the one-time migration before deploying: pnpm exec vite-node scripts/migrate-season-to-ranges.ts It coalesces contiguous month runs into single fixed ranges and merges Dec/Jan wrap into one wrapping range; the new code does not read the legacy season field, so order matters. --- package.json | 2 +- scripts/migrate-season-to-ranges.ts | 107 ++++++ src/lib/components/recipes/Card.svelte | 7 +- src/lib/components/recipes/CompactCard.svelte | 4 +- .../components/recipes/RecipeEditor.svelte | 10 +- src/lib/components/recipes/Search.svelte | 8 +- .../components/recipes/SeasonSelect.svelte | 313 +++++++++++++----- src/lib/js/easter.svelte.ts | 50 +++ src/lib/js/seasonRange.ts | 118 +++++++ src/lib/js/season_store.js | 3 - src/lib/offline/db.ts | 26 +- src/lib/offline/sync.ts | 4 +- src/lib/server/recipeHelpers.ts | 6 +- src/models/Recipe.ts | 22 +- .../[recipeLang=recipeLang]/+page.server.ts | 6 +- .../[recipeLang=recipeLang]/+page.svelte | 3 - src/routes/[recipeLang=recipeLang]/+page.ts | 6 +- .../[name]/+page.svelte | 62 +--- .../[recipeLang=recipeLang]/add/+page.svelte | 31 +- .../admin/untranslated/+page.svelte | 2 - .../category/[category]/+page.svelte | 3 +- .../edit/[name]/+page.svelte | 32 +- .../favorites/+page.svelte | 3 +- .../search/+page.svelte | 3 +- .../season/+page.server.ts | 5 +- .../season/+page.svelte | 2 +- .../[recipeLang=recipeLang]/season/+page.ts | 5 +- .../season/[month]/+page.ts | 4 +- .../tag/[tag]/+page.svelte | 3 +- .../favorites/recipes/+server.ts | 2 +- .../items/[name]/+server.ts | 2 +- .../items/in_season/[month]/+server.ts | 12 +- .../items/in_season/today/+server.ts | 27 ++ .../offline-db/+server.ts | 2 +- .../[recipeLang=recipeLang]/search/+server.ts | 12 +- .../translate/untranslated/+server.ts | 4 +- src/types/types.ts | 22 +- src/utils/recipeFormHelpers.ts | 54 ++- 38 files changed, 692 insertions(+), 295 deletions(-) create mode 100644 scripts/migrate-season-to-ranges.ts create mode 100644 src/lib/js/seasonRange.ts delete mode 100644 src/lib/js/season_store.js create mode 100644 src/routes/api/[recipeLang=recipeLang]/items/in_season/today/+server.ts diff --git a/package.json b/package.json index f3d18604..0af74e4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.58.1", + "version": "1.59.1", "private": true, "type": "module", "scripts": { diff --git a/scripts/migrate-season-to-ranges.ts b/scripts/migrate-season-to-ranges.ts new file mode 100644 index 00000000..394b4221 --- /dev/null +++ b/scripts/migrate-season-to-ranges.ts @@ -0,0 +1,107 @@ +/** + * One-time migration: convert legacy `season: number[]` (months 1–12) on every + * Recipe document to the new `seasonRanges: SeasonRange[]` shape. + * + * Contiguous months are coalesced into a single range. A wrap across the year + * boundary (e.g. months [11, 12, 1, 2]) merges into one wrapping range + * Nov 1 → Feb 28; non-contiguous months stay as separate ranges. + * + * The legacy `season` field is then $unset. + * + * Run before deploying the new code path: + * pnpm exec vite-node scripts/migrate-season-to-ranges.ts + * + * Idempotent: a recipe with no `season` field is left untouched. + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import mongoose from 'mongoose'; + +const envPath = resolve(import.meta.dirname ?? '.', '..', '.env'); +const envText = readFileSync(envPath, 'utf-8'); +const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m); +if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); } +const MONGO_URL = mongoMatch[1]; + +const LAST_DAY = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +type FixedRange = { startM: number; endM: number }; + +/** + * Coalesce a set of months (1–12) into contiguous ranges, merging the + * year-boundary wrap if both Jan and Dec runs are present. + */ +function coalesceMonths(months: number[]): FixedRange[] { + const sorted = [...new Set(months.filter(m => Number.isInteger(m) && m >= 1 && m <= 12))].sort((a, b) => a - b); + if (sorted.length === 0) return []; + + const runs: FixedRange[] = []; + let runStart = sorted[0]; + let runEnd = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === runEnd + 1) { + runEnd = sorted[i]; + } else { + runs.push({ startM: runStart, endM: runEnd }); + runStart = sorted[i]; + runEnd = sorted[i]; + } + } + runs.push({ startM: runStart, endM: runEnd }); + + // Merge the trailing-Dec run into the leading-Jan run so a winter span + // like [11,12,1,2] becomes one wrapping Nov→Feb range instead of two. + if (runs.length >= 2 && runs[0].startM === 1 && runs[runs.length - 1].endM === 12) { + const wrapped = { startM: runs[runs.length - 1].startM, endM: runs[0].endM }; + return [wrapped, ...runs.slice(1, -1)]; + } + return runs; +} + +function rangeFromRun(run: FixedRange) { + return { + start: { kind: 'fixed', m: run.startM, d: 1 }, + end: { kind: 'fixed', m: run.endM, d: LAST_DAY[run.endM - 1] } + }; +} + +async function main() { + await mongoose.connect(MONGO_URL); + const Recipe = mongoose.connection.collection('recipes'); + + const cursor = Recipe.find({ season: { $exists: true } }); + let migrated = 0; + let skipped = 0; + + while (await cursor.hasNext()) { + const doc = await cursor.next() as any; + if (!doc) break; + + const months: number[] = Array.isArray(doc.season) ? doc.season : []; + const runs = coalesceMonths(months); + + if (runs.length === 0) { + await Recipe.updateOne({ _id: doc._id }, { $unset: { season: '' } }); + skipped++; + continue; + } + + const seasonRanges = runs.map(rangeFromRun); + + await Recipe.updateOne( + { _id: doc._id }, + { $set: { seasonRanges }, $unset: { season: '' } } + ); + migrated++; + if (migrated % 25 === 0) console.log(` migrated ${migrated}…`); + } + + console.log(`\nDone. Migrated: ${migrated}. Skipped (empty season): ${skipped}.`); + await mongoose.disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/lib/components/recipes/Card.svelte b/src/lib/components/recipes/Card.svelte index 60bd7bf0..b18dc607 100644 --- a/src/lib/components/recipes/Card.svelte +++ b/src/lib/components/recipes/Card.svelte @@ -3,10 +3,10 @@ import "$lib/css/shake.css"; import "$lib/css/icon.css"; import { onMount } from "svelte"; import Heart from '@lucide/svelte/icons/heart'; +import { isRecipeInSeason } from '$lib/js/seasonRange'; let { recipe, - current_month: currentMonthProp = 0, icon_override = false, search = true, do_margin_right = false, @@ -17,8 +17,7 @@ let { translationStatus = undefined } = $props(); -// Make current_month reactive based on icon_override -let current_month = $derived(icon_override ? recipe.season[0] : currentMonthProp); +const isInSeason = $derived(icon_override || isRecipeInSeason(recipe)); let isloaded = $state(false); @@ -259,7 +258,7 @@ function preloadHeroImage() { {/if} {/if} - {#if icon_override || recipe.season.includes(current_month)} + {#if isInSeason} {recipe.icon} {/if}
diff --git a/src/lib/components/recipes/CompactCard.svelte b/src/lib/components/recipes/CompactCard.svelte index 21e49934..36c7d800 100644 --- a/src/lib/components/recipes/CompactCard.svelte +++ b/src/lib/components/recipes/CompactCard.svelte @@ -1,10 +1,10 @@ + -
-{#each months as month} -
- - - +
+ {#if ranges.length === 0} +
Keine Saison-Bereiche – immer verfügbar.
+ {/if} + + {#each ranges as range, i (i)} +
+ {#each ['start', 'end'] as const as which, wi (which)} + {#if wi > 0} + + {/if} +
+
+ + +
+ {#if range[which].kind === 'fixed'} + + updateFixed(i, which, 'd', parseInt((e.currentTarget as HTMLInputElement).value))} /> + {:else} + + updateLiturgical(i, which, 'offsetDays', parseInt((e.currentTarget as HTMLInputElement).value || '0'))} title="Tage Versatz" /> + Tage + {/if} +
+ {/each} + +
{currentYear}: {formatRangePreview(range, currentYear, 'de')}
+
+ {/each} + +
+ Hinzufügen: + + + +
-{/each}
diff --git a/src/lib/js/easter.svelte.ts b/src/lib/js/easter.svelte.ts index 769760b3..8b82e38a 100644 --- a/src/lib/js/easter.svelte.ts +++ b/src/lib/js/easter.svelte.ts @@ -64,3 +64,53 @@ export function getLiturgicalSeason(date: Date = new Date()): LiturgicalSeason { if (isLent(date) && date.getDay() !== 0) return 'lent'; return null; } + +import type { SeasonAnchorKey } from '$types/types'; + +function addDays(d: Date, days: number): Date { + const out = new Date(d); + out.setDate(out.getDate() + days); + return out; +} + +export function computeAshWednesday(year: number): Date { + return addDays(computeEaster(year), -46); +} + +export function computePalmSunday(year: number): Date { + return addDays(computeEaster(year), -7); +} + +export function computePentecost(year: number): Date { + return addDays(computeEaster(year), 49); +} + +/** + * First Sunday of Advent: the Sunday on or before December 24, minus 21 days + * (i.e. the 4th Sunday before Christmas Day). + */ +export function computeAdventI(year: number): Date { + const dec24 = new Date(year, 11, 24); + const adventIV = addDays(dec24, -dec24.getDay()); // Sunday on or before Dec 24 + return addDays(adventIV, -21); +} + +const anchorCache = new Map>(); + +/** + * Resolved anchor dates for a given civil year. Memoized — the work is cheap + * but the season evaluator hits this on every range × every recipe. + */ +export function getLiturgicalAnchors(year: number): Record { + const cached = anchorCache.get(year); + if (cached) return cached; + const anchors: Record = { + easter: computeEaster(year), + 'ash-wednesday': computeAshWednesday(year), + 'palm-sunday': computePalmSunday(year), + pentecost: computePentecost(year), + 'advent-i': computeAdventI(year) + }; + anchorCache.set(year, anchors); + return anchors; +} diff --git a/src/lib/js/seasonRange.ts b/src/lib/js/seasonRange.ts new file mode 100644 index 00000000..2f0ec71b --- /dev/null +++ b/src/lib/js/seasonRange.ts @@ -0,0 +1,118 @@ +import type { SeasonAnchorKey, SeasonEndpoint, SeasonRange } from '$types/types'; +import { getLiturgicalAnchors } from './easter.svelte'; + +function midnight(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function addDays(d: Date, days: number): Date { + const out = new Date(d); + out.setDate(out.getDate() + days); + return out; +} + +function lastDayOfMonth(year: number, month1to12: number): number { + return new Date(year, month1to12, 0).getDate(); +} + +function clampDay(year: number, month1to12: number, day: number): number { + const last = lastDayOfMonth(year, month1to12); + return Math.min(Math.max(day, 1), last); +} + +export function resolveEndpoint( + ep: SeasonEndpoint, + year: number, + anchors: Record = getLiturgicalAnchors(year) +): Date { + if (ep.kind === 'fixed') { + return new Date(year, ep.m - 1, clampDay(year, ep.m, ep.d)); + } + return midnight(addDays(anchors[ep.anchor], ep.offsetDays || 0)); +} + +/** + * Resolve a range against multiple candidate years. A range like + * `christmas + 0 → christmas + 7` resolved in year Y produces the interval + * Dec 25 Y .. Jan 1 Y+1; resolved in Y-1 produces Dec 25 Y-1 .. Jan 1 Y. + * Callers pass `[Y-1, Y, Y+1]` so a test date sees both wrapping intervals. + */ +function intervalsForYears(range: SeasonRange, years: number[]): Array<{ start: Date; end: Date }> { + const out: Array<{ start: Date; end: Date }> = []; + for (const y of years) { + const anchors = getLiturgicalAnchors(y); + const start = midnight(resolveEndpoint(range.start, y, anchors)); + const end = midnight(resolveEndpoint(range.end, y, anchors)); + // If the resolved start is after the resolved end, it is a same-year wrap + // (e.g. fixed 12-25 → 01-01 within year Y). Treat as two slices: [start, Dec 31 Y] + // and [Jan 1 Y, end]. Most ranges go single-slice — this only catches + // the case where the user wrote a same-year wrap with two fixed endpoints. + if (start.getTime() <= end.getTime()) { + out.push({ start, end }); + } else { + out.push({ start, end: new Date(y, 11, 31) }); + out.push({ start: new Date(y, 0, 1), end }); + } + } + return out; +} + +export function isDateInRange(range: SeasonRange, date: Date): boolean { + const d = midnight(date); + const y = d.getFullYear(); + const intervals = intervalsForYears(range, [y - 1, y, y + 1]); + const t = d.getTime(); + for (const iv of intervals) { + if (t >= iv.start.getTime() && t <= iv.end.getTime()) return true; + } + return false; +} + +type RecipeWithRanges = { seasonRanges?: SeasonRange[] }; + +export function isRecipeInSeason(recipe: RecipeWithRanges, date: Date = new Date()): boolean { + const ranges = recipe.seasonRanges; + if (!ranges || ranges.length === 0) return false; + for (const r of ranges) { + if (isDateInRange(r, date)) return true; + } + return false; +} + +/** + * Whether any resolved interval of `range` (across years Y-1, Y, Y+1) overlaps + * any day of `month` (1–12) in year Y. Used by the legacy `?season=N` URL filter. + */ +export function rangeOverlapsMonth(range: SeasonRange, month: number, year: number = new Date().getFullYear()): boolean { + const monthStart = new Date(year, month - 1, 1).getTime(); + const monthEnd = new Date(year, month - 1, lastDayOfMonth(year, month)).getTime(); + const intervals = intervalsForYears(range, [year - 1, year, year + 1]); + for (const iv of intervals) { + if (iv.start.getTime() <= monthEnd && iv.end.getTime() >= monthStart) return true; + } + return false; +} + +export function recipeOverlapsMonth(recipe: RecipeWithRanges, month: number, year: number = new Date().getFullYear()): boolean { + const ranges = recipe.seasonRanges; + if (!ranges || ranges.length === 0) return false; + for (const r of ranges) { + if (rangeOverlapsMonth(r, month, year)) return true; + } + return false; +} + +/** + * Format a resolved range as a human-readable string for the editor preview, + * resolved against `year`. Fixed/fixed renders as `Mar 1 – Mar 31`; ranges + * touching liturgical anchors include the year for clarity since they shift. + */ +export function formatRangePreview(range: SeasonRange, year: number, lang: 'de' | 'en' = 'de'): string { + const anchors = getLiturgicalAnchors(year); + const start = midnight(resolveEndpoint(range.start, year, anchors)); + const end = midnight(resolveEndpoint(range.end, year, anchors)); + const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'de-DE', { month: 'short', day: 'numeric' }); + const includesAnchor = range.start.kind === 'liturgical' || range.end.kind === 'liturgical'; + const yearTag = includesAnchor ? ` (${year})` : ''; + return `${fmt.format(start)} – ${fmt.format(end)}${yearTag}`; +} diff --git a/src/lib/js/season_store.js b/src/lib/js/season_store.js deleted file mode 100644 index b4ea8fc6..00000000 --- a/src/lib/js/season_store.js +++ /dev/null @@ -1,3 +0,0 @@ -import { writable } from 'svelte/store'; - -export const season = writable(/** @type {number[]} */ ([])); diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts index 6ce00b19..bb828783 100644 --- a/src/lib/offline/db.ts +++ b/src/lib/offline/db.ts @@ -1,7 +1,8 @@ import type { BriefRecipeType, RecipeModelType } from '$types/types'; +import { isRecipeInSeason, recipeOverlapsMonth } from '$lib/js/seasonRange'; const DB_NAME = 'bocken-recipes'; -const DB_VERSION = 2; // Bumped to force recreation of stores +const DB_VERSION = 3; // v3: dropped multi-entry season index after migration to seasonRanges const STORE_BRIEF = 'recipes_brief'; const STORE_FULL = 'recipes_full'; @@ -51,10 +52,12 @@ function openDB(): Promise { db.deleteObjectStore(STORE_META); } - // Brief recipes store - keyed by short_name for quick lookups + // Brief recipes store - keyed by short_name for quick lookups. + // Season membership is now driven by date ranges with movable + // liturgical anchors that can't be expressed as a static index, so + // season filtering loads all rows and runs the shared evaluator. const briefStore = db.createObjectStore(STORE_BRIEF, { keyPath: 'short_name' }); briefStore.createIndex('category', 'category', { unique: false }); - briefStore.createIndex('season', 'season', { unique: false, multiEntry: true }); // Full recipes store - keyed by short_name db.createObjectStore(STORE_FULL, { keyPath: 'short_name' }); @@ -104,17 +107,14 @@ export async function getBriefRecipesByCategory(category: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_BRIEF, 'readonly'); - const store = tx.objectStore(STORE_BRIEF); - const index = store.index('season'); - const request = index.getAll(month); +export async function getBriefRecipesInSeasonOn(date: Date = new Date()): Promise { + const all = await getAllBriefRecipes(); + return all.filter(r => isRecipeInSeason(r as any, date)); +} - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); +export async function getBriefRecipesOverlappingMonth(month: number, year: number = new Date().getFullYear()): Promise { + const all = await getAllBriefRecipes(); + return all.filter(r => recipeOverlapsMonth(r as any, month, year)); } export async function getBriefRecipesByTag(tag: string): Promise { diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index 1830b9bd..2d935bb2 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -241,11 +241,13 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise { dataUrls.push(`/recipes/icon/${encodeURIComponent(icon)}/__data.json`); } - // Add season subroute data (all 12 months) + // Add season subroute data (all 12 months + today's liturgical-aware view) for (let month = 1; month <= 12; month++) { dataUrls.push(`/rezepte/season/${month}/__data.json`); dataUrls.push(`/recipes/season/${month}/__data.json`); } + dataUrls.push(`/rezepte/season/__data.json`); + dataUrls.push(`/recipes/season/__data.json`); // Send message to service worker to cache these URLs if (dataUrls.length > 0) { diff --git a/src/lib/server/recipeHelpers.ts b/src/lib/server/recipeHelpers.ts index d34ad369..f97196f1 100644 --- a/src/lib/server/recipeHelpers.ts +++ b/src/lib/server/recipeHelpers.ts @@ -20,8 +20,8 @@ export function briefQueryConfig(recipeLang: string) { prefix: en ? 'translations.en.' : '', /** Projection for brief list queries */ projection: en - ? '_id translations.en short_name images season icon' - : 'name short_name images tags category icon description season', + ? '_id translations.en short_name images seasonRanges icon' + : 'name short_name images tags category icon description seasonRanges', }; } @@ -44,7 +44,7 @@ export function toBrief(recipe: RecipeModelType, recipeLang: string): BriefRecip category: en?.category ?? '', icon: recipe.icon, description: en?.description, - season: recipe.season || [], + seasonRanges: recipe.seasonRanges || [], germanShortName: recipe.short_name, } as unknown as BriefRecipeType; } diff --git a/src/models/Recipe.ts b/src/models/Recipe.ts index 74a42bff..2a4d9811 100644 --- a/src/models/Recipe.ts +++ b/src/models/Recipe.ts @@ -17,7 +17,25 @@ const RecipeSchema = new mongoose.Schema( description: {type: String, required: true}, note: {type: String}, tags : [String], - season : [Number], + seasonRanges: [{ + _id: false, + start: { + _id: false, + kind: { type: String, enum: ['fixed', 'liturgical'], required: true }, + m: { type: Number }, + d: { type: Number }, + anchor: { type: String, enum: ['easter', 'ash-wednesday', 'palm-sunday', 'pentecost', 'advent-i'] }, + offsetDays: { type: Number, default: 0 }, + }, + end: { + _id: false, + kind: { type: String, enum: ['fixed', 'liturgical'], required: true }, + m: { type: Number }, + d: { type: Number }, + anchor: { type: String, enum: ['easter', 'ash-wednesday', 'palm-sunday', 'pentecost', 'advent-i'] }, + offsetDays: { type: Number, default: 0 }, + }, + }], baking: { temperature: {type:String, default: ""}, length: {type:String, default: ""}, mode: {type:String, default: ""}, @@ -198,7 +216,7 @@ const RecipeSchema = new mongoose.Schema( // Indexes for efficient querying RecipeSchema.index({ short_name: 1 }); -RecipeSchema.index({ season: 1 }); +RecipeSchema.index({ 'seasonRanges.start.anchor': 1 }); RecipeSchema.index({ "translations.en.short_name": 1 }); RecipeSchema.index({ "translations.en.translationStatus": 1 }); diff --git a/src/routes/[recipeLang=recipeLang]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/+page.server.ts index fd805532..48c42434 100644 --- a/src/routes/[recipeLang=recipeLang]/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/+page.server.ts @@ -1,9 +1,9 @@ import type { PageServerLoad } from "./$types"; import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites"; +import { isRecipeInSeason } from "$lib/js/seasonRange"; export const load: PageServerLoad = async ({ fetch, locals, params }) => { const apiBase = `/api/${params.recipeLang}`; - const currentMonth = new Date().getMonth() + 1; const session = locals.session ?? await locals.auth(); const [res_all_brief, userFavorites] = await Promise.all([ @@ -12,8 +12,8 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => { ]); const all_brief = addFavoriteStatusToRecipes(res_all_brief, userFavorites); - // Derive seasonal subset from all_brief instead of a separate DB query - const season = all_brief.filter((r: any) => r.season?.includes(currentMonth) && r.icon !== '🍽️'); + const today = new Date(); + const season = all_brief.filter((r: any) => r.icon !== '🍽️' && isRecipeInSeason(r, today)); return { season, diff --git a/src/routes/[recipeLang=recipeLang]/+page.svelte b/src/routes/[recipeLang=recipeLang]/+page.svelte index b75233c2..15fa317d 100644 --- a/src/routes/[recipeLang=recipeLang]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/+page.svelte @@ -11,7 +11,6 @@ let { data } = $props<{ data: PageData }>(); const lang = $derived(data.lang as RecipesLang); const t = $derived(m[lang]); - let current_month = new Date().getMonth() + 1; // Search state let matchedRecipeIds = $state(new Set()); @@ -448,7 +447,6 @@ {#each visibleRecipes as recipe, i (recipe._id)} { try { const hasOfflineData = await isOfflineDataAvailable(); if (hasOfflineData) { - const currentMonth = new Date().getMonth() + 1; - const [allBrief, seasonRecipes] = await Promise.all([ getAllBriefRecipes(), - getBriefRecipesBySeason(currentMonth) + getBriefRecipesInSeasonOn(new Date()) ]); return { diff --git a/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte b/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte index 9413cefd..fc7ece54 100644 --- a/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte @@ -9,7 +9,7 @@ import IngredientsPage from '$lib/components/recipes/IngredientsPage.svelte'; import TitleImgParallax from '$lib/components/recipes/TitleImgParallax.svelte'; import { afterNavigate } from '$app/navigation'; - import {season} from '$lib/js/season_store'; + import { formatRangePreview, resolveEndpoint } from '$lib/js/seasonRange'; import RecipeNote from '$lib/components/recipes/RecipeNote.svelte'; import FavoriteButton from '$lib/components/FavoriteButton.svelte'; import { onDestroy } from 'svelte'; @@ -53,44 +53,15 @@ ? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] : ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]); - function season_intervals() { - // Guard against missing season data (can happen in offline mode) - if (!data.season || !Array.isArray(data.season) || data.season.length === 0) { - return []; - } - - let interval_arr = [] - - let start_i = 0 - for(var i = 12; i > 0; i--){ - if(data.season.includes(i)){ - start_i = data.season.indexOf(i); - } - else{ - break - } - } - - var start = data.season[start_i] - var end_i: number = start_i - const len = data.season.length - for(var i = 0; i < len -1; i++){ - if(data.season.includes((start + i) %12 + 1)){ - end_i = (start_i + i + 1) % len - } - else{ - interval_arr.push([start, data.season[end_i]]) - start = data.season[(start + i + 1) % len] - } - - } - if(interval_arr.length == 0){ - interval_arr.push([start, data.season[end_i]]) - } - - return interval_arr - } - const season_iv = $derived(season_intervals()); + const seasonRangeChips = $derived.by(() => { + const ranges = data.seasonRanges; + if (!ranges || !Array.isArray(ranges) || ranges.length === 0) return []; + const year = new Date().getFullYear(); + return ranges.map((r: any) => ({ + label: formatRangePreview(r, year, isEnglish ? 'en' : 'de'), + month: resolveEndpoint(r.start, year).getMonth() + 1 + })); + }); const display_date = $derived(data.updatedAt ? new Date(data.updatedAt) : new Date(data.dateCreated)); const options: Intl.DateTimeFormatOptions = { @@ -318,17 +289,12 @@ h2{ {#if data.preamble}

{@html data.preamble}

{/if} - {#if season_iv.length > 0} + {#if seasonRangeChips.length > 0} diff --git a/src/routes/[recipeLang=recipeLang]/add/+page.svelte b/src/routes/[recipeLang=recipeLang]/add/+page.svelte index 5cc313e2..f2e6decd 100644 --- a/src/routes/[recipeLang=recipeLang]/add/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/add/+page.svelte @@ -24,15 +24,11 @@ let showTranslationWorkflow = $state(false); let translationData: any = $state(null); - // Season store - import { season } from '$lib/js/season_store'; + // Season ranges (controlled by SeasonSelect via bind:ranges) + import type { SeasonRange } from '$types/types'; import { portions } from '$lib/js/portions_store'; - season.update(() => []); - let season_local = $state([]); - season.subscribe((s) => { - season_local = s; - }); + let season_local = $state([]); let portions_local = $state(""); portions.update(() => ""); @@ -73,27 +69,12 @@ let submitting = $state(false); let formElement: HTMLFormElement; - // Get season data from checkboxes - function get_season(): number[] { - const season: number[] = []; - const el = document.getElementById("labels"); - if (!el) return season; - - for (let i = 0; i < el.children.length; i++) { - const checkbox = el.children[i].children[0].children[0] as HTMLInputElement; - if (checkbox?.checked) { - season.push(i + 1); - } - } - return season; - } - // Prepare German recipe data - use $derived to prevent infinite effect loops let germanRecipeData = $derived({ ...card_data, ...add_info, images: selected_image_file ? [{ mediapath: 'pending', alt: "", caption: "" }] : [], - season: season_local, + seasonRanges: season_local, short_name: short_name.trim(), portions: portions_local, datecreated: new Date(), @@ -337,7 +318,7 @@ button.action_button { - + @@ -425,7 +406,7 @@ button.action_button {

Saison:

- +
diff --git a/src/routes/[recipeLang=recipeLang]/admin/untranslated/+page.svelte b/src/routes/[recipeLang=recipeLang]/admin/untranslated/+page.svelte index c425cf99..21cae6ce 100644 --- a/src/routes/[recipeLang=recipeLang]/admin/untranslated/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/admin/untranslated/+page.svelte @@ -4,7 +4,6 @@ import CompactCard from '$lib/components/recipes/CompactCard.svelte'; let { data } = $props<{ data: PageData }>(); - let current_month = new Date().getMonth() + 1; // Calculate statistics const stats = $derived.by(() => { @@ -161,7 +160,6 @@ h1 {
diff --git a/src/routes/[recipeLang=recipeLang]/category/[category]/+page.svelte b/src/routes/[recipeLang=recipeLang]/category/[category]/+page.svelte index f1a02a0f..8b518795 100644 --- a/src/routes/[recipeLang=recipeLang]/category/[category]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/category/[category]/+page.svelte @@ -9,7 +9,6 @@ type RecipeItem = BriefRecipeType & { isFavorite: boolean }; let { data } = $props<{ data: PageData }>(); - let current_month = new Date().getMonth() + 1; import { m, type RecipesLang } from '$lib/js/recipesI18n'; const lang = $derived(data.lang as RecipesLang); @@ -47,6 +46,6 @@
{#each rand_array(displayRecipes) as recipe (recipe._id)} - + {/each}
diff --git a/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte b/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte index 94a7569f..17315024 100644 --- a/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte @@ -12,7 +12,7 @@ import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte'; import CreateStepList from '$lib/components/recipes/CreateStepList.svelte'; import Toggle from '$lib/components/Toggle.svelte'; - import { season } from '$lib/js/season_store'; + import type { SeasonRange } from '$types/types'; import { portions } from '$lib/js/portions_store'; import '$lib/css/action_button.css'; import { toast } from '$lib/js/toast.svelte'; @@ -57,14 +57,7 @@ }); // svelte-ignore state_referenced_locally - season.update(() => data.recipe.season || []); - // svelte-ignore state_referenced_locally - let season_local = $state(data.recipe.season || []); - $effect(() => { - season.subscribe((s) => { - season_local = s; - }); - }); + let season_local = $state(data.recipe.seasonRanges || []); // svelte-ignore state_referenced_locally let card_data = $state({ @@ -111,21 +104,6 @@ let submitting = $state(false); let formElement: HTMLFormElement; - // Get season data from checkboxes - function get_season(): number[] { - const season: number[] = []; - const el = document.getElementById("labels"); - if (!el) return season; - - for (let i = 0; i < el.children.length; i++) { - const checkbox = el.children[i].children[0].children[0] as HTMLInputElement; - if (checkbox?.checked) { - season.push(i + 1); - } - } - return season; - } - // Get current German recipe data - use $derived to prevent infinite effect loops let currentRecipeData = $derived.by(() => { // Ensure we always have a valid images array with at least one item @@ -153,7 +131,7 @@ ...card_data, ...add_info, images: recipeImages, - season: season_local, + seasonRanges: season_local, short_name: short_name.trim(), datecreated, portions: portions_local, @@ -1147,7 +1125,7 @@ - + @@ -1179,7 +1157,7 @@ {#snippet titleExtras()}
- +
diff --git a/src/routes/[recipeLang=recipeLang]/favorites/+page.svelte b/src/routes/[recipeLang=recipeLang]/favorites/+page.svelte index bb2a3fb7..99387678 100644 --- a/src/routes/[recipeLang=recipeLang]/favorites/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/favorites/+page.svelte @@ -5,7 +5,6 @@ import Search from '$lib/components/recipes/Search.svelte'; let { data } = $props<{ data: PageData }>(); - let current_month = new Date().getMonth() + 1; import { m, type RecipesLang } from '$lib/js/recipesI18n'; const lang = $derived(data.lang as RecipesLang); @@ -100,7 +99,7 @@ {:else if filteredFavorites.length > 0}
{#each filteredFavorites as recipe (recipe._id)} - + {/each}
{:else if data.favorites.length > 0} diff --git a/src/routes/[recipeLang=recipeLang]/search/+page.svelte b/src/routes/[recipeLang=recipeLang]/search/+page.svelte index f6559358..0aff26f7 100644 --- a/src/routes/[recipeLang=recipeLang]/search/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/search/+page.svelte @@ -5,7 +5,6 @@ import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import { m, type RecipesLang } from '$lib/js/recipesI18n'; let { data } = $props<{ data: PageData }>(); - let current_month = new Date().getMonth() + 1; const lang = $derived(data.lang as RecipesLang); const t = $derived(m[lang]); @@ -111,7 +110,7 @@ {#if displayedRecipes.length > 0}
{#each displayedRecipes as recipe (recipe._id)} - + {/each}
{:else if (data.query || hasActiveSearch) && !data.error} diff --git a/src/routes/[recipeLang=recipeLang]/season/+page.server.ts b/src/routes/[recipeLang=recipeLang]/season/+page.server.ts index 99e14170..be937b2a 100644 --- a/src/routes/[recipeLang=recipeLang]/season/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/season/+page.server.ts @@ -4,8 +4,7 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori export const load: PageServerLoad = async ({ fetch, locals, params }) => { const apiBase = `/api/${params.recipeLang}`; - let current_month = new Date().getMonth() + 1 - const res_season = await fetch(`${apiBase}/items/in_season/` + current_month); + const res_season = await fetch(`${apiBase}/items/in_season/today`); const item_season = await res_season.json(); const session = locals.session ?? await locals.auth(); @@ -15,4 +14,4 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => { season: addFavoriteStatusToRecipes(item_season, userFavorites), session }; -}; \ No newline at end of file +}; diff --git a/src/routes/[recipeLang=recipeLang]/season/+page.svelte b/src/routes/[recipeLang=recipeLang]/season/+page.svelte index a2e04fbe..1915aa86 100644 --- a/src/routes/[recipeLang=recipeLang]/season/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/season/+page.svelte @@ -46,7 +46,7 @@ {#snippet recipesSlot()}
{#each rand_array(filteredRecipes) as recipe (recipe._id)} - + {/each}
{/snippet} diff --git a/src/routes/[recipeLang=recipeLang]/season/+page.ts b/src/routes/[recipeLang=recipeLang]/season/+page.ts index 7c4ba520..a0c719a7 100644 --- a/src/routes/[recipeLang=recipeLang]/season/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/season/+page.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment'; import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; -import { getBriefRecipesBySeason, isOfflineDataAvailable } from '$lib/offline/db'; +import { getBriefRecipesInSeasonOn, isOfflineDataAvailable } from '$lib/offline/db'; import { rand_array } from '$lib/js/randomize'; import type { PageLoad } from './$types'; @@ -20,8 +20,7 @@ export const load: PageLoad = async ({ data }) => { try { const hasOfflineData = await isOfflineDataAvailable(); if (hasOfflineData) { - const currentMonth = new Date().getMonth() + 1; - const recipes = await getBriefRecipesBySeason(currentMonth); + const recipes = await getBriefRecipesInSeasonOn(new Date()); return { ...data, diff --git a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts index 47a5ea00..087b9cef 100644 --- a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment'; import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; -import { getBriefRecipesBySeason, isOfflineDataAvailable } from '$lib/offline/db'; +import { getBriefRecipesOverlappingMonth, isOfflineDataAvailable } from '$lib/offline/db'; import { rand_array } from '$lib/js/randomize'; import type { PageLoad } from './$types'; @@ -21,7 +21,7 @@ export const load: PageLoad = async ({ data, params }) => { const hasOfflineData = await isOfflineDataAvailable(); if (hasOfflineData) { const month = parseInt(params.month); - const recipes = await getBriefRecipesBySeason(month); + const recipes = await getBriefRecipesOverlappingMonth(month); return { ...data, diff --git a/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.svelte b/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.svelte index d5ce09a4..f2fa9fb9 100644 --- a/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.svelte @@ -9,7 +9,6 @@ type RecipeItem = BriefRecipeType & { isFavorite: boolean }; let { data } = $props<{ data: PageData }>(); - let current_month = new Date().getMonth() + 1; import { m, type RecipesLang } from '$lib/js/recipesI18n'; const lang = $derived(data.lang as RecipesLang); @@ -47,6 +46,6 @@
{#each rand_array(displayRecipes) as recipe (recipe._id)} - + {/each}
diff --git a/src/routes/api/[recipeLang=recipeLang]/favorites/recipes/+server.ts b/src/routes/api/[recipeLang=recipeLang]/favorites/recipes/+server.ts index 5e6f271d..f4ecf7e0 100644 --- a/src/routes/api/[recipeLang=recipeLang]/favorites/recipes/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/favorites/recipes/+server.ts @@ -51,7 +51,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { description: t?.description, note: t?.note, tags: t?.tags || [], - season: recipe.season, + seasonRanges: recipe.seasonRanges, baking: recipe.baking, preparation: recipe.preparation, fermentation: recipe.fermentation, diff --git a/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts b/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts index 26279fc4..33ae3fdd 100644 --- a/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts @@ -142,7 +142,7 @@ export const GET: RequestHandler = async ({ params, setHeaders }) => { icon: rawRecipe.icon || '', dateCreated: rawRecipe.dateCreated, dateModified: rawRecipe.dateModified, - season: rawRecipe.season || [], + seasonRanges: rawRecipe.seasonRanges || [], baking: t.baking || rawRecipe.baking || { temperature: '', length: '', mode: '' }, preparation: t.preparation || rawRecipe.preparation || '', fermentation: t.fermentation || rawRecipe.fermentation || { bulk: '', final: '' }, diff --git a/src/routes/api/[recipeLang=recipeLang]/items/in_season/[month]/+server.ts b/src/routes/api/[recipeLang=recipeLang]/items/in_season/[month]/+server.ts index 7331a95e..acc908c8 100644 --- a/src/routes/api/[recipeLang=recipeLang]/items/in_season/[month]/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/items/in_season/[month]/+server.ts @@ -3,18 +3,24 @@ import { Recipe } from '$models/Recipe'; import { dbConnect } from '$utils/db'; import { rand_array } from '$lib/js/randomize'; import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers'; +import { recipeOverlapsMonth } from '$lib/js/seasonRange'; export const GET: RequestHandler = async ({ params, setHeaders }) => { const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!); await dbConnect(); + const month = parseInt(params.month!, 10); + + // Range membership for movable anchors can't be expressed in Mongo, so we + // load every approved (non-plate-icon) recipe with seasonRanges and filter + // in-app. Dataset is small (~hundreds) — sub-ms. const dbRecipes = await Recipe.find( - { season: parseInt(params.month!, 10), icon: { $ne: "🍽️" }, ...approvalFilter }, + { icon: { $ne: '🍽️' }, seasonRanges: { $exists: true, $ne: [] }, ...approvalFilter }, projection ).lean(); - const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!)); + const briefs = dbRecipes.map(r => toBrief(r, params.recipeLang!)); + const recipes = briefs.filter(r => recipeOverlapsMonth(r as any, month)); - // rand_array is seeded per UTC day, same for every caller → cacheable. setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' }); return json(rand_array(recipes)); }; diff --git a/src/routes/api/[recipeLang=recipeLang]/items/in_season/today/+server.ts b/src/routes/api/[recipeLang=recipeLang]/items/in_season/today/+server.ts new file mode 100644 index 00000000..e471fd4c --- /dev/null +++ b/src/routes/api/[recipeLang=recipeLang]/items/in_season/today/+server.ts @@ -0,0 +1,27 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { Recipe } from '$models/Recipe'; +import { dbConnect } from '$utils/db'; +import { rand_array } from '$lib/js/randomize'; +import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers'; +import { isRecipeInSeason } from '$lib/js/seasonRange'; + +export const GET: RequestHandler = async ({ params, setHeaders }) => { + const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!); + + await dbConnect(); + + // Same shape as the [month] endpoint: load all candidates, filter in-app + // against the resolved liturgical anchors for the current date. + const dbRecipes = await Recipe.find( + { icon: { $ne: '🍽️' }, seasonRanges: { $exists: true, $ne: [] }, ...approvalFilter }, + projection + ).lean(); + const briefs = dbRecipes.map(r => toBrief(r, params.recipeLang!)); + const today = new Date(); + const recipes = briefs.filter(r => isRecipeInSeason(r as any, today)); + + // 1h browser, 1h edge, 24h SWR — anchors are stable within a day, daily + // revalidation is fine for season transitions. + setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400' }); + return json(rand_array(recipes)); +}; diff --git a/src/routes/api/[recipeLang=recipeLang]/offline-db/+server.ts b/src/routes/api/[recipeLang=recipeLang]/offline-db/+server.ts index 71718546..52eaac68 100644 --- a/src/routes/api/[recipeLang=recipeLang]/offline-db/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/offline-db/+server.ts @@ -10,7 +10,7 @@ export const GET: RequestHandler = async () => { const [briefRecipes, fullRecipes] = await Promise.all([ Recipe.find( {}, - 'name short_name tags category icon description season dateModified images translations' + 'name short_name tags category icon description seasonRanges dateModified images translations' ).lean() as unknown as Promise, Recipe.find({}) .populate({ diff --git a/src/routes/api/[recipeLang=recipeLang]/search/+server.ts b/src/routes/api/[recipeLang=recipeLang]/search/+server.ts index af5d3528..affc66d1 100644 --- a/src/routes/api/[recipeLang=recipeLang]/search/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/search/+server.ts @@ -3,6 +3,7 @@ import type { BriefRecipeType } from '$types/types'; import { Recipe } from '$models/Recipe'; import { dbConnect } from '$utils/db'; import { isEnglish, briefQueryConfig, toBrief } from '$lib/server/recipeHelpers'; +import { recipeOverlapsMonth } from '$lib/js/seasonRange'; export const GET: RequestHandler = async ({ url, params, locals }) => { await dbConnect(); @@ -41,13 +42,18 @@ export const GET: RequestHandler = async ({ url, params, locals }) => { if (icon) { dbQuery.icon = icon; } - if (seasons.length > 0) { - dbQuery.season = { $in: seasons }; - } const dbRecipes = await Recipe.find(dbQuery, projection).lean(); let recipes: BriefRecipeType[] = dbRecipes.map(r => toBrief(r, params.recipeLang!)); + // Season filter: ranges with movable anchors can't be expressed in Mongo, + // so filter in-app. Range-based recipes resolve to concrete intervals via + // the shared evaluator; the recipe matches if any selected month overlaps + // any of its ranges in the current civil year (with year-wrap handling). + if (seasons.length > 0) { + recipes = recipes.filter(r => seasons.some(m => recipeOverlapsMonth(r as any, m))); + } + // Handle favorites filter const session = locals.session ?? await locals.auth(); if (favoritesOnly && session?.user) { diff --git a/src/routes/api/[recipeLang=recipeLang]/translate/untranslated/+server.ts b/src/routes/api/[recipeLang=recipeLang]/translate/untranslated/+server.ts index 2ad40f23..38e8146e 100644 --- a/src/routes/api/[recipeLang=recipeLang]/translate/untranslated/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/translate/untranslated/+server.ts @@ -22,7 +22,7 @@ export const GET: RequestHandler = async ({ locals }) => { { 'translations.en': { $exists: false } }, { 'translations.en.translationStatus': { $ne: 'approved' } } ] - }, 'name short_name category icon description tags season dateModified translations.en.translationStatus') + }, 'name short_name category icon description tags seasonRanges dateModified translations.en.translationStatus') .sort({ dateModified: 1 }) // Oldest first - highest priority .lean(); @@ -35,7 +35,7 @@ export const GET: RequestHandler = async ({ locals }) => { icon: recipe.icon, description: recipe.description, tags: recipe.tags || [], - season: recipe.season || [], + seasonRanges: recipe.seasonRanges || [], dateModified: recipe.dateModified, translationStatus: recipe.translations?.en?.translationStatus || undefined })); diff --git a/src/types/types.ts b/src/types/types.ts index 7c8e5d8a..9a76d1e5 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -37,6 +37,24 @@ export type NutritionMapping = { recipeRefMultiplier?: number; }; +// Movable liturgical anchors usable as range endpoints. Fixed feasts +// (Christmas, Epiphany, etc.) are expressed as `{kind:'fixed', m, d}`. +export type SeasonAnchorKey = + | 'easter' + | 'ash-wednesday' + | 'palm-sunday' + | 'pentecost' + | 'advent-i'; + +export type SeasonEndpoint = + | { kind: 'fixed'; m: number; d: number } + | { kind: 'liturgical'; anchor: SeasonAnchorKey; offsetDays: number }; + +export type SeasonRange = { + start: SeasonEndpoint; + end: SeasonEndpoint; +}; + // Translation status enum export type TranslationStatus = 'pending' | 'approved' | 'needs_update'; @@ -175,7 +193,7 @@ export type RecipeModelType = { }]; description: string; tags: [string]; - season: [number]; + seasonRanges?: SeasonRange[]; baking?: { temperature: string; length: string; @@ -225,5 +243,5 @@ export type BriefRecipeType = { }] description: string; tags: [string]; - season: [number]; + seasonRanges?: SeasonRange[]; } diff --git a/src/utils/recipeFormHelpers.ts b/src/utils/recipeFormHelpers.ts index 16f44cc3..d8ab9010 100644 --- a/src/utils/recipeFormHelpers.ts +++ b/src/utils/recipeFormHelpers.ts @@ -5,7 +5,7 @@ * for SvelteKit form actions with progressive enhancement support. */ -import type { IngredientItem, InstructionItem, TranslatedRecipeType, TranslationMetadata } from '$types/types'; +import type { IngredientItem, InstructionItem, SeasonRange, TranslatedRecipeType, TranslationMetadata } from '$types/types'; export interface RecipeFormData { // Basic fields @@ -16,7 +16,7 @@ export interface RecipeFormData { icon: string; tags: string[]; portions: string; - season: number[]; + seasonRanges: SeasonRange[]; // Optional text fields preamble?: string; @@ -97,14 +97,14 @@ export function extractRecipeFromFormData(formData: FormData): RecipeFormData { } } - // Season (JSON array of month numbers) - let season: number[] = []; - const seasonData = formData.get('season')?.toString(); - if (seasonData) { + // Season ranges (JSON array of {start, end} endpoints) + let seasonRanges: SeasonRange[] = []; + const seasonRangesData = formData.get('seasonRanges')?.toString(); + if (seasonRangesData) { try { - season = JSON.parse(seasonData); + seasonRanges = JSON.parse(seasonRangesData); } catch { - // Ignore invalid season data + // Ignore invalid range data } } @@ -207,7 +207,7 @@ export function extractRecipeFromFormData(formData: FormData): RecipeFormData { icon, tags, portions, - season, + seasonRanges, preamble, addendum, note, @@ -294,8 +294,8 @@ export function detectChangedFields(original: Record, current: changedFields.push('tags'); } - if (JSON.stringify(original.season) !== JSON.stringify(current.season)) { - changedFields.push('season'); + if (JSON.stringify(original.seasonRanges) !== JSON.stringify(current.seasonRanges)) { + changedFields.push('seasonRanges'); } if (JSON.stringify(original.ingredients) !== JSON.stringify(current.ingredients)) { @@ -319,30 +319,16 @@ export function detectChangedFields(original: Record, current: } /** - * Parses season data from form input - * Handles both checkbox-based input and JSON arrays + * Parses season-range data from form input (JSON-encoded SeasonRange[]). */ -export function parseSeasonData(formData: FormData): number[] { - const season: number[] = []; - - // Try JSON format first - const seasonJson = formData.get('season')?.toString(); - if (seasonJson) { - try { - return JSON.parse(seasonJson); - } catch { - // Fall through to checkbox parsing - } +export function parseSeasonRangesData(formData: FormData): SeasonRange[] { + const seasonJson = formData.get('seasonRanges')?.toString(); + if (!seasonJson) return []; + try { + return JSON.parse(seasonJson); + } catch { + return []; } - - // Parse individual checkbox inputs (season_1, season_2, etc.) - for (let month = 1; month <= 12; month++) { - if (formData.get(`season_${month}`) === 'true') { - season.push(month); - } - } - - return season; } /** @@ -358,7 +344,7 @@ export function serializeRecipeForDatabase(data: RecipeFormData): Record