← Back to task

Commit b0f5316f

commit b0f5316fb360f8b3fe0d027d01394cb96d3c5a0d
Author: Coder Agent <coder@agents.omni>
Date:   Mon Apr 20 12:03:37 2026

    feat(cal): wire weather widget into brief view
    
    Instantiate WeatherCache in cal app startup and pass it into brief rendering.
    
    Render a today-only weather card using NWS current conditions + short periods.
    
    Task-Id: t-808

diff --git a/Omni/Cal/Web.hs b/Omni/Cal/Web.hs
index e5af9c71..43415efc 100644
--- a/Omni/Cal/Web.hs
+++ b/Omni/Cal/Web.hs
@@ -2,6 +2,14 @@
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
+{- HLINT ignore "Use traverse_" -}
+{- HLINT ignore "Use unless" -}
+{- HLINT ignore "Use guards" -}
+{- HLINT ignore "Avoid lambda" -}
+{- HLINT ignore "Redundant bracket" -}
+{- HLINT ignore "Use tuple-section" -}
+{- HLINT ignore "Replace case with fromMaybe" -}
+
 -- | Calendar web viewer.
 --
 -- Read-only calendar views served at /cal. Reads ICS files from the
@@ -64,7 +72,8 @@ main = do
   putText <| "Starting calendar viewer on http://localhost:" <> tshow port
   cache <- Parse.newCache
   geoCache <- Geo.newGeoCache "/var/fund/geocache.json"
-  Warp.run port (app "" calPath cache geoCache)
+  weatherCache <- Weather.newWeatherCache
+  Warp.run port (app "" calPath cache geoCache weatherCache)
 
 -- | Create an app with a warmed cache. For use when mounting in Omni.Web.Core.
 -- Loads and caches all calendar events on startup so the first page load is fast.
@@ -72,17 +81,18 @@ mkApp :: FilePath -> IO (Text -> Wai.Application)
 mkApp calPath = do
   cache <- Parse.newCache
   geoCache <- Geo.newGeoCache "/var/fund/geocache.json"
+  weatherCache <- Weather.newWeatherCache
   -- Warm the cache: load events for a wide range so first request is instant
   let sources = Parse.defaultSources calPath
   now <- Time.getCurrentTime
   let warmStart = Time.addDays (-30) (Time.utctDay now)
       warmEnd = Time.addDays 60 (Time.utctDay now)
   _ <- Parse.loadEventsInRange cache sources warmStart warmEnd
-  pure (\basePath -> app basePath calPath cache geoCache)
+  pure (\basePath -> app basePath calPath cache geoCache weatherCache)
 
 -- | WAI application for the calendar viewer.
-app :: Text -> FilePath -> Parse.CalendarCache -> Geo.GeoCache -> Wai.Application
-app basePath calPath cache geoCache req respond = do
+app :: Text -> FilePath -> Parse.CalendarCache -> Geo.GeoCache -> Weather.WeatherCache -> Wai.Application
+app basePath calPath cache geoCache weatherCache req respond = do
   let path = Wai.pathInfo req
       query = Wai.queryString req
       sources = Parse.defaultSources calPath
@@ -94,7 +104,7 @@ app basePath calPath cache geoCache req respond = do
       let endDay = Time.addDays 7 fromDay
           historyStart = Time.addDays (-28) fromDay
       events <- Parse.loadEventsInRange cache sources historyStart endDay
-      briefHtml <- briefPage basePath sources now fromDay events geoCache
+      briefHtml <- briefPage basePath sources now fromDay events geoCache weatherCache
       respond <| htmlResponse briefHtml
     ["day"] -> do
       let endDay = Time.addDays 7 fromDay
@@ -136,7 +146,7 @@ app basePath calPath cache geoCache req respond = do
       let endDay = Time.addDays 7 fromDay
           historyStart = Time.addDays (-28) fromDay
       events <- Parse.loadEventsInRange cache sources historyStart endDay
