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}
-
-
-
-
do_on_key(event, 'Enter', false, () => {toggle_checkbox_on_key(event)}) } > {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}
+
+
+ setEndpointKind(i, which, 'fixed')}>Datum
+ setEndpointKind(i, which, 'liturgical')}>Liturgisch
+
+ {#if range[which].kind === 'fixed'}
+
updateFixed(i, which, 'm', parseInt((e.currentTarget as HTMLSelectElement).value))}>
+ {#each MONTHS_DE as name, mi (mi)}
+ {name}
+ {/each}
+
+
updateFixed(i, which, 'd', parseInt((e.currentTarget as HTMLInputElement).value))} />
+ {:else}
+
updateLiturgical(i, which, 'anchor', (e.currentTarget as HTMLSelectElement).value as SeasonAnchorKey)}>
+ {#each ANCHOR_KEYS as a (a)}
+ {ANCHOR_LABELS[a]}
+ {/each}
+
+
updateLiturgical(i, which, 'offsetDays', parseInt((e.currentTarget as HTMLInputElement).value || '0'))} title="Tage Versatz" />
+
Tage
+ {/if}
+
+ {/each}
+
removeRange(i)} aria-label="Bereich entfernen">×
+
{currentYear}: {formatRangePreview(range, currentYear, 'de')}
+
+ {/each}
+
+
+ Hinzufügen:
+
+ {#each MONTHS_DE as name, mi (mi)}
+ {name}
+ {/each}
+
+ addMonthRange(selectedMonth)}>Monat
+
+ Liturgisch…
+ Fastenzeit
+ Karwoche
+ Osteroktav
+ Osterzeit
+ Advent
+ Weihnachtsoktav
+
+ { if (selectedPreset) { addPreset(selectedPreset); selectedPreset = ''; } }}>Vorlage
-{/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()}
Saison
-
+
Einleitung
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