fix: URLs im Aufgabentitel als anklickbaren Link darstellen
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

Eine im Titel getippte URL war bisher reiner Text innerhalb des klickbaren Bereichs
der Aufgabe -- ein Klick darauf hat je nach Ansicht die Aufgabe durchgestrichen
(Tagesraster, GridTaskBlock) oder den Bearbeiten-Modus geoeffnet (Kanban/Someday/
Liste/Prioritaet, weekly/TaskItem.tsx und PriorityView.tsx), statt den Link zu oeffnen.

- src/lib/weekly/format.ts: neue reine Funktion splitTextAndUrls zerlegt einen Titel
  in Text- und URL-Abschnitte (http(s)://, www.-Adressen). Abschliessende Satzzeichen
  (".", ",", ")", ...) bleiben Text, nicht Teil des Links.
- src/components/weekly/LinkifiedText.tsx: rendert diese Abschnitte, URL-Abschnitte als
  echtes <a target="_blank" rel="noopener noreferrer"> mit stopPropagation, damit ein
  Klick ausschliesslich den Link oeffnet und nicht an den umgebenden Klick-Handler
  durchgereicht wird. Kein Titel ohne URL bekommt ein zusaetzliches Wrapper-Element.
- Eingebaut ueberall dort, wo ein Aufgabentitel bislang als reiner Text stand:
  GridTaskBlock.tsx, weekly/TaskItem.tsx, PriorityView.tsx.

Keine Aenderung an Daten, API oder dem separaten task.url-Feld (der eigene "Termin-
Link"-Button in TaskItem.tsx) -- reine Darstellungsaenderung.

8 neue Unit-Tests fuer splitTextAndUrls (Titel ohne URL, nur URL, URL mit Text davor/
danach, www.-Form, Satzzeichen am Ende, Klammern, zwei URLs im selben Titel).

Verifiziert: Typecheck, Lint (0 Fehler), 108 Unit-Tests (8 neu), Produktions-Build.

v1.128.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-14 14:03:17 +02:00
parent 2042c54b2d
commit 7f850bb4e9
9 changed files with 203 additions and 4 deletions

View File

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

View File

@ -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": {

View File

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

View File

@ -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({
/>
)}
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
<span style={{ marginRight: "4px" }}>{task.title}</span>
<span style={{ marginRight: "4px" }}><LinkifiedText text={task.title} /></span>
{/* Subtask indicator */}
{task.subTasks && task.subTasks.length > 0 && (() => {
const completed = task.subTasks.filter(s => s.completed).length;

View File

@ -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}
<LinkifiedText text={task.title} />
</span>
)}
{/* Action icons */}

View File

@ -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 <span>, 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 <a>-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" ? (
<a
key={i}
href={segment.value.startsWith("http") ? segment.value : `https://${segment.value}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="task-title-link"
>
{segment.value}
</a>
) : (
<React.Fragment key={i}>{segment.value}</React.Fragment>
),
)}
</>
);
}

View File

@ -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({
<ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
</span>
)}
{task.title}
<LinkifiedText text={task.title} />
</span>
{(() => {
const provider = task.externalProvider

View File

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

View File

@ -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" },
],
);
});
});