Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guide/daily-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ If you create notes named `YYYY-MM-DD.md`, Emanote will treat them as daily note

1. The backlinks panel will render daily notes separate from regular notes. Daily notes render as a year-stacked **timeline heatmap** (see [[backlinks#timeline-backlinks]]); regular notes render as a "Linked from" chip list.
2. Each daily note automatically gets a hierarchical tag (eg: `#calendar/2025/03`) allowing you to browse them by calendar navigation in the tag index.
3. Sidebar tree nodes whose immediate children are all daily notes of the same month (typically a `Daily/2026/04/` folder) render as a small **7-column calendar grid** in place of the linear list — each filled cell links to that day's note, missing days are muted day numbers.

## Timeline backlinks demo

Expand Down
1 change: 1 addition & 0 deletions emanote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Popup footnotes as the only on-screen UI (desktop card / mobile bottom-sheet); printed output renders the footnote list ([#642](https://github.com/srid/emanote/pull/642)).
- Site-authored interactive JS extracted from per-template `<script>` blocks into ES modules under `_emanote-static/js/` (loaded once, cached across pages). Code-copy buttons now also appear on code blocks added by the live-server's DOM patches, not only those present at first load ([#643](https://github.com/srid/emanote/issues/643)).
- Stork search controller migrated to the same ES-module pattern. Templates expose the search trigger via `data-emanote-stork-toggle` (event delegation) instead of inline `onclick="window.emanote.stork.toggleSearch()"`; the dark-mode mirror that re-skins the search dialog moved into the module too. Closes the Stork follow-up implied by [#643](https://github.com/srid/emanote/issues/643).
- **Sidebar month calendar** ([#700](https://github.com/srid/emanote/issues/700)): when a sidebar tree node holds nothing but daily-note leaves of one month (e.g. `Daily/2026/04/2026-04-21.md` … `2026-04-30.md`), the children list is replaced in-place by a 7-column calendar grid for that month. Cells with notes link to the daily note; missing days render as muted day numbers. Date detection lives in Haskell (`Calendar.parseRouteDay` emits `data-iso-date` on each leaf via the new `node:iso-date` splice) so JS never re-implements the YYYY-MM-DD parser. Cell palette + size constants extracted from `timeline-heatmap.js` into a shared `calendar-grid` module so a Tailwind palette refresh edits one file.
- **Default theme refresh** ([#699](https://github.com/srid/emanote/pull/699)): every panel — title, sidebar, right-panel (TOC + backlinks + timeline), bottom strip, footer — attaches inside one rounded `#container` card, so the page UI reads as a single composed unit instead of stacked stripes. Wikilinks, backlinks, queries, timeline entries, tasks index, and inline tags share one chip palette (`bg-primary-50/70 text-primary-600 font-semibold tracking-tight`); TOC drops primary entirely (entries map to plain-text headings). Heading scale tightens to a ~1.20 ratio with uniform `font-semibold`. Daily-note backlinks render as a year-stacked heatmap with cell-hover context flyouts. Special pages (`/-/all`, `/-/tags`, `/-/tasks`) pick up the same chrome plus a back-to-Home link. Tables, in-prose task lists, and the footer all get coordinated styling so the page reads as one design language. Mona Sans replaces Space Grotesk for the chrome typeface.
- Mermaid: add `elk` layout ([#618](https://github.com/srid/emanote/pull/618))
- Home Manager module: macOS support via launchd ([#623](https://github.com/srid/emanote/pull/623))
Expand Down
66 changes: 66 additions & 0 deletions emanote/default/_emanote-static/js/calendar-grid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Cell-level primitives shared by every calendar/heatmap widget — the
// palette, the size variants, and the filled/empty cell builders. The
// row/grid composition is widget-specific: timeline-heatmap stacks 31-cell
// linear strips, sidebar-calendar lays cells out in a 7-column weekday
// grid. Pulling the cell concept into one module keeps the primary palette
// + sizing in one place; a Tailwind palette refresh edits one file, not N.

export const MONTH_LABELS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];

export const CELL_BASE = 'block rounded-[1px]';
export const CELL_FILLED_BASE = CELL_BASE + ' group relative bg-primary-500 dark:bg-primary-400 hover:bg-primary-700 dark:hover:bg-primary-300 transition-colors';
export const CELL_EMPTY_BG = ' bg-gray-200 dark:bg-gray-800';

// Two render contexts:
// - "narrow" → right-panel column (~150-210px) and sidebar (~200px).
// Fixed 4×4px squares (w-1 h-1).
// - "wide" → bottom strip at <lg (~700-960px). flex-1 cells with
// min-w + h-2.5 stretch as horizontal bars rather than
// leaving 70% empty space.
export const SIZE_NARROW = ' w-1 h-1';
export const SIZE_WIDE = ' flex-1 min-w-[6px] h-2.5';

// Hover flyout: outer wrapper with pt-1.5 acts as a transparent
// hover-bridge so cursor traversal between cell and visible card never
// drops `group-hover`. Positioned below the cell (top-full) so flyouts
// near the top don't clip on the viewport edge.
export const FLYOUT_OUTER = 'cell-flyout absolute z-50 hidden group-hover:block group-focus-within:block top-full left-1/2 -translate-x-1/2 pt-1.5';
export const FLYOUT_CARD = 'w-64 max-w-[min(16rem,80vw)] px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 shadow-xl text-[0.8rem] leading-[1.5] text-gray-700 dark:text-gray-300 [&_p]:m-0 [&_p+p]:mt-2';
export const FLYOUT_HEADER = 'text-[0.65rem] font-semibold uppercase tracking-wider text-primary-600 dark:text-primary-400 mb-1.5';

// Build a filled cell anchor. `sizeClass` is one of SIZE_NARROW / SIZE_WIDE.
// `flyoutBuilder(headerText)` is optional — if supplied, its return DOM
// node is appended inside the anchor as a CSS-only hover flyout. Widgets
// that don't want a flyout (e.g. sidebar-calendar) just omit it.
export function createFilledCell({ url, headerText, sizeClass, flyoutBuilder }) {
const a = document.createElement('a');
a.className = CELL_FILLED_BASE + sizeClass;
a.href = url;
// aria-label (not title) so screen readers announce the cell without
// the browser layering its native gray tooltip on top of any rich
// hover flyout.
a.setAttribute('aria-label', headerText);
if (flyoutBuilder) {
a.appendChild(flyoutBuilder(headerText));
}
return a;
}

export function createEmptyCell(sizeClass) {
const sp = document.createElement('span');
sp.className = CELL_BASE + sizeClass + CELL_EMPTY_BG;
sp.setAttribute('aria-hidden', 'true');
return sp;
}

// Header text shown by the cell's tooltip / flyout. Shared so both
// widgets format dates identically — a drift here would surface as
// "the timeline says 2026-04-21 but the sidebar says 21 Apr 2026".
export function formatCellHeader(year, month, day, title) {
const moStr = String(month).padStart(2, '0');
const dStr = String(day).padStart(2, '0');
return year + '-' + moStr + '-' + dStr + ' — ' + title;
}
200 changes: 200 additions & 0 deletions emanote/default/_emanote-static/js/sidebar-calendar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// When a sidebar tree node holds nothing but daily-note leaves of one
// month (e.g. `Daily/2026/04/` containing 2026-04-21 … 2026-04-30),
// swap the linear list for a 7-column calendar grid for that month.
// Days with notes show as clickable primary-coloured cells; days
// without are muted day-numbers; both carry the day's date as text.
//
// Detection seam: the sidebar tree wraps each subtree in
// `<div class="emanote-tree-children">` and stamps every leaf anchor
// with `data-iso-date="YYYY-MM-DD"` (empty for non-daily notes).
// Both come from Heist; this module never re-parses dates from text.
// See `routeTreeSplices` in `Emanote.View.Template`.
//
// Cell shape is sidebar-specific (day-numbered tile), distinct from
// the timeline-heatmap's text-less colour-only square. Both share
// `MONTH_LABELS` and `formatCellHeader` from `@emanote/calendar-grid`,
// but the cell builders themselves live here.
//
// Re-runs on @emanote/morph for live-server in-app navigation.

import { ready, onMorph } from '@emanote/morph';
import { MONTH_LABELS, formatCellHeader } from '@emanote/calendar-grid';

const WEEKDAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];

const CALENDAR_CLASS = 'emanote-sidebar-calendar';
const WRAPPER_CLASSES = CALENDAR_CLASS + ' my-1.5 px-2 py-2 rounded-md bg-gray-50 dark:bg-gray-900/50';
const HEADER_CLASSES = 'text-[0.7rem] font-semibold tracking-tight text-gray-700 dark:text-gray-300 mb-1.5';
const WEEKDAY_ROW_CLASSES = 'grid grid-cols-7 mb-1';
const WEEKDAY_LABEL_CLASSES = 'text-[0.55rem] uppercase tracking-wider text-gray-400 dark:text-gray-500 text-center select-none';
const GRID_CLASSES = 'grid grid-cols-7 gap-0.5 place-items-center';
const CELL_BASE = 'flex items-center justify-center text-[0.65rem] w-6 h-6 rounded-sm tabular-nums transition-colors';
const CELL_FILLED = CELL_BASE + ' font-semibold bg-primary-500 dark:bg-primary-400 text-white hover:bg-primary-700 dark:hover:bg-primary-300';
const CELL_TODAY_MARKER = ' underline decoration-2 underline-offset-2';
const CELL_FILLED_TODAY = CELL_FILLED + ' ring-2 ring-primary-300/80 dark:ring-primary-500/80 ring-offset-1 ring-offset-gray-50 dark:ring-offset-gray-900' + CELL_TODAY_MARKER;
const CELL_ACTIVE = CELL_BASE + ' font-bold bg-primary-700 dark:bg-primary-300 text-white dark:text-gray-950 ring-2 ring-primary-300/80 dark:ring-primary-500/80 ring-offset-1 ring-offset-gray-50 dark:ring-offset-gray-900 shadow-sm';
const CELL_ACTIVE_TODAY = CELL_ACTIVE + CELL_TODAY_MARKER;
const CELL_EMPTY = CELL_BASE + ' text-gray-400 dark:text-gray-600';
const CELL_EMPTY_TODAY = CELL_BASE + ' font-semibold text-primary-700 dark:text-primary-300 bg-white dark:bg-gray-950 border border-primary-400/80 dark:border-primary-500/80' + CELL_TODAY_MARKER;

function dateToIso(year, month, day) {
return [
String(year).padStart(4, '0'),
String(month).padStart(2, '0'),
String(day).padStart(2, '0'),
].join('-');
}

function todayIso() {
const now = new Date();
return dateToIso(now.getFullYear(), now.getMonth() + 1, now.getDate());
}

function isCurrentRoute(url) {
return new URL(url, document.baseURI).pathname === window.location.pathname;
}

function refreshDayCell(cell, today) {
const isToday = cell.dataset.isoDate === today;
cell.toggleAttribute('data-today', isToday);

if (cell.tagName === 'A') {
const isActive = isCurrentRoute(cell.href);
cell.className = isActive
? (isToday ? CELL_ACTIVE_TODAY : CELL_ACTIVE)
: (isToday ? CELL_FILLED_TODAY : CELL_FILLED);
cell.toggleAttribute('data-active-route', isActive);
if (isActive) {
cell.setAttribute('aria-current', 'page');
} else {
cell.removeAttribute('aria-current');
}
} else {
cell.className = isToday ? CELL_EMPTY_TODAY : CELL_EMPTY;
}
}

function refreshCalendar(calendar) {
const today = todayIso();
for (const cell of calendar.querySelectorAll('[data-iso-date]')) {
refreshDayCell(cell, today);
}
}

// Returns { year, month, leaves: Map<day, {url, title}> } when every
// child of `wrapper` is a leaf with a parseable iso-date in the same
// year+month. Returns null if the wrapper holds anything else
// (subfolder, non-daily note, mixed months).
function classifyMonthGroup(wrapper) {
const childNodes = wrapper.querySelectorAll(':scope > div');
if (childNodes.length === 0) return null;
let year = null;
let month = null;
const leaves = new Map();
for (const child of childNodes) {
// A leaf has no actual nested subtree. The wrapper div may still
// be present (empty) when `tree:open` is true on the leaf — we
// care only whether the wrapper has content.
if (child.querySelector(':scope > .emanote-tree-children > *')) return null;
const a = child.querySelector(':scope a[data-iso-date]');
if (!a) return null;
const iso = a.dataset.isoDate;
if (!iso) return null;
const [y, mo, d] = iso.split('-').map(Number);
if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return null;
if (year === null) {
year = y;
month = mo;
} else if (y !== year || mo !== month) {
return null;
}
leaves.set(d, { url: a.href, title: a.getAttribute('title') || iso });
}
return { year, month, leaves };
}

// Build one day cell. `entry` is the leaves-map value for this day, or
// undefined when no daily note exists for it. Filled cells link to the
// note; empty cells are non-interactive but still show the day number
// so the calendar reads as a calendar at a glance.
function buildDayCell(year, month, day, entry, today) {
const iso = dateToIso(year, month, day);
if (entry) {
const a = document.createElement('a');
a.href = entry.url;
a.dataset.day = String(day);
a.dataset.isoDate = iso;
a.setAttribute('aria-label', formatCellHeader(year, month, day, entry.title));
a.textContent = String(day);
refreshDayCell(a, today);
return a;
}
const sp = document.createElement('span');
sp.dataset.day = String(day);
sp.dataset.isoDate = iso;
sp.textContent = String(day);
refreshDayCell(sp, today);
return sp;
}

function buildCalendar({ year, month, leaves }) {
const wrapper = document.createElement('div');
wrapper.className = WRAPPER_CLASSES;
const today = todayIso();

const header = document.createElement('div');
header.className = HEADER_CLASSES;
header.textContent = MONTH_LABELS[month - 1] + ' ' + year;
wrapper.appendChild(header);

const weekdayRow = document.createElement('div');
weekdayRow.className = WEEKDAY_ROW_CLASSES;
for (const wl of WEEKDAY_LABELS) {
const span = document.createElement('span');
span.className = WEEKDAY_LABEL_CLASSES;
span.textContent = wl;
weekdayRow.appendChild(span);
}
wrapper.appendChild(weekdayRow);

const grid = document.createElement('div');
grid.className = GRID_CLASSES;

// ISO weekday: Mon=1 … Sun=7. Pad leading blanks so day 1 lands in
// the right weekday column.
const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay() || 7;
for (let i = 1; i < firstWeekday; i++) {
const blank = document.createElement('span');
blank.setAttribute('aria-hidden', 'true');
grid.appendChild(blank);
}

const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
for (let day = 1; day <= lastDay; day++) {
grid.appendChild(buildDayCell(year, month, day, leaves.get(day), today));
}
wrapper.appendChild(grid);

return wrapper;
}

function isAlreadyRendered(wrapper) {
return wrapper.firstElementChild?.classList.contains(CALENDAR_CLASS) ?? false;
}

function render() {
for (const wrapper of document.querySelectorAll('.emanote-tree-children')) {
if (isAlreadyRendered(wrapper)) {
refreshCalendar(wrapper.firstElementChild);
continue;
}
const group = classifyMonthGroup(wrapper);
if (!group) continue;
const calendar = buildCalendar(group);
wrapper.textContent = '';
wrapper.appendChild(calendar);
}
}

ready(render);
onMorph(render);
Loading
Loading