Repository navigation
feat: weather from MET Norway for household locations - #355
Conversation
Security Agent review of PR #355Scope: outbound HTTP to api.met.no / frost.met.no, scheduler, new endpoints and the five Findings (blocking until fixed or explicitly acknowledged by the team)1. Medium - read path loads every historical forecast run for the window and resolves the effective step in memory
Observations (non-blocking, no action required)
Verdict: one Medium finding above. All findings are blocking until resolved or explicitly acknowledged by the team. Generated by Claude Code |
|
Accessibility Agent review: changes requested (frontend changes detected). Scope: What is already good: decorative icons go through 1. Dashboard picker is unmounted while it has focus, so focus is lost
2. "Refresh on a schedule" error is not programmatically tied to the switch
3. Settings → "Household default location" select probably has no accessible name or description
4. Failure explanation is hover-only (
5. 9-day strip is a scroll container that is not guaranteed keyboard-reachable, and needs sideways scrolling at narrow widths
6. Loading state and live regions
Low severity / best practice (not blocking):
Items 1, 2, 3 and 4 (Level A or AA) should be fixed before merge. Items 5 and 6 are also AA/A, but I rate them lower in practice. Please add tests for the name and describedby on the settings select and the tracked switch. Generated by Claude Code |
Senior Frontend Agent review of PR #355Scope: What is good:
Blocking1. Purge dialog can be dismissed while the purge is running (
2. Choosing a location in the picker drops keyboard focus (
3. A failed weather-list load is silent and shows the wrong state (
Non-blocking
Verdict: three blocking items (1-3) and the non-blocking items above. Not approved until the blocking ones are fixed or explicitly acknowledged. This is a review verdict, not an approval action. Generated by Claude Code |
Senior Architect Agent review of PR #355 (weather, issue #348)Reviewed at Verdict: changes requested. There is one blocking finding, two should-fix findings and a few non-blocking ones. The security posture is otherwise strong. What is right
Blocking1. Every read materialises every stored run's steps in the window, and nothing prunes them.
Should fix2. A tracked location suspended by the cap serves frozen data flagged as fresh.
3. Admin writes race the scheduler and return 500 instead of 409.
Non-blocking
No convention violations found on package versions, field naming, migrations or hand-written role-claim migrations (none needed). Generated by Claude Code |
|
❌ Senior Tester Agent: test review found issues I ran the weather tests on this branch and all pass:
The tests that exist are good: deterministic ( 1.
2.
3. The two weather rate-limit policies are untested.
4. The HTTP success paths for several endpoints are not exercised.
5. The data-export row content is not asserted (minor).
6. Non-determinism (minor).
Items 1 and 2 are the ones to fix before merge: they are new behaviour that nothing exercises, and the first concerns the only code that makes scheduled outbound calls to MET. I did not file separate Generated by Claude Code |
Fixes from the PR #355 review agents. Backend: - Reads and the day rebuild pick the effective step in SQL with an anti-join on the composition index. Superseded runs are history and never pruned, so ranking every candidate in memory cost one row per stored run per hour, on a read any role can repeat. - "Stale" also covers age: a forecast older than three refresh intervals (never under 3 h). This catches a place paused past a lowered cap, or a scheduler that is down, which record no failure. - An Admin enable, configure or apply that loses the schedule Version race now returns 409 fetch-in-progress, like the purge, instead of a 500. - The day rebuild retries a duplicate key once instead of recursing. Client: - Choosing a place in the picker reloads in place, so focus stays on it (WCAG 2.4.3). - The purge dialog cannot be dismissed while it runs. - A failed weather-list load hides the Admin weather surfaces instead of offering "Turn on weather" on places that have it. - Settings Save cannot double-submit. Refresh guards against a second click and treats a 504 as "may still finish". - The settings select is named via aria-labelledby. Failure help is visible text, not a title. The scrollable day strip is focusable. - The tracked-switch error is in the switch's description. The status regions and the purge run are announced. Tests added for the scheduler (cap, no contact, no Frost secret), the weather settings lookup, both rate limits, the hourly, daily, event, entry and apply success paths, the many-runs read, the Version race and age-based staleness. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
|
This is the response to review round 1. Every blocking finding is fixed in 9fee5e0. Security
Architecture
Frontend
Accessibility
Tests
Local results: Core 1810, Api 3053 and Client 3817 tests pass; the weather MariaDB tests pass (6). Generated by Claude Code |
|
✅ PR was re-reviewed by Security Agent at 9fee5e0 and no issues were found: Approved. Finding 1 (Medium, unbounded in-memory effective-step pick) is resolved: Generated by Claude Code |
Senior Architect Agent re-review of PR #355 at
|
|
Accessibility Agent re-review of head 9fee5e0: one residual finding. This was a static review of the diff Verified as resolved
Residual finding 1. Focus is still lost on the dashboard picker while it saves (partially fixed)
Everything else in this PR's frontend looks fine from an accessibility standpoint. Please fix item 1 and I will approve. Generated by Claude Code |
Senior Frontend Agent re-review of PR #355 at
|
|
✅ PR was reviewed by Accessibility Agent and no issues were found: Approved. Re-review of head a44ed2e. This was a static review of the diff The residual 2.4.3 Focus Order finding is resolved. Generated by Claude Code |
|
✅ PR was reviewed by Senior Tester Agent (round 2, head a44ed2e) and no blocking issues were found. Round-1 items 1-4 are resolved. I ran the weather tests at this head, and everything passed:
I accept your answers on items 5 and 6. Non-blocking observations:
Generated by Claude Code |
Forecasts come from Locationforecast 2.0 and measured history from Frost, for the locations an Admin turns weather on for. Weather copies a frozen, 3-decimal site when it is turned on, and a later move of the location changes nothing until an Admin applies it. Only an Admin can cause coordinates to leave the deployment, and no read ever calls Frost. - Eight tables, append-only for sites, runs, steps and observations, and derived for days. Location keys are RESTRICT. A scheduler lease and a Version token give claiming and write fencing, so a disable, an apply or a purge discards any fetch already in flight. - The scheduler runs once a minute at 1 request/s or less. Backoff is 15 min x 2^n, capped at 6 h. 403 and 429 go straight to the cap, and a longer Retry-After wins. - Reads (current, hourly, daily, calendar event, journal entry), the picker and a per-user preference, plus the Admin endpoints: upsert, apply-location, refresh and a resumable batched purge. - Four settings (contact, interval, tracked cap, household default) and the Frost client id as a secret. Five claims, three of them Admin-only. - Day coverage counts a period that reaches into a day from the one before it. Its values stay on its own day. The Admin data export carries WeatherLocations only. The history tables are excluded with their reasons, and the deployment docs gain the disable -> purge erasure step and the release notes. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
Brings the client in line with the design system's weather pass (Weather.jsx, weather-data.js, weather.css) for issue #348: - The dashboard weather card. It shows current conditions, up to nine days, yesterday's measured values and MET's attribution as served. A picker saves the caller's own default. Each empty state names its way out, and there is no refresh button. The strip anchors on the place's own today, not the viewer's. - On Locations, for an Admin only: the row chip, the Weather section, the weather menu items, and the Turn on / Weather settings, Apply new position and Delete weather history dialogs. The purge needs a typed confirmation and can be resumed after a 504. - System settings gains a Weather card: the contact, the interval, the tracked cap, the household default (a served select) and the Frost client id. - IWeatherApiClient in Odyssey.ApiClient, with no UI dependencies. The coordinate rounding and the fingerprint are WeatherRules in Odyssey.Dtos, so the client names the same rule the server does. The design-system sync is recorded at 1792793890-06cb. The exchange-rate sync design and the revised Alert, Button, Modal, RecordTable and Switch atoms are not part of this issue and stay pending. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
Fixes from the PR #355 review agents. Backend: - Reads and the day rebuild pick the effective step in SQL with an anti-join on the composition index. Superseded runs are history and never pruned, so ranking every candidate in memory cost one row per stored run per hour, on a read any role can repeat. - "Stale" also covers age: a forecast older than three refresh intervals (never under 3 h). This catches a place paused past a lowered cap, or a scheduler that is down, which record no failure. - An Admin enable, configure or apply that loses the schedule Version race now returns 409 fetch-in-progress, like the purge, instead of a 500. - The day rebuild retries a duplicate key once instead of recursing. Client: - Choosing a place in the picker reloads in place, so focus stays on it (WCAG 2.4.3). - The purge dialog cannot be dismissed while it runs. - A failed weather-list load hides the Admin weather surfaces instead of offering "Turn on weather" on places that have it. - Settings Save cannot double-submit. Refresh guards against a second click and treats a 504 as "may still finish". - The settings select is named via aria-labelledby. Failure help is visible text, not a title. The scrollable day strip is focusable. - The tracked-switch error is in the switch's description. The status regions and the purge run are announced. Tests added for the scheduler (cap, no contact, no Frost secret), the weather settings lookup, both rate limits, the hourly, daily, event, entry and apply success paths, the many-runs read, the Version race and age-based staleness. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
Disabling the focused select during the save dropped focus to <body>, which fails WCAG 2.4.3 even though the reload now happens in place. The picker stays enabled. A change made while a save is running is ignored, and aria-busy plus the live announcement carry the state. The saving flag now clears after the reload, so two choices cannot overlap. Also tidies two doc nits in WeatherReadService. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
The scheduled forecast refresh interval now defaults to 1440 minutes (24 h) instead of 60, and its maximum rises from 360 to 1440. A household dashboard needs no fresher forecast than daily, and a longer interval keeps MET Norway traffic minimal. Administrators can still set anything from 30 minutes to 24 hours in System settings → Weather. The background job still wakes every minute. That tick only reads the database to find places that are due, and sends nothing to MET unless one is. The AddWeather migration was regenerated with dotnet ef rather than edited: it has not shipped, and the only differences are the seed value and the migration id. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
MET's legend (weathericon/2.0/legends) has 83 codes. Seven were lost: three exceed the 40-character cap and were dropped by the parser (heavysleetshowersandthunder_polartwilight and the two below at _polartwilight), and MET spells two thunder bases with a doubled "s" (lightssleetshowersandthunder, lightssnowshowersandthunder), which the client rendered as "Unknown conditions". The cap rises to 48 (longest code is 42), regenerating the unreleased AddWeather migration, and both the design system's symbolFor and the client's SymbolOf resolve the doubled-s bases to their real base. A shared fixture of all 83 legend codes pins both halves. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
Typing_again_takes_the_previous_results_out_of_reach asserted right after typing while the component's real 350 ms debounce ran on; on a slow CI runner the second search finished and rendered its own option first (failed twice on the weather PR). The second query now never answers, so the test pins the pending state it is about. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
Arrowing_down_brings_the_active_option_into_view and An_unrelated_edit_saved_through_the_dialog_leaves_both_stamps_intact asserted immediately after a key press / click whose handler awaits before acting. On CI the expected call landed just after the check (the Moq failure lists the exact matching invocation). Both now use WaitForAssertion, as the neighbouring tests already do. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
The caller's weather location moves from its own UserWeatherPreferences
table into a UserPreferences row under the server-owned key
"weather-preferences", as {"defaultLocationId": "…"}. It keeps its own
row so the Preferences page's whole-blob save cannot overwrite it.
What the dedicated table gave is either kept or not load-bearing:
- validation: the row is written only by PUT /api/weather/preferences;
the generic user-preferences PUT refuses the key with a 400, compared
case- and trailing-space-insensitively to match the column collation;
- the SET NULL key to Location: every read already re-checks that the
stored location is selectable and falls back to the household default,
and an unparseable row reads as no preference;
- user deletion: UserPreferences already cascades with the user.
The unreleased AddWeather migration is regenerated without the table.
Refs #348
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
7711c02 to
e112480
Compare
The weather schedule moves out of the API into Odyssey.Worker, a .NET Worker Service with its own container, so the background jobs that are planned next have one home from the start. - A job is an Odyssey.Core.Jobs.IPeriodicJob, written and tested in Core like any business service; the worker only hosts it. PeriodicJobService owns the loop: first run at once, one run per interval, a failed run logged and the next one going ahead, runs never overlapping. WeatherSchedulerJob is the first job. - Code both hosts need moves from Odyssey.Api/SystemSettings to Odyssey.Core/SystemSettings (IntSettingResolver, the weather settings lookup, the secret registry and reader), with shared registrations (AddWeatherFetching, AddSecretSettingsReading) so the two service graphs cannot drift. TimeZoneDataGuard moves to Core and runs in both. - The worker reads the API's Data Protection key ring read-only, a product decision: the keys volume is mounted :ro and automatic key generation is off, so the API stays the only key writer. This makes the worker the second container able to decrypt every stored credential; docs/deployment.md records the trade-off and the incident response. The migrations job still holds no ring. - Compose (dev and prod), the CA override and Aspire run the worker; its image installs tzdata and creates the keys directory app-owned. - Odyssey.Worker.Tests covers the loop with a manual clock, the production graph with ValidateOnBuild, and the key ring: an API- protected secret is readable, an empty ring creates no key. The key-ring guard tests now pin one read-write holder (api), one read-only (worker) and none elsewhere. Verified on a live Compose stack: the worker decrypted a secret the API stored, through the read-only mount, and could not write to it. Not included: .github/workflows/publish-images.yml needs a worker matrix entry before the next release, or the prod overlay cannot pull odyssey-worker. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
The production overlay pulls ghcr.io/<owner>/odyssey-worker, so the release workflow has to build and push it beside api, client and migrations; without this leg the first deploy after the worker landed would fail on the pull. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
The maintainer lifted the "cannot modify .github/workflows/" limitation so a change that needs a workflow edit (such as the worker's publish- images matrix leg) can carry it itself instead of leaving a manual follow-up. The allow-list rule further down still applies. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG
|
✅ PR was reviewed by Security Agent and no issues were found: Approved. Scope reviewed: the weather feature (MET/Frost outbound clients, 5 new claims, rate limits, purge, Verified, no finding:
Non-blocking observations (Low, no CVSS meaningful, not gating approval):
No findings are blocking. Generated by Claude Code |
Implements the design system's Temperature history section in a location's record: a low-to-high band and a daily-mean line over the place's measured days, with gaps shown rather than bridged, a facts row and a visually hidden table of every day. It follows weather.read, so every role sees it on a place the dashboard picker offers; an Admin also sees it on a place whose weather is off, with a notice. The window is 30, 60 or 365 days, or a custom range picked from the two dates, at most a year. Its bounds come from the daily response's history block, so every role pages the same way, and the first window re-anchors on the last measured day of a place that stopped measuring. OdsDatePicker gains AriaLabel, for the label-less From and To ends, and OpenAsync, which the Custom segment uses to open the From calendar. Checked in the running app in both themes and at phone width. Three things found there and fixed: MudDatePicker reports a value set from code as a change, which flipped the window to Custom; a visually hidden table is not clipped by its own overflow and scrolled the page sideways; and the phone controls needed a smaller segment label than the design's, because the app's nav rail leaves a record narrower than the kit assumes. The first two are now in docs/frontend-mudblazor-gotchas.md. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hasObservationStation was read as "a Frost station id is stored", so a place nobody had looked a station up for read as having none within 25 km. That is the ordinary state of an instance without a Frost client ID, or of a place whose Worker has not run yet, and both the location record's temperature history and the Admin's measured-history tile then blamed a missing station for what was missing setup. The flag is now false only once a lookup for the current position came back empty (StationResolvedAtUtc set, no station id), on the daily response's history block and on the Admin row alike. The history block also gains observationsConfigured — a MET contact and a Frost client ID are both set, an unreadable ID counting as set — so the client can say why nothing is measured instead of promising it will arrive. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implements the design system's new Temperature history state for an instance that cannot fetch measured history (observationsConfigured false) and has none stored. An Admin (weather.locations.update) is told that MET Norway's Frost service needs a client ID and where to add it, with Open System settings for a system-settings.update holder; everyone else is told an administrator needs to finish the setup. History stored before the setting was cleared still shows. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the design system: one short line for everyone, "Historical weather service is not configured.", and for an Admin who can open System settings, "See system settings." with the words as an inline link instead of a separate Open System settings button. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the design system: a location without weather now reads "Weather is turned off. Turn it on to see forecasts and historical weather data for this location." and carries no Turn on weather button, since the row's menu already offers that action, as it does every other weather action. An Admin who cannot update weather, and an archived location, keep the plain "Weather is off for this place." Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A location's weather was one switch. It is now two independent ones, ForecastEnabled and HistoryEnabled, so a place can have a forecast without measured history, history without a forecast, both or neither. The forecast half owns everything a forecast feeds: only forecast-on places are selectable (dashboard picker, preferences, household default), count against the schedule cap, refresh or fetch on demand. The history half owns the Frost fetch. The weather options response gains historyLocations, which is where every role now sees Temperature history, since a place can have history without being on the picker. Delete weather history is allowed once historical data is off. With the forecast still on it deletes only the measured data — observations, the measured half of each day, the resolved station and the observation markers — and keeps the row, sites and forecast; with both off it removes everything, as before. Both purgers implement the new mode, and the shared write fence now knows which half it guards, so turning one half off cannot discard the other's fetch in flight. The client follows the design system: a Forecast and a Historical data switch in the settings dialog, per-half tiles in the Weather section, a row chip that names the half that is on, and a delete dialog that says which scope it has. The migration adds both columns, copies the old Enabled into each and drops it; Down restores Enabled from either half. It is a data migration: Down restores the schema, and an existing row's two halves come back as one switch. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Frost fetch waited for 08:00 at the location before doing anything, because that is when yesterday's measurements are complete. A place whose history had just been turned on, or an instance whose Frost client ID had just been saved, therefore showed nothing until the next morning, even though every day before yesterday was already available. A place that has never been fetched for its current site now starts at once: the station lookup, then the days up to the last complete one (the day before yesterday before 08:00), then the backfill. It then wakes at this morning's window to add yesterday, and keeps to the morning rhythm from there. A place an earlier build had already parked until the next morning is also started: one with no station lookup, nothing fetched and no failure pause counts as due whatever its wake-up time says, in the schedule job and in the fetch itself. A failure pause still holds. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Accessibility Agent review of PR #355 (frontend changes detected). The weather UI is mostly well built: the chart has a keyboard readout and a hidden data table, decorative glyphs are hidden from AT, and focus rings and live regions are present. I found one blocking-level issue and two minor ones. 1. Focus is lost when the purge starts (
|
Senior Architect Agent review: no blocking issues, 4 non-blocking suggestionsReviewed the backend, API, migrations, worker and infra. Frontend was only checked for contract drift. I read the weather services, purger, schedule job, both controllers, both migrations, the worker host and Dockerfile, the compose/AppHost/CI changes and the DI wiring. I did not run the build or tests. What checks out:
Non-blocking suggestions:
Verdict: approve with the suggestions above. Nothing blocks merge. |
|
✅ PR was reviewed by Senior Tester Agent and no issues were found. Scope: Core (WeatherServiceTests/Parsing/Composition/Harness), Api (WeatherApiTests: permission matrix, 403/404 paths, bounds), Client, Worker and IntegrationTests (WeatherRelationalTests). I ran the weather, worker, preferences and authorization subsets of the Core, Worker, Client and Api test suites and all passed (184, 9, 79 and 151). The Docker tier was not run. Tests use deterministic fixtures and fakes, with no skipped or assertion-free tests and no field-naming violations. Non-blocking nit: WeatherClientTests.Ok_shows_now_the_days_and_the_attribution_from_the_response builds |
|
✅ PR was reviewed by Security Agent and no issues were found: Approved. Scope: the full diff, reviewed statically. The branch was not switched. Checked and found sound
Observations (non-blocking, no action required)
|
|
Senior Frontend Agent review (PR #355, frontend scope) No blocking issues found. Typed Non-blocking nits
Distinguished as intentional and not flagged: ligature-string icons on |
Accessibility: - Delete weather history moves focus to its explanation before the run disables every control, so focus no longer drops to <body> (2.4.3). - The dashboard card's title is a real <h2>, not a span with a role. - odsSetAttr was defined twice in index.html with different empty-value handling; the later one silently won. One definition remains, and OdsSelect and OdsDatePicker re-apply AriaLabel when it changes. Architecture: - The schedule job isolates each location: an unexpected exception is logged and the run moves on, instead of ending it and starving every place behind the failing one in the ordered queue. - The Data Protection application name is one constant in Core, DataProtectionKeyRing.ApplicationName, named by both the API and the worker; a source check now asserts both hosts use it. - The DELETE summary describes the history-only mode. - RequestSpacing documents that it is per process, which matches the deployment's single worker. Frontend: - The weather picker's options and stored-value fallback are one helper in WeatherVisuals, used by the card and the Preferences row. - The card loads options and preferences only for someone who can choose a place. - Try again is awaited and ignored while a load runs, on the card and on Temperature history. - A quiet reload that fails after a picker save keeps the picker and says the choice is saved, instead of unmounting it under focus. - The settings dialog cannot be dismissed while a save is in flight, as the delete dialog already could not. - Removed three unused element ids; moved a stray [Parameter]. Security and tests: - The dev compose file hardens the worker as the production one does. - The card's clock is a seam, and its test pins it, so a run that straddles midnight cannot disagree with itself. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Senior Architect Agent re-review (commit 2052bbd): ApprovedAll four earlier suggestions are resolved:
Additionally, the dev compose now hardens the worker as the production file does. I did not re-run the build or tests. No new issues found. |
|
✅ PR was reviewed by Senior Tester Agent and no issues were found (re-review of 2052bbd). The earlier |
|
✅ PR was reviewed by Security Agent and no issues were found: Approved. Re-review of commit 2052bbd. My earlier notes were non-blocking observations:
New in this commit, reviewed and clean:
|
|
Senior Frontend Agent re-review (PR #355, commit 2052bbd) All eight prior nits are resolved. Read-only review; I did not build or run the tests.
The other changes in the commit also look right: ✅ PR was reviewed by Senior Frontend Agent and no issues were found: Approved. |
|
Accessibility Agent re-review of PR #355 at 2052bbd. All three earlier findings are resolved. While re-reading I found the same focus-loss pattern as finding 1 in a second dialog. Resolved:
New finding, found in this re-read: Settings dialog loses focus while saving (
|
Second-round review findings on PR #355. Accessibility: Save is disabled while it loads and Cancel is disabled during a save, so the focused control went disabled and focus dropped to <body> for the whole save (WCAG 2.4.3). The dialog body is now a programmatic focus target, focused before the controls are disabled, as WeatherPurgeDialog already does. Frontend: after a picker save whose reload fails, the card announced "Weather now shows …" over its own message that the weather could not be reloaded. It now announces the failure. Refs #348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Accessibility Agent re-review of PR #355 at b6dcf35. The settings-dialog focus-loss finding (2.4.3 Focus Order – Level A) is resolved: ✅ PR was reviewed by Accessibility Agent and no issues were found: Approved. |
|
Senior Frontend Agent update (b6dcf35): confirmed. After a quiet reload failure the card now announces the failure text instead of "Weather now shows X" ( |
The location's Temperature history becomes Weather history: a quiet Measures picker in the chart's head chooses what is plotted, following the contract-terms chart's rules. Something is always plotted, and a measure in another unit replaces the selection, so temperature is never drawn on a millimetre axis. Precipitation is drawn as bars from zero, with unmeasured days shaded so a missing bar cannot read as a dry day. Mean wind and gusts share the m/s axis and differ by shape as well as hue. Each measure keeps its own colour in the picker, the marks and the .odc-sc-legend ledger, which states the window's figure per measure. The facts row follows the unit plotted. Choosing a measure is client-side: one daily response already carries every measure, so it costs no request. WeatherTemperatureChart is renamed WeatherHistoryChart, and WeatherMeasures is the one registry the picker, marks, ledger, facts and hidden table read. The ledger figure is painted through OdsGlyphInk.Text, as the colour-as-text lint requires. Matches design-system artifact v75 (Weather.jsx WeatherHistoryChart, weather.css, components-weather.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chart now sits in a card, as the contract-terms chart does (.odc-lc). The Measures picker heads it in place of a title, and the facts, the plot and the ledger follow, all on the card's one content edge. Before, the picker floated above the facts with the quiet trigger's padding indenting it, and nothing tied the facts row to the chart it describes. Alignment fixes: - The quiet trigger's 12px padding and 1px border are pulled back so its icon lines up with the facts and the ledger. - The ledger loses its own left inset. - The date nav reaches past the bar's right edge by the button's inset, so its chevron lines up with the card below (both ends on a phone, where the card's padding also steps down). - The precipitation swatch is drawn as two bars instead of a sliver. Matches design-system artifact v76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 30 / 60 / 365 days / Custom toggle is gone. The window is two dates,
with the arrows still stepping a whole window. It defaults to the last
365 days and sits in the card's head top right, opposite the Measures
picker, where the contract-terms card has its axis toggle. The card now
belongs to the section and stays mounted through loading, empty and
error states, so the control in use is never unmounted while a window
loads.
The window and the measures are remembered per user and location in
the existing UI-state store (weather-history-{locationId}). No server
change is needed, and nothing is written for a place nobody touched. A
window ending on the newest measured day is stored with an open end:
the next visit keeps its From and runs to whatever is newest then,
because choosing "up to now" means up to now. A From that has fallen
more than 366 days behind moves up to stay inside the endpoint's cap,
and a closed window comes back as it was, pulled inside the stored
history if that has shrunk.
The ledger is now exactly the contract-terms chart's: square swatches
in .odc-sc-legend with its own inset, directly under the axis.
Matches design-system artifact v77.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The facts row (lowest, highest, average of daily means and days measured, or the wind and precipitation equivalents) is centred over the plot: each figure is centred in its column, so the row reads as one balanced line above the chart. The note on what the values are (the nearest MET station, the UTC day, precipitation 06:00 to 06:00) moves from under the card into an "About these values" info button beside the Measures picker. The attribution stays visible under the card, because NLOD and CC BY both require it to be. The tooltip opens on a tap as well as hover and focus. MudTooltip's popover is not linked to its trigger, so the same text is also the button's aria-describedby. OdsTooltip gains a typed ShowOnClick for this: passed as an unmatched attribute, the value reached MudTooltip as a string and threw at render. Matches design-system artifact v78. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MET Norway attribution moves from under the card to its bottom right. It closes the ledger row, pushed right, or stands on its own right-aligned line when nothing was measured. The ledger keeps its left inset but drops its right one, so the attribution ends on the card's content edge, and it wraps on a narrow card instead of running out of it. It stays visible, as NLOD and CC BY require, and still comes from the response, so it shows only once one has arrived. Matches design-system artifact v79. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured history used to take every value from one station, the nearest within 25 km that reports temperature. Many of those stations measure no wind or precipitation, so those values stayed empty even where a station nearby had them. Each measure group now has its own station, chosen for how that measure behaves: - Temperature: within 25 km, and within 300 m of the place's height when both heights are known (about 0.65 °C per 100 m). - Precipitation: within 15 km, because it is local and gauges are dense. - Wind: within 30 km, because wind stations are few. The scheduler stays at one Frost request per call. Lookups run one group per call. The daily fetch names every chosen station in one request (Frost takes a list of sources), and each group's values are taken from its own station only. The backfill chunk is divided by the number of stations to stay under the 1 MB response cap. A group with no station in reach is asked again at most once per 24 h, and history starts over when one turns up. The stations live in a new WeatherStations table (one row per place and group, CASCADE with the weather row), replacing WeatherLocations. FrostStationId. Each observation records which station each group came from. The daily response's history block and the Admin row gain `stations` (name, whole-km distance, reach searched). That discloses nothing new: weather.read implies locations.read on every role, which already shows the address and coordinates. The client offers only the measures whose group has a station, lists each group's station and distance (or the reach searched) in the info tooltip, and the no-station text gives each reach. The migration discards measured history (observations, the observed half of day rows, station and backfill markers) so every place looks its stations up again. Weather has not shipped, so no deployed database holds these rows. Forecasts are untouched. Down() restores the columns, not the rows. Matches design-system artifact v80. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
This implements issue #348 (Weather Integration — Backend, Draft v7, with its approval amendments) and the matching frontend from the design-system weather pass. It also adds
Odyssey.Worker, a dedicated background-job host; the weather schedule is its first job.publish-images.ymlgains aworkermatrix leg, so releases publishodyssey-workerbeside the other three images.Backend
Data model. Seven new tables:
WeatherLocations, for the per-place settings.WeatherLocationSites, an append-only history of the frozen 3-decimal sites.WeatherLocationSchedules, holding the lease and theVersionfencing token.WeatherForecastRuns,WeatherForecastStepsandWeatherObservations, all append-only.WeatherDays, derived from the above.Locationkeys are RESTRICT, and there is a composite RESTRICT key to the current site. Attribution columns get SET NULL keys. One additive migration,AddWeather.The caller's weather location is a
UserPreferencesrow under the server-owned keyweather-preferences, stored as{"defaultLocationId": "…"}.PUT /api/weather/preferenceswrites it, and it validates the value. The genericPUT /api/user-preferences/{key}refuses the key with a400; the comparison ignores case and trailing spaces, to match the column's collation.Location. Every read re-checks that the stored location is selectable, otherwise it falls back to the household default. An unparseable row reads as no preference.UserPreferencesrow already is.Sources. Two clients: Locationforecast 2.0 (2 MB cap) and Frost (1 MB cap, Basic auth). Both have a compile-time base address, no redirects and no HttpClient logging. Provider bodies are never relayed.
Symbol codes. All 83 codes in MET's legend are kept. The stored code is capped at 48 characters; the longest legend code is 42. MET's two doubled-"s" thunder bases (
lightssleetshowersandthunder,lightssnowshowersandthunder) resolve to their real base in both the design system and the client.Schedule (
WeatherScheduleJob, run by the worker). It wakes every 60 s to find places that are due, using the database only. A scheduled place is fetched once per refresh interval (default 24 h), at 1 request/s or less, and only places within the tracked cap are fetched on a schedule.Retry-Afterwins.Versionrotation. Disable, apply and purge also rotateVersion, so a fetch already in flight is discarded instead of stored.Endpoints.
calendar.readorjournal.readwithweather.read.RateLimiting:WeatherRead(120 / 60 s per user) andRateLimiting:WeatherRefresh(6 / 5 min per user).Purge. It goes through
IWeatherHistoryPurger.ExecuteDeletein batches of 10 000, in amendment 1's order.RemoveRangeimplementation.PurgeStartedAtand can be resumed after an interruption.Settings.
WeatherUserAgentContact: email or https URL, fails closed.WeatherRefreshIntervalMinutes: default 1440 (once a day), 30–1440; a degraded read resolves to the larger value.WeatherMaxTrackedLocations: 1–20.WeatherDefaultLocationId: must be selectable, checked inside the settings transaction.WeatherFrostClientId: a secret of kindRotatableCredential.Claims. Five new claims;
weather.locations.*andweather.history.deleteare Admin-only. The CLAUDE.md count is now 106.Shared rule. The coordinate rounding and the fingerprint (amendment 2) are
WeatherRulesinOdyssey.Dtos, so the client uses the same rule as the server rather than a copy of it.Docs.
docs/deployment.mdgets the release notes, the data flow and the disable → purge erasure runbook.Data export. Only
WeatherLocationsis exported. The history tables are excluded, each with its reason.Background-job host (
Odyssey.Worker, with its own container)Odyssey.Core.Jobs.IPeriodicJob. It is written and tested in Core like any other business service; the worker only hosts it.AddPeriodicJob<TJob>()line inOdyssey.Worker/WorkerHost.cs.PeriodicJobServiceowns the loop. The first run is immediate, then one run per interval. A failed run is logged and the next goes ahead, and runs never overlap.Odyssey.Api/SystemSettingstoOdyssey.Core/SystemSettings, because a second host can't reference a web project:IntSettingResolver, the weather settings lookup, and the secret registry and reader.AddWeatherFetching,AddSecretSettingsReading), so their service graphs can't drift.TimeZoneDataGuardmoved to Core and runs in both hosts.dataprotection_keys:ro, and automatic key generation is off, so the API stays the only key writer.docs/deployment.md→ Two containers hold the key ring records the trade-off and the incident response.tzdataand creates the keys directory owned byapp./settingschange within its 30 s TTL.api), one read-only holder (worker) and none anywhere else. The CA-override guards cover the worker image.Frontend (
Odyssey.Client/Pages/Weather/,wwwroot/css/weather.css)IWeatherApiClientinOdyssey.ApiClient, with no UI dependencies.1792835567-d10d. The exchange-rate-sync design and the revised Alert, Button, Modal, RecordTable and Switch atoms are not part of this issue, so they stay inpending.Test robustness (files from main)
Three client tests from main failed intermittently on this PR's CI runs. Each one asserted before an async handler had finished, and each is now robust:
LocationDialogTests.Typing_again_takes_the_previous_results_out_of_reach: the second search is held pending.LocationDialogTests.Arrowing_down_brings_the_active_option_into_view: now waits for the call withWaitForAssertion.ContractSignatureSurfaceTests.An_unrelated_edit_saved_through_the_dialog_leaves_both_stamps_intact: now waits for the call withWaitForAssertion.Deviations from the spec
WeatherLocationSchedule.ForecastExpiresAtUtcis added. It lets an Admin refresh honour MET'sExpiresand any pause without also waiting out the refresh interval.staleis also true when the newest forecast is older than 3 × the interval (never under 3 h). With the 24 h default, that is 72 h.UserPreferencesrow rather than the spec's dedicated table, at the product owner's request (see above). The wire contract of/api/weather/preferencesis unchanged.Type of Change
Testing Steps
dotnet test Odyssey.Core.Tests: the weather tests cover:TestShared/Fixtures/met-symbol-codes.txt);preferences-pageuntouched, an unreadable value falls back, and the generic write refuses the key in three spellings.dotnet test Odyssey.Api.Tests: the weather tests (403s on every endpoint, the stacked policies, the success paths, enable, apply, purge, refresh 503, both 429s, the schedule job, the settings lookup, preferences and settings), plusPUT /api/user-preferences/weather-preferences→400, the key-ring and CA-override guards, and the updated guard counts.dotnet test Odyssey.Worker.Tests(9 tests):ValidateOnBuild.DisableAutomaticKeyGeneration.ODYSSEY_REQUIRE_TIER=integration dotnet test Odyssey.IntegrationTests: the migration, the RESTRICT keys, the batched relational purge over more than 20 000 steps, resumption, fencing on MariaDB, and the seed.dotnet test Odyssey.Client.Tests: the weather tests (symbols, including every legend code and both doubled-"s" codes; units; the Admin menu; the card's states; the picker keeping focus).dotnet build Odyssey.sln -c Release -warnaserror, as CI runs it.Live Compose stack (
docker compose up):Read-only file system, and the ring holds only the API's key.Live Aspire run (
dotnet run --project Odyssey.AppHost --launch-profile http), done before the worker existed:The worker's Aspire resource is built but has not been run live.
Related Issues
Closes #348
🤖 Generated with Claude Code
https://claude.ai/code/session_013nYmpkMxvSyGGbpvCfpfgG