307 Commits

Author SHA1 Message Date
Peter Meier 23faef550e feat(calendar): image/attachment per event, direct links, copy link, ImageModal component
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 53s
- calendar_item schema: flyer → image (type: image), attachment (assetUrl)
- CalendarItemData: image + attachment fields
- event anchor IDs (#event-{slug}) with onMount hash scroll + auto-open
- share button uses direct calendar anchor URL
- copy link button (mdi:link-variant) copies anchor URL to clipboard
- ImageModal extracted as reusable component
- action buttons always show text (no expand-on-hover)
- onDestroy window guard (SSR fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:02:30 +02:00
Peter Meier 6fc2c0a345 feat(calendar): hover tooltip, light theme grid, UX polish
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 55s
- Hover tooltip on event cells (pointer:fine only, position:fixed bypasses overflow-hidden)
  — shows title + right-aligned time, max 3 events, "+N weitere"
- Grid background changed to himmel-100 (light), all text tokens updated to dark
- Today cell: wald-200 green background + wald-400 outline
- Empty day cells: subtle bg-himmel-50/40 tint
- Event badge larger on desktop (sm:min-w-4.5 sm:h-4.5 sm:text-[10px])
- Weekday headers: font-semibold text-himmel-800
- Calendar takes full width inside widget (removed max-w-2xl cap)
- Web Share API per event (navigator.share / clipboard fallback, Kopiert\! toast)
- Subscribe bar: webcal:// on Apple, direct ICS download on other platforms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:23:41 +02:00
Peter Meier 6fb2592eae feat(calendar): add feed subscription, bulk export, share & Google Cal
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m3s
- /api/kalender.ics SSR route: ICS feed of all calendar items (webcal-subscribable, Cache 1h)
- buildICSFeed() / downloadICSFeed() for multi-event ICS export
- googleCalUrl() deep-link pre-fills Google Calendar create form
- Web Share API per event (navigator.share on mobile, clipboard fallback on desktop)
  — "Kopiert\!" toast per event for 2s after clipboard write
- Actions bar (subscribe + bulk export) only rendered after onMount
  — Apple platforms: webcal:// link opens Calendar.app natively
  — All others: direct ICS download via <a download>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:50:56 +02:00
Peter Meier 7fa52bb629 fix(calendar): remove hard max-w cap on location text
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:08:17 +02:00
Peter Meier 4dd17b7ac4 fix(docker): add healthcheck and stop grace period
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 56s
Watchtower waits for container to be healthy before stopping the old
one. Grace period allows in-flight requests to drain on shutdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:06:46 +02:00
Peter Meier ec9930762d fix(TopBanner): align text-only page title with content container
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 58s
Replace `container mx-auto px-6` with `container-custom` so the
headline/subheadline block (no image case) matches the main content
padding and max-width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:11:10 +02:00
Peter Meier ef163fb336 feat(layout): add global announcement banner above nav
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m6s
Adds AnnouncementBanner component fed by a new top_banner CMS entry
(site-announcement). Extended top_banner schema and TS types with
optional link + linkLabel fields. Rendered inline-markdown text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:56:42 +02:00
Peter Meier 8db68e073c refactor(calendar): stack layout, wider grid, fixed cell height
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 57s
- Calendar widget stays stacked (calendar above events list)
- Grid widens to max-w-2xl with fixed h-10 cells (no aspect-square height growth)
- Gap between cells: 0.5 → 1.5
- Button system extracted to shared btn-* utility classes in app.css
- Replaced Google Calendar link + Google Maps with OSM
- Removed google icon from iconify subset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:56:40 +02:00
Peter Meier b491e3767a Revert "fix(calendar): stack layout, cap block at 1000px, restore events scroll"
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 51s
This reverts commit bf6ca475db.
2026-05-08 08:18:05 +02:00
Peter Meier bf6ca475db fix(calendar): stack layout, cap block at 1000px, restore events scroll
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 53s
The 2-col grid forced the events list to share the calendar's square
height which cropped event details. Stack calendar over events instead,
cap the whole block at 1000px (centered), constrain the calendar grid to
480px so day cells don't grow oversized, and re-add a 600px max-height
with internal scroll on the events list so the page stays compact and
sticky day-group headers regain their scroll context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:16:57 +02:00
Peter Meier ce4c4570d6 feat(header): keep internal social-nav links in same tab
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 57s
Social-link entries with internal URLs (starting with /) used to open in a
new tab via target=_blank, which is wrong for internal navigation like the
new /kalender entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 07:53:43 +02:00
Peter Meier 892afc2d9f refactor(post-events): read event data from resolved calendarItem
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 53s
Replace direct post.isEvent/eventDate/eventLocation reads with
getPostEventInfo() helper that derives info from the resolved
calendarItem reference. Loaders now resolve "calendarItem" and request
that field instead of legacy flat properties.

Also regenerate iconify mdi subset (map-marker-outline, download,
google, calendar-search, refresh).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:08:16 +02:00
Peter Meier fc6c59bc10 fix(deadline-banner): count calendar days, not 24h windows
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 53s
daysLeft used Math.ceil(ms/24h) which counted partial 24h windows:
30h before a midnight event tomorrow showed "Noch 2 Tage" instead
of "Morgen". Compare midnight-to-midnight day-keys instead, same
approach as CalendarBlock relativeDays().

Also adds data-block="DeadlineBanner" identifier.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:45:22 +02:00
Peter Meier a1c59b549d feat(calendar): future-events accordion, ICS/Google export, multi-day
Right panel now shows all upcoming events as a card-style accordion
(date block + title + location/time chips visible, body collapsed).
Day-grouped with sticky headers + relative countdown.

- ICS download per event (RFC-5545, client-side blob)
- Google Calendar render-template link per event
- Maps link via location field (new schema field)
- Multi-day events via terminEnde (new schema field): grid spans,
  range "Mai / 14–16 / 2026" date block, expanded eventsByDay map
- Tag filter chips (new schema field) above calendar
- Past events <details> at list end, expanded if no future
- Live status badge: "läuft gerade" / "beginnt in 45 min"
  (60s tick interval)
- Today marker on calendar grid (outline + sr-only label)
- Auto-scroll next event into panel viewport on mount
- "Im Kalender anzeigen" jumps grid to event month
- Urgency-colored left border per event: now/today/tomorrow/week/
  later in fire/erde/himmel/stein
- Zebra striping + 4px separator between day-groups
- ARIA: aria-current=date, aria-pressed on day/tag buttons
- Print CSS: details auto-open, day-group break-inside: avoid
- Timezone fix: dayKey uses local Y/M/D not ISO string slice
- Empty-title fallback to "Termin"
- Side-by-side layout from md+ (was always stacked)

Extends CalendarItemData with terminEnde, location, tags
inline until OpenAPI regen catches up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:45:16 +02:00
Peter Meier 4790d571fd feat(strommix): hero variant, 24h sparkline, trend, polishing
- New "hero" variant between compact and full: pct + status +
  sparkline + trend, no sub-cards or accordion
- 24h Wind+Solar sparkline below hero pct, accent-colored
- Trend arrow vs previous hour with delta percentage points
- Polling pauses on tab hidden, refetches on visibility return
  if last fetch >5min old
- Skeleton loader replaces "Lade…" text (matches final layout)
- Retry button on error state (silent vs explicit fetch)
- Stale-flag now prominent red badge in header bar
- Stand-time wrapped in <time datetime title> for full-date hover
- loadMw=0 falls back to last hourly slot instead of red error
- Negative-price banner replaces standalone price card (dedup)
- Production breakdown grid: clean auto/1fr label-value alignment
- Print CSS: details auto-open, chevrons hidden
- All German strings moved to translation keys
- STROMMIX_DEFAULT_THRESHOLDS const as single source for buckets

Includes data-block="LiveStrommix" attribute for the new
identifier scheme + new RenewableSparkline.svelte component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:57 +02:00
Peter Meier 5a86ff701e feat(blocks): data-block PascalCase identifier on every CMS block
Adds data-block="PascalName" alongside existing data-block-type
snake_case so blocks can be located via [data-block="Calendar"] etc.
in DevTools. Snake-case attribute kept for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:40 +02:00
Peter Meier b633ed2d2b fix(rusty-image): preserve animated GIFs through markdown image processing
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 54s
RustyCMS /api/transform decodes only the first frame of animated formats,
which silently kills GIF animation when markdown HTML images get rewritten
to /api/transform URLs. Skip the transform for `.gif` URLs so the original
asset URL flows through and animation survives.

Also picks up an arrow-right entry in the iconify mdi subset generated
during a UI build.
2026-05-07 13:06:37 +02:00
Peter Meier 88514b065a feat(strommix): history-driven panels (residual load, dunkelflaute, neg-price, flow balance)
Aggregator route /api/strommix now reads three RustyCMS history-mode
collections in parallel — strommix_history_de, price_de_history,
crossborder_history_de — and projects derived metrics on top of the raw
upstream snapshots stored there:

- todayHourly: per-slot Last + Wind+Solar for the residual-load chart
- negPriceHoursYtd: count of YTD slots where DE-LU day-ahead < 0
- crossborderTodayGwh / netCrossborderGwhYtd / crossborderByCountryGwhYtd:
  GW × slot-hours integrated into a per-country GWh balance
- lastDunkelflaute / worstDunkelflauten: contiguous runs where
  (wind+solar)/load drops below 10 % for at least 1 h, top 5 by duration

All four are null-graceful — collections that the CMS hasn't backfilled
yet leave the corresponding panel hidden instead of breaking the widget.

Five new components in lib/components/blocks/strommix/:
- ResidualLoadChart.svelte — SVG line chart, no chart-lib dep
- CapacityFactorBars.svelte — live vs installed Wind/Wind-Off/Solar
- DunkelflauteCounter.svelte — last phase card + top-5 list
- NegPriceCounter.svelte — YTD hours + manual EinsMan compensation
- FlowBalanceTodayYtd.svelte — today + YTD saldo with per-country breakdown

Block schema additions (block-types.ts + strommix-default.json5 in
cms_content): installed_*_gw / installed_as_of for the capacity-factor
bars (BNetzA Marktstammdatenregister snapshot, manually maintained), and
einsman_costs_ytd_eur_mio / einsman_costs_as_of for the NegPrice panel
(BNetzA quarterly report figures, no API).
2026-05-07 13:06:30 +02:00
Peter Meier c4a09f03f4 chore: sync lockfile with @fontsource-variable/aleo, drop yarn.lock
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 1m9s
Lora -> Aleo dependency change in 4419742 left package-lock.json out of
sync, breaking `npm ci` in CI. Regenerated via `npm install --package-
lock-only`. Removed yarn.lock that came back with the same commit;
single source of truth is package-lock.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 16:22:09 +02:00
Peter Meier 44197429d6 feat(ui): deadline banner with gradient blob bg, swap Lora→Aleo
Deploy / verify (push) Failing after 17s
Deploy / deploy (push) Has been skipped
- DeadlineBannerBlock: redesign nach Tailwind-UI-Vorlage (gray-50, blurry blobs, dismiss button)
- new .gradient-blob-bg utility (radial-gradient blobs via ::before/::after)
- apply blob bg to Header
- @theme: register --font-sans (Inter) + --font-serif (Aleo)
- replace @fontsource-variable/lora with /aleo for blockquote font
- Header: simplify active nav-link styling
- QuoteBlock: formatting + drop borderClass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 16:17:08 +02:00
Peter Meier 3f62473c32 fix(embed): skip site chrome on /widget routes via root layout
Deploy / verify (push) Successful in 50s
Deploy / deploy (push) Successful in 52s
(embed)/+layout@.svelte only resets intermediate layouts — root
+layout.svelte still wrapped every widget page with Header, Footer,
TopBanner, Breadcrumbs, Skip-Link, OG/JSON-LD/Analytics.

Root layout now derives isEmbed from $page.route.id (matches /(embed))
and renders just {@render children()} in that branch. svelte:head is
gated too: embed mode emits only the favicon, leaving the page free to
set its own <title> and noindex meta.

+layout.server.ts short-circuits the bootstrap batch (navigation,
footer, page_config, logo) for embed routes — saves one CMS roundtrip
+ logo transform per widget request, and avoids double-counting in
Umami/pm86 analytics when the widget is iframed elsewhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 10:04:01 +02:00
Peter Meier 63a9721841 feat(embed): standalone widget route /widget/<collection>/<slug>
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 53s
Renders a single CMS entry as a block in isolation — no header, footer, or
preview banner. Lets us share a deep link to a single widget (e.g. the live
strommix gauge) for embedding or quick previews without exposing the rest of
the page.

- New BlockRenderer.svelte: switch over `_type` shared with ContentRows.
- Generic `getEntryBySlug(collection, slug, options)` in cms.ts so any
  collection (live_strommix, deadline_banner, image, ...) is fetchable.
- Route group `(embed)/+layout@.svelte` resets the layout chain so no
  Header/Footer/TopBanner inherit. `x-robots-tag: noindex`.
- Optional `?preview=<token>` for drafts. Cache: short s-maxage for live,
  no-store for preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:37:21 +02:00
Peter Meier 1f3fbe7fa7 fix(footer): scope lead-paragraph rule to <article> only
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 58s
Lead-paragraph rule matched footer MarkdownBlocks (copyright line)
and made it 1.25rem. Restrict to article descendants so footer +
sidebar Markdown stay at their natural size.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 01:12:05 +02:00
Peter Meier b7093b703a style(design): consistency sweep + UX upgrades
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 1m0s
- Palette: gray/zinc/slate/neutral → stein, red → fire, amber → erde,
  sky → himmel, green → wald
- Footer: replaced raw rgb with stein tokens
- Radius: unified to rounded-xs across cards, buttons, inputs, pagination
- Buttons: .btn-primary/.btn-secondary now font-medium; added secondary
- Card hover: -translate-y-0.5 + shadow + wald-300 border
- Header active link: wald border-bottom + wald-700 text
- Mobile nav: bigger touch targets + wald active accent
- Pagination: prev/next text labels + wald-500 active state with
  separated shape/color classes to avoid Tailwind conflicts
- Lead paragraph: scoped .markdown > p:first-child for top-level
  MarkdownBlock only via [data-block-type="markdown"]
- Section dividers: subtle wald-200 gradient between content rows
- Global focus-visible ring (wald-500)
- Inline hex → palette tokens (Badge amber, Tag custom-color contrast)
- Font weights snapped to design system (300/400/500/600/700)
- transition-all → transition-colors where only color changes
- Removed em-dashes from user-visible templates

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 01:06:22 +02:00
Peter Meier 3ded972642 feat(strommix): variant system + wald palette + UX polish
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 52s
Add full/compact variants, prominent share, source badge, big
negative-price banner with conditional disclaimer, cards-style
context row, fade-in animation, and remove em-dashes from
visible strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 00:43:18 +02:00
Peter Meier 080e69dcac fix(strommix): bypass CMS for Energy-Charts production — upstream now needs explicit start/end
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 49s
Energy-Charts' /public_power endpoint started rejecting requests without
`start` + `end` query params ("HTTP 404: no content available"). The
CMS-cached `strommix_de` external schema cannot pass dynamic time
windows (external-collections v1 has no runtime-vars), so the per-field
fallback chain was effectively dead — when SMARD's load filter lagged
12+ h, both sources returned null and the widget surfaced "Lastwert
nicht verfügbar" with all zeros.

Aggregator now fetches /public_power directly with a 2-hour window,
picks the latest non-null per series, and feeds those values into the
existing per-field fallback. CMS strommix_de schema becomes obsolete
for the widget but stays available for admin debugging.

Same shape as the previous CMS payload, so the fallback merge is
unchanged. Picks `Wind onshore`, `Wind offshore`, `Solar`, `Biomass`,
`Hydro Run-of-River`, `Fossil brown coal / lignite`, `Fossil hard
coal`, `Fossil gas`, `Nuclear`, `Load` from production_types.
2026-05-05 00:09:25 +02:00
Peter Meier 0d13e85639 fix(strommix): discard SMARD snapshot when aligned timestamp >90 min stale
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 49s
When SMARD's load filter (id 410) lags 12+ hours, the aligned-min
strategy in fetchSmardSnapshot produces a snapshot stamped with that
12-hour-old timestamp — and the widget surfaces it as the live "Stand
HH:MM" header. With Energy-Charts already returning fresher numbers
through the CMS path, falling back entirely is the right move.

Threshold is 90 min: tight enough to catch obvious lag, loose enough
to keep normal 30–60 min SMARD delay as the canonical source.
2026-05-05 00:06:11 +02:00
Peter Meier 9ebca1c237 fix(strommix): full i18n + drop hardcoded grid constraints
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 52s
Two issues from the audit:

1. Hardcoded `lg:col-span-8 lg:col-start-3` collided with the layout
   coming from `getBlockLayoutClasses(block.layout)`. Tailwind picked
   `lg:col-start-3` from the hardcoded class (no opposing
   `lg:col-start-1` in the layout output) so the widget started at
   column 3 with span 12 → overflowed past the 12-col grid on
   desktop, looking like it was "hanging right". Drop the hardcoded
   constraints; the layout config from the CMS controls width
   (mobile/tablet/desktop = 12 by default = full width).
2. Roughly 25 German strings were inline. Extracted them to
   translation keys (T.strommix_*, registered in translations.ts) and
   read via `useTranslate()` like ImageGalleryBlock /
   PostOverviewBlock / YoutubeVideoBlock. Includes field labels for
   the breakdown disclosure, source attribution variants, status
   bucket markers, error / loading / fallback messages, and the
   conventional formula text shown under the "Konventionell jetzt"
   number.

Default DE strings live in cms_content/windwiderstand/translation_bundle/
app.json5 (separate CMS push).
2026-05-05 00:02:27 +02:00
Peter Meier 2d85681252 fix(strommix): per-field fallback to Energy-Charts when SMARD value missing
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 48s
The widget rendered "Keine gültigen Live-Werte" the moment SMARD's
load filter (id 410) lagged behind the production filters by even one
slot — the aggregator coalesced "filter unavailable" with "filter
returned 0" and the percent calculation refused to divide by zero.

Three changes:

1. SmardSnapshot.values now uses `number | null` so callers can tell
   "filter genuinely missing" apart from "filter reported zero" (e.g.
   solar at night, decommissioned nuclear).
2. Aggregator pulls Energy-Charts (CMS strommix_de) in parallel and
   uses it as a per-field fallback for any SMARD null. New
   `dataSource: "smard+energy-charts"` flag travels in the response so
   the attribution stays honest. Pure SMARD when everything resolves;
   pure Energy-Charts when SMARD is entirely down; the hybrid label
   for the common case where only load drops out.
3. Widget renders the production breakdown + context row + price row
   even when the percentage is unavailable, with a clear "Lastwert
   von SMARD gerade nicht verfügbar" hint instead of going blank.
2026-05-04 23:55:57 +02:00
Peter Meier 96cd9e9c57 fix(strommix): SMARD as primary source — accuracy parity with audit
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 49s
Audit at 22:15 MESZ flagged the conventional sum (lignite + hard coal
+ gas + nuclear) as 19.1 GW in the widget vs 15.65 GW in reality. The
breakdown showed the gap was Energy-Charts' `Fossil gas` (6.9 GW)
which bundles natural gas + GuD + similar, while SMARD's `Erdgas`
filter 4069 (3.8 GW) tracks just natural gas the way the widget label
implies. SMARD is the canonical German grid feed; switching the
primary source resolves the discrepancy directly.

- New `lib/smard.ts` fetches the 11 relevant filter IDs in parallel:
  conventional (1223/4070/4069/1224), wind (4067/1225), solar (1226),
  hydro (1228), biomass (4066), other-conventional + pumped storage
  (1227/4071, surfaced separately), load (410). Two-step protocol
  (index.json → archive.json) handled inside; output is MW (MWh × 4)
  aligned to one common slot timestamp so every value on screen comes
  from the same point in time.
- Aggregator route uses SMARD for production + load, Energy-Charts
  only for cross-border saldo + day-ahead prices (where SMARD has no
  convenient single-call endpoint). `dataSource: "smard" |
  "energy-charts"` flag travels in the response so the widget can
  attribute the numbers and fall back gracefully when SMARD is down.
- StrommixBlock gets a "Werte prüfen" disclosure that lists every
  component (wind onshore + offshore, solar, the four conventional
  buckets, biomass, hydro, load) with its MW value and the slot
  timestamp + data source. Fact-check users can verify the addition
  themselves now without asking.

Verified live: SMARD @ 21:00 UTC = lignite 8484 + hard coal 3480 +
gas 3811 + nuclear 0 = 15.78 GW conventional. Matches the user's
ground-truth 15.65 GW within sampling noise.
2026-05-04 23:47:52 +02:00
Peter Meier f4e4abd978 fix(strommix): pick price at "now" + surface cross-border timestamp
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 49s
Audit against ground truth at 22:15 lokal exposed three real bugs:

1. Day-ahead price was wrong (widget 125, real 153 €/MWh). The CMS'
   `last_non_null` transform always picks the last cell of the 96-slot
   array — typically a slot in the future, not the slot covering "now".
   Bypass the CMS for prices: aggregator fetches energy-charts /price
   directly, walks unix_seconds, and picks the largest start ≤ now.
   `priceMeasuredAtUnix` is surfaced so the widget can show the slot
   timestamp as a sanity check.

2. Cross-border saldo (cbpf) typically lags 1.5–2 h. Widget labelled
   it as "jetzt" even when the upstream's last datapoint was 90 min
   old. Now the cross-border timestamp travels through the aggregator
   and the widget renders "Stand 20:30 Uhr · verzögert" when the gap
   to the production snapshot exceeds 30 min.

3. Bucket label "Dunkelflaute" misuses the term — the formal
   definition needs ≥24 h history, not a single threshold crossing.
   Updated the strommix-default content entry to use "Sehr niedrige
   Einspeisung" / "Very low feed-in" instead, and explained the
   reasoning in a comment so editors don't re-introduce the misnomer.

Aggregator response shape gained `crossborderMeasuredAtUnix` +
`priceMeasuredAtUnix`; the widget renders both as small timestamps
under their values, plus a "verzögert" hint when cross-border data is
older than the 30-min tolerance window.

Cache TTLs on the price external collections lowered from 30 min to
15 min so the admin-side cache flips at the same cadence as the
day-ahead slots (since 30.09.2025 prices come in 15-min buckets).
2026-05-04 23:35:13 +02:00
Peter Meier 8ddfd026bc feat(blocks): live_strommix widget for energy-charts data
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 53s
Editor-controlled live-strommix card that surfaces what share of
electricity demand is currently covered by weather-dependent
renewables (Wind+Solar / Load), plus the conventional generation that
fills the gap, the cross-border saldo, and the day-ahead price (DE/FR
side-by-side). Status bucket + label + argument come from the CMS so
editors can iterate copy without a deploy.

- LiveStrommixBlockData type next to the other block-types.
- New `/api/strommix` aggregator route bundles four CMS external
  collections (strommix_de, crossborder_de, price_de, price_fr) into a
  single payload, computes the renewable + conventional sums and the
  cross-border saldo (Σ flow_xx_gw, sign convention: + = export from
  DE), surfaces stale-on-error flags from the CMS response headers.
  Cache-control 5min so a single node only hits energy-charts once
  per window.
- StrommixBlock.svelte renders the card: percentage, bucket label,
  bucket argument (markdown), context row (conventional + saldo with
  ↑/↓ arrow), price row with optional FR comparison + negative-price
  badge, footer with disclaimer + "Wie wird das berechnet?" link +
  stale-data hint when the CMS served fallback. Re-polls every 5min
  client-side; bucket thresholds + labels + arguments come from the
  block instance so multiple placements can carry different copy.
- ContentRows.svelte dispatcher branch on `_type === "live_strommix"`.

Wired against `cms_content/_types/core/live_strommix.json5` (added in
the rustycms repo today). content_layout.row{1,2,3}Content already
allow the new block-type via the same commit on the CMS side.
2026-05-04 22:57:40 +02:00
Peter Meier a2ca0db9ca feat(security+obs): SSRF allowlist, HTML sanitization, timing-safe tokens, fetch timeouts, structured logger
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m17s
Security:
  - cms-images / cms-files now share resolveCmsSource (cms-source.server.ts)
    which enforces an allowlist: asset filename, absolute path on the CMS
    host, or full URL exact-matching the configured CMS host+protocol.
    Foreign hosts, protocol-relative URLs and path traversal return 400.
  - markdown-safe.ts is the single configured marked entrypoint; it
    disables raw HTML rendering globally (renderer.html() returns ''),
    removing the inline script/iframe injection vector via markdown.
    All 8 marked imports across components and loaders now go through it.
  - sanitize-blocks.server.ts walks resolved page/post content and runs
    HtmlBlock html fields through sanitize-html with a tight allowlist
    (no script/style/on*, only youtube/vimeo/spotify iframes, mailto/tel
    plus http(s) schemes, target=_blank gets rel=noopener).
  - Webhook tokens (revalidate, cache/clear) compared via
    crypto.timingSafeEqual through auth-token.server.ts.
  - cms.ts wraps every fetch in cmsFetch(): 5 s default timeout via
    AbortSignal.timeout, override per call or globally via
    PUBLIC_CMS_FETCH_TIMEOUT_MS. Stops slow CMS responses from pinning
    SSR workers indefinitely.

Observability:
  - log.server.ts gives logWarn / logError / logInfo with scope + context;
    swallowed catch {} blocks in hooks, layout.server, +page.server,
    [...slug]/+page.server and sitemap now log instead of going silent.

Routing:
  - Legacy /blog, /blog/page/:n, /blog/tag/:t and /p/:slug stub routes
    deleted; their 301 redirects moved into hooks.server.ts so the
    request never enters the SvelteKit page pipeline.

Tests (vitest, server-side):
  - auth-token.server.test.ts (6 cases for constant-time compare)
  - cms-source.server.test.ts  (8 cases for SSRF allowlist)
  - sanitize-blocks.server.test.ts (9 cases for sanitizer + walker)
  All 23 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:09:25 +02:00
Peter Meier 615d9cdbd1 chore(tooling): add vitest + svelte-check, drop yarn.lock, CI gates, typed env imports
Toolchain hygiene:
  - svelte-check is now a pinned devDep (was npx-fetched per CI run)
  - vitest + coverage as devDeps; vitest.config.ts is separate from
    vite.config.ts so vite build does not pull vitest
  - $env stub for vitest (alias resolves $env/dynamic/{public,private}
    to a process.env proxy) — keeps server modules testable without
    booting SvelteKit
  - npm scripts: check / check:watch / typecheck / test / test:watch
  - drop yarn.lock + packageManager field; CI uses npm ci, single source
    of truth is package-lock.json
  - CI runs generate:icons + svelte-check + tsc --noEmit before deploy

Switch from raw process.env.X to $env/dynamic/{public,private} in
file-cache, image-cache, transform-cache and rusty-image.server. The
typed imports get tree-shaken correctly per server/client and fail
loudly at build time on accidental client-side use of private vars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:09:02 +02:00
Peter Meier d0dede92fe perf(iconify): tree-shake MDI to used icons (-99.5% JSON, -71% client bundle)
The previous setup loaded the full @iconify-json/mdi collection (7000+
icons, 2.95 MB) just to render the ~30 icons we actually use, producing a
2.99 MB client chunk.

generate-icons.mjs scans src/ for static `icon="mdi:..."` references,
extracts a minimal IconifyJSON containing only those icons (plus an
EXTRA_ICONS list for dynamic CMS-driven names) from
@iconify-json/mdi/icons.json, and writes it to a generated subset file.
iconify-offline.ts now registers that subset instead of the full pack.

  build/client: 5.6 MB -> 1.6 MB (-71%)
  build/server: 5.3 MB -> 2.4 MB (-55%)
  largest client chunk: 2.99 MB -> 127 KB (-96%)
  iconify subset: 2.95 MB -> 15.5 KB (-99.5%)

Hooked into predev/prebuild so the subset stays fresh; missing icons
fall back to the iconify API at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:08:47 +02:00
Peter Meier 56084ac542 feat(maintenance): static fallback when the CMS is unreachable
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m4s
Adds a self-contained maintenance page that the SvelteKit handle hook
serves before the first CMS call when `isCmsAvailable()` returns
false. The probe hits `/api-docs/openapi.json` with a short timeout
and caches the result (10s healthy, 3s unhealthy) so we don't pay a
round-trip per request. `/maintenance` and `?cms_down=1` simulate
the page without taking the CMS down — useful for previewing the
copy.

The maintenance HTML is fully inline (no CMS calls, no layout, no
external CSS) so it survives even when every dependency is down.
Returned with 503 + Retry-After:60 + noindex headers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:47:15 +02:00
Peter Meier 1e9ef44b52 feat(header): highlight active nav link with bold + underline
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 1m9s
Compares the link href against `$page.url.pathname` (with trailing-slash
normalization + subpath match), applies `font-bold underline` and
`aria-current="page"` on a match. Wired into both the desktop horizontal
nav and the mobile slide-down menu.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:29:05 +02:00
Peter Meier 5cf141fa4a chore(blog-overview): apply prettier formatting
No behavior change — IDE auto-format pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:29:00 +02:00
Peter Meier 616fb66c00 fix(event-badges): suppress parent-link underline on hover
When EventBadges sits inside a `<a hover:underline>` (upcoming-events
list, post cards) the underline propagated through the badge spans on
hover. Force `text-decoration: none` on the date and location pills so
only the headline text underlines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:07:56 +02:00
Peter Meier 4db2fd8562 fix(blog-overview): use EventBadges in upcoming-events list
The plain `<time>` was visually weak next to the headline. Reuse the
`EventBadges` component from the post card so the date renders as a
proper wald-tinted pill — same shape as event posts. Drops the local
`formatEventDate` since EventBadges does the localization itself
(weekday + time included, matching post cards).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:06:41 +02:00
Peter Meier dcce4fdea1 fix(blog-overview): upcoming-events block escapes prose ul-bullet rule
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 1m7s
`main ul:not(.not-prose) li` injects a disc bullet on every list under
`main`, which the upcoming-events list inherited. Mark the ul `not-prose`
+ `list-none` so the rule doesn't match. Also bump the calendar-clock
icon from `h-4 w-4` to `size-5` so it visually matches the heading
ascender.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:03:47 +02:00
Peter Meier 00d42ebb42 feat(preview): consume rustycms live-preview drafts
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 1m1s
`?preview_draft=<sid>` on any page/post route forwards the signed
session id to the CMS, which renders the in-memory draft instead of
the persisted entry. Layout listens for `rustycms:reload` postMessages
from the admin iframe parent and re-runs `invalidateAll()` to pick up
fresh draft state. CSP `frame-ancestors` is set per-request when
`PUBLIC_PREVIEW_PARENT_ORIGIN` is configured so the admin iframe can
embed the response.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 09:24:05 +02:00
Peter Meier 3bc7273e71 perf(cms): batch layout bootstrap, sparse fieldsets, depth caps
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m16s
Adopt RustyCMS' new resolve semantics (_depth, _fields,
POST /_batch) where they pay off:

- +layout.server.ts: 4 sequential GETs (navHeader, navSocial,
  footer, page_config) collapsed into one batchFetch() call.
  Legacy "default" page_config slug stays as fallback Single-
  Batch only when the canonical slug is missing.
- New getPageStubs() / getPostStubs() use _fields= so the
  Slug→Label/Slug→URL maps don't pull body/rows. Typically
  10-50× smaller payload, eligible for the same TTL cache.
- loadPostsList() now requests posts with _depth=2 and a
  sparse fieldset that keeps card-level fields plus
  postImage.* and postTag.{name,icon,color,_slug}. Body
  Markdown stays out of the listing entirely.
- Page + post detail routes set _depth=3 as a cycle/blast-radius
  cap (page → block → image → img reaches in three levels).
- cms.ts: ContentGetOptions gains depth/fields, getPageBySlug /
  getPostBySlug / getPosts thread them through (cache key
  extended). New batchFetch() / batchData<T>() helpers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:00:10 +02:00
Peter Meier 35f7aa8f90 feat(blocks): YoutubeVideoGalleryBlock (carousel + grid)
Deploy / verify (push) Successful in 2m19s
Deploy / deploy (push) Successful in 1m7s
New block for `youtube_video_gallery` CMS type. Carousel renders
2 iframes side-by-side on md+ (1 on mobile) with prev/next/dots; grid
shows YT thumbnails opening modal embeds. Description supports
markdown via marked; long URLs break with overflow-wrap:anywhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:31:38 +02:00
Peter Meier 3d343822e9 feat: print-friendly layout + comment count on every post overview
Deploy / verify (push) Successful in 42s
Deploy / deploy (push) Successful in 1m4s
- Hides Header / HeaderOverlay / Footer / TopBanner / Breadcrumbs and
  the comment accordion when printing. Action toolbar already had
  `print:hidden`.
- Adds @media print rules in app.css: 1.5cm page margins, 11pt body,
  forced black-on-white, expand link URLs after anchor text inside
  articles, keep images and headings together at page breaks.
- PostCard now renders the count pill whenever a value is known (incl.
  zero); previously hidden for `count === 0`, so posts whose comments
  are still pending always looked empty.
- Extracts the bulk-fetch helper to `lib/comment-counts.ts`
  (`fetchCommentCounts` + `postSlugForComments`) and uses it from both
  `BlogOverview` and `PostOverviewBlock`. Homepage / any page with a
  post_overview block now shows the badges too — previously only the
  paginated /posts/* routes did.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 15:29:15 +02:00
Peter Meier 6f14e4f150 style(post): polish post header + actions toolbar
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m4s
- PostActions: every item is a rounded-full pill with subtle border;
  the three icon-only actions (share/copy/print) cluster in one
  segmented control with divider lines, count + reading-time stand
  alone left and right
- Share button only renders when `navigator.share` is available so
  desktop doesn't get a duplicate of "Copy link"
- All action buttons drop their inline labels and use `title=` +
  `aria-label` for tooltip-only access; copy still flips icon to a
  checkmark for 1.5s on success
- Empty comment state stacks vertically on mobile (icon above), goes
  horizontal + left-aligned on ≥sm
- Post header rebuilt: image fixed at 150x150 (no more mobile blow-up),
  right column is `flex flex-col gap-3` so date/tags/actions share one
  rhythm instead of ad-hoc margins
- Reading-time now also walks `row*Content` and pulls `content`/`body`/
  `text` strings — Termin-Posts with empty body but markdown rows now
  show a meaningful estimate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 15:08:04 +02:00
Peter Meier 6b2b7245f4 refactor: fold comment count into PostActions toolbar
CommentCountBadge was a separate component rendered next to PostActions
— two adjacent strips of buttons that should have been one toolbar.
Merges the count fetch + click-to-scroll behaviour into PostActions
behind `commentPageId` / `commentTargetId` props. The standalone
CommentCountBadge.svelte is now redundant; deleted.

Layout under the post hero is now a single row:
[💬 3]  [↗ Teilen]  [📋 Link]  [🖨️]  [⏱ 4 min]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:52:30 +02:00
Peter Meier 3318fe9e17 feat(post): post actions toolbar (share / copy / print / reading time)
Adds a small toolbar next to the comment-count badge with four actions:

- Share — uses `navigator.share()` on supporting devices, falls back
  to copy-link otherwise
- Copy link — clipboard API with `<input>` + `execCommand` fallback,
  flashes a checkmark for 1.5s on success
- Print — `window.print()`, hidden via `print:hidden` so it doesn't
  show up in the printed output itself
- Reading time — server-computed from headline+body HTML at ~200 wpm,
  hidden when 0

Reading time + canonical URL come from `+page.server.ts` so SSR
output is identical and shareable URLs render correctly.

All labels go through the translations layer (5 new
`post_action_*` keys).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:42:16 +02:00
Peter Meier e4804ce673 i18n(comments): all UI strings through translations
Pulls every German string out of Comments.svelte, PostCard.svelte and
CommentCountBadge.svelte and routes it through `useTranslate()` +
the `T.*` key registry. New keys (`comments_*`, `post_overview` count
aria, `comments_jump_to`) are registered in `lib/translations.ts`.

Default DE values land in
`cms_content/windwiderstand/de/translation_bundle/app.json5` (committed
separately) so the CMS-loaded bundle keeps the same look without each
component falling back to its raw key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:34:48 +02:00
Peter Meier 8f791ebc54 feat(blog): comment count badge on post overview
Deploy / verify (push) Successful in 38s
Deploy / deploy (push) Successful in 59s
PostCard now shows a small comment-count pill in the top-left of the
image when count > 0. BlogOverview bulk-fetches counts for the visible
posts via the new `/api/comments/counts?ids=…` endpoint and passes
them down. One request per page render, regardless of card count.

- New helper `getCommentCounts()` in `cms.ts`
- `BlogOverview.svelte` derives slugs from posts, fires bulk fetch
  in `$effect`, hands the result map to `PostCard`
- `PostCard.svelte` accepts `commentCount?: number | null`, renders
  pill only when value is a positive number

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:26:09 +02:00