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