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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.58.1",
|
||||
"version": "1.59.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* One-time migration: convert legacy `season: number[]` (months 1–12) 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 (1–12) 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,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;
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+20
-2
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user