Tap a Coming up card -> hourly-detail modal, 20s idle auto-close

Backend now stores hourly points for every day the provider returns (not
just today) -- for Open-Meteo this is free, the hourly= block already
covers the full forecast_days=8 window in the one call, today's strip just
discarded everything past today. Met Office's hourly product window isn't
verified this far out (only daily has been exercised live so far); an
empty result for a given date just means "no hourly detail", handled as a
graceful fallback, not an error.

New GET /api/public/forecast/hourly?date=YYYY-MM-DD backs the modal. The
main /api/public/forecast payload's `hourly` field is now its own scoped
query (today only) rather than filtering the much bigger stored set in JS.

Frontend: tapping any "Coming up" card (tomorrow/day-after or days 4-7)
opens DayModal with that day's hourly points, falling back to the
already-known 3-hourly points (or the single daily summary) if the
provider doesn't have hourly detail that far out. Auto-closes after 20s of
no interaction (pointerdown/keydown/wheel/scroll all reset the timer), with
a depleting progress bar so the countdown isn't a surprise. Extracted
PointMeta/AstroRow and the date/format helpers out of Display.tsx into
shared modules since DayModal needs them too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 13:26:57 +00:00
parent c6be990f6b
commit 13fb30d9a3
10 changed files with 370 additions and 75 deletions

View file

@ -65,8 +65,13 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
const today = londonDateKey(new Date().toISOString())
// Store every hour the endpoint returns, not just today's — the "tap a Coming up
// card" hourly-detail modal needs this for tomorrow/day-after too (see
// routes/forecast.js's /forecast/hourly). Met Office's hourly product window
// isn't documented/verified here (unlike Open-Meteo, which we know covers the
// full forecast_days=8 range) — whatever it actually returns just becomes
// whatever range the modal can show; no assumption made about how far it goes.
const hourlyPoints = hourly
.filter(p => londonDateKey(p.time) === today)
.map(p => ({
resolution: 'hourly',
ts: p.time,
@ -153,8 +158,11 @@ async function fetchFromOpenMeteo(lat, lon) {
windDir: data.hourly.winddirection_10m?.[i] ?? null,
}))
// Store every hour across the full forecast_days=8 window, not just today — the
// "tap a Coming up card" hourly-detail modal needs tomorrow/day-after (and, where
// available, days 4-7) too. This is free: the hourly= block already covers all 8
// days in this one call, today's strip just used to discard everything past today.
const hourlyPoints = hourlyRows
.filter(r => r.dateKey === today)
.map(r => ({
resolution: 'hourly',
ts: r.time,

View file

@ -16,15 +16,25 @@ function rowToPoint(r) {
}
}
// forecast_points' 'hourly' resolution now holds every day the provider returned
// (see lib/weather-fetch.js — needed for the per-day hourly modal below), not just
// today, so the main payload's `hourly` field is scoped to today with its own query
// rather than filtering a much bigger already-fetched set in JS.
async function buildPayload() {
const { rows: hourlyToday } = await pool.query(
`SELECT * FROM forecast_points
WHERE resolution = 'hourly'
AND (ts AT TIME ZONE 'Europe/London')::date = (NOW() AT TIME ZONE 'Europe/London')::date
ORDER BY ts ASC`
)
const { rows } = await pool.query(
`SELECT * FROM forecast_points ORDER BY ts ASC`
`SELECT * FROM forecast_points WHERE resolution IN ('3hourly', 'daily') ORDER BY ts ASC`
)
const { rows: astroRows } = await pool.query(
`SELECT date, sunrise, sunset FROM forecast_astro ORDER BY date ASC`
)
const status = await getStatus()
const hourly = rows.filter(r => r.resolution === 'hourly').map(rowToPoint)
const hourly = hourlyToday.map(rowToPoint)
// "Later" is relative to whoever's looking at the screen right now, not to when
// the hourly data was last fetched — compute this fresh per-request, not at
// refresh time (see lib/summary.js).
@ -44,12 +54,37 @@ async function buildPayload() {
}
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
// Public, unauthenticated — this is the whole point of the app: a reception lobby
// display can't reasonably sit behind a login screen. No PII, no write actions, just
// read-only forecast data, so it deliberately skips requireAuth entirely (see
// project_weather memory's access-model decision).
export async function publicForecastRoutes(app) {
app.get('/api/public/forecast', async () => buildPayload())
// GET /api/public/forecast/hourly?date=YYYY-MM-DD — backs the "tap a Coming up
// card" modal. Whether a date beyond tomorrow/day-after actually has hourly rows
// depends entirely on how far the active provider's hourly product reaches
// (confirmed for Open-Meteo: the full forecast_days=8 window; NOT verified for Met
// Office — see lib/weather-fetch.js) — an empty `points` array just means "no
// hourly detail for this day", the frontend falls back to whatever coarser data
// it already has (3-hourly or the daily summary) rather than treating it as an
// error.
app.get('/api/public/forecast/hourly', async (req, reply) => {
const date = req.query?.date
if (typeof date !== 'string' || !DATE_RE.test(date)) {
return reply.status(400).send({ error: 'date must be YYYY-MM-DD' })
}
const { rows } = await pool.query(
`SELECT * FROM forecast_points
WHERE resolution = 'hourly'
AND (ts AT TIME ZONE 'Europe/London')::date = $1::date
ORDER BY ts ASC`,
[date]
)
return { date, points: rows.map(rowToPoint) }
})
}
// Service-to-service — same bearer-secret convention as settings' /internal/*