-      briefHtml <- briefPage basePath sources now fromDay events geoCache
+      briefHtml <- briefPage basePath sources now fromDay events geoCache weatherCache
       respond <| htmlResponse briefHtml
     ["event", uidParam] -> do
       let dateParam = parseQueryText query "date"
@@ -826,8 +836,8 @@ weekGrid now startDay numDays events = do
 -- Morning brief view
 -- ============================================================================
 
-briefPage :: Text -> [Parse.CalendarSource] -> Time.UTCTime -> Time.Day -> [Parse.CalEvent] -> Geo.GeoCache -> IO (L.Html ())
-briefPage basePath sources now selectedDay events geoCache = do
+briefPage :: Text -> [Parse.CalendarSource] -> Time.UTCTime -> Time.Day -> [Parse.CalEvent] -> Geo.GeoCache -> Weather.WeatherCache -> IO (L.Html ())
+briefPage basePath sources now selectedDay events geoCache weatherCache = do
   -- Geocode selected day's timed events for the map
   let dayStart = Parse.easternDayStart selectedDay
       dayEnd = Parse.easternDayStart (Time.addDays 1 selectedDay)
@@ -837,6 +847,7 @@ briefPage basePath sources now selectedDay events geoCache = do
       indexedLocs = zipWith (\i e -> (i, Parse.ceLocation e, Parse.ceDescription e)) [1 ..] sorted
       isToday = selectedDay == Time.utctDay now
   geoResults <- Geo.geocodeEvents geoCache indexedLocs
+  weather <- if isToday then Weather.getWeather weatherCache else pure Nothing
   pure
     <| calShell basePath "Brief"
     <| do
@@ -845,6 +856,8 @@ briefPage basePath sources now selectedDay events geoCache = do
         L.div_ [L.class_ "cal-brief"] <| do
           -- Next up (only when viewing today)
           when isToday <| briefNextUp basePath now dayTimed
+          -- Current weather (today only)
+          when isToday <| briefWeather weather
           -- Day summary with geo data on each event row
           briefDaySummary basePath now selectedDay dayEvents geoResults
           -- Day map (container + Leaflet init; markers rebuilt by JS)
@@ -892,6 +905,43 @@ briefNextUp _basePath now timedEvents = do
                   L.div_ [L.class_ "cal-brief-next-loc"] (L.toHtml loc)
             _ -> pure ()
 
+-- | Current weather card for the brief view.
+briefWeather :: Maybe Weather.CurrentWeather -> L.Html ()
+briefWeather mWeather =
+  L.div_ [L.class_ "cal-brief-section"] <| do
+    L.div_ [L.class_ "cal-brief-label"] "Weather"
+    case mWeather of
+      Nothing ->
+        L.div_ [L.class_ "cal-brief-empty"] "Weather unavailable"
+      Just weather ->
+        L.div_ [L.class_ "cal-brief-weather"] <| do
+          L.div_ [L.class_ "cal-brief-weather-now"] <| do
+            L.span_ [L.class_ "cal-brief-weather-icon"] (L.toHtml (Weather.cwIcon weather))
+            L.div_ [L.class_ "cal-brief-weather-main"] <| do
+              L.div_ [L.class_ "cal-brief-weather-temp"]
+                <| L.toHtml (tshow (Weather.cwTemp weather) <> "°" <> Weather.cwTempUnit weather)
+              L.div_ [L.class_ "cal-brief-weather-forecast"] (L.toHtml (Weather.cwShortForecast weather))
+            L.div_ [L.class_ "cal-brief-weather-range"] <| do
+              case Weather.cwHigh weather of
+                Nothing -> pure ()
+                Just hi -> L.div_ [] (L.toHtml ("H " <> tshow hi <> "°"))
+              case Weather.cwLow weather of
+                Nothing -> pure ()
+                Just lo -> L.div_ [] (L.toHtml ("L " <> tshow lo <> "°"))
+          L.div_ [L.class_ "cal-brief-weather-meta"]
+            <| L.toHtml ("Wind " <> Weather.cwWindDirection weather <> " " <> Weather.cwWindSpeed weather)
+          let periods = take 4 (Weather.cwPeriods weather)
+          when (not (null periods))
+            <| L.div_ [L.class_ "cal-brief-weather-periods"]
+            <| mapM_ renderWeatherPeriod periods
+
+renderWeatherPeriod :: Weather.ForecastPeriod -> L.Html ()
+renderWeatherPeriod period =
+  L.div_ [L.class_ "cal-brief-weather-period"] <| do
+    L.div_ [L.class_ "cal-brief-weather-period-name"] (L.toHtml (Weather.fpName period))
+    L.div_ [L.class_ "cal-brief-weather-period-temp"]
+      <| L.toHtml (Weather.fpIcon period <> " " <> tshow (Weather.fpTemp period) <> "°")
+
 -- | Day summary section with geo data attributes for client-side map rendering.
 briefDaySummary :: Text -> Time.UTCTime -> Time.Day -> [Parse.CalEvent] -> [(Int, Geo.GeoResult)] -> L.Html ()
 briefDaySummary _basePath now selectedDay dayEvents geoResults = do
