From 526f54a640af13fb0cb865e1f13258a5bdf82bff Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 7 Sep 2026 11:45:45 -0400 Subject: [PATCH] 1.1.0: fix Live Preview crash, add Reading View inline support, birthday labels - Fix RangeSetBuilder ordering crash: inline and fenced-block decorations are now merged and sorted before being added, instead of two unsorted passes (crashed whenever a fenced block preceded inline code in a note). - Add explicit @codemirror/state and @codemirror/view devDependencies (previously only transitive via @codemirror/language). - Add Reading View support for inline `date-calc:` spans via registerMarkdownPostProcessor (previously Live Preview only), mirroring the approach used by Dataview's inline queries. - Fix verbose=false being silently truthy (non-empty string) in inline key=value config, which meant it never actually disabled verbose output. - Fix diff type auto-inference: from/to was dead code (countdown's check always matched first); reordered so from+to or start+end infer diff. - Implement documented but missing birthday label support: cfg.label, falling back to frontmatter name ("Jane's age:"), then "Age:". - Split main.ts (777 lines) into src/ modules per AGENTS.md conventions. - Fix duplicated command-palette prefixes, move inline JS styles to styles.css, fix package.json author, drop orphaned showArrow setting. Verified: unit tests against the real bundled logic (mocked obsidian module), a reproduction of the RangeSetBuilder crash against the real @codemirror/state package, and live end-to-end testing in Obsidian via CDP/Playwright (Tchernobyl vault). --- README.md | 7 +- main.ts | 762 ++------------------------------------------ manifest.json | 2 +- package-lock.json | 10 +- package.json | 6 +- src/calculate.ts | 159 +++++++++ src/config.ts | 114 +++++++ src/live-preview.ts | 170 ++++++++++ src/moment-utils.ts | 74 +++++ src/settings-tab.ts | 95 ++++++ src/types.ts | 43 +++ src/widgets.ts | 65 ++++ styles.css | 20 ++ versions.json | 3 +- 14 files changed, 784 insertions(+), 746 deletions(-) create mode 100644 src/calculate.ts create mode 100644 src/config.ts create mode 100644 src/live-preview.ts create mode 100644 src/moment-utils.ts create mode 100644 src/settings-tab.ts create mode 100644 src/types.ts create mode 100644 src/widgets.ts diff --git a/README.md b/README.md index add5a17..3edd3dd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ An Obsidian plugin that adds dynamic date calculations to your notes using `date ## Todo -- [ ] Make everything work not only in Live Preview, but Reading also. - [ ] Add custom language (default to Obsidian language, overridable) @@ -24,20 +23,20 @@ birthday: 1992-08-16 Use inline code for quick calculations: `date-calc: birthday=1992-08-16` -**Note about Live Preview**: Inline code works in both Reading view and Live Preview mode. In Live Preview, the result appears after the inline code (e.g., `date-calc: birthday=1992-08-16` → Age: 32 years old...). However, inline code does **not** work for live-preview in Source mode - you'll see the literal code until you switch to Reading view or Live Preview mode. +**Rendering modes**: Inline code and fenced blocks both render in Reading view and Live Preview mode. In Source mode, you'll see the literal `date-calc:` code until you switch to Reading view or Live Preview. ## Supported Calculation Types ### Birthday (`birthday`) -Calculates age and time until next birthday with enhanced personalization features. +Calculates age and time until next birthday, with an optional custom label and automatic personalization from your note's frontmatter. **Fields:** - `birthday` (or `birthdate`, `date`): Birth date in YYYY-MM-DD format - `label` (optional): Custom label (defaults to "Age:") -**Frontmatter Support:** If not provided in the code block, the plugin will look for `birthday`/`birthdate`, and `name` properties in the note's frontmatter. +**Frontmatter Support:** If not provided in the code block, the plugin reads `birthday`/`birthdate` from the note's frontmatter. If no `label` is set, a frontmatter `name` property becomes the default label automatically (e.g. `name: John` → "John's age:"). **Examples:** diff --git a/main.ts b/main.ts index cc6fd2a..f2c98b9 100644 --- a/main.ts +++ b/main.ts @@ -1,611 +1,9 @@ -import { App, Notice, Plugin, PluginSettingTab, Setting, parseYaml, moment } from "obsidian"; -import { RangeSetBuilder } from "@codemirror/state"; -import { syntaxTree } from "@codemirror/language"; -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate, - WidgetType, -} from "@codemirror/view"; - - -/* ========================= - Settings -========================= */ - - -interface DateCalcSettings { - debug: boolean; - hideResultWhileCursorInside: boolean; - verbose: boolean; -} - - -const DEFAULT_SETTINGS: DateCalcSettings = { - debug: false, - hideResultWhileCursorInside: true, - verbose: false, -}; - - -/* ========================= - Types & Interfaces -========================= */ - - -interface DateCalcResult { - text: string; - tooltip?: string; -} - - -interface DateCalcConfig { - type?: string; - birthday?: string; - birthdate?: string; - date?: string; - from?: string; - to?: string; - until?: string; - since?: string; - start?: string; - end?: string; - label?: string; - verbose?: boolean; -} - - -/* ========================= - Moment helpers -========================= */ - - -type M = moment.Moment; - - -function isLivePreviewEditor(cm: EditorView): boolean { - const container = cm.dom.closest(".markdown-source-view"); - return !!container?.classList.contains("is-live-preview"); -} - - -export function parseMoment(input: unknown): M | null { - if (input == null || input === "") return null; - - if (input instanceof Date) { - if (Number.isNaN(input.getTime())) return null; - - const isUtcMidnight = - input.getUTCHours() === 0 && - input.getUTCMinutes() === 0 && - input.getUTCSeconds() === 0 && - input.getUTCMilliseconds() === 0; - - if (isUtcMidnight) { - return moment({ - year: input.getUTCFullYear(), - month: input.getUTCMonth(), - day: input.getUTCDate(), - }).startOf("day"); - } - - return moment(input); - } - - const s = String(input).trim(); - const dateOnly = moment(s, "YYYY-MM-DD", true); - if (dateOnly.isValid()) return dateOnly.startOf("day"); - - const any = moment(s); - return any.isValid() ? any : null; -} - - -function formatNiceDate(m: M): string { - return m.format("MMMM Do, YYYY"); -} - - -function formatSpan(from: M, to: M, verbose: boolean): { text: string; isNegative: boolean } { - let a = from.clone(); - let b = to.clone(); - - const isNegative = b.isBefore(a); - if (isNegative) [a, b] = [b, a]; - - const years = b.diff(a, "years"); a.add(years, "years"); - const months = b.diff(a, "months"); a.add(months, "months"); - const days = b.diff(a, "days"); a.add(days, "days"); - const hours = b.diff(a, "hours"); a.add(hours, "hours"); - const minutes = b.diff(a, "minutes"); - - if (verbose) { - const parts: string[] = []; - if (years) parts.push(`${years} year${years === 1 ? "" : "s"}`); - if (months) parts.push(`${months} month${months === 1 ? "" : "s"}`); - if (days) parts.push(`${days} day${days === 1 ? "" : "s"}`); - if (hours && parts.length < 3) parts.push(`${hours} hour${hours === 1 ? "" : "s"}`); - if (minutes && parts.length < 3) parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`); - return { text: parts.join(", ") || "0 minutes", isNegative }; - } else { - const parts: string[] = []; - if (years) parts.push(`${years}y`); - if (months) parts.push(`${months}mo`); - if (days) parts.push(`${days}d`); - if (hours && parts.length < 3) parts.push(`${hours}h`); - if (minutes && parts.length < 3) parts.push(`${minutes}m`); - return { text: parts.join(" ") || "0m", isNegative }; - } -} - - -/* ========================= - Config parsing & normalization -========================= */ - - -function parseKv(params: string): Record { - const cfg: Record = {}; - const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g; - - let m: RegExpExecArray | null; - while ((m = rx.exec(params)) !== null) { - const key = m[1]; - let val = m[2]; - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { - val = val.slice(1, -1); - } - cfg[key] = val; - } - return cfg; -} - - -function parseConfig(params: string): DateCalcConfig | string { - if (!params) return {}; - - if (params.includes("=")) return parseKv(params); - - try { - const v = parseYaml(params); - return v ?? {}; - } catch { - return params.trim(); - } -} - - - -function normalizeConfig(paramsRaw: string, cfg: any): { type: string; cfg: DateCalcConfig } { - const params = paramsRaw.trim(); - let type = (cfg?.type ?? "").toString().toLowerCase().trim(); - - if (!type && typeof cfg === "string" && /^[A-Za-z][A-Za-z-]*$/.test(cfg.trim())) { - type = cfg.trim().toLowerCase(); - cfg = {}; - } - if (!type && /^[A-Za-z][A-Za-z-]*$/.test(params)) { - type = params.toLowerCase(); - cfg = {}; - } - - if (!type && cfg && typeof cfg === "object") { - if (cfg.birthday || cfg.birthdate) type = "birthday"; - else if (cfg.to || cfg.until) type = "countdown"; - else if (cfg.since) type = "since"; - else if (cfg.from && cfg.to) type = "diff"; - } - - if (type === "bday") type = "birthday"; - if (type === "until") type = "countdown"; - if (type === "difference") type = "diff"; - - return { type, cfg }; -} - - -/* ========================= - Core date calculation logic -========================= */ - - -function calculateDateResult( - type: string, - cfg: DateCalcConfig, - app: App, - sourcePath: string, - verbose: boolean -): DateCalcResult { - - const useVerbose = cfg.verbose !== undefined ? cfg.verbose : verbose; - - try { - switch (type) { - case "birthday": - return calculateBirthday(cfg, app, sourcePath, useVerbose); - case "countdown": - return calculateCountdown(cfg, useVerbose); - case "diff": - return calculateDiff(cfg, useVerbose); - case "since": - return calculateSince(cfg, useVerbose); - default: - return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" }; - } - } catch (e: any) { - return { text: `date-calc error: ${e?.message || e}` }; - } -} - - -function calculateBirthday( - cfg: DateCalcConfig, - app: App, - sourcePath: string, - verbose: boolean -): DateCalcResult { - const fileCache = app.metadataCache.getCache(sourcePath); - const fm = (fileCache?.frontmatter ?? {}) as any; - - const bstr = cfg.birthday || cfg.birthdate || cfg.date || fm?.birthday || fm?.birthdate; - const bd = parseMoment(bstr)?.startOf("day"); - if (!bd) return { text: `date-calc: Missing or invalid "birthday" date.` }; - - const today = moment().startOf("day"); - const age = today.diff(bd, "years"); - - const next = bd.clone().year(today.year()); - if (next.isBefore(today, "day")) next.add(1, "year"); - const daysUntil = next.diff(today, "days"); - - let msg: string; - if (daysUntil === 0) { - msg = verbose ? "Wish them Happy Birthday!" : "Happy bday!"; - } else if (daysUntil === 1) { - msg = verbose ? "Their birthday is tomorrow!" : "Bday's tomorrow!"; - } else if (daysUntil > 31) { - const monthsUntil = next.diff(today, "months"); - msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next bday in ${monthsUntil}mo`; - } else { - msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next bday in ${daysUntil}d`; - } - - const ageStr = verbose ? `${age} years old` : `${age}y`; - return { text: `${ageStr}. ${msg}`, tooltip: formatNiceDate(bd) }; -} - - -function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { - const to = parseMoment(cfg.to || cfg.date || cfg.until); - if (!to) return { text: `date-calc: Missing or invalid "to" date.` }; - - const from = parseMoment(cfg.from) ?? moment(); - const label = cfg.label ? `${cfg.label}: ` : ""; - const span = formatSpan(from, to, verbose); - - const text = !span.isNegative - ? `${label}${verbose ? "Countdown: " : ""}${span.text}` - : `${label}${verbose ? "Event passed " : ""}${span.text}${verbose ? " ago" : ""}`; - - return { text }; -} - - -function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { - const from = parseMoment(cfg.from || cfg.start); - const to = parseMoment(cfg.to || cfg.end); - if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` }; - - const span = formatSpan(from, to, verbose); - - const text = verbose - ? `Difference: ${span.text}${span.isNegative ? " (to is before from)" : ""}` - : `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`; - - return { text }; -} - - -function calculateSince(cfg: DateCalcConfig, verbose: boolean): DateCalcResult { - const since = parseMoment(cfg.since || cfg.from || cfg.date); - if (!since) return { text: `date-calc: Missing or invalid "since" date.` }; - - const now = moment(); - const span = formatSpan(since, now, verbose); - - const text = !span.isNegative - ? `Since: ${span.text}${verbose ? " ago" : ""}` - : `In: ${span.text}`; - - return { text }; -} - - -/* ========================= - Processing entry points -========================= */ - - -function processInlineCode( - raw: string, - app: App, - sourcePath: string, - settings: DateCalcSettings -): DateCalcResult { - if (!/^date-calc\s*:/.test(raw)) return { text: "" }; - - const paramsRaw = raw.replace(/^date-calc\s*:/, "").trim(); - const cfg: any = parseConfig(paramsRaw); - const norm = normalizeConfig(paramsRaw, cfg); - - if (!norm.type) return { text: "" }; - - return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose); -} - - -function processFencedBlock( - source: string, - app: App, - sourcePath: string, - settings: DateCalcSettings -): DateCalcResult { - let cfg: any = {}; - try { - cfg = parseYaml(source) ?? {}; - } catch { - cfg = parseConfig(source.trim()); - } - - const norm = normalizeConfig("", cfg); - if (!norm.type) { - return { text: 'date-calc: Missing "type" (birthday/countdown/diff/since).' }; - } - - return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose); -} - - -/* ========================= - Widgets -========================= */ - - -class DateCalcInlineWidget extends WidgetType { - constructor( - private text: string, - private tooltip: string | undefined, - private from: number, - private to: number - ) { - super(); - } - - eq(other: DateCalcInlineWidget) { - return other.text === this.text && other.tooltip === this.tooltip; - } - - toDOM() { - const span = document.createElement("span"); - span.className = "date-calc-inline"; - span.textContent = this.text; - span.setAttribute("contenteditable", "false"); - - span.dataset.dcFrom = String(this.from); - span.dataset.dcTo = String(this.to); - - if (this.tooltip) { - span.className += " has-tooltip"; - span.setAttribute("aria-label", this.tooltip); - } - return span; - } - - ignoreEvent() { - return false; - } -} - - -class DateCalcBlockWidget extends WidgetType { - constructor(private text: string, private tooltip?: string) { - super(); - } - - eq(other: DateCalcBlockWidget) { - return other.text === this.text && other.tooltip === this.tooltip; - } - - toDOM() { - const div = document.createElement("div"); - div.className = "date-calc-block"; - div.textContent = this.text; - div.setAttribute("contenteditable", "false"); - if (this.tooltip) div.setAttribute("aria-label", this.tooltip); - return div; - } -} - - -/* ========================= - Helpers for fenced code -========================= */ - - -function parseFencedCode(text: string): { lang: string; body: string } | null { - const lines = text.split("\n"); - if (lines.length < 2) return null; - - const m = lines[0].match(/^(\s*)(`{3,}|~{3,})(.*)$/); - if (!m) return null; - - const fence = m[2]; - const info = (m[3] ?? "").trim(); - const lang = (info.split(/\s+/)[0] ?? "").toLowerCase(); - - const last = lines[lines.length - 1].trim(); - if (!last.startsWith(fence)) return null; - - const body = lines.slice(1, -1).join("\n"); - return { lang, body }; -} - - -/* ========================= - Live Preview decorations -========================= */ - - -export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) { - const inlineRe = /`date-calc:[^`]*`/g; - const margin = 2000; - - return ViewPlugin.fromClass( - class { - decorations: DecorationSet = Decoration.none; - - constructor(view: EditorView) { - this.decorations = this.build(view); - } - - update(u: ViewUpdate) { - if (u.docChanged || u.viewportChanged || u.selectionSet) { - this.decorations = this.build(u.view); - } - } - - build(view: EditorView): DecorationSet { - if (!isLivePreviewEditor(view)) return Decoration.none; - - const settings = getSettings(); - const sourcePath = app.workspace.getActiveFile()?.path ?? ""; - const sel = view.state.selection.main; - - const b = new RangeSetBuilder(); - const used = new Set(); - - // Process inline code - for (const r of view.visibleRanges) { - const scanFrom = Math.max(0, r.from - margin); - const scanTo = Math.min(view.state.doc.length, r.to + margin); - const slice = view.state.doc.sliceString(scanFrom, scanTo); - - inlineRe.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = inlineRe.exec(slice)) !== null) { - const absStart = scanFrom + m.index; - const absEnd = absStart + m[0].length; - const key = `inline:${absStart}:${absEnd}`; - if (used.has(key)) continue; - used.add(key); - - const intersects = sel.from <= absEnd && sel.to >= absStart; - if (settings.hideResultWhileCursorInside && intersects) continue; - - const inner = m[0].slice(1, -1).trim(); - const result = processInlineCode(inner, app, sourcePath, settings); - if (!result.text) continue; - - b.add( - absStart, - absEnd, - Decoration.replace({ - widget: new DateCalcInlineWidget(result.text, result.tooltip, absStart, absEnd), - inclusive: false, - }) - ); - } - } - - // Process fenced code blocks - for (const r of view.visibleRanges) { - const scanFrom = Math.max(0, r.from - margin); - const scanTo = Math.min(view.state.doc.length, r.to + margin); - - syntaxTree(view.state).iterate({ - from: scanFrom, - to: scanTo, - enter: (node) => { - if (node.name !== "FencedCode") return; - - const absStart = node.from; - const absEnd = node.to; - const key = `block:${absStart}:${absEnd}`; - if (used.has(key)) return; - used.add(key); - - const intersects = sel.from <= absEnd && sel.to >= absStart; - if (settings.hideResultWhileCursorInside && intersects) return; - - const blockText = view.state.doc.sliceString(absStart, absEnd); - const parsed = parseFencedCode(blockText); - if (!parsed || parsed.lang !== "date-calc") return; - - const result = processFencedBlock(parsed.body, app, sourcePath, settings); - if (!result.text) return; - - b.add( - absEnd, - absEnd, - Decoration.widget({ - widget: new DateCalcBlockWidget(result.text, result.tooltip), - side: 1, - block: true, - }) - ); - }, - }); - } - - if (settings.debug) { - console.log("[date-calc] decorations built", { sourcePath }); - } - - return b.finish(); - } - }, - { - decorations: (v) => v.decorations, - - eventHandlers: { - mousedown: (e, view) => { - const el = (e.target as HTMLElement | null)?.closest?.( - ".date-calc-inline" - ) as HTMLElement | null; - if (!el) return false; - - const from = Number(el.dataset.dcFrom); - const to = Number(el.dataset.dcTo); - if (!Number.isFinite(from) || !Number.isFinite(to)) return false; - - view.dispatch({ - selection: { anchor: from, head: to }, - scrollIntoView: true, - }); - view.focus(); - - return true; - }, - }, - - provide: (plugin) => - EditorView.atomicRanges.of( - (view) => view.plugin(plugin)?.decorations ?? Decoration.none - ), - } - ); -} - - -/* ========================= - Plugin -========================= */ - +import { Notice, Plugin } from "obsidian"; +import { DEFAULT_SETTINGS, DateCalcSettings } from "./src/types"; +import { processFencedBlock, processInlineCode } from "./src/calculate"; +import { dateCalcLivePreview } from "./src/live-preview"; +import { renderInlineResult } from "./src/widgets"; +import { DateCalcSettingTab } from "./src/settings-tab"; export default class DateCalcPlugin extends Plugin { settings: DateCalcSettings; @@ -615,10 +13,10 @@ export default class DateCalcPlugin extends Plugin { if (this.settings.debug) console.log("[date-calc] loaded"); - // Register Live Preview editor extension + // Live Preview: render inline code and fenced blocks via a CM6 decoration extension. this.registerEditorExtension(dateCalcLivePreview(this.app, () => this.settings)); - // Register Reading mode fenced code block processor + // Reading View: fenced ```date-calc``` blocks via Obsidian's codeblock processor. this.registerMarkdownCodeBlockProcessor("date-calc", (source, el, ctx) => { const result = processFencedBlock(source, this.app, ctx.sourcePath, this.settings); el.empty(); @@ -628,10 +26,28 @@ export default class DateCalcPlugin extends Plugin { } }); + // Reading View: inline `date-calc:` spans. Obsidian has no dedicated hook for + // these (unlike fenced blocks), so scan rendered elements ourselves — + // same approach Dataview uses for its inline queries. + this.registerMarkdownPostProcessor((el, ctx) => { + const codeblocks = el.querySelectorAll("code"); + for (let i = 0; i < codeblocks.length; i++) { + const codeblock = codeblocks.item(i); + // Skip
 (fenced blocks); those are handled above.
+        if (codeblock.parentElement?.nodeName.toLowerCase() === "pre") continue;
+
+        const raw = codeblock.innerText.trim();
+        const result = processInlineCode(raw, this.app, ctx.sourcePath, this.settings);
+        if (!result.text) continue;
+
+        codeblock.replaceWith(renderInlineResult(result.text, result.tooltip));
+      }
+    });
+
     // Commands
     this.addCommand({
       id: "date-calc-toggle-debug",
-      name: "Date Calc: Toggle debug",
+      name: "Toggle debug logging",
       callback: async () => {
         this.settings.debug = !this.settings.debug;
         await this.saveData(this.settings);
@@ -641,7 +57,7 @@ export default class DateCalcPlugin extends Plugin {
 
     this.addCommand({
       id: "date-calc-toggle-verbose",
-      name: "Date Calc: Toggle verbose output",
+      name: "Toggle verbose output",
       callback: async () => {
         this.settings.verbose = !this.settings.verbose;
         await this.saveData(this.settings);
@@ -652,125 +68,3 @@ export default class DateCalcPlugin extends Plugin {
     this.addSettingTab(new DateCalcSettingTab(this.app, this));
   }
 }
-
-
-/* =========================
-   Settings tab
-========================= */
-
-
-class DateCalcSettingTab extends PluginSettingTab {
-  constructor(app: App, private plugin: DateCalcPlugin) {
-    super(app, plugin);
-  }
-
-  display(): void {
-    const { containerEl } = this;
-    containerEl.empty();
-
-    containerEl.createEl("h2", { text: "Date Calc" });
-
-    // Add documentation link
-    const docsDiv = containerEl.createDiv({ cls: "setting-item-description" });
-    docsDiv.createEl("a", {
-      text: "📖 View Full Documentation",
-      href: "https://github.com/thelegend09/date-calculator",
-    });
-    docsDiv.style.marginBottom = "1em";
-
-    new Setting(containerEl)
-      .setName("Debug logging")
-      .setDesc("Logs decoration activity to the console.")
-      .addToggle((t) =>
-        t.setValue(this.plugin.settings.debug).onChange(async (v) => {
-          this.plugin.settings.debug = v;
-          await this.plugin.saveData(this.plugin.settings);
-        })
-      );
-
-    new Setting(containerEl)
-      .setName("Hide result while cursor inside")
-      .setDesc("Prevents the widget from showing while you edit inline/fenced code.")
-      .addToggle((t) =>
-        t.setValue(this.plugin.settings.hideResultWhileCursorInside).onChange(async (v) => {
-          this.plugin.settings.hideResultWhileCursorInside = v;
-          await this.plugin.saveData(this.plugin.settings);
-        })
-      );
-
-    new Setting(containerEl)
-      .setName("Verbose output")
-      .setDesc("When enabled, show longer/wordier messages. Can be overridden per-calculation with verbose=true/false.")
-      .addToggle((t) =>
-        t.setValue(this.plugin.settings.verbose).onChange(async (v) => {
-          this.plugin.settings.verbose = v;
-          await this.plugin.saveData(this.plugin.settings);
-        })
-      );
-
-    // Usage examples section
-    containerEl.createEl("h3", { text: "Usage Examples" });
-
-    const examplesDiv = containerEl.createDiv({ cls: "date-calc-examples" });
-    examplesDiv.style.fontSize = "0.9em";
-    examplesDiv.style.lineHeight = "1.6";
-
-    // Inline examples
-    examplesDiv.createEl("h4", { text: "Inline Code (single backticks)" });
-    const inlineList = examplesDiv.createEl("ul");
-    inlineList.createEl("li").setText("`date-calc: birthday=2003-02-15`");
-    inlineList.createEl("li").setText("`date-calc: countdown to=2026-12-31 label=\"New Year\"`");
-    inlineList.createEl("li").setText("`date-calc: since=2024-01-01`");
-    inlineList.createEl("li").setText("`date-calc: diff from=2024-01-01 to=2024-12-31`");
-    inlineList.createEl("li").setText("`date-calc: birthday=1992-08-16 verbose=true`");
-
-    // Block examples
-    examplesDiv.createEl("h4", { text: "Fenced Blocks (triple backticks)" });
-    
-    const blockExample1 = examplesDiv.createEl("pre");
-    blockExample1.style.background = "var(--background-secondary)";
-    blockExample1.style.padding = "8px";
-    blockExample1.style.borderRadius = "4px";
-    blockExample1.style.marginBottom = "8px";
-    blockExample1.setText(
-      "```date-calc\n" +
-      "type: birthday\n" +
-      "birthday: 1992-08-16\n" +
-      "```"
-    );
-
-    const blockExample2 = examplesDiv.createEl("pre");
-    blockExample2.style.background = "var(--background-secondary)";
-    blockExample2.style.padding = "8px";
-    blockExample2.style.borderRadius = "4px";
-    blockExample2.style.marginBottom = "8px";
-    blockExample2.setText(
-      "```date-calc\n" +
-      "type: countdown\n" +
-      "to: 2026-12-31 23:59\n" +
-      "label: New Year\n" +
-      "verbose: true\n" +
-      "```"
-    );
-
-    const blockExample3 = examplesDiv.createEl("pre");
-    blockExample3.style.background = "var(--background-secondary)";
-    blockExample3.style.padding = "8px";
-    blockExample3.style.borderRadius = "4px";
-    blockExample3.setText(
-      "```date-calc\n" +
-      "type: diff\n" +
-      "from: 2024-01-01\n" +
-      "to: 2024-12-31\n" +
-      "```"
-    );
-
-    // Tips section
-    examplesDiv.createEl("h4", { text: "Tips" });
-    const tipsList = examplesDiv.createEl("ul");
-    tipsList.createEl("li").setText("Use YYYY-MM-DD format for dates");
-    tipsList.createEl("li").setText("Add time with YYYY-MM-DD HH:mm for precise countdowns");
-    tipsList.createEl("li").setText("Supported types: birthday, countdown, since, diff");
-    tipsList.createEl("li").setText("Add verbose=true/false to override global setting");
-  }
-}
diff --git a/manifest.json b/manifest.json
index e5be500..7e379dc 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
 	"id": "date-calc",
 	"name": "Date calculator",
-	"version": "1.0.0",
+	"version": "1.1.0",
 	"minAppVersion": "0.15.0",
 	"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
 	"author": "Olivier Legendre",
diff --git a/package-lock.json b/package-lock.json
index d25b9c8..7908665 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,17 @@
 {
-	"name": "obsidian-sample-plugin",
-	"version": "1.0.0",
+	"name": "date-calculator",
+	"version": "1.1.0",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
-			"name": "obsidian-sample-plugin",
-			"version": "1.0.0",
+			"name": "date-calculator",
+			"version": "1.1.0",
 			"license": "MIT",
 			"devDependencies": {
 				"@codemirror/language": "^6.12.1",
+				"@codemirror/state": "^6.5.2",
+				"@codemirror/view": "^6.38.2",
 				"@lezer/common": "^1.5.0",
 				"@types/node": "^16.11.6",
 				"@typescript-eslint/eslint-plugin": "5.29.0",
diff --git a/package.json b/package.json
index 1056d60..6126358 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "date-calculator",
-	"version": "1.0.0",
+	"version": "1.1.0",
 	"description": "Calculate ages, countdowns, and date differences dynamically in your notes using inline code or fenced blocks.",
 	"main": "main.js",
 	"scripts": {
@@ -9,10 +9,12 @@
 		"version": "node version-bump.mjs && git add manifest.json versions.json"
 	},
 	"keywords": [],
-	"author": "",
+	"author": "Olivier Legendre",
 	"license": "MIT",
 	"devDependencies": {
 		"@codemirror/language": "^6.12.1",
+		"@codemirror/state": "^6.5.2",
+		"@codemirror/view": "^6.38.2",
 		"@lezer/common": "^1.5.0",
 		"@types/node": "^16.11.6",
 		"@typescript-eslint/eslint-plugin": "5.29.0",
diff --git a/src/calculate.ts b/src/calculate.ts
new file mode 100644
index 0000000..14a11f0
--- /dev/null
+++ b/src/calculate.ts
@@ -0,0 +1,159 @@
+import { App, moment } from "obsidian";
+import { formatSpan, parseMoment } from "./moment-utils";
+import { normalizeConfig, parseConfig, parseYamlConfig } from "./config";
+import type { DateCalcConfig, DateCalcResult, DateCalcSettings } from "./types";
+
+/** Read a string or Date value out of frontmatter, ignoring any other shape. */
+function frontmatterField(
+  fm: Record | undefined,
+  key: string
+): string | Date | undefined {
+  const value = fm?.[key];
+  return typeof value === "string" || value instanceof Date ? value : undefined;
+}
+
+/** Dispatch to the calculator for `type`, catching and surfacing any error inline. */
+export function calculateDateResult(
+  type: string,
+  cfg: DateCalcConfig,
+  app: App,
+  sourcePath: string,
+  verbose: boolean
+): DateCalcResult {
+  const useVerbose = cfg.verbose !== undefined ? cfg.verbose : verbose;
+
+  try {
+    switch (type) {
+      case "birthday":
+        return calculateBirthday(cfg, app, sourcePath, useVerbose);
+      case "countdown":
+        return calculateCountdown(cfg, useVerbose);
+      case "diff":
+        return calculateDiff(cfg, useVerbose);
+      case "since":
+        return calculateSince(cfg, useVerbose);
+      default:
+        return { text: "date-calc: Unknown type. Supported: birthday, countdown, diff, since" };
+    }
+  } catch (e: unknown) {
+    const message = e instanceof Error ? e.message : String(e);
+    return { text: `date-calc error: ${message}` };
+  }
+}
+
+function calculateBirthday(
+  cfg: DateCalcConfig,
+  app: App,
+  sourcePath: string,
+  verbose: boolean
+): DateCalcResult {
+  const fm = app.metadataCache.getCache(sourcePath)?.frontmatter;
+
+  const bstr =
+    cfg.birthday ?? cfg.birthdate ?? cfg.date ??
+    frontmatterField(fm, "birthday") ?? frontmatterField(fm, "birthdate");
+  const bd = parseMoment(bstr)?.startOf("day");
+  if (!bd) return { text: `date-calc: Missing or invalid "birthday" date.` };
+
+  const today = moment().startOf("day");
+  const age = today.diff(bd, "years");
+
+  const next = bd.clone().year(today.year());
+  if (next.isBefore(today, "day")) next.add(1, "year");
+  const daysUntil = next.diff(today, "days");
+
+  let msg: string;
+  if (daysUntil === 0) {
+    msg = verbose ? "Wish them Happy Birthday!" : "Happy bday!";
+  } else if (daysUntil === 1) {
+    msg = verbose ? "Their birthday is tomorrow!" : "Bday's tomorrow!";
+  } else if (daysUntil > 31) {
+    const monthsUntil = next.diff(today, "months");
+    msg = verbose ? `Next birthday in ${monthsUntil} months.` : `Next bday in ${monthsUntil}mo`;
+  } else {
+    msg = verbose ? `Next birthday in ${daysUntil} days.` : `Next bday in ${daysUntil}d`;
+  }
+
+  const ageStr = verbose ? `${age} years old` : `${age}y`;
+  // Custom label > frontmatter-name personalization > plain "Age:" default.
+  const name = frontmatterField(fm, "name");
+  const label = cfg.label ?? (typeof name === "string" ? `${name}'s age:` : "Age:");
+  return { text: `${label} ${ageStr}. ${msg}`, tooltip: bd.format("MMMM Do, YYYY") };
+}
+
+function calculateCountdown(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
+  const to = parseMoment(cfg.to ?? cfg.date ?? cfg.until);
+  if (!to) return { text: `date-calc: Missing or invalid "to" date.` };
+
+  const from = parseMoment(cfg.from) ?? moment();
+  const label = cfg.label ? `${cfg.label}: ` : "";
+  const span = formatSpan(from, to, verbose);
+
+  const text = !span.isNegative
+    ? `${label}${verbose ? "Countdown: " : ""}${span.text}`
+    : `${label}${verbose ? "Event passed " : ""}${span.text}${verbose ? " ago" : ""}`;
+
+  return { text };
+}
+
+function calculateDiff(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
+  const from = parseMoment(cfg.from ?? cfg.start);
+  const to = parseMoment(cfg.to ?? cfg.end);
+  if (!from || !to) return { text: `date-calc: Provide valid "from" and "to" dates.` };
+
+  const span = formatSpan(from, to, verbose);
+
+  const text = verbose
+    ? `Difference: ${span.text}${span.isNegative ? " (to is before from)" : ""}`
+    : `Diff: ${span.text}${span.isNegative ? " (reverse)" : ""}`;
+
+  return { text };
+}
+
+function calculateSince(cfg: DateCalcConfig, verbose: boolean): DateCalcResult {
+  const since = parseMoment(cfg.since ?? cfg.from ?? cfg.date);
+  if (!since) return { text: `date-calc: Missing or invalid "since" date.` };
+
+  const now = moment();
+  const span = formatSpan(since, now, verbose);
+
+  const text = !span.isNegative
+    ? `Since: ${span.text}${verbose ? " ago" : ""}`
+    : `In: ${span.text}`;
+
+  return { text };
+}
+
+/** Parse and evaluate a `date-calc: ...` inline code span's raw text. */
+export function processInlineCode(
+  raw: string,
+  app: App,
+  sourcePath: string,
+  settings: DateCalcSettings
+): DateCalcResult {
+  if (!/^date-calc\s*:/.test(raw)) return { text: "" };
+
+  const paramsRaw = raw.replace(/^date-calc\s*:/, "").trim();
+  const cfg = parseConfig(paramsRaw);
+  const norm = normalizeConfig(paramsRaw, cfg);
+
+  if (!norm.type) return { text: "" };
+
+  return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose);
+}
+
+/** Parse and evaluate a fenced ```date-calc``` block's YAML body. */
+export function processFencedBlock(
+  source: string,
+  app: App,
+  sourcePath: string,
+  settings: DateCalcSettings
+): DateCalcResult {
+  const cfg = parseYamlConfig(source);
+  const norm = normalizeConfig("", cfg);
+  if (!norm.type) {
+    return { text: 'date-calc: Missing "type" (birthday/countdown/diff/since).' };
+  }
+
+  return calculateDateResult(norm.type, norm.cfg, app, sourcePath, settings.verbose);
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..819ee66
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,114 @@
+import { parseYaml } from "obsidian";
+import type { DateCalcConfig } from "./types";
+
+/** A loosely-parsed config prior to type coercion: from inline key=value pairs or YAML. */
+type RawConfig = Record;
+
+/**
+ * Coerce a raw parsed config into `DateCalcConfig`. `verbose` needs explicit
+ * coercion because key=value parsing always yields the *string* "true"/"false",
+ * which — left alone — is truthy either way (a non-empty string), silently
+ * breaking `verbose=false`.
+ */
+function coerceConfig(raw: RawConfig): DateCalcConfig {
+  const { verbose, ...rest } = raw;
+  if (verbose === undefined) return rest as DateCalcConfig;
+  const verboseBool = typeof verbose === "boolean" ? verbose : String(verbose).toLowerCase() === "true";
+  return { ...rest, verbose: verboseBool } as DateCalcConfig;
+}
+
+/** Parse `key=value key2="quoted value"` pairs into a plain string map. */
+export function parseKv(params: string): Record {
+  const cfg: Record = {};
+  const rx = /(\w+)=("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+)/g;
+
+  let m: RegExpExecArray | null;
+  while ((m = rx.exec(params)) !== null) {
+    const key = m[1];
+    let val = m[2];
+    if (
+      (val.startsWith('"') && val.endsWith('"')) ||
+      (val.startsWith("'") && val.endsWith("'"))
+    ) {
+      val = val.slice(1, -1);
+    }
+    cfg[key] = val;
+  }
+  return cfg;
+}
+
+/** Parse a block of YAML into a `DateCalcConfig`, falling back to the trimmed
+ *  source string when it isn't valid YAML or doesn't parse to an object. */
+export function parseYamlConfig(source: string): DateCalcConfig | string {
+  try {
+    const parsed: unknown = parseYaml(source);
+    if (parsed == null) return {};
+    if (typeof parsed === "string") return parsed;
+    // Any non-array object is treated as a config map; the fields we actually
+    // read off it (birthday/to/from/... ) are validated downstream by parseMoment.
+    if (typeof parsed === "object" && !Array.isArray(parsed)) {
+      return coerceConfig(parsed as RawConfig);
+    }
+    return source.trim();
+  } catch {
+    return source.trim();
+  }
+}
+
+/** Parse inline params as key=value pairs, YAML, or a bare type string. */
+export function parseConfig(params: string): DateCalcConfig | string {
+  if (!params) return {};
+  if (params.includes("=")) return coerceConfig(parseKv(params));
+  return parseYamlConfig(params);
+}
+
+/** Resolve the calculation `type`, inferring it from config shape when omitted. */
+export function normalizeConfig(
+  paramsRaw: string,
+  cfg: DateCalcConfig | string
+): { type: string; cfg: DateCalcConfig } {
+  const params = paramsRaw.trim();
+  let resolved: DateCalcConfig = typeof cfg === "string" ? {} : cfg;
+  let type = (typeof cfg === "string" ? "" : cfg.type ?? "").toLowerCase().trim();
+
+  if (!type && typeof cfg === "string" && /^[A-Za-z][A-Za-z-]*$/.test(cfg.trim())) {
+    type = cfg.trim().toLowerCase();
+    resolved = {};
+  }
+  if (!type && /^[A-Za-z][A-Za-z-]*$/.test(params)) {
+    type = params.toLowerCase();
+    resolved = {};
+  }
+
+  if (!type) {
+    if (resolved.birthday || resolved.birthdate) type = "birthday";
+    else if ((resolved.from && resolved.to) || (resolved.start && resolved.end)) type = "diff";
+    else if (resolved.to || resolved.until) type = "countdown";
+    else if (resolved.since) type = "since";
+  }
+
+  if (type === "bday") type = "birthday";
+  if (type === "until") type = "countdown";
+  if (type === "difference") type = "diff";
+
+  return { type, cfg: resolved };
+}
+
+/** Split a fenced code block's opening/closing fence from its body. */
+export function parseFencedCode(text: string): { lang: string; body: string } | null {
+  const lines = text.split("\n");
+  if (lines.length < 2) return null;
+
+  const m = lines[0].match(/^(\s*)(`{3,}|~{3,})(.*)$/);
+  if (!m) return null;
+
+  const fence = m[2];
+  const info = (m[3] ?? "").trim();
+  const lang = (info.split(/\s+/)[0] ?? "").toLowerCase();
+
+  const last = lines[lines.length - 1].trim();
+  if (!last.startsWith(fence)) return null;
+
+  const body = lines.slice(1, -1).join("\n");
+  return { lang, body };
+}
diff --git a/src/live-preview.ts b/src/live-preview.ts
new file mode 100644
index 0000000..8ef7173
--- /dev/null
+++ b/src/live-preview.ts
@@ -0,0 +1,170 @@
+import type { App } from "obsidian";
+import { RangeSetBuilder } from "@codemirror/state";
+import { syntaxTree } from "@codemirror/language";
+import {
+  Decoration,
+  DecorationSet,
+  EditorView,
+  ViewPlugin,
+  ViewUpdate,
+} from "@codemirror/view";
+import type { DateCalcSettings } from "./types";
+import { processFencedBlock, processInlineCode } from "./calculate";
+import { parseFencedCode } from "./config";
+import { DateCalcBlockWidget, DateCalcInlineWidget } from "./widgets";
+
+const INLINE_RE = /`date-calc:[^`]*`/g;
+const SCAN_MARGIN = 2000;
+
+/** A pending decoration, collected before being handed to RangeSetBuilder in order. */
+interface PendingDeco {
+  from: number;
+  to: number;
+  deco: Decoration;
+}
+
+export function dateCalcLivePreview(app: App, getSettings: () => DateCalcSettings) {
+  return ViewPlugin.fromClass(
+    class {
+      decorations: DecorationSet = Decoration.none;
+
+      constructor(view: EditorView) {
+        this.decorations = this.build(view);
+      }
+
+      update(u: ViewUpdate) {
+        if (u.docChanged || u.viewportChanged || u.selectionSet) {
+          this.decorations = this.build(u.view);
+        }
+      }
+
+      build(view: EditorView): DecorationSet {
+        const container = view.dom.closest(".markdown-source-view");
+        if (!container?.classList.contains("is-live-preview")) return Decoration.none;
+
+        const settings = getSettings();
+        const sourcePath = app.workspace.getActiveFile()?.path ?? "";
+        const sel = view.state.selection.main;
+        const used = new Set();
+        const pending: PendingDeco[] = [];
+
+        // Collect inline code decorations.
+        for (const r of view.visibleRanges) {
+          const scanFrom = Math.max(0, r.from - SCAN_MARGIN);
+          const scanTo = Math.min(view.state.doc.length, r.to + SCAN_MARGIN);
+          const slice = view.state.doc.sliceString(scanFrom, scanTo);
+
+          INLINE_RE.lastIndex = 0;
+          let m: RegExpExecArray | null;
+          while ((m = INLINE_RE.exec(slice)) !== null) {
+            const absStart = scanFrom + m.index;
+            const absEnd = absStart + m[0].length;
+            const key = `inline:${absStart}:${absEnd}`;
+            if (used.has(key)) continue;
+            used.add(key);
+
+            const intersects = sel.from <= absEnd && sel.to >= absStart;
+            if (settings.hideResultWhileCursorInside && intersects) continue;
+
+            const inner = m[0].slice(1, -1).trim();
+            const result = processInlineCode(inner, app, sourcePath, settings);
+            if (!result.text) continue;
+
+            pending.push({
+              from: absStart,
+              to: absEnd,
+              deco: Decoration.replace({
+                widget: new DateCalcInlineWidget(result.text, result.tooltip, absStart, absEnd),
+                inclusive: false,
+              }),
+            });
+          }
+        }
+
+        // Collect fenced code block decorations.
+        for (const r of view.visibleRanges) {
+          const scanFrom = Math.max(0, r.from - SCAN_MARGIN);
+          const scanTo = Math.min(view.state.doc.length, r.to + SCAN_MARGIN);
+
+          syntaxTree(view.state).iterate({
+            from: scanFrom,
+            to: scanTo,
+            enter: (node) => {
+              if (node.name !== "FencedCode") return;
+
+              const absStart = node.from;
+              const absEnd = node.to;
+              const key = `block:${absStart}:${absEnd}`;
+              if (used.has(key)) return;
+              used.add(key);
+
+              const intersects = sel.from <= absEnd && sel.to >= absStart;
+              if (settings.hideResultWhileCursorInside && intersects) return;
+
+              const blockText = view.state.doc.sliceString(absStart, absEnd);
+              const parsed = parseFencedCode(blockText);
+              if (!parsed || parsed.lang !== "date-calc") return;
+
+              const result = processFencedBlock(parsed.body, app, sourcePath, settings);
+              if (!result.text) return;
+
+              pending.push({
+                from: absEnd,
+                to: absEnd,
+                deco: Decoration.widget({
+                  widget: new DateCalcBlockWidget(result.text, result.tooltip),
+                  side: 1,
+                  block: true,
+                }),
+              });
+            },
+          });
+        }
+
+        if (settings.debug) {
+          console.log("[date-calc] decorations built", { sourcePath, count: pending.length });
+        }
+
+        // RangeSetBuilder requires decorations added in ascending `from` order.
+        // The inline pass and the fenced-block pass above are each internally
+        // sorted, but not sorted *relative to each other* — a fenced block
+        // followed later by inline code (or vice versa) previously threw
+        // "Ranges must be added sorted" and broke Live Preview outright.
+        pending.sort((a, b) => a.from - b.from || a.to - b.to);
+
+        const builder = new RangeSetBuilder();
+        for (const { from, to, deco } of pending) builder.add(from, to, deco);
+        return builder.finish();
+      }
+    },
+    {
+      decorations: (v) => v.decorations,
+
+      eventHandlers: {
+        mousedown: (e, view) => {
+          const el = (e.target as HTMLElement | null)?.closest?.(
+            ".date-calc-inline"
+          ) as HTMLElement | null;
+          if (!el) return false;
+
+          const from = Number(el.dataset.dcFrom);
+          const to = Number(el.dataset.dcTo);
+          if (!Number.isFinite(from) || !Number.isFinite(to)) return false;
+
+          view.dispatch({
+            selection: { anchor: from, head: to },
+            scrollIntoView: true,
+          });
+          view.focus();
+
+          return true;
+        },
+      },
+
+      provide: (plugin) =>
+        EditorView.atomicRanges.of(
+          (view) => view.plugin(plugin)?.decorations ?? Decoration.none
+        ),
+    }
+  );
+}
diff --git a/src/moment-utils.ts b/src/moment-utils.ts
new file mode 100644
index 0000000..94e60bc
--- /dev/null
+++ b/src/moment-utils.ts
@@ -0,0 +1,74 @@
+import { moment } from "obsidian";
+import type { M } from "./types";
+
+/**
+ * Parse a date-ish input (frontmatter Date object, "YYYY-MM-DD" string, or any
+ * moment-parseable string) into a moment. Returns null if unparseable.
+ */
+export function parseMoment(input: unknown): M | null {
+  if (input == null || input === "") return null;
+
+  if (input instanceof Date) {
+    if (Number.isNaN(input.getTime())) return null;
+
+    const isUtcMidnight =
+      input.getUTCHours() === 0 &&
+      input.getUTCMinutes() === 0 &&
+      input.getUTCSeconds() === 0 &&
+      input.getUTCMilliseconds() === 0;
+
+    if (isUtcMidnight) {
+      return moment({
+        year: input.getUTCFullYear(),
+        month: input.getUTCMonth(),
+        day: input.getUTCDate(),
+      }).startOf("day");
+    }
+
+    return moment(input);
+  }
+
+  const s = String(input).trim();
+  const dateOnly = moment(s, "YYYY-MM-DD", true);
+  if (dateOnly.isValid()) return dateOnly.startOf("day");
+
+  const any = moment(s);
+  return any.isValid() ? any : null;
+}
+
+/** Break the span between two moments into years/months/days/hours/minutes. */
+export function formatSpan(
+  from: M,
+  to: M,
+  verbose: boolean
+): { text: string; isNegative: boolean } {
+  let a = from.clone();
+  let b = to.clone();
+
+  const isNegative = b.isBefore(a);
+  if (isNegative) [a, b] = [b, a];
+
+  const years = b.diff(a, "years"); a.add(years, "years");
+  const months = b.diff(a, "months"); a.add(months, "months");
+  const days = b.diff(a, "days"); a.add(days, "days");
+  const hours = b.diff(a, "hours"); a.add(hours, "hours");
+  const minutes = b.diff(a, "minutes");
+
+  if (verbose) {
+    const parts: string[] = [];
+    if (years) parts.push(`${years} year${years === 1 ? "" : "s"}`);
+    if (months) parts.push(`${months} month${months === 1 ? "" : "s"}`);
+    if (days) parts.push(`${days} day${days === 1 ? "" : "s"}`);
+    if (hours && parts.length < 3) parts.push(`${hours} hour${hours === 1 ? "" : "s"}`);
+    if (minutes && parts.length < 3) parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`);
+    return { text: parts.join(", ") || "0 minutes", isNegative };
+  } else {
+    const parts: string[] = [];
+    if (years) parts.push(`${years}y`);
+    if (months) parts.push(`${months}mo`);
+    if (days) parts.push(`${days}d`);
+    if (hours && parts.length < 3) parts.push(`${hours}h`);
+    if (minutes && parts.length < 3) parts.push(`${minutes}m`);
+    return { text: parts.join(" ") || "0m", isNegative };
+  }
+}
diff --git a/src/settings-tab.ts b/src/settings-tab.ts
new file mode 100644
index 0000000..4bfa0da
--- /dev/null
+++ b/src/settings-tab.ts
@@ -0,0 +1,95 @@
+import { App, PluginSettingTab, Setting } from "obsidian";
+import type DateCalcPlugin from "../main";
+
+export class DateCalcSettingTab extends PluginSettingTab {
+  constructor(app: App, private plugin: DateCalcPlugin) {
+    super(app, plugin);
+  }
+
+  display(): void {
+    const { containerEl } = this;
+    containerEl.empty();
+
+    containerEl.createEl("h2", { text: "Date Calc" });
+
+    // Documentation link
+    const docsDiv = containerEl.createDiv({
+      cls: "setting-item-description date-calc-settings-docs",
+    });
+    docsDiv.createEl("a", {
+      text: "📖 View Full Documentation",
+      href: "https://github.com/thelegend09/date-calculator",
+    });
+
+    new Setting(containerEl)
+      .setName("Debug logging")
+      .setDesc("Logs decoration activity to the console.")
+      .addToggle((t) =>
+        t.setValue(this.plugin.settings.debug).onChange(async (v) => {
+          this.plugin.settings.debug = v;
+          await this.plugin.saveData(this.plugin.settings);
+        })
+      );
+
+    new Setting(containerEl)
+      .setName("Hide result while cursor inside")
+      .setDesc("Prevents the widget from showing while you edit inline/fenced code.")
+      .addToggle((t) =>
+        t.setValue(this.plugin.settings.hideResultWhileCursorInside).onChange(async (v) => {
+          this.plugin.settings.hideResultWhileCursorInside = v;
+          await this.plugin.saveData(this.plugin.settings);
+        })
+      );
+
+    new Setting(containerEl)
+      .setName("Verbose output")
+      .setDesc(
+        "When enabled, show longer/wordier messages. Can be overridden per-calculation with verbose=true/false."
+      )
+      .addToggle((t) =>
+        t.setValue(this.plugin.settings.verbose).onChange(async (v) => {
+          this.plugin.settings.verbose = v;
+          await this.plugin.saveData(this.plugin.settings);
+        })
+      );
+
+    // Usage examples section
+    containerEl.createEl("h3", { text: "Usage Examples" });
+
+    const examplesDiv = containerEl.createDiv({ cls: "date-calc-examples" });
+
+    // Inline examples
+    examplesDiv.createEl("h4", { text: "Inline Code (single backticks)" });
+    const inlineList = examplesDiv.createEl("ul");
+    inlineList.createEl("li").setText("`date-calc: birthday=2003-02-15`");
+    inlineList.createEl("li").setText("`date-calc: countdown to=2026-12-31 label=\"New Year\"`");
+    inlineList.createEl("li").setText("`date-calc: since=2024-01-01`");
+    inlineList.createEl("li").setText("`date-calc: diff from=2024-01-01 to=2024-12-31`");
+    inlineList.createEl("li").setText("`date-calc: birthday=1992-08-16 verbose=true`");
+
+    // Block examples
+    examplesDiv.createEl("h4", { text: "Fenced Blocks (triple backticks)" });
+
+    examplesDiv
+      .createEl("pre", { cls: "date-calc-example-block" })
+      .setText("```date-calc\ntype: birthday\nbirthday: 1992-08-16\n```");
+
+    examplesDiv
+      .createEl("pre", { cls: "date-calc-example-block" })
+      .setText(
+        "```date-calc\ntype: countdown\nto: 2026-12-31 23:59\nlabel: New Year\nverbose: true\n```"
+      );
+
+    examplesDiv
+      .createEl("pre", { cls: "date-calc-example-block" })
+      .setText("```date-calc\ntype: diff\nfrom: 2024-01-01\nto: 2024-12-31\n```");
+
+    // Tips section
+    examplesDiv.createEl("h4", { text: "Tips" });
+    const tipsList = examplesDiv.createEl("ul");
+    tipsList.createEl("li").setText("Use YYYY-MM-DD format for dates");
+    tipsList.createEl("li").setText("Add time with YYYY-MM-DD HH:mm for precise countdowns");
+    tipsList.createEl("li").setText("Supported types: birthday, countdown, since, diff");
+    tipsList.createEl("li").setText("Add verbose=true/false to override global setting");
+  }
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..acf5175
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,43 @@
+import type { moment } from "obsidian";
+
+/** Persisted plugin settings (Plugin#loadData / #saveData). */
+export interface DateCalcSettings {
+  debug: boolean;
+  hideResultWhileCursorInside: boolean;
+  verbose: boolean;
+}
+
+export const DEFAULT_SETTINGS: DateCalcSettings = {
+  debug: false,
+  hideResultWhileCursorInside: true,
+  verbose: false,
+};
+
+/** Result of a date calculation, ready to render. */
+export interface DateCalcResult {
+  text: string;
+  tooltip?: string;
+}
+
+/**
+ * Normalized `date-calc` config, parsed from key=value pairs or YAML.
+ * Date-ish fields are `string | Date` because unquoted YAML scalars
+ * (fenced blocks, frontmatter) parse to native `Date` objects.
+ */
+export interface DateCalcConfig {
+  type?: string;
+  birthday?: string | Date;
+  birthdate?: string | Date;
+  date?: string | Date;
+  from?: string | Date;
+  to?: string | Date;
+  until?: string | Date;
+  since?: string | Date;
+  start?: string | Date;
+  end?: string | Date;
+  label?: string;
+  verbose?: boolean;
+}
+
+/** A moment.js instance; aliased for brevity across modules. */
+export type M = moment.Moment;
diff --git a/src/widgets.ts b/src/widgets.ts
new file mode 100644
index 0000000..bfe9ae1
--- /dev/null
+++ b/src/widgets.ts
@@ -0,0 +1,65 @@
+import { WidgetType } from "@codemirror/view";
+
+/**
+ * Build the shared `` result element used by both
+ * the Live Preview widget (below) and the Reading View post-processor (main.ts),
+ * so both rendering modes produce identical, stylable DOM.
+ */
+export function renderInlineResult(text: string, tooltip?: string): HTMLSpanElement {
+  const span = document.createElement("span");
+  span.className = "date-calc-inline";
+  span.textContent = text;
+  if (tooltip) {
+    span.className += " has-tooltip";
+    span.setAttribute("aria-label", tooltip);
+  }
+  return span;
+}
+
+/** Inline `date-calc:` result, rendered in place of the backtick code span in Live Preview. */
+export class DateCalcInlineWidget extends WidgetType {
+  constructor(
+    private text: string,
+    private tooltip: string | undefined,
+    private from: number,
+    private to: number
+  ) {
+    super();
+  }
+
+  eq(other: DateCalcInlineWidget) {
+    return other.text === this.text && other.tooltip === this.tooltip;
+  }
+
+  toDOM() {
+    const span = renderInlineResult(this.text, this.tooltip);
+    span.setAttribute("contenteditable", "false");
+    span.dataset.dcFrom = String(this.from);
+    span.dataset.dcTo = String(this.to);
+    return span;
+  }
+
+  ignoreEvent() {
+    return false;
+  }
+}
+
+/** Fenced ```date-calc``` block result, rendered after the block in Live Preview. */
+export class DateCalcBlockWidget extends WidgetType {
+  constructor(private text: string, private tooltip?: string) {
+    super();
+  }
+
+  eq(other: DateCalcBlockWidget) {
+    return other.text === this.text && other.tooltip === this.tooltip;
+  }
+
+  toDOM() {
+    const div = document.createElement("div");
+    div.className = "date-calc-block";
+    div.textContent = this.text;
+    div.setAttribute("contenteditable", "false");
+    if (this.tooltip) div.setAttribute("aria-label", this.tooltip);
+    return div;
+  }
+}
diff --git a/styles.css b/styles.css
index c2b5d7d..9773ac8 100644
--- a/styles.css
+++ b/styles.css
@@ -4,4 +4,24 @@ Date Calc Plugin Styles
 
 .date-calc-inline {
     /* color: var(--text-accent); */
+}
+
+.date-calc-inline.has-tooltip {
+    cursor: help;
+}
+
+.date-calc-settings-docs {
+    margin-bottom: 1em;
+}
+
+.date-calc-examples {
+    font-size: 0.9em;
+    line-height: 1.6;
+}
+
+.date-calc-example-block {
+    background: var(--background-secondary);
+    padding: 8px;
+    border-radius: 4px;
+    margin-bottom: 8px;
 }
\ No newline at end of file
diff --git a/versions.json b/versions.json
index 26382a1..5934189 100644
--- a/versions.json
+++ b/versions.json
@@ -1,3 +1,4 @@
 {
-	"1.0.0": "0.15.0"
+	"1.0.0": "0.15.0",
+	"1.1.0": "0.15.0"
 }