feat(rezepte)!: liturgical-aware seasonality via date ranges
CI / update (push) Successful in 3m31s

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.
This commit is contained in:
2026-05-02 17:53:27 +02:00
parent 68b078c146
commit 096d6e2868
38 changed files with 692 additions and 295 deletions
+20 -34
View File
@@ -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<string, unknown>, 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<string, unknown>, 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<string,
icon: data.icon || '',
tags: data.tags || [],
portions: data.portions || '',
season: data.season || [],
seasonRanges: data.seasonRanges || [],
ingredients: data.ingredients || [],
instructions: data.instructions || [],
isBaseRecipe: data.isBaseRecipe || false,