@@ -2059,6 +2109,64 @@ calStyles = do
     Clay.color WebStyle.cFgMuted
     Clay.marginTop (Clay.px 4)
 
+  -- Weather
+  ".cal-brief-weather" Clay.? do
+    Clay.backgroundColor WebStyle.cBgRaised
+    Clay.borderRadius (Clay.px 6) (Clay.px 6) (Clay.px 6) (Clay.px 6)
+    Clay.padding (Clay.px 12) (Clay.px 14) (Clay.px 12) (Clay.px 14)
+
+  ".cal-brief-weather-now" Clay.? do
+    Clay.display Clay.flex
+    Clay.alignItems Clay.center
+    Stylesheet.key "gap" ("10px" :: Text)
+
+  ".cal-brief-weather-icon" Clay.? do
+    Clay.fontSize (Clay.px 26)
+
+  ".cal-brief-weather-main" Clay.? do
+    Stylesheet.key "flex" ("1" :: Text)
+
+  ".cal-brief-weather-temp" Clay.? do
+    Clay.fontSize (Clay.px 24)
+    Clay.fontWeight Clay.bold
+    Clay.color WebStyle.cAccent
+    Clay.lineHeight (Clay.unitless 1.1)
+
+  ".cal-brief-weather-forecast" Clay.? do
+    Clay.fontSize (Clay.px 13)
+    Clay.color WebStyle.cFgMuted
+
+  ".cal-brief-weather-range" Clay.? do
+    Clay.fontSize (Clay.px 12)
+    Clay.color WebStyle.cFgMuted
+    Clay.textAlign Clay.end
+
+  ".cal-brief-weather-meta" Clay.? do
+    Clay.marginTop (Clay.px 6)
+    Clay.fontSize (Clay.px 12)
+    Clay.color WebStyle.cFgMuted
+
+  ".cal-brief-weather-periods" Clay.? do
+    Clay.marginTop (Clay.px 10)
+    Clay.display Clay.flex
+    Stylesheet.key "gap" ("6px" :: Text)
+
+  ".cal-brief-weather-period" Clay.? do
+    Stylesheet.key "flex" ("1" :: Text)
+    Clay.backgroundColor WebStyle.cBgActive
+    Clay.borderRadius (Clay.px 4) (Clay.px 4) (Clay.px 4) (Clay.px 4)
+    Clay.padding (Clay.px 6) (Clay.px 8) (Clay.px 6) (Clay.px 8)
+
+  ".cal-brief-weather-period-name" Clay.? do
+    Clay.fontSize (Clay.px 10)
+    Clay.color WebStyle.cFgFaint
+    Stylesheet.key "text-transform" ("uppercase" :: Text)
+
+  ".cal-brief-weather-period-temp" Clay.? do
+    Clay.fontSize (Clay.px 12)
+    Clay.color WebStyle.cFg
+    Clay.fontWeight Clay.bold
+
   -- Event list
   ".cal-brief-events" Clay.? do
     Clay.display Clay.flex