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