diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ef821..dc79eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project are documented in this file. +## [1.128.0] - 2026-09-14 + +### Fixed +- **Eine im Aufgabentitel getippte URL war kein Link, sondern nur Text** — ein Klick + darauf hat je nach Ansicht die Aufgabe durchgestrichen (Tagesraster) oder den + Bearbeiten-Modus geöffnet (Kanban/Someday/Liste/Priorität), statt den Link zu öffnen. + Neue reine Erkennungsfunktion `splitTextAndUrls` (`src/lib/weekly/format.ts`, 8 + Unit-Tests) zerlegt den Titel in Text- und URL-Abschnitte; die neue Komponente + `LinkifiedText` (`src/components/weekly/LinkifiedText.tsx`) rendert URL-Abschnitte + als echten Link mit `stopPropagation`, damit ein Klick darauf ausschließlich den Link + öffnet. Eingebaut in `GridTaskBlock.tsx`, `weekly/TaskItem.tsx` und `PriorityView.tsx` + — überall dort, wo ein Aufgabentitel als reiner Text stand. Keine Änderung an Daten, + API oder dem separaten `task.url`-Feld. + +Verifiziert: Typecheck, Lint (0 Fehler), 108 Unit-Tests (8 neu), Produktions-Build. + ## [1.127.0] - 2026-09-14 ### Added diff --git a/package.json b/package.json index b2ecb6d..fcf187b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.127.0", + "version": "1.128.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/globals.css b/src/app/globals.css index 819dbe7..663fade 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1206,6 +1206,15 @@ h3 { color: var(--weekly-teal); } +/* URL innerhalb eines Aufgabentitels, siehe LinkifiedText.tsx */ +.task-title-link { + color: var(--weekly-teal); + text-decoration: underline; +} +.task-title-link:hover { + opacity: 0.8; +} + .subtask-edit-input { width: 100%; border: none; diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx index d975fc2..bb030ee 100644 --- a/src/components/GridTaskBlock.tsx +++ b/src/components/GridTaskBlock.tsx @@ -8,6 +8,7 @@ import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg- import MdiIcon from "@mdi/react"; import { allIcons } from "./iconRegistry"; import { Task, KanbanStage, getPriorityBadge } from "./WeeklyView"; +import { LinkifiedText } from "./weekly/LinkifiedText"; interface GridTaskBlockProps { task: Task; @@ -392,7 +393,7 @@ export function GridTaskBlock({ /> )} - {task.title} + {/* Subtask indicator */} {task.subTasks && task.subTasks.length > 0 && (() => { const completed = task.subTasks.filter(s => s.completed).length; diff --git a/src/components/PriorityView.tsx b/src/components/PriorityView.tsx index ce9ec6e..2796a76 100644 --- a/src/components/PriorityView.tsx +++ b/src/components/PriorityView.tsx @@ -29,6 +29,7 @@ import { Link2, ExternalLink, } from "lucide-react"; +import { LinkifiedText } from "./weekly/LinkifiedText"; export interface PriorityTask { id: string; @@ -708,7 +709,7 @@ function TaskCard({ task, darkMode, cardBg, border, textPrimary, textSecondary, title={task.title} onDoubleClick={() => setEditingTitle(true)} > - {task.title} + )} {/* Action icons */} diff --git a/src/components/weekly/LinkifiedText.tsx b/src/components/weekly/LinkifiedText.tsx new file mode 100644 index 0000000..8c2d1ca --- /dev/null +++ b/src/components/weekly/LinkifiedText.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { splitTextAndUrls } from "@/lib/weekly/format"; + +interface LinkifiedTextProps { + text: string; +} + +/** + * Zeigt einen Aufgabentitel an und macht darin enthaltene URLs anklickbar. + * + * Aufgabentitel liegen bisher als reiner Text in einem , das selbst Teil des + * anklickbaren Bereichs zum Erledigt-Umschalten ist -- eine getippte URL im Titel war + * dadurch nie ein Link, sondern hat beim Antippen die Aufgabe durchgestrichen. Jedes + * erkannte URL-Segment bekommt hier ein eigenes -Tag mit stopPropagation, damit ein + * Klick auf den Link tatsaechlich den Link oeffnet statt die Aufgabe umzuschalten. + */ +export function LinkifiedText({ text }: LinkifiedTextProps) { + const segments = splitTextAndUrls(text); + if (segments.length === 0) return null; + if (segments.length === 1 && segments[0].type === "text") { + // Haeufigster Fall (kein Link im Titel): unveraendert als reiner Text, kein + // zusaetzliches Wrapper-Element im DOM. + return <>{segments[0].value}; + } + + return ( + <> + {segments.map((segment, i) => + segment.type === "url" ? ( + e.stopPropagation()} + className="task-title-link" + > + {segment.value} + + ) : ( + {segment.value} + ), + )} + + ); +} diff --git a/src/components/weekly/TaskItem.tsx b/src/components/weekly/TaskItem.tsx index bfa2978..3472195 100644 --- a/src/components/weekly/TaskItem.tsx +++ b/src/components/weekly/TaskItem.tsx @@ -9,6 +9,7 @@ import { faServer } from "@fortawesome/free-solid-svg-icons"; import type { Task, SomedayList, KanbanStage } from "@/lib/weekly/types"; import { getPriorityBadge } from "./priority-badge"; import { ProjectIcon } from "./ProjectIcon"; +import { LinkifiedText } from "./LinkifiedText"; const RichTextEditor = dynamic(() => import("../RichTextEditor"), { ssr: false }); @@ -393,7 +394,7 @@ export function TaskItem({ )} - {task.title} + {(() => { const provider = task.externalProvider diff --git a/src/lib/weekly/format.ts b/src/lib/weekly/format.ts index b6cc56f..79eb8ca 100644 --- a/src/lib/weekly/format.ts +++ b/src/lib/weekly/format.ts @@ -22,6 +22,60 @@ export const getSomedaySlotCount = (tasks: Task[]) => { return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2); }; +/** + * Ein Textabschnitt: entweder normaler Text oder eine erkannte URL. + * + * Rein für die Erkennung/Aufteilung zuständig — nichts hier weiß etwas von React oder + * vom DOM. Das eigentliche Rendern (als ``-Tag, mit `stopPropagation` gegen das + * Toggle-auf-Klick der umgebenden Aufgabe) übernimmt `LinkifiedText.tsx`. + */ +export interface TextSegment { + type: "text" | "url"; + value: string; +} + +// http(s)://... oder www.-Adressen, aber ohne einen abschließenden Satzzeichen-Rest +// (".", ",", ")", "!", "?") mitzureißen -- sonst würde "siehe https://x.de." den Punkt +// noch in den Link ziehen. +const URL_PATTERN = /(https?:\/\/[^\s<>"]+|www\.[^\s<>"]+)/gi; +const TRAILING_PUNCTUATION = /[.,!?;:)\]]+$/; + +/** + * Zerlegt einen Aufgabentitel in Text- und URL-Abschnitte, damit eine im Titel + * getippte Adresse (z. B. "Rechnung siehe https://example.com/invoice pruefen") als + * anklickbarer Link dargestellt werden kann, statt als reiner Text. + */ +export function splitTextAndUrls(text: string): TextSegment[] { + if (!text) return []; + + const segments: TextSegment[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(URL_PATTERN)) { + const start = match.index ?? 0; + let url = match[0]; + + // Satzzeichen, die grammatisch zum umgebenden Satz gehören, nicht zur Adresse. + const trailing = url.match(TRAILING_PUNCTUATION); + if (trailing) { + url = url.slice(0, url.length - trailing[0].length); + } + if (!url) continue; + + if (start > lastIndex) { + segments.push({ type: "text", value: text.slice(lastIndex, start) }); + } + segments.push({ type: "url", value: url }); + lastIndex = start + url.length; + } + + if (lastIndex < text.length) { + segments.push({ type: "text", value: text.slice(lastIndex) }); + } + + return segments; +} + export function invertColor(hex: string): string { if (!hex) return hex; let color = hex.startsWith("#") ? hex.slice(1) : hex; diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts index 0ea61db..e05f261 100644 --- a/tests/unit/format.test.ts +++ b/tests/unit/format.test.ts @@ -2,6 +2,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { stripHtml, invertColor, getSomedaySlotCount, DEFAULT_SOMEDAY_SLOT_COUNT, + splitTextAndUrls, } from "../../src/lib/weekly/format.ts"; import { isAllDayEvent } from "../../src/lib/weekly/date-utils.ts"; import { clampViewDays, MAX_VIEW_DAYS } from "../../src/lib/weekly/device-settings.ts"; @@ -131,3 +132,73 @@ describe("clampViewDays", () => { assert.equal(clampViewDays(Infinity), 7); }); }); + +/** + * URLs in Aufgabentiteln erkennen (damit sie als Link statt als reiner Text angezeigt + * werden können, siehe LinkifiedText.tsx). Erwartetes Verhalten: eine getippte Adresse + * im Titel wird als eigenes Segment erkannt, umgebender Text bleibt unverändert, und + * Satzzeichen, die grammatisch zum Satz gehören, werden nicht mit in den Link gezogen. + */ +describe("splitTextAndUrls", () => { + test("Titel ohne URL bleibt ein einzelnes Textsegment", () => { + assert.deepEqual(splitTextAndUrls("Einkaufen gehen"), [ + { type: "text", value: "Einkaufen gehen" }, + ]); + }); + + test("leerer Titel ergibt keine Segmente", () => { + assert.deepEqual(splitTextAndUrls(""), []); + }); + + test("Titel, der nur aus einer URL besteht", () => { + assert.deepEqual(splitTextAndUrls("https://example.com"), [ + { type: "url", value: "https://example.com" }, + ]); + }); + + test("URL mit Text davor und danach", () => { + assert.deepEqual( + splitTextAndUrls("Rechnung siehe https://example.com/invoice prüfen"), + [ + { type: "text", value: "Rechnung siehe " }, + { type: "url", value: "https://example.com/invoice" }, + { type: "text", value: " prüfen" }, + ], + ); + }); + + test("www.-Adresse ohne Schema wird ebenfalls erkannt", () => { + assert.deepEqual(splitTextAndUrls("siehe www.example.com"), [ + { type: "text", value: "siehe " }, + { type: "url", value: "www.example.com" }, + ]); + }); + + test("abschließender Satzpunkt gehört zum Text, nicht zur URL", () => { + const segments = splitTextAndUrls("Link: https://example.com."); + assert.deepEqual(segments, [ + { type: "text", value: "Link: " }, + { type: "url", value: "https://example.com" }, + { type: "text", value: "." }, + ]); + }); + + test("URL in Klammern: die schließende Klammer bleibt Text", () => { + assert.deepEqual(splitTextAndUrls("Details (https://example.com/x) nachlesen"), [ + { type: "text", value: "Details (" }, + { type: "url", value: "https://example.com/x" }, + { type: "text", value: ") nachlesen" }, + ]); + }); + + test("zwei URLs im selben Titel", () => { + assert.deepEqual( + splitTextAndUrls("https://a.example.com und https://b.example.com"), + [ + { type: "url", value: "https://a.example.com" }, + { type: "text", value: " und " }, + { type: "url", value: "https://b.example.com" }, + ], + ); + }); +});