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>
105 lines
3.5 KiB
TypeScript
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;
|
|
}
|
|
}
|