mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(sonar): locale-aware sorts, linear share and redaction patterns, focus only on controls
This commit is contained in:
@@ -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(' 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${'}\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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user