feat: parse admission notes into structured price table

Parse semi-structured notes text (e.g. "Samstag: Erw. 15 €, Kinder 8 €")
into grouped table rows instead of showing raw text below the table.

- Split notes on sentence boundaries (". " + uppercase), handling
  abbreviations like "Erw." correctly
- Extract "Label: Category Price €" patterns into table groups
- Expand common abbreviations (Erw. → Erwachsene, Erm. → Ermäßigt)
- Guard against "Cat: Price, Cat: Price" format (colons in pricesStr)
- Guard against bare prices after colon (letter-start requirement)
- Graceful fallback to flat table + raw notes when parsing fails
This commit is contained in:
2026-02-23 06:12:39 +01:00
parent 6b23cc330e
commit c582c7c4d6

View File

@@ -31,6 +31,80 @@
: null
);
interface ParsedPriceEntry {
category: string;
price: string;
}
interface ParsedPriceGroup {
label: string;
entries: ParsedPriceEntry[];
}
const categoryLabels: Record<string, string> = {
Erw: 'Erwachsene',
'Erw.': 'Erwachsene',
Erm: 'Ermäßigt',
'Erm.': 'Ermäßigt'
};
function parseAdmissionNotes(notes: string): {
groups: ParsedPriceGroup[];
remaining: string;
} {
const groups: ParsedPriceGroup[] = [];
const remainingParts: string[] = [];
// Split on ". " followed by uppercase — avoids breaking on "Erw." abbreviation
const segments = notes.split(/\.\s+(?=[A-ZÄÖÜ])/).filter((s) => s.trim());
for (const raw of segments) {
const segment = raw.replace(/\.+$/, '').trim();
const colonIdx = segment.indexOf(':');
if (colonIdx === -1) {
remainingParts.push(segment);
continue;
}
const label = segment.substring(0, colonIdx).trim();
const pricesStr = segment.substring(colonIdx + 1).trim();
// If pricesStr contains colons, it's "Cat: Price, Cat: Price" format — skip parsing
if (pricesStr.includes(':')) {
remainingParts.push(segment);
continue;
}
const entries: ParsedPriceEntry[] = [];
// Category must start with a letter (prevents matching bare prices like "13,00 €")
const pricePattern = /(?:^|,\s*)([A-Za-zÄÖÜäöüß].*?)\s+(\d+(?:,\d+)?)\s*€/g;
let m;
while ((m = pricePattern.exec(pricesStr)) !== null) {
const cat = m[1].trim();
entries.push({
category: categoryLabels[cat] ?? cat,
price: m[2] + ' €'
});
}
if (entries.length > 0) {
groups.push({ label, entries });
} else {
remainingParts.push(segment);
}
}
return {
groups,
remaining: remainingParts.length > 0 ? remainingParts.join('. ') + '.' : ''
};
}
const parsedNotes = $derived(
admission?.notes ? parseAdmissionNotes(admission.notes) : { groups: [], remaining: '' }
);
const jsonLdBreadcrumbHtml = $derived(
'<script type="application/ld+json">' +
JSON.stringify({
@@ -227,27 +301,50 @@
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Eintrittspreise</h2>
<table class="mt-3 w-full text-sm">
<tbody>
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Erwachsene</td>
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
>{centsToEuro(admission.adult_cents)}</td
>
</tr>
{#if admission.reduced_cents > 0}
{#if parsedNotes.groups.length > 0}
{#each parsedNotes.groups as group, i}
<tr class="border-b border-stone-200 dark:border-stone-600">
<td
colspan="2"
class="{i > 0
? 'pt-3'
: 'pt-1'} pb-1 font-semibold text-stone-800 dark:text-stone-200"
>{group.label}</td
>
</tr>
{#each group.entries as entry}
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 pl-3 font-medium text-stone-700 dark:text-stone-200"
>{entry.category}</td
>
<td class="py-2 text-right text-stone-600 dark:text-stone-300">{entry.price}</td
>
</tr>
{/each}
{/each}
{:else}
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Ermäßigt</td>
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Erwachsene</td>
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
>{centsToEuro(admission.reduced_cents)}</td
>
</tr>
{/if}
{#if admission.child_cents > 0}
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Kinder</td>
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
>{centsToEuro(admission.child_cents)}</td
>{centsToEuro(admission.adult_cents)}</td
>
</tr>
{#if admission.reduced_cents > 0}
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Ermäßigt</td>
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
>{centsToEuro(admission.reduced_cents)}</td
>
</tr>
{/if}
{#if admission.child_cents > 0}
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Kinder</td>
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
>{centsToEuro(admission.child_cents)}</td
>
</tr>
{/if}
{/if}
{#if admission.free_under_age > 0}
<tr class="border-b border-stone-100 dark:border-stone-700">
@@ -259,7 +356,9 @@
{/if}
</tbody>
</table>
{#if admission.notes}
{#if parsedNotes.groups.length > 0 && parsedNotes.remaining}
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{parsedNotes.remaining}</p>
{:else if parsedNotes.groups.length === 0 && admission.notes}
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{admission.notes}</p>
{/if}
</div>