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",
"version": "1.58.1",
"version": "1.59.1",
"private": true,
"type": "module",
"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 { 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}
</div>
{/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>
{/if}
<div class="card_title">
@@ -1,10 +1,10 @@
<script lang="ts">
import "$lib/css/shake.css";
import Heart from '@lucide/svelte/icons/heart';
import { isRecipeInSeason } from '$lib/js/seasonRange';
let {
recipe,
current_month = 0,
icon_override = false,
isFavorite = false,
showFavoriteIndicator = false,
@@ -23,7 +23,7 @@
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) {
const img = (event.currentTarget as HTMLElement)?.querySelector('.img-wrap img') as HTMLElement | null;
@@ -8,12 +8,12 @@
let {
card_data = $bindable({}),
season = $bindable([]),
seasonRanges = $bindable([]),
ingredients = $bindable([]),
instructions = $bindable([])
}: {
card_data?: any,
season?: any[],
seasonRanges?: any[],
ingredients?: any[],
instructions?: any[]
} = $props();
@@ -31,7 +31,7 @@
method: 'POST',
body: JSON.stringify({
recipe: {
season: season,
seasonRanges: seasonRanges,
...card_data,
images: [{
mediapath: short_name + '.webp',
@@ -71,8 +71,8 @@ input.temp{
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
<SeasonSelect></SeasonSelect>
<button onclick={() => console.log(season)}>PRINTOUT season</button>
<SeasonSelect bind:ranges={seasonRanges} />
<button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
<h2>Zutaten</h2>
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
+4 -4
View File
@@ -4,6 +4,7 @@
import FilterPanel from './FilterPanel.svelte';
import { getCategories } from '$lib/js/categories';
import { m } from '$lib/js/recipesI18n';
import { recipeOverlapsMonth } from '$lib/js/seasonRange';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
// Filter props for different contexts
@@ -85,10 +86,9 @@
return false;
}
// Season filter: recipe in any selected season
// Season filter: recipe overlaps any selected month
if (selectedSeasons.length > 0) {
const recipeSeasons = recipe.season || [];
if (!selectedSeasons.some(s => recipeSeasons.includes(s))) {
if (!selectedSeasons.some(m => recipeOverlapsMonth(recipe, m))) {
return false;
}
}
@@ -117,7 +117,7 @@
const matchesCategory = categoryArray.length > 0 ? categoryArray.includes(recipe.category) : 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 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;
return matchesCategory || matchesTags || matchesIcon || matchesSeasons || matchesFavorites;
+231 -80
View File
@@ -1,102 +1,253 @@
<script lang=ts>
import { season } from '$lib/js/season_store.js'
import {onMount} from "svelte";
import {do_on_key} from "./do_on_key";
let months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
<script lang="ts">
import { formatRangePreview } from '$lib/js/seasonRange';
import type { SeasonAnchorKey, SeasonEndpoint, SeasonRange } from '$types/types';
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[]) => {
season_local = s;
});
export function set_season(){
let temp: number[] = [];
const el = document.getElementById("labels");
if (!el) return;
for(var i = 0; i < el.children.length; i++){
if((el.children[i].children[0].children[0] as HTMLInputElement).checked){
temp.push(i+1)
}
}
season.update(() => temp)
function commit(next: SeasonRange[]) {
ranges = next;
}
function write_season(season: number[]){
const el = document.getElementById("labels");
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 fixed(m: number, d: number): SeasonEndpoint {
return { kind: 'fixed', m, d };
}
function liturgical(anchor: SeasonAnchorKey, offsetDays = 0): SeasonEndpoint {
return { kind: 'liturgical', anchor, offsetDays };
}
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 } }]);
}
}
function toggle_checkbox_on_key(event: Event){
const target = event.target as HTMLElement;
const checkbox = target.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox) checkbox.checked = !checkbox.checked;
function removeRange(i: number) {
commit(ranges.filter((_, idx) => idx !== i));
}
onMount(() => {
write_season(season_local)
});
function setEndpointKind(i: number, which: 'start' | 'end', kind: 'fixed' | 'liturgical') {
const ep = ranges[i][which];
if (kind === ep.kind) return;
const next: SeasonEndpoint = kind === 'fixed' ? fixed(1, 1) : liturgical('easter', 0);
commit(withEndpoint(i, which, next));
}
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>
<style>
label{
background-color: var(--nord0);
color: white;
padding: 0.25em 1em;
margin-inline: 0.1em;
line-height: 2em;
border-radius: var(--radius-pill);
cursor: pointer;
position: relative;
transition: var(--transition-fast);
user-select: none;
.season-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 720px;
margin: 0 auto;
}
.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{
.range-row {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
margin-bottom: 1em;
gap: 0.5rem;
align-items: center;
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>
<div id=labels>
{#each months as month}
<div class=checkbox_container>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- 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>
<div class="season-editor">
{#if ranges.length === 0}
<div class="empty">Keine Saison-Bereiche immer verfügbar.</div>
{/if}
{#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>
+50
View File
@@ -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<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 { 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<IDBDatabase> {
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<Brief
});
}
export async function getBriefRecipesBySeason(month: number): Promise<BriefRecipeType[]> {
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<BriefRecipeType[]> {
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<BriefRecipeType[]> {
const all = await getAllBriefRecipes();
return all.filter(r => recipeOverlapsMonth(r as any, month, year));
}
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`);
}
// 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) {
+3 -3
View File
@@ -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;
}
+20 -2
View File
@@ -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 });
@@ -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,
@@ -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)}
<CompactCard
{recipe}
{current_month}
isFavorite={recipe.isFavorite}
showFavoriteIndicator={!!data.session?.user}
loading_strat={i < 12 ? "eager" : "lazy"}
@@ -481,7 +479,6 @@
{#each visibleRecipes as recipe, i (recipe._id)}
<CompactCard
{recipe}
{current_month}
isFavorite={recipe.isFavorite}
showFavoriteIndicator={!!data.session?.user}
loading_strat={i < 12 ? "eager" : "lazy"}
+2 -4
View File
@@ -1,6 +1,6 @@
import { browser } from '$app/environment';
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 type { PageLoad } from './$types';
@@ -24,11 +24,9 @@ export const load: PageLoad = async ({ data }) => {
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 {
@@ -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}
<p>{@html data.preamble}</p>
{/if}
{#if season_iv.length > 0}
{#if seasonRangeChips.length > 0}
<div class=tags>
<h2>{labels.season}</h2>
{#each season_iv as season}
<a class="g-tag" href={resolve('/[recipeLang=recipeLang]/season/[month]', { recipeLang: data.recipeLang, month: String(season[0]) })}>
{#if season[0]}
{months[season[0] - 1]}
{/if}
{#if season[1]}
- {months[season[1] - 1]}
{/if}
{#each seasonRangeChips as chip}
<a class="g-tag" href={resolve('/[recipeLang=recipeLang]/season/[month]', { recipeLang: data.recipeLang, month: String(chip.month) })}>
{chip.label}
</a>
{/each}
</div>
@@ -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<number[]>([]);
season.subscribe((s) => {
season_local = s;
});
let season_local = $state<SeasonRange[]>([]);
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 {
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
<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="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)} />
<!-- Translation data (added after approval) -->
@@ -425,7 +406,7 @@ button.action_button {
<div class="tags">
<h4>Saison:</h4>
<SeasonSelect />
<SeasonSelect bind:ranges={season_local} />
</div>
</div>
</div>
@@ -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 {
<div class="card-wrapper">
<CompactCard
{recipe}
{current_month}
routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })}
/>
<div class="translation-badge {recipe.translationStatus || 'none'}">
@@ -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 @@
<Search category={data.category} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<div class="recipe-grid">
{#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}
</div>
@@ -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<number[]>(data.recipe.season || []);
$effect(() => {
season.subscribe((s) => {
season_local = s;
});
});
let season_local = $state<SeasonRange[]>(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 @@
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
<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="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="datecreated" value={datecreated?.toString()} />
@@ -1179,7 +1157,7 @@
{#snippet titleExtras()}
<h2 class="section-label">Saison</h2>
<div class="season-wrapper">
<SeasonSelect />
<SeasonSelect bind:ranges={season_local} />
</div>
<h2 class="section-label">Einleitung</h2>
@@ -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}
<div class="recipe-grid">
{#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}
</div>
{:else if data.favorites.length > 0}
@@ -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}
<div class="recipe-grid">
{#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}
</div>
{: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 }) => {
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();
@@ -46,7 +46,7 @@
{#snippet recipesSlot()}
<div class="recipe-grid">
{#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}
</div>
{/snippet}
@@ -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,
@@ -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,
@@ -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 @@
<Search tag={data.tag} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<div class="recipe-grid">
{#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}
</div>
@@ -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,
@@ -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: '' },
@@ -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));
};
@@ -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([
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[]>,
Recipe.find({})
.populate({
@@ -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) {
@@ -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
}));
+20 -2
View File
@@ -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[];
}
+17 -31
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,32 +319,18 @@ 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) {
export function parseSeasonRangesData(formData: FormData): SeasonRange[] {
const seasonJson = formData.get('seasonRanges')?.toString();
if (!seasonJson) return [];
try {
return JSON.parse(seasonJson);
} catch {
// Fall through to checkbox parsing
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;
}
/**
* Serializes complex recipe data for storage
* Ensures all required fields are present and properly typed
@@ -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,