From 92a3b2f6d50e44564ca029a492205af108f4c40f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:59:05 +0200 Subject: [PATCH] #152: Datums-Marker >> (Zieldatum) / << (Startdatum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/date-marker.tsx | 91 +++++++++++++++++++ apps/web/src/styles/base.css | 19 ++++ packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/en/editor.json | 4 + .../src/editor-schema/date-marker.test.ts | 73 +++++++++++++++ packages/shared/src/editor-schema/html.ts | 8 ++ packages/shared/src/editor-schema/markdown.ts | 91 +++++++++++++++++++ .../shared/src/editor-schema/plain-text.ts | 2 + packages/shared/src/editor-schema/schema.ts | 32 +++++++ 10 files changed, 326 insertions(+) create mode 100644 apps/web/src/editor/nodes/date-marker.tsx create mode 100644 packages/shared/src/editor-schema/date-marker.test.ts 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