Files
Speisekarte/app/feed.xml/route.ts
T
florian.wilde 08a5ef8a2b v1.6: RSS-Feed für den Speiseplan + Badge für offene Rezeptvorschläge
app/feed.xml: RSS-2.0-Feed mit einem Eintrag pro Tag (aktuelle +
nächste Woche), Gerichte je Essenszeit im CDATA-Beschreibungstext.
Immer auf Deutsch, da Feedreader kein Sprach-Cookie mitschicken.

Autodiscovery-Link im <head> (app/layout.tsx) sowie sichtbares
RSS-Icon im Header der öffentlichen Speisekarte (neben Sprachumschalter/
Anmelden – die einzige Stelle, die auf allen Bildschirmgrößen sichtbar
ist).

AppHeader: "Vorschläge"-Navigationspunkt zeigt jetzt die Anzahl
offener Rezeptvorschläge als Zahl im roten Kreis (GET
/api/dish-suggestions, bereits staff/admin-beschränkt), aktualisiert
sich bei Navigation innerhalb des internen Bereichs.
2026-07-18 12:18:46 +02:00

107 lines
3.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 { 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] = await Promise.all([
query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, rangeEnd),
]);
const days = Array.from(new Set(entries.map((e) => e.menu_date))).sort();
const items = days
.map((iso) => {
const dayContent = 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>
</item>`;
})
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<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",
},
});
}