#152: Datums-Marker >> (Zieldatum) / << (Startdatum)

Neuer Inline-Atom date_marker {kind: due|start, date: ISO}. Markdown
kanonisch ISO (>>2026-12-31), Eingabe-Kulanz dd.mm.yyyy; Block-Guard
vor blockquote hält zeilenführende >>Daten aus dem Zitat-Parser;
ungültige Kalenderdaten bleiben Text. Editor: InputRule beim Tippen
(+Leerzeichen), Anzeige per Intl.DateTimeFormat in Nutzersprache,
Überfällig-Färbung. Die User.locale-Verdrahtung existierte bereits
(auth-context, #17) — keine Änderung nötig. 6 Unit-Tests inkl.
Task-Listen-Zeile mit Marker und Mention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
Claude Fable 5 2026-07-20 00:59:05 +02:00
parent 7471fc70f7
commit 92a3b2f6d5
10 changed files with 326 additions and 0 deletions

View File

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

View File

@ -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 (
<NodeViewWrapper as="span" className="dt-date-nodeview">
<span
className={classes.join(' ')}
title={t(kind === 'due' ? 'dateMarker.due' : 'dateMarker.start')}
contentEditable={false}
>
{kind === 'due' ? '»' : '«'} {formatted}
</span>
</NodeViewWrapper>
);
}
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);
},
});

View File

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

View File

@ -185,5 +185,9 @@
"mention": {
"unresolved": "Unbekannter Nutzer",
"suggestLabel": "Nutzer-Vorschläge"
},
"dateMarker": {
"due": "Zieldatum",
"start": "Startdatum"
}
}

View File

@ -185,5 +185,9 @@
"mention": {
"unresolved": "Unknown user",
"suggestLabel": "User suggestions"
},
"dateMarker": {
"due": "Due date",
"start": "Start date"
}
}

View File

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

View File

@ -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 += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
} 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 +=
`<span class="dt-date dt-date--${kind}" data-date-marker="${kind}"` +
` data-date="${date}">${kind === 'due' ? '»' : '«'} ${date}</span>`;
} 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.

View File

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

View File

@ -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();

View File

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