diff --git a/docs/guide/html-template/backlinks.md b/docs/guide/html-template/backlinks.md
index ad664496d..b895da8e9 100644
--- a/docs/guide/html-template/backlinks.md
+++ b/docs/guide/html-template/backlinks.md
@@ -28,6 +28,6 @@ Hovering a filled cell opens a context flyout (header `YYYY-MM-DD — title`, th
- Right-panel at `lg:`+ (compact 4×4 cells in the narrow column)
- Bottom strip at ``) as a year-stacked
// heatmap into #timeline-heatmap, using the hidden as
-// the data source. Dates are parsed out of each backlink's title via a
-// YYYY-MM-DD regex; entries without a parseable date are silently skipped
+// the data source. Dates come from Haskell-emitted data-iso-date attributes;
+// entries without a parseable date are silently skipped
// (the hidden list is also the screen-reader / no-JS fallback so they're
// still reachable).
//
@@ -30,8 +30,6 @@ import {
formatCellHeader,
} from '@emanote/calendar-grid';
-const DATE_RE = /(\d{4})-(\d{2})-(\d{2})/;
-
const ROW_CLASSES = 'flex items-center gap-1.5';
const LABEL_CLASSES = 'w-7 text-[0.65rem] uppercase tracking-wider text-gray-500 dark:text-gray-400 select-none shrink-0';
const YEAR_LABEL_CLASSES = 'text-xs font-semibold tracking-tight text-gray-700 dark:text-gray-300 mb-1';
@@ -41,15 +39,17 @@ function parseEntries(listEl) {
for (const li of listEl.querySelectorAll('li')) {
const title = li.dataset.title || '';
const url = li.dataset.url || '';
+ const isoDate = li.dataset.isoDate || '';
// The - 's inner HTML is the rendered backlink context (paragraphs
// around where this note is referenced from the daily note). Used
// verbatim inside the cell's hover flyout — same context shape as
// backlinks-margin so timeline + backlinks read as one family.
const contextHTML = li.innerHTML.trim();
- const m = title.match(DATE_RE);
- if (!m) continue;
- const [, y, mo, d] = m;
+ const parts = isoDate.split('-');
+ if (parts.length !== 3) continue;
+ const [y, mo, d] = parts;
const year = +y, month = +mo, day = +d;
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) continue;
if (!out.has(year)) out.set(year, new Map());
const yr = out.get(year);
if (!yr.has(month)) yr.set(month, new Map());
diff --git a/emanote/default/templates/components/timeline.tpl b/emanote/default/templates/components/timeline.tpl
index 59ad41380..840c88080 100644
--- a/emanote/default/templates/components/timeline.tpl
+++ b/emanote/default/templates/components/timeline.tpl
@@ -14,7 +14,7 @@
- -
+
-
diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs
index 1d1b2a925..cedef28fc 100644
--- a/emanote/src/Emanote/View/Template.hs
+++ b/emanote/src/Emanote/View/Template.hs
@@ -2,10 +2,10 @@ module Emanote.View.Template (emanoteSiteOutput, render) where
import Control.Monad.Logger (MonadLoggerIO)
import Data.Aeson.Types qualified as Aeson
-import Data.List (partition)
import Data.Map.Syntax ((##))
import Data.Set qualified as Set
import Data.Text qualified as T
+import Data.Time.Calendar (Day)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Tree qualified as Tree
import Ema qualified
@@ -190,9 +190,16 @@ renderLmlHtml model note = do
else mempty
"ema:note:backlinks" ##
backlinksSplice model (G.modelLookupBacklinks r model)
- let (backlinksDaily, backlinksNoDaily) = partition (Calendar.isDailyNote . fst) $ G.modelLookupBacklinks r model
+ let (backlinksDaily, backlinksNoDaily) =
+ partitionEithers
+ [ maybe
+ (Right (source, contexts))
+ (\day -> Left (source, day, contexts))
+ (Calendar.parseRouteDay source)
+ | (source, contexts) <- G.modelLookupBacklinks r model
+ ]
"ema:note:backlinks:daily" ##
- backlinksSplice model backlinksDaily
+ dailyBacklinksSplice model backlinksDaily
"ema:note:backlinks:nodaily" ##
backlinksSplice model backlinksNoDaily
let folgeAnc = G.modelFolgezettelAncestorTree model r
@@ -216,20 +223,35 @@ backlinksSplice :: Model -> [(R.LMLRoute, NonEmpty [B.Block])] -> HI.Splice Iden
backlinksSplice model (bs :: [(R.LMLRoute, NonEmpty [B.Block])]) =
Splices.listSplice bs "backlink"
$ \(source, contexts) -> do
- let bnote = fromMaybe (error "backlink note missing - impossible") $ M.modelLookupNoteByRoute' source model
- bmeta = Meta.getEffectiveRouteMetaWith (bnote ^. MN.noteMeta) source model
- bctx = C.mkTemplateRenderCtx model source bmeta
- -- TODO: reuse note splice
- "backlink:note:title" ## C.titleSplice bctx (M.modelLookupTitle source model)
- "backlink:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute (R.LMLView_Html, source))
- "backlink:note:contexts" ##
- Splices.listSplice (toList contexts) "context"
- $ \backlinkCtx -> do
- let ctxDoc = Pandoc mempty $ one $ B.Div B.nullAttr backlinkCtx
- "context:body" ##
- C.withInlineCtx bctx
- $ \ctx' ->
- Splices.pandocSplice ctx' ctxDoc
+ backlinkSplices model source contexts Nothing
+
+dailyBacklinksSplice :: Model -> [(R.LMLRoute, Day, NonEmpty [B.Block])] -> HI.Splice Identity
+dailyBacklinksSplice model bs =
+ Splices.listSplice bs "backlink"
+ $ \(source, day, contexts) ->
+ backlinkSplices model source contexts (Just day)
+
+backlinkSplices :: Model -> R.LMLRoute -> NonEmpty [B.Block] -> Maybe Day -> H.Splices (HI.Splice Identity)
+backlinkSplices model source contexts mDay = do
+ let bnote = fromMaybe (error "backlink note missing - impossible") $ M.modelLookupNoteByRoute' source model
+ bmeta = Meta.getEffectiveRouteMetaWith (bnote ^. MN.noteMeta) source model
+ bctx = C.mkTemplateRenderCtx model source bmeta
+ -- TODO: reuse note splice
+ "backlink:note:title" ## C.titleSplice bctx (M.modelLookupTitle source model)
+ "backlink:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute (R.LMLView_Html, source))
+ "backlink:note:iso-date" ## HI.textSplice (maybe "" dayIsoText mDay)
+ "backlink:note:contexts" ##
+ Splices.listSplice (toList contexts) "context"
+ $ \backlinkCtx -> do
+ let ctxDoc = Pandoc mempty $ one $ B.Div B.nullAttr backlinkCtx
+ "context:body" ##
+ C.withInlineCtx bctx
+ $ \ctx' ->
+ Splices.pandocSplice ctx' ctxDoc
+
+dayIsoText :: Day -> Text
+dayIsoText =
+ toText . formatTime defaultTimeLocale "%Y-%m-%d"
{- | Heist splice for the sidebar tree.
@@ -247,7 +269,7 @@ routeTreeSplices tCtx mCurrentRoute model = do
-- otherwise. Sole source of truth for daily-note dates on the JS side
-- (sidebar-calendar reads it via data-iso-date) so date detection
-- stays in Haskell, not duplicated as a regex per widget.
- "node:iso-date" ## HI.textSplice (maybe "" (toText . formatTime defaultTimeLocale "%Y-%m-%d") $ Calendar.parseRouteDay nodeRoute)
+ "node:iso-date" ## HI.textSplice (maybe "" dayIsoText $ Calendar.parseRouteDay nodeRoute)
let isActiveNode = Just nodeRoute == mCurrentRoute
isActiveTree =
-- Active tree checking is applicable only when there is an
diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature
index ba267f1dc..1948ad3f6 100644
--- a/tests/features/smoke.feature
+++ b/tests/features/smoke.feature
@@ -142,6 +142,7 @@ Feature: Smoke
When I open "/dailyhost.html"
Then the Timeline panel links to "dailyhost/2025-01-01"
And the Timeline panel links to "dailyhost/2025-01-02"
+ And every Timeline data entry has an ISO date
Scenario: Non-daily backlinks land in the Backlinks panel and not the Timeline
When I open "/dailyhost.html"
diff --git a/tests/fixtures/notebook/dailyhost/2025-01-02.md b/tests/fixtures/notebook/dailyhost/2025-01-02.md
index 2ed47f197..5bb32692c 100644
--- a/tests/fixtures/notebook/dailyhost/2025-01-02.md
+++ b/tests/fixtures/notebook/dailyhost/2025-01-02.md
@@ -1,3 +1,3 @@
-# 2025-01-02
+# Retitled daily note
Another daily-named note linking to [[dailyhost]].
diff --git a/tests/step_definitions/smoke_steps.ts b/tests/step_definitions/smoke_steps.ts
index 06cbd9a69..fb4e8585c 100644
--- a/tests/step_definitions/smoke_steps.ts
+++ b/tests/step_definitions/smoke_steps.ts
@@ -725,6 +725,31 @@ Then(
},
);
+Then(
+ "every Timeline data entry has an ISO date",
+ async function (this: EmanoteWorld) {
+ const entries = await this.page.evaluate(() =>
+ Array.from(
+ document.querySelectorAll(".emanote-timeline .timeline-data li"),
+ ).map((li) => ({
+ title: (li as HTMLElement).dataset.title ?? "",
+ isoDate: (li as HTMLElement).dataset.isoDate ?? "",
+ })),
+ );
+ assert.ok(
+ entries.length > 0,
+ "Expected at least one hidden Timeline data entry in the fixture.",
+ );
+ for (const entry of entries) {
+ assert.match(
+ entry.isoDate,
+ /^\d{4}-\d{2}-\d{2}$/,
+ `Expected Timeline entry ${JSON.stringify(entry.title)} to carry data-iso-date from Haskell, got ${JSON.stringify(entry.isoDate)}.`,
+ );
+ }
+ },
+);
+
Then(
"the Backlinks panel links to {string}",
async function (this: EmanoteWorld, hrefSubstring: string) {