fix(sonar): locale-aware sorts, linear share and redaction patterns, focus only on controls

This commit is contained in:
Maurice
2026-10-22 18:48:51 +02:00
parent e291735c95
commit c8ce36e9af
14 changed files with 290 additions and 94 deletions
@@ -66,9 +66,9 @@ export default function AssistantFitCheck({ changes }: { changes: readonly DayCh
if (ok) return pill;
return (
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
<button type="button" className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
{pill}
</span>
</button>
</Tooltip>
);
}
@@ -76,6 +76,23 @@ describe('markdownToText', () => {
it('FE-AI-SHARE-002: a link whose text is its address stays one address', () => {
expect(markdownToText('[https://trek.app](https://trek.app)')).toBe('https://trek.app');
});
it('FE-AI-SHARE-006: pictures, odd links and divider rows read as before', () => {
expect(markdownToText('![Map](a.png) and [[nested]](u) and [a b](x y) and [](z) and [ok](u)')).toBe(
'Map and [[nested]](u) and [a b](x y) and [](z) and ok (u)'
);
expect(markdownToText('|:-|-:|\n| a | b |')).toBe('a, b');
expect(markdownToText('| - | x |')).toBe('-, x');
expect(markdownToText('# Wide heading\n- wide item')).toBe('Wide heading\n• wide item');
});
it('FE-AI-SHARE-007: long runs of brackets, spaces and dashes are read in one pass', () => {
const started = Date.now();
const long = 20000;
markdownToText(`${'['.repeat(long)}\n${'![a](b '.repeat(long / 7)}\n${'-'.repeat(long)}\n#${' '.repeat(long)}x`);
markdownToText(`|${' - |'.repeat(long / 4)} x`);
expect(Date.now() - started).toBeLessThan(2000);
});
});
describe('chatTextOf', () => {
@@ -14,8 +14,19 @@ export const SHARE_MAX_CHARS = 4800;
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** A table's divider row (`|---|:--:|`), which has no words. */
const TABLE_RULE = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/;
/** What a divider cell holds once trimmed: dashes, with a colon at either end for alignment. */
const RULE_CELL = /^:?-*:?$/;
/** A table's divider row (`|---|:--:|`), which has no words. Read cell by cell, so no pattern backtracks over it. */
function isTableRule(line: string): boolean {
const cells = (line.startsWith('|') ? line.slice(1) : line).split('|');
const last = cells.pop() ?? '';
if (cells.length === 0 || !RULE_CELL.test(last.trim())) return false;
return cells.every((cell) => {
const trimmed = cell.trim();
return trimmed.includes('-') && RULE_CELL.test(trimmed);
});
}
/** One table row as its cells in a line. */
function tableRow(line: string): string {
@@ -28,11 +39,45 @@ function tableRow(line: string): string {
.join(', ');
}
/**
* Rewrites every `[text](url)` of a line (with a leading `!` for a picture), found by scanning:
* a `[` and the first `]` after it decide the match, so a long line of brackets is read once.
*/
function rewriteBrackets(line: string, picture: boolean, build: (text: string, url: string) => string): string {
let out = '';
let from = 0;
let paren = -1;
let open = line.indexOf('[');
while (open !== -1) {
if (picture && (open === 0 || line[open - 1] !== '!')) {
open = line.indexOf('[', open + 1);
continue;
}
const close = line.indexOf(']', open + 1);
if (close === -1) break;
let next = close;
if (line[close + 1] === '(') {
if (paren < close + 2) paren = line.indexOf(')', close + 2);
if (paren === -1) break;
let clean = true;
for (let at = close + 2; at < paren && clean; at++) clean = !/\s/.test(line.charAt(at));
const text = line.slice(open + 1, close);
const url = line.slice(close + 2, paren);
if (clean && (picture || (text !== '' && url !== ''))) {
out += line.slice(from, picture ? open - 1 : open) + build(text, url);
from = paren + 1;
next = paren;
}
}
open = line.indexOf('[', next + 1);
}
return out + line.slice(from);
}
/** The marks of one line of running text: emphasis, code, links and pictures. */
function inline(line: string): string {
return line
.replace(/!\[([^\]]*)\]\([^)\s]*\)/g, '$1')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) => (text === url ? url : `${text} (${url})`))
const pictures = rewriteBrackets(line, true, (text) => text);
return rewriteBrackets(pictures, false, (text, url) => (text === url ? url : `${text} (${url})`))
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1$2')
@@ -42,11 +87,11 @@ function inline(line: string): string {
/** One line of Markdown as text, or null for a line that only carries layout (a fence, a table rule). */
function plainLine(line: string): string | null {
if (/^\s*(?:```|~~~)/.test(line)) return null;
if (TABLE_RULE.test(line.trim())) return null;
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line);
if (heading) return inline(heading[1] ?? '');
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (bullet) return `${bullet[1] ?? ''}• ${inline(bullet[2] ?? '')}`;
if (isTableRule(line.trim())) return null;
const heading = /^\s{0,3}#{1,6}\s(.*)$/.exec(line);
if (heading) return inline((heading[1] ?? '').trimStart());
const bullet = /^(\s*)[-*+]\s(.*)$/.exec(line);
if (bullet) return `${bullet[1] ?? ''}• ${inline((bullet[2] ?? '').trimStart())}`;
const quote = /^\s*>\s?(.*)$/.exec(line);
if (quote) return inline(quote[1] ?? '');
if (line.trim().startsWith('|')) return tableRow(inline(line));
@@ -329,8 +329,10 @@ export function ChangeRows({ items, folded, excluded, disabled, undone, undoable
const Group = view?.Group;
const Run = view?.Run;
const body: ReactNode[] = [];
for (let i = 0; i < run.length; i++) {
let i = 0;
while (i < run.length) {
let end = i;
let next = i + 1;
while (end < run.length && runnable(run[end] as ChangeItem, view)) end++;
if (Run && end - i >= 2) {
const taken = run.slice(i, end);
@@ -342,8 +344,9 @@ export function ChangeRows({ items, folded, excluded, disabled, undone, undoable
</CardBoundary>
);
}
i = end - 1;
next = end;
} else body.push(row(run[i] as ChangeItem));
i = next;
}
if (!Group || !first) return body;
const group = (place: 'head' | 'foot') => (
@@ -75,7 +75,7 @@ function LabelChips({ labels }: { labels: readonly CollectionCardLabel[] }) {
))}
{rest.length > 0 && (
<Tooltip label={rest.map((label) => label.name).join(', ')} placement="top">
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
<span className="rounded-full">
<WhiteBadge>+{rest.length}</WhiteBadge>
</span>
</Tooltip>
+189 -71
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'
import { PlugZap, Coins, ChevronDown } from 'lucide-react'
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared'
import { useTranslation } from '../../i18n/TranslationContext'
import { useTripStore } from '../../store/tripStore'
import { chargingRepo } from '../../repo/chargingRepo'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { Tooltip } from '../shared/Tooltip'
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n/TranslationContext';
import { chargingRepo } from '../../repo/chargingRepo';
import { useTripStore } from '../../store/tripStore';
import { isEffectivelyOffline } from '../../sync/networkMode';
import { Tooltip } from '../shared/Tooltip';
/**
* Which station the panel is asking about, one way or the other.
@@ -18,85 +18,203 @@ import { Tooltip } from '../shared/Tooltip'
*/
type ChargingTarget =
| { placeId: number; lat?: undefined; lng?: undefined; name?: undefined }
| { placeId?: undefined; lat: number; lng: number; name: string }
| { placeId?: undefined; lat: number; lng: number; name: string };
/**
* A charger's two compact badges: the free points of all its points, when that is known
* and fresh, and its cheapest energy price. The live badge on the rail draws them, and so
* does the assistant's card of one look at the charger, which never asks again.
*/
export function ChargingBadges({ label, known, fresh = true, available, total, energy }: {
export function ChargingBadges({
label,
known,
fresh = true,
available,
total,
energy,
}: {
/** What the availability badge says when it is pointed at. */
label: string
known: boolean
label: string;
known: boolean;
/** False for a count checked a while ago: still shown, but no longer green. */
fresh?: boolean
available: number | null
total: number | null
energy: ChargingEnergyPrice | null
fresh?: boolean;
available: number | null;
total: number | null;
energy: ChargingEnergyPrice | null;
}) {
const { t } = useTranslation()
const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
return <span className="inline-flex shrink-0 items-center gap-1 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none">
<Tooltip label={label}><span tabIndex={0} className={`inline-flex items-center gap-1 rounded px-1 py-0.5 ${known && fresh && available! > 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}><PlugZap size={10} />{known ? `${available}/${total}` : '?'}</span></Tooltip>
{energy && <Tooltip label={t('roadtrip.charging.note')}><span tabIndex={0} className="inline-flex items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 text-content-muted"><Coins size={10} />{price(energy.price, energy.currency)}/kWh*</span></Tooltip>}
</span>
const { t } = useTranslation();
const price = (value: number, currency: string) =>
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
return (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap align-middle text-[length:calc(10px*var(--fs-scale-caption,1))] font-medium leading-none">
<Tooltip label={label}>
<span
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 ${known && fresh && available! > 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}
>
<PlugZap size={10} />
{known ? `${available}/${total}` : '?'}
</span>
</Tooltip>
{energy && (
<Tooltip label={t('roadtrip.charging.note')}>
<span className="inline-flex items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 text-content-muted">
<Coins size={10} />
{price(energy.price, energy.currency)}/kWh*
</span>
</Tooltip>
)}
</span>
);
}
export default function ChargingInfo({ placeId, lat, lng, name, compact = false }: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore(s => s.trip?.id)
const { t } = useTranslation()
const [info, setInfo] = useState<Info | null>(null)
const [loading, setLoading] = useState(true)
export default function ChargingInfo({
placeId,
lat,
lng,
name,
compact = false,
}: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore((s) => s.trip?.id);
const { t } = useTranslation();
const [info, setInfo] = useState<Info | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true
setInfo(null); setLoading(true)
let active = true;
setInfo(null);
setLoading(true);
const refresh = async () => {
if (!tripId || document.hidden) return
if (!tripId || document.hidden) return;
try {
const value = placeId != null
? await chargingRepo.read(tripId, placeId)
: await chargingRepo.readAt(tripId, lat!, lng!, name!)
if (active) setInfo(value)
const value =
placeId != null
? await chargingRepo.read(tripId, placeId)
: await chargingRepo.readAt(tripId, lat!, lng!, name!);
if (active) setInfo(value);
} catch {
if (active) setInfo(null);
} finally {
if (active) setLoading(false);
}
catch { if (active) setInfo(null) }
finally { if (active) setLoading(false) }
}
const offline = () => { setInfo(null); setLoading(false) }
void refresh()
const timer = window.setInterval(() => void refresh(), 60000)
window.addEventListener('offline', offline)
window.addEventListener('online', refresh)
document.addEventListener('visibilitychange', refresh)
return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) }
};
const offline = () => {
setInfo(null);
setLoading(false);
};
void refresh();
const timer = window.setInterval(() => void refresh(), 60000);
window.addEventListener('offline', offline);
window.addEventListener('online', refresh);
document.addEventListener('visibilitychange', refresh);
return () => {
active = false;
clearInterval(timer);
window.removeEventListener('offline', offline);
window.removeEventListener('online', refresh);
document.removeEventListener('visibilitychange', refresh);
};
// The identity of the station, whichever way it was given: a changed coordinate is a
// different charger and has to re-ask, exactly as a changed place id does.
}, [tripId, placeId, lat, lng, name])
const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000
const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown')
const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? []
}, [tripId, placeId, lat, lng, name]);
const known =
!isEffectivelyOffline() &&
info?.available != null &&
!info.stale &&
Date.now() - Date.parse(info.checkedAt) < 120000;
const status = loading
? t('common.loading')
: known
? `${info.available}/${info.total} ${t('roadtrip.charging.available')}`
: t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown');
const components =
info?.tariffs.flatMap((tariff) =>
tariff.components.map((component) => ({ ...component, currency: tariff.currency }))
) ?? [];
// The same price the assistant's card shows: tax included, no condition, one currency.
const cheapest = info ? cheapestEnergy(info.tariffs) : null
const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`
if (compact) return <ChargingBadges label={label} known={known} available={info?.available ?? null} total={info?.total ?? null} energy={cheapest} />
return <details className="group rounded-[10px] bg-surface-hover text-[length:calc(12px*var(--fs-scale-body,1))] text-content-secondary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-3 py-2 [&::-webkit-details-marker]:hidden">
<PlugZap size={13} className="shrink-0 text-content-faint" />
<span className="font-medium">{t('roadtrip.poi.charging')}</span>
<span className="ms-2 text-content-muted">{status}</span>
{cheapest && <span className="ms-auto whitespace-nowrap font-medium">{price(cheapest.price, cheapest.currency)}/kWh*</span>}
<ChevronDown size={13} className={'shrink-0 text-content-faint group-open:rotate-180 ' + (cheapest ? '' : 'ms-auto')} />
</summary>
<div className="space-y-2 px-3 pb-3 text-content-muted">
{info?.station && <p>{info.station}</p>}
<div className="space-y-1"><p className="font-medium">{t('roadtrip.charging.prices')}</p>
{!components.length && <p className="text-content-muted">{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}</p>}
{components.map((component, index) => <p key={index}>{price(component.price, component.currency)}{component.kind === 'ENERGY' ? '/kWh' : ['TIME', 'PARKING_TIME'].includes(component.kind) ? '/h' : ''} {component.kind === 'FLAT' ? 'Σ' : ''}{component.conditional || !component.taxIncluded ? '*' : ''}{component.afterSeconds != null && component.afterSeconds > 0 ? ` (> ${Math.round(component.afterSeconds / 60)} min)` : ''}</p>)}
{!!components.length && <p className="text-content-muted">* {t('roadtrip.charging.note')}</p>}
</div>
<p className="text-content-muted">{t('roadtrip.charging.coverage')}</p>
{info?.source && <p className="text-content-muted"><a className="underline" href={info.sourceUrl?.startsWith('https://') ? info.sourceUrl : 'https://mobidata-bw.de/de/dataset/e-ladesaulen'} target="_blank" rel="noreferrer">{info.source}</a> · <a className="underline" href="https://mobidata-bw.de/de/dataset/e-ladesaulen" target="_blank" rel="noreferrer">MobiData BW</a><br />{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}</p>}
</div>
</details>
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
const price = (value: number, currency: string) =>
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
if (compact)
return (
<ChargingBadges
label={label}
known={known}
available={info?.available ?? null}
total={info?.total ?? null}
energy={cheapest}
/>
);
return (
<details className="group rounded-[10px] bg-surface-hover text-[length:calc(12px*var(--fs-scale-body,1))] text-content-secondary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-3 py-2 [&::-webkit-details-marker]:hidden">
<PlugZap size={13} className="shrink-0 text-content-faint" />
<span className="font-medium">{t('roadtrip.poi.charging')}</span>
<span className="ms-2 text-content-muted">{status}</span>
{cheapest && (
<span className="ms-auto whitespace-nowrap font-medium">{price(cheapest.price, cheapest.currency)}/kWh*</span>
)}
<ChevronDown
size={13}
className={'shrink-0 text-content-faint group-open:rotate-180 ' + (cheapest ? '' : 'ms-auto')}
/>
</summary>
<div className="space-y-2 px-3 pb-3 text-content-muted">
{info?.station && <p>{info.station}</p>}
<div className="space-y-1">
<p className="font-medium">{t('roadtrip.charging.prices')}</p>
{!components.length && (
<p className="text-content-muted">
{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}
{t('roadtrip.charging.unknown')}
</p>
)}
{components.map((component, index) => (
<p key={index}>
{price(component.price, component.currency)}
{component.kind === 'ENERGY'
? '/kWh'
: ['TIME', 'PARKING_TIME'].includes(component.kind)
? '/h'
: ''}{' '}
{component.kind === 'FLAT' ? 'Σ' : ''}
{component.conditional || !component.taxIncluded ? '*' : ''}
{component.afterSeconds != null && component.afterSeconds > 0
? ` (> ${Math.round(component.afterSeconds / 60)} min)`
: ''}
</p>
))}
{!!components.length && <p className="text-content-muted">* {t('roadtrip.charging.note')}</p>}
</div>
<p className="text-content-muted">{t('roadtrip.charging.coverage')}</p>
{info?.source && (
<p className="text-content-muted">
<a
className="underline"
href={
info.sourceUrl?.startsWith('https://')
? info.sourceUrl
: 'https://mobidata-bw.de/de/dataset/e-ladesaulen'
}
target="_blank"
rel="noreferrer"
>
{info.source}
</a>{' '}
·{' '}
<a
className="underline"
href="https://mobidata-bw.de/de/dataset/e-ladesaulen"
target="_blank"
rel="noreferrer"
>
MobiData BW
</a>
<br />
{info.license}
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
</p>
)}
</div>
</details>
);
}