Files
Speisekarte/app/feed.xml/route.ts
T
florian.wilde 2a2c5affd5 feat: Tage als geschlossen markierbar (Feiertage o. Ä.)
Im Wochenplan lässt sich pro Tag ein optionaler, mehrsprachiger
Schließungsgrund hinterlegen. Geschlossene Tage werden auf der
öffentlichen Speisekarte (Desktop + mobil) und im RSS-Feed als
"Geschlossen" statt mit Essenszeiten angezeigt, im Wochenplan bleibt
"Mahlzeit hinzufügen" für diesen Tag deaktiviert.
2026-07-20 12:35:00 +02:00

124 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { listClosedDays } from "@/lib/db/closed-days";
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
import { localized, SITE_TITLE } from "@/lib/types";
import type { Section } from "@/lib/types";
// RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten,
// damit man ihn in einem Feedreader abonnieren kann und täglich sieht,
// was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie,
// eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default.
export const dynamic = "force-dynamic";
function escapeXml(text: string): string {
return text.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&apos;";
}
});
}
function formatFeedDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
export async function GET() {
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const monday = mondayOf(todayISO());
// Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per
// Wochennavigation anzeigt.
const rangeEnd = addDays(monday, 11);
const [sections, entries, closedDays] = await Promise.all([
query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, rangeEnd),
listClosedDays(monday, rangeEnd),
]);
const days = Array.from(
new Set([...entries.map((e) => e.menu_date), ...closedDays.map((c) => c.closed_date)])
).sort();
const items = days
.map((iso) => {
const closed = closedDays.find((c) => c.closed_date === iso);
const dayContent = closed
? `<p><strong>Geschlossen</strong>${
closed.reason ? ` – ${escapeXml(closed.reason)}` : ""
}</p>`
: sections
.map((s) => {
const dishes = entriesFor(entries, iso, s.key);
if (dishes.length === 0) return "";
const list = dishes
.map(
(e) =>
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
)
.join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
if (!dayContent) return "";
const [y, m, d] = iso.split("-").map(Number);
const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString();
return ` <item>
<title>${escapeXml(formatFeedDate(iso))}</title>
<link>${appUrl}/?woche=${mondayOf(iso)}</link>
<guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${dayContent}]]></description>
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
</item>`;
})
.join("\n");
// "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook,
// etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig
// gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur
// die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als
// vollständiger Artikeltext und wird von den meisten Readern
// ungekürzt angezeigt, wenn vorhanden – redundant zu description für
// Reader ohne Unterstützung des Moduls.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link>
<description>Täglicher Speiseplan der Kantine</description>
<language>de</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>
`;
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=1800",
},
});
}