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.
This commit is contained in:
2026-07-18 12:18:46 +02:00
parent b7c24364b8
commit 08a5ef8a2b
5 changed files with 160 additions and 1 deletions
+106
View File
@@ -0,0 +1,106 @@
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",
},
});
}
+8
View File
@@ -25,6 +25,14 @@ export async function generateMetadata(): Promise<Metadata> {
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
// Konvention und Metadata statt sie zu ersetzen).
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
// Autodiscovery für Feedreader (Browser zeigen dafür kein eigenes
// Icon mehr an, daher zusätzlich ein sichtbares RSS-Icon im Header
// der öffentlichen Speisekarte, s. app/page.tsx).
alternates: {
types: {
"application/rss+xml": [{ url: "/feed.xml", title: `${SITE_TITLE} – Speiseplan` }],
},
},
};
}
+19
View File
@@ -56,6 +56,25 @@ export default async function Home({
<div className="hidden sm:block">
<LanguageSwitcher variant="light" />
</div>
<a
href="/feed.xml"
title={dict.publicMenu.rssFeed}
aria-label={dict.publicMenu.rssFeed}
className="text-gray-400 hover:text-[#ee802f]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="currentColor"
aria-hidden="true"
>
<circle cx="5.5" cy="18.5" r="2.5" />
<path d="M4 11a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3z" />
<path d="M4 4a16 16 0 0 1 16 16h-3A13 13 0 0 0 4 7V4z" />
</svg>
</a>
<Link
href="/login"
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"