diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts
index 7442699..46e2fbc 100644
--- a/apps/web/src/editor/document-extensions.ts
+++ b/apps/web/src/editor/document-extensions.ts
@@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item';
+import { DateMarker } from './nodes/date-marker';
import { Mention } from './nodes/mention';
import { Transclusion } from './nodes/transclusion';
import { Wikilink } from './nodes/wikilink';
@@ -46,6 +47,7 @@ export const documentExtensions: AnyExtension[] = [
PluginBlock,
Wikilink,
Mention,
+ DateMarker,
Transclusion,
Table,
TableRow,
diff --git a/apps/web/src/editor/nodes/date-marker.tsx b/apps/web/src/editor/nodes/date-marker.tsx
new file mode 100644
index 0000000..37ab2ad
--- /dev/null
+++ b/apps/web/src/editor/nodes/date-marker.tsx
@@ -0,0 +1,91 @@
+import { InputRule, Node } from '@tiptap/core';
+import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
+import type { NodeViewProps } from '@tiptap/react';
+import { useTranslation } from 'react-i18next';
+
+import { attributesFromSpec, nodeSpec } from '../spec-utils';
+
+/** `>>`/`<<` + ISO or `dd.mm.yyyy`, completed by a space (the trigger). */
+const INPUT_PATTERN =
+ /(?:^|\s)([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))\s$/;
+
+function isoDateOf(match: RegExpMatchArray): string | null {
+ const [year, month, day] = match[2]
+ ? [Number(match[2]), Number(match[3]), Number(match[4])]
+ : [Number(match[7]), Number(match[6]), Number(match[5])];
+ const date = new Date(Date.UTC(year, month - 1, day));
+ const valid =
+ date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
+ if (!valid) return null;
+ const pad = (value: number): string => String(value).padStart(2, '0');
+ return `${year}-${pad(month)}-${pad(day)}`;
+}
+
+/**
+ * Renders a date marker (issue #152): `»` = due date (`>>`), `«` = start
+ * date (`<<`), formatted per the viewer's language. A due date in the past
+ * gets an "overdue" tint. The stored attr is always canonical ISO.
+ */
+function DateMarkerView({ node }: NodeViewProps): React.JSX.Element {
+ const { t, i18n } = useTranslation('editor');
+ const kind = node.attrs.kind as 'due' | 'start';
+ const iso = node.attrs.date as string;
+
+ const formatted = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
+ new Date(`${iso}T00:00:00`),
+ );
+ const overdue = kind === 'due' && iso < new Date().toISOString().slice(0, 10);
+ const classes = ['dt-date', `dt-date--${kind}`];
+ if (overdue) classes.push('dt-date--overdue');
+
+ return (
+
+
+ {kind === 'due' ? '»' : '«'} {formatted}
+
+
+ );
+}
+
+const dateMarkerSpec = nodeSpec('date_marker');
+export const DateMarker = Node.create({
+ name: 'date_marker',
+ group: dateMarkerSpec.group,
+ inline: dateMarkerSpec.inline,
+ atom: dateMarkerSpec.atom,
+ addAttributes() {
+ return attributesFromSpec(dateMarkerSpec);
+ },
+ parseHTML: () => dateMarkerSpec.parseDOM,
+ renderHTML: ({ node }) => dateMarkerSpec.toDOM!(node),
+ addInputRules() {
+ return [
+ new InputRule({
+ find: INPUT_PATTERN,
+ handler: ({ range, match, chain }) => {
+ const iso = isoDateOf(match);
+ if (!iso) return;
+ // Keep a leading boundary character (space/line start) intact.
+ const full = match[0];
+ const markerStart = range.from + (full.length - full.trimStart().length);
+ chain()
+ .insertContentAt({ from: markerStart, to: range.to }, [
+ {
+ type: 'date_marker',
+ attrs: { kind: match[1] === '>' ? 'due' : 'start', date: iso },
+ },
+ { type: 'text', text: ' ' },
+ ])
+ .run();
+ },
+ }),
+ ];
+ },
+ addNodeView() {
+ return ReactNodeViewRenderer(DateMarkerView);
+ },
+});
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index 4dd2eb4..77df6f4 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -3824,3 +3824,22 @@ ul[data-type='task_list'] li p:last-of-type {
color: var(--color-text-muted);
text-decoration: line-through;
}
+
+/* Date marker chip (issue #152): » = due date, « = start date. */
+.dt-date {
+ display: inline-block;
+ padding: 0 0.35em;
+ border-radius: 6px;
+ background: var(--color-bg-subtle);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
+.dt-date--due {
+ color: var(--color-accent);
+}
+
+.dt-date--overdue {
+ color: #b91c1c;
+}
diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json
index 49f973d..73708f4 100644
--- a/packages/shared/i18n/de/editor.json
+++ b/packages/shared/i18n/de/editor.json
@@ -185,5 +185,9 @@
"mention": {
"unresolved": "Unbekannter Nutzer",
"suggestLabel": "Nutzer-Vorschläge"
+ },
+ "dateMarker": {
+ "due": "Zieldatum",
+ "start": "Startdatum"
}
}
diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json
index c183b6e..57a5e9e 100644
--- a/packages/shared/i18n/en/editor.json
+++ b/packages/shared/i18n/en/editor.json
@@ -185,5 +185,9 @@
"mention": {
"unresolved": "Unknown user",
"suggestLabel": "User suggestions"
+ },
+ "dateMarker": {
+ "due": "Due date",
+ "start": "Start date"
}
}
diff --git a/packages/shared/src/editor-schema/date-marker.test.ts b/packages/shared/src/editor-schema/date-marker.test.ts
new file mode 100644
index 0000000..813efec
--- /dev/null
+++ b/packages/shared/src/editor-schema/date-marker.test.ts
@@ -0,0 +1,73 @@
+import { Node } from 'prosemirror-model';
+import { describe, expect, it } from 'vitest';
+
+import { docToHtml } from './html';
+import { docToMarkdown, markdownToDoc } from './markdown';
+import { docToPlainText } from './plain-text';
+
+function markers(doc: Node): { kind: string; date: string }[] {
+ const found: { kind: string; date: string }[] = [];
+ doc.descendants((node) => {
+ if (node.type.name === 'date_marker') {
+ found.push({ kind: node.attrs.kind as string, date: node.attrs.date as string });
+ }
+ });
+ return found;
+}
+
+describe('date markers >>/<< (issue #152)', () => {
+ it('parses ISO due and start dates and round-trips canonically', () => {
+ const doc = markdownToDoc('Projekt <<2026-07-01 bis >>2026-12-31 fertig.');
+ expect(markers(doc)).toEqual([
+ { kind: 'start', date: '2026-07-01' },
+ { kind: 'due', date: '2026-12-31' },
+ ]);
+ const markdown = docToMarkdown(doc);
+ expect(markdown).toContain('<<2026-07-01');
+ expect(markdown).toContain('>>2026-12-31');
+ });
+
+ it('accepts dd.mm.yyyy as input lenience but serializes ISO', () => {
+ const doc = markdownToDoc('Zieltermin >>31.12.2026 bitte.');
+ expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
+ expect(docToMarkdown(doc)).toContain('>>2026-12-31');
+ });
+
+ it('keeps a line-leading >>date out of blockquote parsing', () => {
+ const doc = markdownToDoc('>>2026-12-31 ist die Deadline.');
+ expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
+ let quotes = 0;
+ doc.descendants((node) => {
+ if (node.type.name === 'blockquote') quotes += 1;
+ });
+ expect(quotes).toBe(0);
+ // A real blockquote still works.
+ const quote = markdownToDoc('> ein Zitat');
+ let realQuotes = 0;
+ quote.descendants((node) => {
+ if (node.type.name === 'blockquote') realQuotes += 1;
+ });
+ expect(realQuotes).toBe(1);
+ });
+
+ it('leaves invalid calendar dates as plain text', () => {
+ const doc = markdownToDoc('Kaputt: >>31.02.2026 bleibt Text.');
+ expect(markers(doc)).toEqual([]);
+ expect(docToMarkdown(doc)).toContain('31.02.2026');
+ });
+
+ it('renders HTML with kind, ISO date, and shows up in plain text', () => {
+ const doc = markdownToDoc('Bis >>2026-12-31.');
+ const html = docToHtml(doc);
+ expect(html).toContain('data-date-marker="due"');
+ expect(html).toContain('data-date="2026-12-31"');
+ expect(docToPlainText(doc)).toContain('2026-12-31');
+ });
+
+ it('works inside task list lines', () => {
+ const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01 @nadia');
+ expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-08-01' }]);
+ const markdown = docToMarkdown(doc);
+ expect(markdown).toContain('- [ ] Bühne buchen >>2026-08-01 @nadia');
+ });
+});
diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts
index ad47a44..40d3fd9 100644
--- a/packages/shared/src/editor-schema/html.ts
+++ b/packages/shared/src/editor-schema/html.ts
@@ -69,6 +69,14 @@ function renderInline(node: Node): string {
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
out += `${text}`;
+ } else if (child.type.name === 'date_marker') {
+ // A date marker (issue #152): static HTML shows the unambiguous ISO
+ // date; locale-aware formatting happens where the viewer is known.
+ const kind = escapeHtml(child.attrs.kind as string);
+ const date = escapeHtml(child.attrs.date as string);
+ out +=
+ `${kind === 'due' ? '»' : '«'} ${date}`;
} else if (child.type.name === 'mention') {
// A user mention (issue #150): static HTML shows the @username; the
// stable user id rides along for consumers that can resolve it.
diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts
index 9590c66..322ae79 100644
--- a/packages/shared/src/editor-schema/markdown.ts
+++ b/packages/shared/src/editor-schema/markdown.ts
@@ -192,6 +192,82 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
return true;
}
+/** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */
+const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/;
+
+/** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */
+function isoDateOf(match: RegExpExecArray): string | null {
+ const [year, month, day] = match[2]
+ ? [Number(match[2]), Number(match[3]), Number(match[4])]
+ : [Number(match[7]), Number(match[6]), Number(match[5])];
+ const date = new Date(Date.UTC(year, month - 1, day));
+ const valid =
+ date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
+ if (!valid) return null;
+ const pad = (value: number): string => String(value).padStart(2, '0');
+ return `${year}-${pad(month)}-${pad(day)}`;
+}
+
+/** markdown-it inline rule for date markers (issue #152): `>>` = due date,
+ * `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input
+ * lenience. An invalid calendar date stays plain text. */
+function dateMarkerRule(state: StateInline, silent: boolean): boolean {
+ const match = DATE_MARKER.exec(state.src.slice(state.pos));
+ if (!match) return false;
+ const iso = isoDateOf(match);
+ if (!iso) return false;
+ if (!silent) {
+ const token = state.push('date_marker', '', 0);
+ token.attrs = [
+ ['kind', match[1] === '>' ? 'due' : 'start'],
+ ['date', iso],
+ ];
+ }
+ state.pos += match[0].length;
+ return true;
+}
+
+/** Block guard (issue #152): a line starting with `>>2026-…` is a paragraph
+ * with a due-date marker, not a nested blockquote — registered before
+ * `blockquote` so markdown-it never sees the `>` as a quote. */
+function dateLineRule(
+ state: StateBlock,
+ startLine: number,
+ endLine: number,
+ silent: boolean,
+): boolean {
+ const start = state.bMarks[startLine]! + state.tShift[startLine]!;
+ const max = state.eMarks[startLine]!;
+ const match = DATE_MARKER.exec(state.src.slice(start, max));
+ if (!match || !isoDateOf(match)) return false;
+ if (silent) return true;
+ // Consume the paragraph like markdown-it's own paragraph rule would.
+ const terminatorRules = state.md.block.ruler.getRules('paragraph');
+ let nextLine = startLine + 1;
+ for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) {
+ if (state.sCount[nextLine]! - state.blkIndent > 3) continue;
+ if (state.sCount[nextLine]! < 0) continue;
+ let terminate = false;
+ for (const rule of terminatorRules) {
+ if (rule(state, nextLine, endLine, true)) {
+ terminate = true;
+ break;
+ }
+ }
+ if (terminate) break;
+ }
+ const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim();
+ const open = state.push('paragraph_open', 'p', 1);
+ open.map = [startLine, nextLine];
+ const inline = state.push('inline', '', 0);
+ inline.content = content;
+ inline.map = [startLine, nextLine];
+ inline.children = [];
+ state.push('paragraph_close', 'p', -1);
+ state.line = nextLine;
+ return true;
+}
+
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
@@ -308,6 +384,10 @@ function createTokenizer(): MarkdownIt {
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
// `@username` mentions (issue #150).
md.inline.ruler.before('link', 'mention', mentionRule);
+ // `>>`/`<<` date markers (issue #152) …
+ md.inline.ruler.before('link', 'date_marker', dateMarkerRule);
+ // … and the guard that keeps a line-leading `>>date` out of blockquote.
+ md.block.ruler.before('blockquote', 'date_line', dateLineRule);
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
// as plain text (issue #135).
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
@@ -383,6 +463,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
userId: tok.attrGet('userId') ?? '',
}),
},
+ date_marker: {
+ node: 'date_marker',
+ getAttrs: (tok) => ({
+ kind: tok.attrGet('kind') ?? 'due',
+ date: tok.attrGet('date') ?? '',
+ }),
+ },
transclusion: {
node: 'transclusion',
getAttrs: (tok) => ({
@@ -517,6 +604,10 @@ const markdownSerializer = new MarkdownSerializer(
mention(state, node) {
state.write(`@${node.attrs.username as string}`);
},
+ date_marker(state, node) {
+ const prefix = node.attrs.kind === 'due' ? '>>' : '<<';
+ state.write(`${prefix}${node.attrs.date as string}`);
+ },
transclusion(state, node) {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
diff --git a/packages/shared/src/editor-schema/plain-text.ts b/packages/shared/src/editor-schema/plain-text.ts
index 45a9671..5d3901b 100644
--- a/packages/shared/src/editor-schema/plain-text.ts
+++ b/packages/shared/src/editor-schema/plain-text.ts
@@ -11,6 +11,8 @@ export function docToPlainText(doc: Node): string {
}
// A mention reads as `@username` (issue #150), so search finds it.
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
+ // A date marker contributes its ISO date (issue #152).
+ if (leaf.type.name === 'date_marker') return leaf.attrs.date as string;
return '';
});
return text.replace(/\n{3,}/g, '\n\n').trim();
diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts
index b15aeff..035405b 100644
--- a/packages/shared/src/editor-schema/schema.ts
+++ b/packages/shared/src/editor-schema/schema.ts
@@ -256,6 +256,38 @@ export const editorSchema = new Schema({
},
},
+ // `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
+ // atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
+ // a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in
+ // the editor, where the viewer's language is known.
+ date_marker: {
+ group: 'inline',
+ inline: true,
+ atom: true,
+ attrs: {
+ kind: { validate: 'string' },
+ date: { validate: 'string' },
+ },
+ parseDOM: [
+ {
+ tag: 'span[data-date-marker]',
+ getAttrs: (dom) => ({
+ kind: dom.getAttribute('data-date-marker'),
+ date: dom.getAttribute('data-date'),
+ }),
+ },
+ ],
+ toDOM: (node) => {
+ const kind = node.attrs.kind as string;
+ const date = node.attrs.date as string;
+ return [
+ 'span',
+ { 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` },
+ `${kind === 'due' ? '»' : '«'} ${date}`,
+ ];
+ },
+ },
+
// `@username` user mention (issue #150). An inline atom carrying the
// mentioned user's stable id plus the username as serialization/display
// fallback. Live display-name resolution happens in the editor where a