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
+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;
+232 -81
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;
});
function commit(next: SeasonRange[]) {
ranges = next;
}
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)
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 } }]);
}
}
season.update(() => temp)
}
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 removeRange(i: number) {
commit(ranges.filter((_, idx) => idx !== i));
}
}
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;
}
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>
{/each}
</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;
}