From 237380c4bee455a0f4fdd8be2595d14b1350a5aa Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 17 Apr 2026 15:15:04 -0400 Subject: [PATCH] date data --- build/Contexts.hs | 10 ++- build/Stability.hs | 72 ++++++++++++++++++++-- static/css/popups.css | 49 +++++++++++++++ static/js/popups.js | 95 +++++++++++++++++++++++++++++ templates/blog-post.html | 2 +- templates/partials/metadata.html | 9 ++- templates/partials/page-footer.html | 6 +- 7 files changed, 230 insertions(+), 13 deletions(-) diff --git a/build/Contexts.hs b/build/Contexts.hs index 5c314ac..ee3a833 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -31,9 +31,11 @@ import Hakyll hiding (trim) import Backlinks (backlinksField) import Dingbat (dingbatField) import SimilarLinks (similarLinksField) -import Stability (stabilityField, lastReviewedField, versionHistoryField, +import Stability (stabilityField, lastReviewedField, lastReviewedIsoField, + versionHistoryField, versionHistoryPrimaryField, versionHistoryRestField, - versionHistoryRangeField) + versionHistoryRangeField, versionHistoryRangeStartField, + versionHistoryRangeEndField, versionHistoryCommitsField) import Utils (authorSlugify, authorNameOf, trim) -- --------------------------------------------------------------------------- @@ -377,6 +379,7 @@ epistemicCtx = <> confidenceTrendField <> stabilityField <> lastReviewedField + <> lastReviewedIsoField -- --------------------------------------------------------------------------- -- Essay context @@ -398,6 +401,9 @@ essayCtx = <> versionHistoryPrimaryField <> versionHistoryRestField <> versionHistoryRangeField + <> versionHistoryRangeStartField + <> versionHistoryRangeEndField + <> versionHistoryCommitsField <> dateField "date-created" "%-d %B %Y" <> dateField "date-modified" "%-d %B %Y" <> constField "math" "true" diff --git a/build/Stability.hs b/build/Stability.hs index 35a4823..0094a45 100644 --- a/build/Stability.hs +++ b/build/Stability.hs @@ -18,10 +18,14 @@ module Stability ( stabilityField , lastReviewedField + , lastReviewedIsoField , versionHistoryField , versionHistoryPrimaryField , versionHistoryRestField , versionHistoryRangeField + , versionHistoryRangeStartField + , versionHistoryRangeEndField + , versionHistoryCommitsField ) where import Control.Exception (catch, IOException) @@ -159,12 +163,30 @@ lastReviewedField = field "last-reviewed" $ \item -> do Nothing -> fail "no last-reviewed" Just d -> return d +-- | Raw-ISO companion to @$last-reviewed$@ — for hover-popup +-- @data-date-start@ attribute. Falls back to the frontmatter value for +-- pinned files (which is expected to already be ISO, the same convention +-- used by 'lastReviewedField' before it applied 'fmtIso'). +lastReviewedIsoField :: Context String +lastReviewedIsoField = field "last-reviewed-iso" $ \item -> do + let srcPath = toFilePath (itemIdentifier item) + meta <- getMetadata (itemIdentifier item) + mIso <- unsafeCompiler $ do + ignored <- readIgnore + if srcPath `elem` ignored + then return $ lookupString "last-reviewed" meta + else listToMaybe <$> gitDates srcPath + case mIso of + Nothing -> fail "no last-reviewed ISO" + Just d -> return d + -- --------------------------------------------------------------------------- -- Version history -- --------------------------------------------------------------------------- data VHEntry = VHEntry - { vhDate :: String + { vhDate :: String -- human-readable, e.g. "12 April 2026" + , vhDateIso :: String -- raw ISO, e.g. "2026-04-12" , vhMessage :: Maybe String -- Nothing for git-log-only entries } @@ -179,7 +201,7 @@ parseFmHistory meta = parseOne (Object o) = case getString =<< KM.lookup "date" o of Nothing -> Nothing - Just d -> Just $ VHEntry (fmtIso d) (getString =<< KM.lookup "note" o) + Just d -> Just $ VHEntry (fmtIso d) d (getString =<< KM.lookup "note" o) parseOne _ = Nothing getString (String t) = Just (T.unpack t) @@ -187,7 +209,7 @@ parseFmHistory meta = -- | Get git log for a file as version history entries (date-only, no message). gitLogHistory :: FilePath -> IO [VHEntry] -gitLogHistory fp = map (\d -> VHEntry (fmtIso d) Nothing) <$> gitDates fp +gitLogHistory fp = map (\d -> VHEntry (fmtIso d) d Nothing) <$> gitDates fp -- | Maximum entries shown by default in the version-history footer block. -- The remainder is revealed via a
/ expand affordance, @@ -213,11 +235,12 @@ vhItems tag = zipWith (\i e -> Item (fromFilePath (tag ++ "-" ++ show (i :: Int))) e) [1..] --- | Shared sub-context for version-history entries: @$vh-date$@ and --- (optionally) @$vh-message$@. +-- | Shared sub-context for version-history entries: @$vh-date$@, +-- @$vh-date-iso$@ (raw ISO for hover popups), and (optionally) @$vh-message$@. vhEntryCtx :: Context VHEntry vhEntryCtx = - field "vh-date" (return . vhDate . itemBody) + field "vh-date" (return . vhDate . itemBody) + <> field "vh-date-iso" (return . vhDateIso . itemBody) <> field "vh-message" (\i -> case vhMessage (itemBody i) of Nothing -> fail "no message" Just m -> return m) @@ -277,3 +300,40 @@ versionHistoryRangeField = field "version-history-range" $ \item -> do in if newD == oldD then return newD else return (oldD ++ " \x2013 " ++ newD) + +-- | Raw-ISO start date (oldest entry) for hover-popup machine use. +versionHistoryRangeStartField :: Context String +versionHistoryRangeStartField = + field "version-history-range-start" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no version-history start" + _ -> return (vhDateIso (last entries)) + +-- | Raw-ISO end date (newest entry) for hover-popup machine use. +-- Only resolves when the range spans more than one calendar day — single-day +-- histories don't need an end attribute on the popup trigger. +versionHistoryRangeEndField :: Context String +versionHistoryRangeEndField = + field "version-history-range-end" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no version-history end" + [_] -> fail "single-day history — no end" + (newest : more) -> + let oldest = last (newest : more) + in if vhDateIso newest == vhDateIso oldest + then fail "single-day history — no end" + else return (vhDateIso newest) + +-- | Commit count — used by the frontmatter popup to surface the *density* +-- of attention the piece has received. Deliberately only wired into the +-- metadata-strip date link, not the aftermatter list (where it would be +-- redundant next to the enumeration of entries). +versionHistoryCommitsField :: Context String +versionHistoryCommitsField = + field "version-history-commits" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no commits" + _ -> return (show (length entries)) diff --git a/static/css/popups.css b/static/css/popups.css index 8f13f1a..e36f048 100644 --- a/static/css/popups.css +++ b/static/css/popups.css @@ -241,3 +241,52 @@ overflow-x: auto; color: var(--text-muted); } + +/* ============================================================ + DATE POPUP — compact, humanised span + "ago" framing, plus + optional cadence line for the frontmatter history link. + ============================================================ */ + +.link-popup:has(.popup-date) { + min-width: 0; + max-width: 260px; + padding: 0.5rem 0.7rem; +} + +.popup-date { + font-family: var(--font-sans); + font-size: 0.78rem; + line-height: 1.45; + color: var(--text-muted); +} + +.popup-date-primary { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.popup-date-cadence { + margin-top: 0.15rem; + font-size: 0.72rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-faint); +} + +/* Subtle cue that the date is interactive. Dotted underline is the standard + convention for explanatory hover; kept faint so it doesn't shout. */ +.date-hover { + cursor: help; + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--border-muted); + text-underline-offset: 0.2em; +} + +/* Don't decorate container elements (li, dd, time) that already fit into + a list with their own layout — the text decoration looks noisy on them. */ +li.date-hover, +dd.date-hover { + text-decoration: none; + cursor: help; +} diff --git a/static/js/popups.js b/static/js/popups.js index 252e2bc..03ebc5f 100644 --- a/static/js/popups.js +++ b/static/js/popups.js @@ -124,6 +124,13 @@ var provider = getProvider(el.getAttribute('href') || ''); if (provider) bind(el, provider); }); + + /* Date hover popups — any element tagged with data-date-start. + Handles the frontmatter range link, version-history list items, + last-reviewed in the epistemic block, blog post dates, etc. */ + root.querySelectorAll('[data-date-start]').forEach(function (el) { + bind(el, dateContent); + }); } /* Public re-init hook used by transclude.js after it injects new @@ -772,6 +779,94 @@ }); } + /* ------------------------------------------------------------------ + Date popups — explain a date or date range in human terms. + Single date: "6 months ago" + Range: "~4 weeks · started 6 months ago" + Frontmatter: range + "17 revisions" cadence line (only when + data-date-commits is present on the trigger). + ------------------------------------------------------------------ */ + + function dateContent(target) { + var startAttr = target.getAttribute('data-date-start'); + if (!startAttr) return Promise.resolve(null); + var start = parseIsoDate(startAttr); + if (!start) return Promise.resolve(null); + + var endAttr = target.getAttribute('data-date-end'); + var end = endAttr ? parseIsoDate(endAttr) : null; + var commits = target.getAttribute('data-date-commits'); + + var today = new Date(); + var lines = []; + + if (end) { + var spanDays = daysBetween(start, end); + var agoDays = daysBetween(start, today); + /* "~" prefix when we've rounded to a unit larger than days. */ + var span = humanDuration(spanDays, true); + var ago = humanAgo(agoDays); + lines.push( + ''); + if (commits && /^\d+$/.test(commits)) { + var n = parseInt(commits, 10); + lines.push( + ''); + } + } else { + var days = daysBetween(start, today); + lines.push( + ''); + } + + return Promise.resolve(''); + } + + /* Parse "YYYY-MM-DD" (UTC midnight) to a Date. Returns null on failure. */ + function parseIsoDate(s) { + var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); + if (!m) return null; + var d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); + return isNaN(d.getTime()) ? null : d; + } + + /* Whole-day difference between two Dates, floored (never negative). */ + function daysBetween(a, b) { + var ms = Math.abs(b.getTime() - a.getTime()); + return Math.floor(ms / 86400000); + } + + /* "5 days" / "3 weeks" / "4 months" / "2 years" — the unit is chosen + to match the magnitude so the number stays small and readable. + `approx` prefixes "~" when the returned unit is coarser than days. */ + function humanDuration(days, approx) { + if (days <= 1) return '1 day'; + if (days < 14) return days + ' days'; + if (days < 60) { + var w = Math.round(days / 7); + return (approx ? '~' : '') + w + ' week' + (w === 1 ? '' : 's'); + } + if (days < 365) { + var mo = Math.round(days / 30); + return (approx ? '~' : '') + mo + ' month' + (mo === 1 ? '' : 's'); + } + var y = Math.round(days / 365); + return (approx ? '~' : '') + y + ' year' + (y === 1 ? '' : 's'); + } + + /* Past-tense phrasing for a date N days in the past. */ + function humanAgo(days) { + if (days <= 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 14) return days + ' days ago'; + return humanDuration(days, true) + ' ago'; + } + /* Defer to the shared utility (loaded synchronously from templates/partials/head.html) so this file cannot drift from annotations.js, semantic-search.js, or build/Utils.hs. */ diff --git a/templates/blog-post.html b/templates/blog-post.html index 449b457..39c4710 100644 --- a/templates/blog-post.html +++ b/templates/blog-post.html @@ -1,7 +1,7 @@

$title$

$if(date)$ - + $endif$ $body$ $if(backlinks)$ diff --git a/templates/partials/metadata.html b/templates/partials/metadata.html index f5c0de1..67d966a 100644 --- a/templates/partials/metadata.html +++ b/templates/partials/metadata.html @@ -30,7 +30,14 @@ $endif$