Pass 0 splits every month into two halves (H1 = days 1-15, H2 = 16-EOM)
so each agent call fits within Mistral's 4096 max_tokens budget. The
response schema picks up richer per-market signals and dead agent URLs
get filtered before they land in the admin queue.
DB:
- 000015: add halbmonat char(2) to discovery_buckets, widen unique key,
backfill existing rows as H1 + insert H2 siblings (624 → 1248 rows).
- 000016: rename discovered_markets.extraktion → konfidenz with
best-effort value mapping (verbatim→hoch, abgeleitet→mittel); add
agent_status column.
Backend:
- model: Bucket gains Halbmonat; Pass0Bucket same. Pass0Market renames
Extraktion → Konfidenz and adds AgentStatus (JSON tag "status").
DiscoveredMarket mirrors both fields; queue-lifecycle Status column
stays distinct from agent-reported AgentStatus.
- repository: all SELECT/INSERT touched to use the new columns; picker
orders by year_month, halbmonat so H1 runs before H2 in the same
month.
- agent client: prompt now injects halbmonat and recherche_datum (today)
so the agent has explicit date context.
- link verification: new LinkChecker does concurrent HEAD (GET fallback
on 405) with a 5s timeout. FilterURLs runs before InsertDiscovered —
markets whose quellen all fail are dropped and counted as
link_check_failed in TickSummary. Failing website URLs are cleared
but don't block insert.
- Service.linkChecker is a narrow interface so tests inject a noop
stub instead of hitting the network.
Web:
- DiscoveredMarket type gains konfidenz + agent_status, drops extraktion.
- Queue column renames "Extraktion" → "Konfidenz" with three-level
coloring (hoch=emerald, mittel=amber, niedrig=red, else neutral).
- A small pill next to markt_name surfaces agent_status when it's not
"bestaetigt" — red for "abgesagt", amber for "unklar" and
"vorjahr_unbestaetigt" — so risky entries are obvious before accept.
Expanding any row in the discovery queue now reveals:
- Quellen as clickable URLs (was just a count)
- Hinweis if the agent emitted one
- Inline edit form for markt_name, stadt, bundesland, start/end date,
and website — the fields the Pass 0 agent gets wrong most often
Backend:
- PATCH /admin/discovery/queue/:id applies a partial update to pending
entries via a COALESCE-based SQL update. Only fields that were set
are written.
- Service recomputes name_normalized when markt_name or stadt change so
dedup stays consistent after edits.
- Status check ensures only 'pending' entries are mutable.
Web:
- Row state $expandedId holds at most one open drawer at a time.
- Dates round-trip through <input type="date"> using the shared
dateInputValue helper; form action converts back to RFC3339 for Go.
- Existing Accept/Reject buttons untouched — workflow is edit-then-accept.
Rate limits (Mistral web_search 429) used to get counted as hard errors,
marking the bucket as queried and bumping the Errors(24h) strip — even
though the right behavior is to wait and try again later.
Backend:
- isRateLimit() matches "rate limit" / "status 429" in the error string.
- On persistent rate-limit after one 10s retry: leave last_queried_at
unchanged (bucket stays eligible for next tick) and abort the
remainder of this tick — Mistral's web_search budget is shared, no
point hammering more buckets in the same batch.
- TickSummary gains rate_limited counter; Errors stays for real failures.
Frontend:
- Dates: RFC3339 → 'DD.MM.YYYY' German format, range rendered as
'DD.MM.YYYY – DD.MM.YYYY'.
- Queue table: cell horizontal padding, uppercase compact headers,
scrollable on narrow viewports, dark-mode variants on every color
(emerald/amber badges, link color, reject button), Region folds
bundesland||land into a single column (Land was always 'Deutschland'
for DACH anyway).
Go's nil slice marshals as JSON null, not [], which crashed the Svelte
page's .length access on fresh installs where no discovery tick has
happened yet. Reproduced in production: /admin/discovery → 500 because
data.queue was null and {queue.length} dereferenced it.
Backend: initialize every returning slice in repository.go via
make([]T, 0) so zero rows serialize as [] consistently. Also applies to
PickStaleBuckets, ListSeriesByCity, and Stats.RecentErrors.
Web: coalesce data.queue / data.stats.recent_errors at the top of the
Svelte script with `?? []` so future nil-slice regressions don't take
the whole page down.
Surfaces CronJob health signals without needing kubectl: last tick time
(stale-amber if > 6h), buckets due now, errors in the last 24h (with an
expandable list of the most recent failing buckets), and queue size.
Also wires the previously-orphaned /admin/discovery route into the admin
sidebar next to Märkte.
- backend: new GET /admin/discovery/stats endpoint; Stats + BucketError
types; repository Stats() aggregates four counters + top 5 failing
buckets.
- web: +page.server.ts fetches stats in parallel with queue;
+page.svelte renders a 4-card strip above the queue table.
The home page dropped `plz` server-side, so /markets was called with
radius but no center (unfiltered) and the PLZ input rendered empty
after reload. +page.server.ts now reads plz, geocodes via /geocode,
and echoes plz back in searchParams for form rehydration.
Relaxes /geocode DTO + guard to accept PLZ without city — Nominatim
already supports postal-only lookups. URL lat/lon (GPS flow) take
priority over plz on tie-break; geocode failures fall through to no
geo-filter so the page always renders.
Public search now deduplicates to one card per series with an edition
count badge. Admin list uses a grouped endpoint with expandable rows.
Market detail page shows an edition year switcher when multiple editions
exist. Admin detail page includes series edition management.
- Search box for name/city filtering
- Clickable column headers for sorting (name, city, status, date, created)
- Sort direction toggle (asc/desc) with arrow indicators
- All filters preserved through pagination and sort changes
- AI research panel with structured display (opening hours, admission)
- Shared MarketForm component with admin/public mode
- Geocode button to derive coordinates from address
- Opening hours, admission info fields in all forms
- Currency-aware pricing, full European country list
- Public submit now includes all market fields
- Weekday normalization from date for AI research results
- Duplicate detection warning on admin detail view
Backend returns code '2fa_required' but frontend checked for
'totp_required', so the TOTP input field never appeared. Also fix
the same Svelte pattern brace escaping issue as in TOTPSetup.
- Replace inline nav items with UserMenu dropdown (display name trigger,
Profil/Sicherheit/Admin/Abmelden, click-outside/Escape to close)
- Add password set/change form to profile security section
- Fix Turnstile site key (extra A, swapped l/1)
- Add PUBLIC_TURNSTILE_SITE_KEY as Docker build arg and Woodpecker CI arg
- Uncomment auth nav in Header and MobileNav (login/logout/profile links)
- Move ThemeToggle from header to footer
- Expand country dropdown from DACH-only to all European countries
- Replace profile route redirect with requireAuth guard
- Set cookie secure flag based on environment (secure in prod)
- Add error handling to admin markets page (403 instead of 500)
- Admin layout with auth guard at /admin
- Admin market list with status filter tabs (pending/approved/rejected)
- Admin market detail/review with approve/reject actions
- Admin market create and edit forms with shared MarketForm component
- Anonymous market submission form at /markt/einreichen with Turnstile
- Optional latitude/longitude fields on submission form
- Admin and submission types added to API types
- requireAdmin guard for role-based frontend access
- Header/mobile nav updated with admin and submission links
- Auth layout redirect removed to re-enable login flow
- Login form action renamed to fix named actions conflict
- Impressum updated with user-submitted content section
- Datenschutz updated with submission form and Turnstile sections
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
Add /healthz handler in hooks.server.ts that returns early without auth
or SSR processing. Update Helm probes from / to /healthz to avoid
unnecessary log noise and wasted SSR renders.
Add /maerkte/ browse-by-state overview, /maerkte/[state]/ state detail
pages, and /maerkte/[state]/[city]/ city detail pages. Each page fetches
all markets and filters client-side (SSR). Includes slug utilities with
umlaut handling, JSON-LD structured data, breadcrumb navigation on all
pages including market detail, and sitemap entries for all new routes.
- Enable kit.version.pollInterval (60s) so SvelteKit detects new
deploys via version.json
- Add beforeNavigate guard that forces a full page reload when a
new version is detected, preventing stale client-side state
- Preload fonts, JS, and CSS for faster initial paint
The auth and profile layout redirects break SvelteKit's type
generation for child page loads, causing false-positive type
errors where PageData requires `user` from the root layout.
- Add dynamic sitemap.xml with static pages and market entries
- Add canonical URLs and base OG/Twitter meta tags in root layout
- Add JSON-LD WebSite schema with SearchAction on home page
- Add JSON-LD Event schema on market detail pages
- Add twitter:card summary_large_image for markets with images
- Add OG tags to impressum and datenschutz pages
- Add font preloading for critical rendering path fonts
- Add Sitemap directive to robots.txt
Add legal pages required by German law (TMG §5, DSGVO).
Impressum includes operator identity, contact, and liability notices.
Datenschutz covers all data processing: registration, OAuth, sessions,
cookies, geolocation, map tiles, and user rights.
Introduces brand identity with a medieval signet ring motif:
gold fleur-de-lis on dark green signet face. Adds SVG favicon,
raster fallbacks (ICO/PNG), apple-touch-icon, web manifest,
and inline signet ring logo in the header next to the wordmark.
- Add MedievalSharp + Crimson Pro fonts (woff2)
- Implement OKLCH color theme: forest green primary, gold accent,
warm stone neutrals, parchment/vellum surfaces, brick red danger
- Add dark/light mode with system preference detection and user toggle
(ThemeToggle component cycling system/light/dark)
- Prevent FOUC with inline script in app.html
- Replace native <select> with custom accessible dropdown (combobox/
listbox pattern, keyboard nav, type-ahead, app-themed styling)
- Add IP geolocation fallback via geojs.io when native geolocation
fails, with visual location badge showing resolved city name
- Shared form control styles via @layer base
- WCAG/a11y: skip-to-content link, aria-invalid, aria-describedby,
aria-live, focus-visible, role="search", color-scheme:dark
- Update all 23 component/page files with new color system and
dark: variants