My-Weekly-ToDo-List/src/lib/weekly/format.ts
mARTin-B78 7f850bb4e9
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run
fix: URLs im Aufgabentitel als anklickbaren Link darstellen
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>
2026-09-14 14:03:17 +02:00

105 lines
3.5 KiB
TypeScript

import type { Task } from "@/lib/weekly/types";
/**
* Darstellungs-Helfer der Wochenansicht: Text, Farben, Ganztags-Erkennung, Slot-Anzahl.
* Reine Funktionen ohne Komponenten-State — lagen bis v1.117.7 in WeeklyView.tsx.
*/
export const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim();
export const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
export const getSomedaySlotCount = (tasks: Task[]) => {
const maxIdx = tasks.reduce((max, t) => {
if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) {
return Math.max(max, t.somedaySlotIndex);
}
return max;
}, -1);
// Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total.
// "add 5 rows and then when 4 are taken add another row"
// Let's ensure there's always at least one empty slot at the bottom.
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;
if (color.length === 3) {
color = color
.split("")
.map((c) => c + c)
.join("");
}
if (color.length !== 6) return hex;
try {
const r = (255 - parseInt(color.slice(0, 2), 16))
.toString(16)
.padStart(2, "0");
const g = (255 - parseInt(color.slice(2, 4), 16))
.toString(16)
.padStart(2, "0");
const b = (255 - parseInt(color.slice(4, 6), 16))
.toString(16)
.padStart(2, "0");
return `#${r}${g}${b}`;
} catch (e) {
return hex;
}
}