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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user