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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.58.1", "version": "1.59.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+107
View File
@@ -0,0 +1,107 @@
/**
* One-time migration: convert legacy `season: number[]` (months 112) 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 (112) 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);
});
+3 -4
View File
@@ -3,10 +3,10 @@ import "$lib/css/shake.css";
import "$lib/css/icon.css"; import "$lib/css/icon.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
import { isRecipeInSeason } from '$lib/js/seasonRange';
let { let {
recipe, recipe,
current_month: currentMonthProp = 0,
icon_override = false, icon_override = false,
search = true, search = true,
do_margin_right = false, do_margin_right = false,
@@ -17,8 +17,7 @@ let {
translationStatus = undefined translationStatus = undefined
} = $props(); } = $props();
// Make current_month reactive based on icon_override const isInSeason = $derived(icon_override || isRecipeInSeason(recipe));
let current_month = $derived(icon_override ? recipe.season[0] : currentMonthProp);
let isloaded = $state(false); let isloaded = $state(false);
@@ -259,7 +258,7 @@ function preloadHeroImage() {
{/if} {/if}
</div> </div>
{/if} {/if}
{#if icon_override || recipe.season.includes(current_month)} {#if isInSeason}
<a href="{routePrefix}/icon/{recipe.icon}" class="icon g-icon-badge">{recipe.icon}</a> <a href="{routePrefix}/icon/{recipe.icon}" class="icon g-icon-badge">{recipe.icon}</a>
{/if} {/if}
<div class="card_title"> <div class="card_title">
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import "$lib/css/shake.css"; import "$lib/css/shake.css";
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
import { isRecipeInSeason } from '$lib/js/seasonRange';
let { let {
recipe, recipe,
current_month = 0,
icon_override = false, icon_override = false,
isFavorite = false, isFavorite = false,
showFavoriteIndicator = false, showFavoriteIndicator = false,
@@ -23,7 +23,7 @@
const img_color = $derived(recipe.images?.[0]?.color || ''); const img_color = $derived(recipe.images?.[0]?.color || '');
const isInSeason = $derived(icon_override || recipe.season?.includes(current_month)); const isInSeason = $derived(icon_override || isRecipeInSeason(recipe));
function activateTransitions(event: MouseEvent) { function activateTransitions(event: MouseEvent) {
const img = (event.currentTarget as HTMLElement)?.querySelector('.img-wrap img') as HTMLElement | null; const img = (event.currentTarget as HTMLElement)?.querySelector('.img-wrap img') as HTMLElement | null;
@@ -8,12 +8,12 @@
let { let {
card_data = $bindable({}), card_data = $bindable({}),
season = $bindable([]), seasonRanges = $bindable([]),
ingredients = $bindable([]), ingredients = $bindable([]),
instructions = $bindable([]) instructions = $bindable([])
}: { }: {
card_data?: any, card_data?: any,
season?: any[], seasonRanges?: any[],
ingredients?: any[], ingredients?: any[],
instructions?: any[] instructions?: any[]
} = $props(); } = $props();
@@ -31,7 +31,7 @@
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
recipe: { recipe: {
season: season, seasonRanges: seasonRanges,
...card_data, ...card_data,
images: [{ images: [{
mediapath: short_name + '.webp', mediapath: short_name + '.webp',
@@ -71,8 +71,8 @@ input.temp{
<input class=temp bind:value={short_name} placeholder="Kurzname"/> <input class=temp bind:value={short_name} placeholder="Kurzname"/>
<SeasonSelect></SeasonSelect> <SeasonSelect bind:ranges={seasonRanges} />
<button onclick={() => console.log(season)}>PRINTOUT season</button> <button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
<h2>Zutaten</h2> <h2>Zutaten</h2>
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList> <CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
+4 -4
View File
@@ -4,6 +4,7 @@
import FilterPanel from './FilterPanel.svelte'; import FilterPanel from './FilterPanel.svelte';
import { getCategories } from '$lib/js/categories'; import { getCategories } from '$lib/js/categories';
import { m } from '$lib/js/recipesI18n'; import { m } from '$lib/js/recipesI18n';
import { recipeOverlapsMonth } from '$lib/js/seasonRange';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */ /** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
// Filter props for different contexts // Filter props for different contexts
@@ -85,10 +86,9 @@
return false; return false;
} }
// Season filter: recipe in any selected season // Season filter: recipe overlaps any selected month
if (selectedSeasons.length > 0) { if (selectedSeasons.length > 0) {
const recipeSeasons = recipe.season || []; if (!selectedSeasons.some(m => recipeOverlapsMonth(recipe, m))) {
if (!selectedSeasons.some(s => recipeSeasons.includes(s))) {
return false; return false;
} }
} }
@@ -117,7 +117,7 @@
const matchesCategory = categoryArray.length > 0 ? categoryArray.includes(recipe.category) : false; const matchesCategory = categoryArray.length > 0 ? categoryArray.includes(recipe.category) : false;
const matchesTags = selectedTags.length > 0 ? selectedTags.some(tag => (recipe.tags || []).includes(tag)) : false; const matchesTags = selectedTags.length > 0 ? selectedTags.some(tag => (recipe.tags || []).includes(tag)) : false;
const matchesIcon = iconArray.length > 0 ? iconArray.includes(recipe.icon) : false; const matchesIcon = iconArray.length > 0 ? iconArray.includes(recipe.icon) : false;
const matchesSeasons = selectedSeasons.length > 0 ? selectedSeasons.some(s => (recipe.season || []).includes(s)) : false; const matchesSeasons = selectedSeasons.length > 0 ? selectedSeasons.some(m => recipeOverlapsMonth(recipe, m)) : false;
const matchesFavorites = selectedFavoritesOnly ? recipe.isFavorite : false; const matchesFavorites = selectedFavoritesOnly ? recipe.isFavorite : false;
return matchesCategory || matchesTags || matchesIcon || matchesSeasons || matchesFavorites; return matchesCategory || matchesTags || matchesIcon || matchesSeasons || matchesFavorites;
+232 -81
View File
@@ -1,102 +1,253 @@
<script lang=ts> <script lang="ts">
import { season } from '$lib/js/season_store.js' import { formatRangePreview } from '$lib/js/seasonRange';
import {onMount} from "svelte"; import type { SeasonAnchorKey, SeasonEndpoint, SeasonRange } from '$types/types';
import {do_on_key} from "./do_on_key";
let months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
let { ranges = $bindable<SeasonRange[]>([]) }: { ranges?: SeasonRange[] } = $props();
const MONTHS_DE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
let season_local: number[] = []; const ANCHOR_LABELS: Record<SeasonAnchorKey, string> = {
easter: 'Ostersonntag',
'ash-wednesday': 'Aschermittwoch',
'palm-sunday': 'Palmsonntag',
pentecost: 'Pfingstsonntag',
'advent-i': '1. Adventssonntag'
};
const ANCHOR_KEYS: SeasonAnchorKey[] = ['easter', 'ash-wednesday', 'palm-sunday', 'pentecost', 'advent-i'];
season.subscribe((s: number[]) => { function commit(next: SeasonRange[]) {
season_local = s; ranges = next;
}); }
export function set_season(){ function fixed(m: number, d: number): SeasonEndpoint {
let temp: number[] = []; return { kind: 'fixed', m, d };
const el = document.getElementById("labels"); }
if (!el) return; function liturgical(anchor: SeasonAnchorKey, offsetDays = 0): SeasonEndpoint {
for(var i = 0; i < el.children.length; i++){ return { kind: 'liturgical', anchor, offsetDays };
if((el.children[i].children[0].children[0] as HTMLInputElement).checked){ }
temp.push(i+1)
function withEndpoint(i: number, which: 'start' | 'end', ep: SeasonEndpoint): SeasonRange[] {
return ranges.map((r, idx) => (idx === i ? { ...r, [which]: ep } : r));
}
function addMonthRange(m: number) {
const last = new Date(2001, m, 0).getDate();
commit([...ranges, { start: fixed(m, 1), end: fixed(m, last) }]);
}
function addPreset(label: string) {
const presets: Record<string, SeasonRange> = {
lent: { start: liturgical('ash-wednesday', 0), end: liturgical('easter', -1) },
'holy-week': { start: liturgical('palm-sunday', 0), end: liturgical('easter', -1) },
'easter-octave': { start: liturgical('easter', 0), end: liturgical('easter', 7) },
eastertide: { start: liturgical('easter', 0), end: liturgical('pentecost', 0) },
advent: { start: liturgical('advent-i', 0), end: fixed(12, 24) },
'christmas-octave': { start: fixed(12, 25), end: fixed(1, 1) }
};
const preset = presets[label];
if (preset) {
commit([...ranges, { start: { ...preset.start }, end: { ...preset.end } }]);
} }
} }
season.update(() => temp)
}
function write_season(season: number[]){ function removeRange(i: number) {
const el = document.getElementById("labels"); commit(ranges.filter((_, idx) => idx !== i));
if (!el) return;
for(var i = 0; i < season.length; i++){
(el.children[season[i]-1].children[0].children[0] as HTMLInputElement).checked = true;
} }
}
function toggle_checkbox_on_key(event: Event){ function setEndpointKind(i: number, which: 'start' | 'end', kind: 'fixed' | 'liturgical') {
const target = event.target as HTMLElement; const ep = ranges[i][which];
const checkbox = target.querySelector('input[type="checkbox"]') as HTMLInputElement; if (kind === ep.kind) return;
if (checkbox) checkbox.checked = !checkbox.checked; const next: SeasonEndpoint = kind === 'fixed' ? fixed(1, 1) : liturgical('easter', 0);
} commit(withEndpoint(i, which, next));
onMount(() => { }
write_season(season_local)
});
function updateFixed(i: number, which: 'start' | 'end', field: 'm' | 'd', value: number) {
const ep = ranges[i][which];
if (ep.kind !== 'fixed') return;
if (Number.isNaN(value)) return;
commit(withEndpoint(i, which, { ...ep, [field]: value }));
}
function updateLiturgical(i: number, which: 'start' | 'end', field: 'anchor' | 'offsetDays', value: SeasonAnchorKey | number) {
const ep = ranges[i][which];
if (ep.kind !== 'liturgical') return;
if (field === 'offsetDays' && Number.isNaN(value as number)) return;
commit(withEndpoint(i, which, { ...ep, [field]: value }));
}
const currentYear = new Date().getFullYear();
let selectedMonth = $state<number>(1);
let selectedPreset = $state<string>('');
</script> </script>
<style> <style>
label{ .season-editor {
background-color: var(--nord0); display: flex;
color: white; flex-direction: column;
padding: 0.25em 1em; gap: 0.75rem;
margin-inline: 0.1em; max-width: 720px;
line-height: 2em; margin: 0 auto;
border-radius: var(--radius-pill);
cursor: pointer;
position: relative;
transition: var(--transition-fast);
user-select: none;
} }
.range-row {
.checkbox_container{
transition: var(--transition-fast);
}
.checkbox_container:hover,
.checkbox_container:focus-within
{
transform: scale(1.1,1.1);
}
label:hover,
label:focus-visible
{
background-color: var(--lightblue);
}
label:has(input:checked){
background-color: var(--blue);
}
input[type=checkbox],
input[type=checkbox]::before,
input[type=checkbox]::after
{
all: unset;
user-select: none;
}
#labels{
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; gap: 0.5rem;
justify-content: center; align-items: center;
margin-bottom: 1em; padding: 0.6rem 0.75rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.endpoint {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
}
.kind-toggle {
display: inline-flex;
border-radius: var(--radius-pill);
overflow: hidden;
background: var(--color-bg-elevated);
}
.kind-toggle button {
all: unset;
padding: 0.2rem 0.7rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text-secondary);
}
.kind-toggle button.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.endpoint select,
.endpoint input[type="number"] {
background: var(--color-bg-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.4rem;
font-size: 0.9rem;
}
.endpoint input[type="number"] {
width: 4.5em;
}
.dash {
color: var(--color-text-tertiary);
padding: 0 0.25rem;
}
.preview {
font-size: 0.85rem;
color: var(--color-text-tertiary);
flex-basis: 100%;
}
.remove-btn {
all: unset;
margin-left: auto;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 1.1rem;
padding: 0 0.4rem;
border-radius: var(--radius-sm);
}
.remove-btn:hover {
background: var(--red);
color: white;
}
.add-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 0.75rem;
background: var(--color-surface);
border-radius: var(--radius-md);
}
.add-bar select,
.add-bar button {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.3rem 0.6rem;
font-size: 0.9rem;
cursor: pointer;
}
.add-bar button {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.add-bar button:hover {
background: var(--color-primary-hover);
}
.empty {
color: var(--color-text-tertiary);
font-style: italic;
font-size: 0.9rem;
text-align: center;
padding: 0.5rem;
} }
</style> </style>
<div id=labels> <div class="season-editor">
{#each months as month} {#if ranges.length === 0}
<div class=checkbox_container> <div class="empty">Keine Saison-Bereiche immer verfügbar.</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> {/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<label tabindex="0" onkeydown={(event) => do_on_key(event, 'Enter', false, () => {toggle_checkbox_on_key(event)}) } ><input tabindex=-1 type="checkbox" name="checkbox" value="value" onclick={set_season}>{month}</label> {#each ranges as range, i (i)}
<div class="range-row">
{#each ['start', 'end'] as const as which, wi (which)}
{#if wi > 0}
<span class="dash"></span>
{/if}
<div class="endpoint">
<div class="kind-toggle">
<button type="button" class:active={range[which].kind === 'fixed'} onclick={() => setEndpointKind(i, which, 'fixed')}>Datum</button>
<button type="button" class:active={range[which].kind === 'liturgical'} onclick={() => setEndpointKind(i, which, 'liturgical')}>Liturgisch</button>
</div>
{#if range[which].kind === 'fixed'}
<select value={range[which].m} onchange={(e) => updateFixed(i, which, 'm', parseInt((e.currentTarget as HTMLSelectElement).value))}>
{#each MONTHS_DE as name, mi (mi)}
<option value={mi + 1}>{name}</option>
{/each}
</select>
<input type="number" min="1" max="31" value={range[which].d} oninput={(e) => updateFixed(i, which, 'd', parseInt((e.currentTarget as HTMLInputElement).value))} />
{:else}
<select value={range[which].anchor} onchange={(e) => updateLiturgical(i, which, 'anchor', (e.currentTarget as HTMLSelectElement).value as SeasonAnchorKey)}>
{#each ANCHOR_KEYS as a (a)}
<option value={a}>{ANCHOR_LABELS[a]}</option>
{/each}
</select>
<input type="number" value={range[which].offsetDays} oninput={(e) => updateLiturgical(i, which, 'offsetDays', parseInt((e.currentTarget as HTMLInputElement).value || '0'))} title="Tage Versatz" />
<span class="dash">Tage</span>
{/if}
</div>
{/each}
<button type="button" class="remove-btn" onclick={() => removeRange(i)} aria-label="Bereich entfernen">×</button>
<div class="preview">{currentYear}: {formatRangePreview(range, currentYear, 'de')}</div>
</div>
{/each}
<div class="add-bar">
<span>Hinzufügen:</span>
<select bind:value={selectedMonth}>
{#each MONTHS_DE as name, mi (mi)}
<option value={mi + 1}>{name}</option>
{/each}
</select>
<button type="button" onclick={() => addMonthRange(selectedMonth)}>Monat</button>
<select bind:value={selectedPreset}>
<option value="">Liturgisch…</option>
<option value="lent">Fastenzeit</option>
<option value="holy-week">Karwoche</option>
<option value="easter-octave">Osteroktav</option>
<option value="eastertide">Osterzeit</option>
<option value="advent">Advent</option>
<option value="christmas-octave">Weihnachtsoktav</option>
</select>
<button type="button" disabled={!selectedPreset} onclick={() => { if (selectedPreset) { addPreset(selectedPreset); selectedPreset = ''; } }}>Vorlage</button>
</div> </div>
{/each}
</div> </div>
+50
View File
@@ -64,3 +64,53 @@ export function getLiturgicalSeason(date: Date = new Date()): LiturgicalSeason {
if (isLent(date) && date.getDay() !== 0) return 'lent'; if (isLent(date) && date.getDay() !== 0) return 'lent';
return null; 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<number, Record<SeasonAnchorKey, Date>>();
/**
* 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<SeasonAnchorKey, Date> {
const cached = anchorCache.get(year);
if (cached) return cached;
const anchors: Record<SeasonAnchorKey, Date> = {
easter: computeEaster(year),
'ash-wednesday': computeAshWednesday(year),
'palm-sunday': computePalmSunday(year),
pentecost: computePentecost(year),
'advent-i': computeAdventI(year)
};
anchorCache.set(year, anchors);
return anchors;
}
+118
View File
@@ -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<SeasonAnchorKey, Date> = 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` (112) 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}`;
}
-3
View File
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const season = writable(/** @type {number[]} */ ([]));
+13 -13
View File
@@ -1,7 +1,8 @@
import type { BriefRecipeType, RecipeModelType } from '$types/types'; import type { BriefRecipeType, RecipeModelType } from '$types/types';
import { isRecipeInSeason, recipeOverlapsMonth } from '$lib/js/seasonRange';
const DB_NAME = 'bocken-recipes'; 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_BRIEF = 'recipes_brief';
const STORE_FULL = 'recipes_full'; const STORE_FULL = 'recipes_full';
@@ -51,10 +52,12 @@ function openDB(): Promise<IDBDatabase> {
db.deleteObjectStore(STORE_META); 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' }); const briefStore = db.createObjectStore(STORE_BRIEF, { keyPath: 'short_name' });
briefStore.createIndex('category', 'category', { unique: false }); briefStore.createIndex('category', 'category', { unique: false });
briefStore.createIndex('season', 'season', { unique: false, multiEntry: true });
// Full recipes store - keyed by short_name // Full recipes store - keyed by short_name
db.createObjectStore(STORE_FULL, { keyPath: 'short_name' }); db.createObjectStore(STORE_FULL, { keyPath: 'short_name' });
@@ -104,17 +107,14 @@ export async function getBriefRecipesByCategory(category: string): Promise<Brief
}); });
} }
export async function getBriefRecipesBySeason(month: number): Promise<BriefRecipeType[]> { export async function getBriefRecipesInSeasonOn(date: Date = new Date()): Promise<BriefRecipeType[]> {
const db = await openDB(); const all = await getAllBriefRecipes();
return new Promise((resolve, reject) => { return all.filter(r => isRecipeInSeason(r as any, date));
const tx = db.transaction(STORE_BRIEF, 'readonly'); }
const store = tx.objectStore(STORE_BRIEF);
const index = store.index('season');
const request = index.getAll(month);
request.onerror = () => reject(request.error); export async function getBriefRecipesOverlappingMonth(month: number, year: number = new Date().getFullYear()): Promise<BriefRecipeType[]> {
request.onsuccess = () => resolve(request.result); const all = await getAllBriefRecipes();
}); return all.filter(r => recipeOverlapsMonth(r as any, month, year));
} }
export async function getBriefRecipesByTag(tag: string): Promise<BriefRecipeType[]> { export async function getBriefRecipesByTag(tag: string): Promise<BriefRecipeType[]> {
+3 -1
View File
@@ -241,11 +241,13 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise<void> {
dataUrls.push(`/recipes/icon/${encodeURIComponent(icon)}/__data.json`); 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++) { for (let month = 1; month <= 12; month++) {
dataUrls.push(`/rezepte/season/${month}/__data.json`); dataUrls.push(`/rezepte/season/${month}/__data.json`);
dataUrls.push(`/recipes/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 // Send message to service worker to cache these URLs
if (dataUrls.length > 0) { if (dataUrls.length > 0) {
+3 -3
View File
@@ -20,8 +20,8 @@ export function briefQueryConfig(recipeLang: string) {
prefix: en ? 'translations.en.' : '', prefix: en ? 'translations.en.' : '',
/** Projection for brief list queries */ /** Projection for brief list queries */
projection: en projection: en
? '_id translations.en short_name images season icon' ? '_id translations.en short_name images seasonRanges icon'
: 'name short_name images tags category icon description season', : 'name short_name images tags category icon description seasonRanges',
}; };
} }
@@ -44,7 +44,7 @@ export function toBrief(recipe: RecipeModelType, recipeLang: string): BriefRecip
category: en?.category ?? '', category: en?.category ?? '',
icon: recipe.icon, icon: recipe.icon,
description: en?.description, description: en?.description,
season: recipe.season || [], seasonRanges: recipe.seasonRanges || [],
germanShortName: recipe.short_name, germanShortName: recipe.short_name,
} as unknown as BriefRecipeType; } as unknown as BriefRecipeType;
} }
+20 -2
View File
@@ -17,7 +17,25 @@ const RecipeSchema = new mongoose.Schema(
description: {type: String, required: true}, description: {type: String, required: true},
note: {type: String}, note: {type: String},
tags : [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: ""}, baking: { temperature: {type:String, default: ""},
length: {type:String, default: ""}, length: {type:String, default: ""},
mode: {type:String, default: ""}, mode: {type:String, default: ""},
@@ -198,7 +216,7 @@ const RecipeSchema = new mongoose.Schema(
// Indexes for efficient querying // Indexes for efficient querying
RecipeSchema.index({ short_name: 1 }); 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.short_name": 1 });
RecipeSchema.index({ "translations.en.translationStatus": 1 }); RecipeSchema.index({ "translations.en.translationStatus": 1 });
@@ -1,9 +1,9 @@
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites"; import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
import { isRecipeInSeason } from "$lib/js/seasonRange";
export const load: PageServerLoad = async ({ fetch, locals, params }) => { export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`; const apiBase = `/api/${params.recipeLang}`;
const currentMonth = new Date().getMonth() + 1;
const session = locals.session ?? await locals.auth(); const session = locals.session ?? await locals.auth();
const [res_all_brief, userFavorites] = await Promise.all([ 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); const all_brief = addFavoriteStatusToRecipes(res_all_brief, userFavorites);
// Derive seasonal subset from all_brief instead of a separate DB query const today = new Date();
const season = all_brief.filter((r: any) => r.season?.includes(currentMonth) && r.icon !== '🍽️'); const season = all_brief.filter((r: any) => r.icon !== '🍽️' && isRecipeInSeason(r, today));
return { return {
season, season,
@@ -11,7 +11,6 @@
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]); const t = $derived(m[lang]);
let current_month = new Date().getMonth() + 1;
// Search state // Search state
let matchedRecipeIds = $state(new Set()); let matchedRecipeIds = $state(new Set());
@@ -448,7 +447,6 @@
{#each visibleRecipes as recipe, i (recipe._id)} {#each visibleRecipes as recipe, i (recipe._id)}
<CompactCard <CompactCard
{recipe} {recipe}
{current_month}
isFavorite={recipe.isFavorite} isFavorite={recipe.isFavorite}
showFavoriteIndicator={!!data.session?.user} showFavoriteIndicator={!!data.session?.user}
loading_strat={i < 12 ? "eager" : "lazy"} loading_strat={i < 12 ? "eager" : "lazy"}
@@ -481,7 +479,6 @@
{#each visibleRecipes as recipe, i (recipe._id)} {#each visibleRecipes as recipe, i (recipe._id)}
<CompactCard <CompactCard
{recipe} {recipe}
{current_month}
isFavorite={recipe.isFavorite} isFavorite={recipe.isFavorite}
showFavoriteIndicator={!!data.session?.user} showFavoriteIndicator={!!data.session?.user}
loading_strat={i < 12 ? "eager" : "lazy"} loading_strat={i < 12 ? "eager" : "lazy"}
+2 -4
View File
@@ -1,6 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getAllBriefRecipes, getBriefRecipesBySeason, isOfflineDataAvailable } from '$lib/offline/db'; import { getAllBriefRecipes, getBriefRecipesInSeasonOn, isOfflineDataAvailable } from '$lib/offline/db';
import { rand_array } from '$lib/js/randomize'; import { rand_array } from '$lib/js/randomize';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
@@ -24,11 +24,9 @@ export const load: PageLoad = async ({ data }) => {
try { try {
const hasOfflineData = await isOfflineDataAvailable(); const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) { if (hasOfflineData) {
const currentMonth = new Date().getMonth() + 1;
const [allBrief, seasonRecipes] = await Promise.all([ const [allBrief, seasonRecipes] = await Promise.all([
getAllBriefRecipes(), getAllBriefRecipes(),
getBriefRecipesBySeason(currentMonth) getBriefRecipesInSeasonOn(new Date())
]); ]);
return { return {
@@ -9,7 +9,7 @@
import IngredientsPage from '$lib/components/recipes/IngredientsPage.svelte'; import IngredientsPage from '$lib/components/recipes/IngredientsPage.svelte';
import TitleImgParallax from '$lib/components/recipes/TitleImgParallax.svelte'; import TitleImgParallax from '$lib/components/recipes/TitleImgParallax.svelte';
import { afterNavigate } from '$app/navigation'; 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 RecipeNote from '$lib/components/recipes/RecipeNote.svelte';
import FavoriteButton from '$lib/components/FavoriteButton.svelte'; import FavoriteButton from '$lib/components/FavoriteButton.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
@@ -53,44 +53,15 @@
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] ? ["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"]); : ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]);
function season_intervals() { const seasonRangeChips = $derived.by(() => {
// Guard against missing season data (can happen in offline mode) const ranges = data.seasonRanges;
if (!data.season || !Array.isArray(data.season) || data.season.length === 0) { if (!ranges || !Array.isArray(ranges) || ranges.length === 0) return [];
return []; const year = new Date().getFullYear();
} return ranges.map((r: any) => ({
label: formatRangePreview(r, year, isEnglish ? 'en' : 'de'),
let interval_arr = [] month: resolveEndpoint(r.start, year).getMonth() + 1
}));
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 display_date = $derived(data.updatedAt ? new Date(data.updatedAt) : new Date(data.dateCreated)); const display_date = $derived(data.updatedAt ? new Date(data.updatedAt) : new Date(data.dateCreated));
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
@@ -318,17 +289,12 @@ h2{
{#if data.preamble} {#if data.preamble}
<p>{@html data.preamble}</p> <p>{@html data.preamble}</p>
{/if} {/if}
{#if season_iv.length > 0} {#if seasonRangeChips.length > 0}
<div class=tags> <div class=tags>
<h2>{labels.season}</h2> <h2>{labels.season}</h2>
{#each season_iv as season} {#each seasonRangeChips as chip}
<a class="g-tag" href={resolve('/[recipeLang=recipeLang]/season/[month]', { recipeLang: data.recipeLang, month: String(season[0]) })}> <a class="g-tag" href={resolve('/[recipeLang=recipeLang]/season/[month]', { recipeLang: data.recipeLang, month: String(chip.month) })}>
{#if season[0]} {chip.label}
{months[season[0] - 1]}
{/if}
{#if season[1]}
- {months[season[1] - 1]}
{/if}
</a> </a>
{/each} {/each}
</div> </div>
@@ -24,15 +24,11 @@
let showTranslationWorkflow = $state(false); let showTranslationWorkflow = $state(false);
let translationData: any = $state(null); let translationData: any = $state(null);
// Season store // Season ranges (controlled by SeasonSelect via bind:ranges)
import { season } from '$lib/js/season_store'; import type { SeasonRange } from '$types/types';
import { portions } from '$lib/js/portions_store'; import { portions } from '$lib/js/portions_store';
season.update(() => []); let season_local = $state<SeasonRange[]>([]);
let season_local = $state<number[]>([]);
season.subscribe((s) => {
season_local = s;
});
let portions_local = $state(""); let portions_local = $state("");
portions.update(() => ""); portions.update(() => "");
@@ -73,27 +69,12 @@
let submitting = $state(false); let submitting = $state(false);
let formElement: HTMLFormElement; 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 // Prepare German recipe data - use $derived to prevent infinite effect loops
let germanRecipeData = $derived({ let germanRecipeData = $derived({
...card_data, ...card_data,
...add_info, ...add_info,
images: selected_image_file ? [{ mediapath: 'pending', alt: "", caption: "" }] : [], images: selected_image_file ? [{ mediapath: 'pending', alt: "", caption: "" }] : [],
season: season_local, seasonRanges: season_local,
short_name: short_name.trim(), short_name: short_name.trim(),
portions: portions_local, portions: portions_local,
datecreated: new Date(), datecreated: new Date(),
@@ -337,7 +318,7 @@ button.action_button {
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} /> <input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
<input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} /> <input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} />
<input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} /> <input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} />
<input type="hidden" name="season" value={JSON.stringify(season_local)} /> <input type="hidden" name="seasonRanges" value={JSON.stringify(season_local)} />
<input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} /> <input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} />
<!-- Translation data (added after approval) --> <!-- Translation data (added after approval) -->
@@ -425,7 +406,7 @@ button.action_button {
<div class="tags"> <div class="tags">
<h4>Saison:</h4> <h4>Saison:</h4>
<SeasonSelect /> <SeasonSelect bind:ranges={season_local} />
</div> </div>
</div> </div>
</div> </div>
@@ -4,7 +4,6 @@
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
// Calculate statistics // Calculate statistics
const stats = $derived.by(() => { const stats = $derived.by(() => {
@@ -161,7 +160,6 @@ h1 {
<div class="card-wrapper"> <div class="card-wrapper">
<CompactCard <CompactCard
{recipe} {recipe}
{current_month}
routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })}
/> />
<div class="translation-badge {recipe.translationStatus || 'none'}"> <div class="translation-badge {recipe.translationStatus || 'none'}">
@@ -9,7 +9,6 @@
type RecipeItem = BriefRecipeType & { isFavorite: boolean }; type RecipeItem = BriefRecipeType & { isFavorite: boolean };
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
@@ -47,6 +46,6 @@
<Search category={data.category} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search> <Search category={data.category} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<div class="recipe-grid"> <div class="recipe-grid">
{#each rand_array(displayRecipes) as recipe (recipe._id)} {#each rand_array(displayRecipes) as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} /> <CompactCard {recipe} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} />
{/each} {/each}
</div> </div>
@@ -12,7 +12,7 @@
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte'; import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte'; import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
import Toggle from '$lib/components/Toggle.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 { portions } from '$lib/js/portions_store';
import '$lib/css/action_button.css'; import '$lib/css/action_button.css';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
@@ -57,14 +57,7 @@
}); });
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
season.update(() => data.recipe.season || []); let season_local = $state<SeasonRange[]>(data.recipe.seasonRanges || []);
// svelte-ignore state_referenced_locally
let season_local = $state<number[]>(data.recipe.season || []);
$effect(() => {
season.subscribe((s) => {
season_local = s;
});
});
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let card_data = $state({ let card_data = $state({
@@ -111,21 +104,6 @@
let submitting = $state(false); let submitting = $state(false);
let formElement: HTMLFormElement; 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 // Get current German recipe data - use $derived to prevent infinite effect loops
let currentRecipeData = $derived.by(() => { let currentRecipeData = $derived.by(() => {
// Ensure we always have a valid images array with at least one item // Ensure we always have a valid images array with at least one item
@@ -153,7 +131,7 @@
...card_data, ...card_data,
...add_info, ...add_info,
images: recipeImages, images: recipeImages,
season: season_local, seasonRanges: season_local,
short_name: short_name.trim(), short_name: short_name.trim(),
datecreated, datecreated,
portions: portions_local, portions: portions_local,
@@ -1147,7 +1125,7 @@
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} /> <input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
<input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} /> <input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} />
<input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} /> <input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} />
<input type="hidden" name="season" value={JSON.stringify(season_local)} /> <input type="hidden" name="seasonRanges" value={JSON.stringify(season_local)} />
<input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} /> <input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} />
<input type="hidden" name="datecreated" value={datecreated?.toString()} /> <input type="hidden" name="datecreated" value={datecreated?.toString()} />
@@ -1179,7 +1157,7 @@
{#snippet titleExtras()} {#snippet titleExtras()}
<h2 class="section-label">Saison</h2> <h2 class="section-label">Saison</h2>
<div class="season-wrapper"> <div class="season-wrapper">
<SeasonSelect /> <SeasonSelect bind:ranges={season_local} />
</div> </div>
<h2 class="section-label">Einleitung</h2> <h2 class="section-label">Einleitung</h2>
@@ -5,7 +5,6 @@
import Search from '$lib/components/recipes/Search.svelte'; import Search from '$lib/components/recipes/Search.svelte';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
@@ -100,7 +99,7 @@
{:else if filteredFavorites.length > 0} {:else if filteredFavorites.length > 0}
<div class="recipe-grid"> <div class="recipe-grid">
{#each filteredFavorites as recipe (recipe._id)} {#each filteredFavorites as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={true} showFavoriteIndicator={true} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} /> <CompactCard {recipe} isFavorite={true} showFavoriteIndicator={true} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} />
{/each} {/each}
</div> </div>
{:else if data.favorites.length > 0} {:else if data.favorites.length > 0}
@@ -5,7 +5,6 @@
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]); const t = $derived(m[lang]);
@@ -111,7 +110,7 @@
{#if displayedRecipes.length > 0} {#if displayedRecipes.length > 0}
<div class="recipe-grid"> <div class="recipe-grid">
{#each displayedRecipes as recipe (recipe._id)} {#each displayedRecipes as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} /> <CompactCard {recipe} isFavorite={recipe.isFavorite} showFavoriteIndicator={true} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} />
{/each} {/each}
</div> </div>
{:else if (data.query || hasActiveSearch) && !data.error} {:else if (data.query || hasActiveSearch) && !data.error}
@@ -4,8 +4,7 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => { export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`; const apiBase = `/api/${params.recipeLang}`;
let current_month = new Date().getMonth() + 1 const res_season = await fetch(`${apiBase}/items/in_season/today`);
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
const item_season = await res_season.json(); const item_season = await res_season.json();
const session = locals.session ?? await locals.auth(); const session = locals.session ?? await locals.auth();
@@ -15,4 +14,4 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
season: addFavoriteStatusToRecipes(item_season, userFavorites), season: addFavoriteStatusToRecipes(item_season, userFavorites),
session session
}; };
}; };
@@ -46,7 +46,7 @@
{#snippet recipesSlot()} {#snippet recipesSlot()}
<div class="recipe-grid"> <div class="recipe-grid">
{#each rand_array(filteredRecipes) as recipe (recipe._id)} {#each rand_array(filteredRecipes) as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} /> <CompactCard {recipe} icon_override={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} />
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
@@ -1,6 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; 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 { rand_array } from '$lib/js/randomize';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
@@ -20,8 +20,7 @@ export const load: PageLoad = async ({ data }) => {
try { try {
const hasOfflineData = await isOfflineDataAvailable(); const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) { if (hasOfflineData) {
const currentMonth = new Date().getMonth() + 1; const recipes = await getBriefRecipesInSeasonOn(new Date());
const recipes = await getBriefRecipesBySeason(currentMonth);
return { return {
...data, ...data,
@@ -1,6 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; 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 { rand_array } from '$lib/js/randomize';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
@@ -21,7 +21,7 @@ export const load: PageLoad = async ({ data, params }) => {
const hasOfflineData = await isOfflineDataAvailable(); const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) { if (hasOfflineData) {
const month = parseInt(params.month); const month = parseInt(params.month);
const recipes = await getBriefRecipesBySeason(month); const recipes = await getBriefRecipesOverlappingMonth(month);
return { return {
...data, ...data,
@@ -9,7 +9,6 @@
type RecipeItem = BriefRecipeType & { isFavorite: boolean }; type RecipeItem = BriefRecipeType & { isFavorite: boolean };
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
@@ -47,6 +46,6 @@
<Search tag={data.tag} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search> <Search tag={data.tag} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<div class="recipe-grid"> <div class="recipe-grid">
{#each rand_array(displayRecipes) as recipe (recipe._id)} {#each rand_array(displayRecipes) as recipe (recipe._id)}
<CompactCard {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} /> <CompactCard {recipe} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} />
{/each} {/each}
</div> </div>
@@ -51,7 +51,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
description: t?.description, description: t?.description,
note: t?.note, note: t?.note,
tags: t?.tags || [], tags: t?.tags || [],
season: recipe.season, seasonRanges: recipe.seasonRanges,
baking: recipe.baking, baking: recipe.baking,
preparation: recipe.preparation, preparation: recipe.preparation,
fermentation: recipe.fermentation, fermentation: recipe.fermentation,
@@ -142,7 +142,7 @@ export const GET: RequestHandler = async ({ params, setHeaders }) => {
icon: rawRecipe.icon || '', icon: rawRecipe.icon || '',
dateCreated: rawRecipe.dateCreated, dateCreated: rawRecipe.dateCreated,
dateModified: rawRecipe.dateModified, dateModified: rawRecipe.dateModified,
season: rawRecipe.season || [], seasonRanges: rawRecipe.seasonRanges || [],
baking: t.baking || rawRecipe.baking || { temperature: '', length: '', mode: '' }, baking: t.baking || rawRecipe.baking || { temperature: '', length: '', mode: '' },
preparation: t.preparation || rawRecipe.preparation || '', preparation: t.preparation || rawRecipe.preparation || '',
fermentation: t.fermentation || rawRecipe.fermentation || { bulk: '', final: '' }, fermentation: t.fermentation || rawRecipe.fermentation || { bulk: '', final: '' },
@@ -3,18 +3,24 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize'; import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers'; import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
import { recipeOverlapsMonth } from '$lib/js/seasonRange';
export const GET: RequestHandler = async ({ params, setHeaders }) => { export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!); const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect(); 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( const dbRecipes = await Recipe.find(
{ season: parseInt(params.month!, 10), icon: { $ne: "🍽️" }, ...approvalFilter }, { icon: { $ne: '🍽️' }, seasonRanges: { $exists: true, $ne: [] }, ...approvalFilter },
projection projection
).lean(); ).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' }); setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes)); return json(rand_array(recipes));
}; };
@@ -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));
};
@@ -10,7 +10,7 @@ export const GET: RequestHandler = async () => {
const [briefRecipes, fullRecipes] = await Promise.all([ const [briefRecipes, fullRecipes] = await Promise.all([
Recipe.find( 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<BriefRecipeType[]>, ).lean() as unknown as Promise<BriefRecipeType[]>,
Recipe.find({}) Recipe.find({})
.populate({ .populate({
@@ -3,6 +3,7 @@ import type { BriefRecipeType } from '$types/types';
import { Recipe } from '$models/Recipe'; import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { isEnglish, briefQueryConfig, toBrief } from '$lib/server/recipeHelpers'; import { isEnglish, briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
import { recipeOverlapsMonth } from '$lib/js/seasonRange';
export const GET: RequestHandler = async ({ url, params, locals }) => { export const GET: RequestHandler = async ({ url, params, locals }) => {
await dbConnect(); await dbConnect();
@@ -41,13 +42,18 @@ export const GET: RequestHandler = async ({ url, params, locals }) => {
if (icon) { if (icon) {
dbQuery.icon = icon; dbQuery.icon = icon;
} }
if (seasons.length > 0) {
dbQuery.season = { $in: seasons };
}
const dbRecipes = await Recipe.find(dbQuery, projection).lean(); const dbRecipes = await Recipe.find(dbQuery, projection).lean();
let recipes: BriefRecipeType[] = dbRecipes.map(r => toBrief(r, params.recipeLang!)); 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 // Handle favorites filter
const session = locals.session ?? await locals.auth(); const session = locals.session ?? await locals.auth();
if (favoritesOnly && session?.user) { if (favoritesOnly && session?.user) {
@@ -22,7 +22,7 @@ export const GET: RequestHandler = async ({ locals }) => {
{ 'translations.en': { $exists: false } }, { 'translations.en': { $exists: false } },
{ 'translations.en.translationStatus': { $ne: 'approved' } } { '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 .sort({ dateModified: 1 }) // Oldest first - highest priority
.lean(); .lean();
@@ -35,7 +35,7 @@ export const GET: RequestHandler = async ({ locals }) => {
icon: recipe.icon, icon: recipe.icon,
description: recipe.description, description: recipe.description,
tags: recipe.tags || [], tags: recipe.tags || [],
season: recipe.season || [], seasonRanges: recipe.seasonRanges || [],
dateModified: recipe.dateModified, dateModified: recipe.dateModified,
translationStatus: recipe.translations?.en?.translationStatus || undefined translationStatus: recipe.translations?.en?.translationStatus || undefined
})); }));
+20 -2
View File
@@ -37,6 +37,24 @@ export type NutritionMapping = {
recipeRefMultiplier?: number; 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 // Translation status enum
export type TranslationStatus = 'pending' | 'approved' | 'needs_update'; export type TranslationStatus = 'pending' | 'approved' | 'needs_update';
@@ -175,7 +193,7 @@ export type RecipeModelType = {
}]; }];
description: string; description: string;
tags: [string]; tags: [string];
season: [number]; seasonRanges?: SeasonRange[];
baking?: { baking?: {
temperature: string; temperature: string;
length: string; length: string;
@@ -225,5 +243,5 @@ export type BriefRecipeType = {
}] }]
description: string; description: string;
tags: [string]; tags: [string];
season: [number]; seasonRanges?: SeasonRange[];
} }
+20 -34
View File
@@ -5,7 +5,7 @@
* for SvelteKit form actions with progressive enhancement support. * 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 { export interface RecipeFormData {
// Basic fields // Basic fields
@@ -16,7 +16,7 @@ export interface RecipeFormData {
icon: string; icon: string;
tags: string[]; tags: string[];
portions: string; portions: string;
season: number[]; seasonRanges: SeasonRange[];
// Optional text fields // Optional text fields
preamble?: string; preamble?: string;
@@ -97,14 +97,14 @@ export function extractRecipeFromFormData(formData: FormData): RecipeFormData {
} }
} }
// Season (JSON array of month numbers) // Season ranges (JSON array of {start, end} endpoints)
let season: number[] = []; let seasonRanges: SeasonRange[] = [];
const seasonData = formData.get('season')?.toString(); const seasonRangesData = formData.get('seasonRanges')?.toString();
if (seasonData) { if (seasonRangesData) {
try { try {
season = JSON.parse(seasonData); seasonRanges = JSON.parse(seasonRangesData);
} catch { } catch {
// Ignore invalid season data // Ignore invalid range data
} }
} }
@@ -207,7 +207,7 @@ export function extractRecipeFromFormData(formData: FormData): RecipeFormData {
icon, icon,
tags, tags,
portions, portions,
season, seasonRanges,
preamble, preamble,
addendum, addendum,
note, note,
@@ -294,8 +294,8 @@ export function detectChangedFields(original: Record<string, unknown>, current:
changedFields.push('tags'); changedFields.push('tags');
} }
if (JSON.stringify(original.season) !== JSON.stringify(current.season)) { if (JSON.stringify(original.seasonRanges) !== JSON.stringify(current.seasonRanges)) {
changedFields.push('season'); changedFields.push('seasonRanges');
} }
if (JSON.stringify(original.ingredients) !== JSON.stringify(current.ingredients)) { 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 * Parses season-range data from form input (JSON-encoded SeasonRange[]).
* Handles both checkbox-based input and JSON arrays
*/ */
export function parseSeasonData(formData: FormData): number[] { export function parseSeasonRangesData(formData: FormData): SeasonRange[] {
const season: number[] = []; const seasonJson = formData.get('seasonRanges')?.toString();
if (!seasonJson) return [];
// Try JSON format first try {
const seasonJson = formData.get('season')?.toString(); return JSON.parse(seasonJson);
if (seasonJson) { } catch {
try { return [];
return JSON.parse(seasonJson);
} catch {
// Fall through to checkbox parsing
}
} }
// 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 || '', icon: data.icon || '',
tags: data.tags || [], tags: data.tags || [],
portions: data.portions || '', portions: data.portions || '',
season: data.season || [], seasonRanges: data.seasonRanges || [],
ingredients: data.ingredients || [], ingredients: data.ingredients || [],
instructions: data.instructions || [], instructions: data.instructions || [],
isBaseRecipe: data.isBaseRecipe || false, isBaseRecipe: data.isBaseRecipe || false,