{-# LANGUAGE GHC2021 #-} {-# LANGUAGE OverloadedStrings #-} -- | Now page: loads data/now.yaml and renders the active-projects view -- and the recently-shipped archive for /current.html. Page-level -- "Last updated" stamp is exposed as a context field; relative time -- ("4 days ago") is computed at build time from getCurrentTime. module Now ( nowCtx ) where import Data.Aeson (FromJSON (..), withObject, (.:), (.:?), (.!=)) import Data.Char (toUpper) import Data.List (nub, sortBy) import Data.Maybe (fromMaybe) import Data.Ord (Down (..), comparing) import Data.Time.Calendar (Day, diffDays) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Yaml as Y import Hakyll hiding (escapeHtml) import Contexts (siteCtx) import Utils (escapeHtml) -- --------------------------------------------------------------------------- -- Entry types -- --------------------------------------------------------------------------- data NowEntry = NowEntry { neTitle :: String , neSection :: String , neStatus :: String , neUpdated :: String , neLink :: Maybe String , neNote :: Maybe String , nePriority :: Int } instance FromJSON NowEntry where parseJSON = withObject "NowEntry" $ \o -> NowEntry <$> o .: "title" <*> o .: "section" <*> o .: "status" <*> o .: "updated" <*> o .:? "link" <*> o .:? "note" <*> o .:? "priority" .!= 0 data NowShipped = NowShipped { nsTitle :: String , nsCompleted :: String , nsLink :: Maybe String , nsNote :: Maybe String } instance FromJSON NowShipped where parseJSON = withObject "NowShipped" $ \o -> NowShipped <$> o .: "title" <*> o .: "completed" <*> o .:? "link" <*> o .:? "note" data NowDoc = NowDoc { nLastUpdated :: String , nEntries :: [NowEntry] , nShipped :: [NowShipped] } instance FromJSON NowDoc where parseJSON = withObject "NowDoc" $ \o -> NowDoc <$> o .: "last-updated" <*> o .:? "entries" .!= [] <*> o .:? "shipped" .!= [] -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- -- | Section ordering follows first-appearance in entries. Reorder the -- YAML to reorder the page; no separate ordering key required. sectionOrder :: [NowEntry] -> [String] sectionOrder = nub . map neSection -- | Status ordering — "how close to shipping." Lower rank sorts first. -- Statuses not listed sort below all known ones (rank 99) so a typo -- surfaces visibly at the bottom of its section instead of silently -- ranking next-to-the-top. statusRanks :: [(String, Int)] statusRanks = [ ("in-review", 1) , ("revising", 2) , ("drafting", 3) , ("building", 4) , ("early-stage", 5) , ("paused", 6) ] statusRank :: String -> Int statusRank s = fromMaybe 99 (lookup s statusRanks) -- | Three-tier sort key for active entries: -- 1. priority — manual override; higher floats up (default 0) -- 2. statusRank — how close to shipping (lower is closer) -- 3. updated — recency tiebreaker within the same rank -- Sectioning is applied to the *unsorted* list so section ordering -- continues to follow YAML source order; sorting happens within each -- section's filtered slice. entrySortKey :: NowEntry -> (Down Int, Int, Down String) entrySortKey e = ( Down (nePriority e) , statusRank (neStatus e) , Down (neUpdated e) ) -- | "early-stage" → "Early Stage", "research" → "Research". titleCaseWords :: String -> String titleCaseWords = unwords . map cap . wordsOnDash where cap [] = [] cap (x:xs) = toUpper x : xs wordsOnDash s = case break (== '-') s of (a, []) -> [a] (a, _:rest) -> a : wordsOnDash rest -- --------------------------------------------------------------------------- -- HTML rendering -- --------------------------------------------------------------------------- renderStatusChip :: String -> String renderStatusChip s = concat [ "" , escapeHtml (titleCaseWords s) , "" ] -- | Active-entry card. Reuses the .item-card / .item-card-* classes from -- item-card.css so the Now page picks up the existing typographic -- register; the .now-* classes layer status-chip + spacing on top. renderEntry :: NowEntry -> String renderEntry e = concat [ "
  • " , "" , renderStatusChip (neStatus e) , "" , "
    " , "
    " , renderTitle (neLink e) (neTitle e) , "" , "
    " , maybe "" (\n -> "

    " ++ escapeHtml n ++ "

    ") (neNote e) , "
    " , "
  • " ] renderShippedEntry :: NowShipped -> String renderShippedEntry s = concat [ "
  • " , "" , renderStatusChip "shipped" , "" , "
    " , "
    " , renderTitle (nsLink s) (nsTitle s) , "" , "
    " , maybe "" (\n -> "

    " ++ escapeHtml n ++ "

    ") (nsNote s) , "
    " , "
  • " ] renderTitle :: Maybe String -> String -> String renderTitle mu title = case mu of Just url -> "" ++ escapeHtml title ++ "" Nothing -> "" ++ escapeHtml title ++ "" renderSection :: String -> [NowEntry] -> String renderSection sec es = concat [ "
    " , "

    " , escapeHtml (titleCaseWords sec) , "

    " , "" , "
    " ] renderEntries :: [NowEntry] -> String renderEntries [] = "" renderEntries entries = concatMap renderOne (sectionOrder entries) where renderOne sec = let inSec = filter ((== sec) . neSection) entries sorted = sortBy (comparing entrySortKey) inSec in renderSection sec sorted renderShippedAll :: [NowShipped] -> String renderShippedAll [] = "" renderShippedAll items = concat [ "
    " , "

    Recently Shipped

    " , "" , "
    " ] where sorted = sortBy (comparing (Down . nsCompleted)) items -- --------------------------------------------------------------------------- -- Date formatters — runs at build time -- --------------------------------------------------------------------------- -- | "2026-04-26" → "26 April 2026". Falls back to the raw ISO string -- if the date is unparseable, so a typo in @last-updated@ surfaces -- in the rendered page rather than blowing up the build. formatWriterly :: String -> String formatWriterly iso = case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso :: Maybe Day of Nothing -> iso Just d -> formatTime defaultTimeLocale "%-d %B %Y" d relativeTime :: Day -> String -> String relativeTime today iso = case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso :: Maybe Day of Nothing -> "" Just d -> bucket (diffDays today d) where bucket n | n < 0 = "" | n == 0 = "today" | n == 1 = "yesterday" | n < 7 = show n ++ " days ago" | n < 28 = pluralize (n `div` 7) "week" | n < 365 = pluralize (n `div` 30) "month" | otherwise = pluralize (n `div` 365) "year" pluralize 1 unit = "1 " ++ unit ++ " ago" pluralize k unit = show k ++ " " ++ unit ++ "s ago" -- --------------------------------------------------------------------------- -- Load -- --------------------------------------------------------------------------- -- | UTF-8 round-trip String → ByteString. Hakyll's @getResourceBody@ -- hands us a 'String' (Unicode codepoints); the yaml library wants -- a UTF-8 'ByteString'. 'Data.ByteString.Char8.pack' would truncate -- each 'Char' to 8 bits — fine for ASCII, silent corruption for any -- codepoint above 0x7F (e.g. em-dash 0x2014 → control char 0x14). loadNow :: Compiler NowDoc loadNow = do rawItem <- load (fromFilePath "data/now.yaml") :: Compiler (Item String) case Y.decodeEither' (TE.encodeUtf8 (T.pack (itemBody rawItem))) of Left err -> fail ("now.yaml: " ++ show err) Right doc -> return doc -- --------------------------------------------------------------------------- -- Context -- --------------------------------------------------------------------------- nowCtx :: Context String nowCtx = constField "now" "true" <> field "now-last-updated" (\_ -> nLastUpdated <$> loadNow) <> field "now-last-updated-display" (\_ -> formatWriterly . nLastUpdated <$> loadNow) <> field "now-last-updated-relative" (\_ -> do doc <- loadNow nowT <- unsafeCompiler getCurrentTime let today = utctDay nowT rel = relativeTime today (nLastUpdated doc) if null rel then noResult "no relative time" else return rel ) <> field "now-entries-html" (\_ -> renderEntries . nEntries <$> loadNow) <> field "now-shipped-html" (\_ -> renderShippedAll . nShipped <$> loadNow) <> siteCtx