feat: mobile timeline — render links as tappable [link] in notes and time entries

- Replace stripHtml() with renderContent() that:
  1. Extracts <a href="...">...</a> anchors before stripping HTML
  2. Strips remaining HTML tags cleanly
  3. Detects bare https?:// URLs in plain text
  4. Renders each as a tappable <a>[link]</a> with text-primary underline
- Collapsed note preview still shows plain text (no link clutter)
- Expanded note/time-entry body shows inline [link] elements
This commit is contained in:
lorentz 2026-03-18 00:05:56 -04:00
parent c8be128c5c
commit 012c7bde50

View file

@ -53,8 +53,42 @@ function fmtHours(h: string | number) {
if (n < 1) return `${Math.round(n * 60)}m`;
return `${n.toFixed(1)}h`;
}
function stripHtml(s: string) {
return s?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() ?? '';
function renderContent(s: string): React.ReactNode[] {
if (!s) return [];
// Step 1: convert <a href="...">...</a> → placeholder §URL§
// Collect links in order
const links: string[] = [];
const withPlaceholders = s.replace(/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>.*?<\/a>/gi, (_, href) => {
links.push(href);
return `\x00LINK${links.length - 1}\x00`;
});
// Step 2: strip remaining HTML tags
const plain = withPlaceholders.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
// Step 3: split on LINK placeholders and bare URLs
const URL_RE = /\x00LINK(\d+)\x00|(https?:\/\/[^\s<>"']+)/g;
const nodes: React.ReactNode[] = [];
let last = 0;
let match: RegExpExecArray | null;
let key = 0;
while ((match = URL_RE.exec(plain)) !== null) {
if (match.index > last) {
nodes.push(plain.slice(last, match.index));
}
const href = match[1] !== undefined ? links[parseInt(match[1])] : match[2];
nodes.push(
<a key={key++} href={href} target="_blank" rel="noopener noreferrer"
className="text-primary underline" onClick={e => e.stopPropagation()}>
[link]
</a>
);
last = match.index + match[0].length;
}
if (last < plain.length) nodes.push(plain.slice(last));
return nodes;
}
function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defaultOpen?: boolean }) {
@ -118,7 +152,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<p className="text-xs font-medium text-foreground flex items-center gap-1.5">
<User className="w-3 h-3 text-muted-foreground" />{te.resource_name}
</p>
{te.notes && <p className="text-sm text-foreground leading-relaxed">{stripHtml(te.notes)}</p>}
{te.notes && <p className="text-sm text-foreground leading-relaxed">{renderContent(te.notes)}</p>}
</div>
)}
</div>
@ -130,7 +164,8 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
const note = item.data;
const isInternal = note.publish === 1;
const isSystem = item.is_system;
const body = stripHtml(note.description ?? '');
const bodyNodes = renderContent(note.description ?? '');
const bodyPlain = (note.description ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const iconColor = isSystem ? 'text-muted-foreground' : isInternal ? 'text-slate-400' : 'text-amber-500';
const iconBg = isSystem ? 'bg-muted' : isInternal ? 'bg-muted' : 'bg-amber-500/10';
return (
@ -149,7 +184,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<p className={`text-sm font-medium line-clamp-1 ${isSystem ? 'text-muted-foreground' : ''}`}>
{note.title || (isInternal ? 'Internal Note' : 'Customer Note')}
</p>
{!open && body && <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{body}</p>}
{!open && bodyPlain && <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{bodyPlain}</p>}
</div>
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />}
</div>
@ -159,7 +194,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<p className="text-xs text-muted-foreground flex items-center gap-1 mb-1.5">
<User className="w-3 h-3" />{note.author || 'System'} · {isSystem ? 'System' : isInternal ? 'Internal' : 'Client-visible'}
</p>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{body}</p>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{bodyNodes}</p>
</div>
)}
</div>