date data

This commit is contained in:
Levi Neuwirth 2026-04-17 15:15:04 -04:00
parent 1a532f881b
commit 237380c4be
7 changed files with 230 additions and 13 deletions

View File

@ -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"

View File

@ -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 <details>/<summary> 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))

View File

@ -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;
}

View File

@ -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(
'<div class="popup-date-primary">'
+ esc(span) + ' · started ' + esc(ago)
+ '</div>');
if (commits && /^\d+$/.test(commits)) {
var n = parseInt(commits, 10);
lines.push(
'<div class="popup-date-cadence">'
+ n + ' revision' + (n === 1 ? '' : 's')
+ '</div>');
}
} else {
var days = daysBetween(start, today);
lines.push(
'<div class="popup-date-primary">'
+ esc(humanAgo(days)) + '</div>');
}
return Promise.resolve('<div class="popup-date">' + lines.join('') + '</div>');
}
/* 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. */

View File

@ -1,7 +1,7 @@
<main id="markdownBody" data-pagefind-body>
<h1 class="page-title">$title$</h1>
$if(date)$
<p class="post-date"><time datetime="$date-iso$">$date$</time></p>
<p class="post-date"><time class="date-hover" datetime="$date-iso$" data-date-start="$date-iso$">$date$</time></p>
$endif$
$body$
$if(backlinks)$

View File

@ -30,7 +30,14 @@
</div>
$endif$
<nav class="meta-row meta-pagelinks" aria-label="Page sections">
<a href="#version-history">$if(version-history-range)$$version-history-range$$else$History$endif$</a>
$if(version-history-range)$
<a href="#version-history" class="date-hover"
data-date-start="$version-history-range-start$"
$if(version-history-range-end)$data-date-end="$version-history-range-end$"$endif$
$if(version-history-commits)$data-date-commits="$version-history-commits$"$endif$>$version-history-range$</a>
$else$
<a href="#version-history">History</a>
$endif$
$if(status)$<a href="#epistemic">Epistemic</a>$endif$
$if(bibliography)$<a href="#bibliography">Bibliography</a>$endif$
$if(backlinks)$<a href="#backlinks">Backlinks</a>$endif$

View File

@ -78,7 +78,7 @@
<h3><a href="/colophon.html#living-documents">Epistemic</a></h3>
<dl class="ep-expanded">
<dt data-ep-term="stability">Stability</dt><dd>$stability$</dd>
$if(last-reviewed)$<dt>Last reviewed</dt><dd>$last-reviewed$</dd>$endif$
$if(last-reviewed)$<dt>Last reviewed</dt><dd class="date-hover"$if(last-reviewed-iso)$ data-date-start="$last-reviewed-iso$"$endif$>$last-reviewed$</dd>$endif$
$if(confidence-trend)$<dt data-ep-term="confidence">Confidence trend</dt><dd>$confidence-trend$</dd>$endif$
</dl>
</div>
@ -89,7 +89,7 @@
$if(version-history-primary)$
<ul class="version-history-list">
$for(version-history-primary)$
<li>$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
<li class="date-hover" data-date-start="$vh-date-iso$">$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
$endfor$
</ul>
$if(version-history-rest)$
@ -97,7 +97,7 @@
<summary>More</summary>
<ul class="version-history-list">
$for(version-history-rest)$
<li>$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
<li class="date-hover" data-date-start="$vh-date-iso$">$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
$endfor$
</ul>
</details>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB