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:
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` (1–12) in year Y. Used by the legacy `?season=N` URL filter.
|
||||
*/
|
||||
export function rangeOverlapsMonth(range: SeasonRange, month: number, year: number = new Date().getFullYear()): boolean {
|
||||
const monthStart = new Date(year, month - 1, 1).getTime();
|
||||
const monthEnd = new Date(year, month - 1, lastDayOfMonth(year, month)).getTime();
|
||||
const intervals = intervalsForYears(range, [year - 1, year, year + 1]);
|
||||
for (const iv of intervals) {
|
||||
if (iv.start.getTime() <= monthEnd && iv.end.getTime() >= monthStart) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function recipeOverlapsMonth(recipe: RecipeWithRanges, month: number, year: number = new Date().getFullYear()): boolean {
|
||||
const ranges = recipe.seasonRanges;
|
||||
if (!ranges || ranges.length === 0) return false;
|
||||
for (const r of ranges) {
|
||||
if (rangeOverlapsMonth(r, month, year)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a resolved range as a human-readable string for the editor preview,
|
||||
* resolved against `year`. Fixed/fixed renders as `Mar 1 – Mar 31`; ranges
|
||||
* touching liturgical anchors include the year for clarity since they shift.
|
||||
*/
|
||||
export function formatRangePreview(range: SeasonRange, year: number, lang: 'de' | 'en' = 'de'): string {
|
||||
const anchors = getLiturgicalAnchors(year);
|
||||
const start = midnight(resolveEndpoint(range.start, year, anchors));
|
||||
const end = midnight(resolveEndpoint(range.end, year, anchors));
|
||||
const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'de-DE', { month: 'short', day: 'numeric' });
|
||||
const includesAnchor = range.start.kind === 'liturgical' || range.end.kind === 'liturgical';
|
||||
const yearTag = includesAnchor ? ` (${year})` : '';
|
||||
return `${fmt.format(start)} – ${fmt.format(end)}${yearTag}`;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const season = writable(/** @type {number[]} */ ([]));
|
||||
+13
-13
@@ -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[]> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user