refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
50
frontend/src/lib/shared/utils/date-buckets.spec.ts
Normal file
50
frontend/src/lib/shared/utils/date-buckets.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { bucketByDay } from './date-buckets';
|
||||
|
||||
function date(iso: string): Date {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
// Wednesday 2026-04-22 at 12:00 Berlin. Week start (Mon) = 2026-04-20.
|
||||
const now = date('2026-04-22T12:00:00+02:00');
|
||||
|
||||
it('returns "today" for a time earlier today', () => {
|
||||
expect(bucketByDay(date('2026-04-22T06:00:00+02:00'), now, 'de-DE')).toBe('today');
|
||||
});
|
||||
|
||||
it('returns "today" at exact midnight start of today', () => {
|
||||
expect(bucketByDay(date('2026-04-22T00:00:00+02:00'), now, 'de-DE')).toBe('today');
|
||||
});
|
||||
|
||||
it('returns "yesterday" for any time on the previous day', () => {
|
||||
expect(bucketByDay(date('2026-04-21T23:59:59+02:00'), now, 'de-DE')).toBe('yesterday');
|
||||
expect(bucketByDay(date('2026-04-21T00:00:00+02:00'), now, 'de-DE')).toBe('yesterday');
|
||||
});
|
||||
|
||||
it('returns "thisWeek" for the Monday that starts this week (Monday-anchored, de-DE)', () => {
|
||||
expect(bucketByDay(date('2026-04-20T10:00:00+02:00'), now, 'de-DE')).toBe('thisWeek');
|
||||
});
|
||||
|
||||
it('returns "older" for anything before the start of this week (de-DE)', () => {
|
||||
expect(bucketByDay(date('2026-04-19T23:00:00+02:00'), now, 'de-DE')).toBe('older');
|
||||
expect(bucketByDay(date('2026-04-13T10:00:00+02:00'), now, 'de-DE')).toBe('older');
|
||||
});
|
||||
|
||||
it('uses Sunday-start week for en-US', () => {
|
||||
const sundayRef = date('2026-04-19T12:00:00+02:00');
|
||||
expect(bucketByDay(date('2026-04-19T06:00:00+02:00'), sundayRef, 'en-US')).toBe('today');
|
||||
expect(
|
||||
bucketByDay(date('2026-04-13T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
|
||||
).toBe('thisWeek');
|
||||
expect(
|
||||
bucketByDay(date('2026-04-11T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
|
||||
).toBe('older');
|
||||
});
|
||||
|
||||
it('handles DST spring-forward correctly (Europe/Berlin 2026-03-29)', () => {
|
||||
const justAfterDst = date('2026-03-29T03:15:00+02:00');
|
||||
const sameDay = date('2026-03-29T10:00:00+02:00');
|
||||
expect(bucketByDay(justAfterDst, sameDay, 'de-DE')).toBe('today');
|
||||
});
|
||||
});
|
||||
35
frontend/src/lib/shared/utils/date-buckets.ts
Normal file
35
frontend/src/lib/shared/utils/date-buckets.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type DayBucket = 'today' | 'yesterday' | 'thisWeek' | 'older';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SUNDAY_START_LOCALES = new Set(['en-us', 'en-ca', 'en-ph', 'ja-jp', 'he-il', 'pt-br']);
|
||||
|
||||
function weekStartDay(locale?: string): 0 | 1 {
|
||||
if (!locale) return 1;
|
||||
return SUNDAY_START_LOCALES.has(locale.toLowerCase()) ? 0 : 1;
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date, firstDay: 0 | 1): Date {
|
||||
const x = startOfDay(d);
|
||||
const diff = (x.getDay() - firstDay + 7) % 7;
|
||||
x.setDate(x.getDate() - diff);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function bucketByDay(date: Date, now: Date = new Date(), locale?: string): DayBucket {
|
||||
const today = startOfDay(now);
|
||||
const target = startOfDay(date);
|
||||
|
||||
if (target.getTime() === today.getTime()) return 'today';
|
||||
if (today.getTime() - target.getTime() <= DAY_MS) return 'yesterday';
|
||||
|
||||
const weekStart = startOfWeek(today, weekStartDay(locale));
|
||||
if (target.getTime() >= weekStart.getTime()) return 'thisWeek';
|
||||
|
||||
return 'older';
|
||||
}
|
||||
129
frontend/src/lib/shared/utils/date.spec.ts
Normal file
129
frontend/src/lib/shared/utils/date.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDate, formatGermanDateInput, isoToGerman, germanToIso } from './date';
|
||||
|
||||
// ─── formatDate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('defaults to long format when no format arg is passed', () => {
|
||||
expect(formatDate('1943-12-24')).toBe('24. Dezember 1943');
|
||||
});
|
||||
|
||||
it('formats long date with German month name', () => {
|
||||
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
|
||||
});
|
||||
|
||||
it('formats short date as dd.mm.yyyy', () => {
|
||||
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
|
||||
});
|
||||
|
||||
it('does not shift Dec 31 to Jan 1 (T12:00:00 UTC guard)', () => {
|
||||
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isoToGerman ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isoToGerman', () => {
|
||||
it('converts a valid ISO date to DD.MM.YYYY', () => {
|
||||
expect(isoToGerman('2024-12-20')).toBe('20.12.2024');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(isoToGerman('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for invalid format', () => {
|
||||
expect(isoToGerman('not-a-date')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── germanToIso ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('germanToIso', () => {
|
||||
it('converts DD.MM.YYYY to ISO', () => {
|
||||
expect(germanToIso('20.12.2024')).toBe('2024-12-20');
|
||||
});
|
||||
|
||||
it('returns empty string for partial input', () => {
|
||||
expect(germanToIso('20.12')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(germanToIso('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatGermanDateInput ────────────────────────────────────────────────────
|
||||
|
||||
describe('formatGermanDateInput – digit stream (no dots typed)', () => {
|
||||
it('leaves 1–2 digits as-is', () => {
|
||||
expect(formatGermanDateInput('2')).toBe('2');
|
||||
expect(formatGermanDateInput('20')).toBe('20');
|
||||
});
|
||||
|
||||
it('auto-inserts dot after 2 digits for 3–4 digit input', () => {
|
||||
expect(formatGermanDateInput('201')).toBe('20.1');
|
||||
expect(formatGermanDateInput('2012')).toBe('20.12');
|
||||
});
|
||||
|
||||
it('auto-inserts two dots for 5–8 digit input', () => {
|
||||
expect(formatGermanDateInput('20121')).toBe('20.12.1');
|
||||
expect(formatGermanDateInput('20122024')).toBe('20.12.2024');
|
||||
});
|
||||
|
||||
it('ignores digits beyond 8', () => {
|
||||
expect(formatGermanDateInput('201220249')).toBe('20.12.2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGermanDateInput – manual dot entry with padding', () => {
|
||||
it('pads single-digit day to 2 digits when dot is typed after it', () => {
|
||||
expect(formatGermanDateInput('3.')).toBe('03.');
|
||||
});
|
||||
|
||||
it('does not pad a 2-digit day', () => {
|
||||
expect(formatGermanDateInput('03.')).toBe('03.');
|
||||
expect(formatGermanDateInput('20.')).toBe('20.');
|
||||
});
|
||||
|
||||
it('pads single-digit month to 2 digits when dot is typed after it', () => {
|
||||
expect(formatGermanDateInput('03.3.')).toBe('03.03.');
|
||||
});
|
||||
|
||||
it('does not pad a 2-digit month', () => {
|
||||
expect(formatGermanDateInput('03.12.')).toBe('03.12.');
|
||||
});
|
||||
|
||||
it('pads both day and month in a fully typed date', () => {
|
||||
expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012');
|
||||
});
|
||||
|
||||
it('pads only day when month is already 2 digits', () => {
|
||||
expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024');
|
||||
});
|
||||
|
||||
it('pads only month when day is already 2 digits', () => {
|
||||
expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024');
|
||||
});
|
||||
|
||||
it('handles a complete date entered with manual dots and no padding needed', () => {
|
||||
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
|
||||
});
|
||||
|
||||
it('overflows excess day digits into month when dot follows', () => {
|
||||
expect(formatGermanDateInput('123.')).toBe('12.3');
|
||||
});
|
||||
|
||||
it('caps year digits at 4', () => {
|
||||
expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024');
|
||||
});
|
||||
|
||||
it('overflows excess month digits into year (digit stream then continue typing)', () => {
|
||||
// User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122"
|
||||
expect(formatGermanDateInput('20.122')).toBe('20.12.2');
|
||||
});
|
||||
|
||||
it('continues building year after overflow', () => {
|
||||
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/shared/utils/date.ts
Normal file
123
frontend/src/lib/shared/utils/date.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Format an ISO date string (YYYY-MM-DD) for display.
|
||||
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
|
||||
* Defaults to 'long' (e.g. "24. Dezember 1943"); pass 'short' for DD.MM.YYYY.
|
||||
*/
|
||||
export function formatDate(isoDate: string, format: 'short' | 'long' = 'long'): string {
|
||||
const date = new Date(isoDate + 'T12:00:00');
|
||||
if (format === 'short') {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string for medium-length display (e.g. "15. Jun. 1920").
|
||||
* Uses T12:00:00 to avoid UTC timezone off-by-one.
|
||||
* Pass an explicit BCP 47 locale tag to respect the app locale; defaults to 'de-DE'.
|
||||
*/
|
||||
export function formatMCDate(isoDate: string, locale: string = 'de-DE'): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(isoDate + 'T12:00:00'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function isoToGerman(iso: string): string {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
const [, d, m, y] = match;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw date string into German DD.MM.YYYY format.
|
||||
*
|
||||
* Handles two modes:
|
||||
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
|
||||
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
|
||||
* and overflows extra digits from day→month and month→year
|
||||
*/
|
||||
export function formatGermanDateInput(raw: string): string {
|
||||
if (!raw.includes('.')) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 8);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
const trailingDot = raw.endsWith('.');
|
||||
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
|
||||
|
||||
let day = parts[0] ?? '';
|
||||
let month = parts[1] ?? '';
|
||||
let year = parts[2] ?? '';
|
||||
|
||||
let dayOverflowed = false;
|
||||
if (day.length > 2) {
|
||||
month = day.slice(2) + month;
|
||||
day = day.slice(0, 2);
|
||||
dayOverflowed = true;
|
||||
}
|
||||
|
||||
let monthOverflowed = false;
|
||||
if (month.length > 2) {
|
||||
year = month.slice(2) + year;
|
||||
month = month.slice(0, 2);
|
||||
monthOverflowed = true;
|
||||
}
|
||||
|
||||
year = year.slice(0, 4);
|
||||
|
||||
const afterDay = !dayOverflowed && parts.length >= 2;
|
||||
|
||||
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
|
||||
day = '0' + day;
|
||||
}
|
||||
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
|
||||
month = '0' + month;
|
||||
}
|
||||
|
||||
if (year) return `${day}.${month}.${year}`;
|
||||
if (month) {
|
||||
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
|
||||
return `${day}.${month}${dot2}`;
|
||||
}
|
||||
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
|
||||
return `${day}${dot1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a date input event for German-format date fields (DD.MM.YYYY).
|
||||
* Strips non-digits, formats with dots, mutates the input's displayed value,
|
||||
* and returns the display string and its ISO equivalent.
|
||||
*/
|
||||
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const display = formatGermanDateInput(input.value);
|
||||
input.value = display;
|
||||
return { display, iso: germanToIso(display) };
|
||||
}
|
||||
69
frontend/src/lib/shared/utils/debounce.spec.ts
Normal file
69
frontend/src/lib/shared/utils/debounce.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { debounce } from './debounce';
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not fire before the delay has elapsed', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 200);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(199);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires exactly once after the delay', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 200);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the timer on each call — fires only once after inactivity', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 200);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(100);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(100);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes the latest arguments to the callback', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 200);
|
||||
|
||||
debounced('first');
|
||||
debounced('second');
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
it('can fire again after the first invocation settles', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 200);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/shared/utils/debounce.ts
Normal file
12
frontend/src/lib/shared/utils/debounce.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Returns a debounced version of fn that delays invocation until after
|
||||
* `delay` ms have elapsed since the last call.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return ((...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
}) as T;
|
||||
}
|
||||
152
frontend/src/lib/shared/utils/deepLinkScroll.spec.ts
Normal file
152
frontend/src/lib/shared/utils/deepLinkScroll.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll';
|
||||
|
||||
const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111';
|
||||
const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222';
|
||||
|
||||
function fakeElement() {
|
||||
return {
|
||||
scrollIntoView: vi.fn(),
|
||||
focus: vi.fn()
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
type Overrides = Partial<DeepLinkScrollOptions>;
|
||||
|
||||
function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions {
|
||||
const el = overrides.getElement ? null : fakeElement();
|
||||
return {
|
||||
transcribeMode: true,
|
||||
setTranscribeMode: vi.fn(),
|
||||
setPanelMode: vi.fn(),
|
||||
loadBlocks: vi.fn().mockResolvedValue(undefined),
|
||||
setActiveAnnotationId: vi.fn(),
|
||||
flashAnnotation: vi.fn(),
|
||||
prefersReducedMotion: false,
|
||||
afterTick: vi.fn().mockResolvedValue(undefined),
|
||||
getElement: vi.fn().mockReturnValue(el),
|
||||
onStripUrl: vi.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('scrollToCommentFromQuery', () => {
|
||||
it('is a no-op when commentId query param is absent', async () => {
|
||||
const url = new URL('https://app/documents/doc-1');
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
|
||||
expect(opts.getElement).not.toHaveBeenCalled();
|
||||
expect(opts.onStripUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when annotationId query param is absent even if commentId is present', async () => {
|
||||
const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`);
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
|
||||
expect(opts.getElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls to the comment element and focuses it when both params are present', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const el = fakeElement();
|
||||
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`);
|
||||
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||
expect(el.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('triggers the annotation flash after scrolling', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const el = fakeElement();
|
||||
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID);
|
||||
});
|
||||
|
||||
it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts({ transcribeMode: false });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setTranscribeMode).toHaveBeenCalledWith(true);
|
||||
expect(opts.loadBlocks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a graceful no-op when the target element is not in the DOM', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) });
|
||||
|
||||
// Must not throw. Flash should not fire — nothing to highlight.
|
||||
await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined();
|
||||
|
||||
expect(opts.flashAnnotation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses behavior "instant" when prefers-reduced-motion is set', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const el = fakeElement();
|
||||
const opts = buildOpts({
|
||||
prefersReducedMotion: true,
|
||||
getElement: vi.fn().mockReturnValue(el)
|
||||
});
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' });
|
||||
});
|
||||
|
||||
it('strips both commentId and annotationId from the URL after handling', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.onStripUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces panel mode to "edit" so the comment DOM exists on reviewed documents', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setPanelMode).toHaveBeenCalledWith('edit');
|
||||
});
|
||||
|
||||
it('forces panel mode to "edit" even when transcribe mode is already on', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts({ transcribeMode: true });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setPanelMode).toHaveBeenCalledWith('edit');
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/shared/utils/deepLinkScroll.ts
Normal file
46
frontend/src/lib/shared/utils/deepLinkScroll.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type DeepLinkScrollOptions = {
|
||||
transcribeMode: boolean;
|
||||
setTranscribeMode: (value: boolean) => void;
|
||||
setPanelMode: (mode: 'read' | 'edit') => void;
|
||||
loadBlocks: () => Promise<void>;
|
||||
setActiveAnnotationId: (id: string) => void;
|
||||
flashAnnotation: (annotationId: string) => void;
|
||||
prefersReducedMotion: boolean;
|
||||
afterTick: () => Promise<void>;
|
||||
getElement: (id: string) => HTMLElement | null;
|
||||
onStripUrl: () => void;
|
||||
};
|
||||
|
||||
export async function scrollToCommentFromQuery(
|
||||
url: URL,
|
||||
opts: DeepLinkScrollOptions
|
||||
): Promise<void> {
|
||||
const commentId = url.searchParams.get('commentId');
|
||||
if (!commentId) return;
|
||||
|
||||
const annotationId = url.searchParams.get('annotationId');
|
||||
if (!annotationId) return;
|
||||
|
||||
if (!opts.transcribeMode) {
|
||||
opts.setTranscribeMode(true);
|
||||
await opts.loadBlocks();
|
||||
}
|
||||
|
||||
// Comments only render in edit mode — force it so the deep-link target
|
||||
// exists in the DOM even if the document already has reviewed transcriptions
|
||||
// (which default the panel to read mode).
|
||||
opts.setPanelMode('edit');
|
||||
|
||||
opts.setActiveAnnotationId(annotationId);
|
||||
await opts.afterTick();
|
||||
|
||||
const el = opts.getElement(`comment-${commentId}`);
|
||||
if (el) {
|
||||
const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth';
|
||||
el.scrollIntoView({ behavior, block: 'center' });
|
||||
el.focus({ preventScroll: true });
|
||||
opts.flashAnnotation(annotationId);
|
||||
}
|
||||
|
||||
opts.onStripUrl();
|
||||
}
|
||||
67
frontend/src/lib/shared/utils/extractText.spec.ts
Normal file
67
frontend/src/lib/shared/utils/extractText.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractText, plainExcerpt } from './extractText';
|
||||
|
||||
describe('extractText', () => {
|
||||
it('returns empty string for null/undefined/empty', () => {
|
||||
expect(extractText(null)).toBe('');
|
||||
expect(extractText(undefined)).toBe('');
|
||||
expect(extractText('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips tags and preserves visible text', () => {
|
||||
expect(extractText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('collapses whitespace within and between blocks', () => {
|
||||
expect(extractText('<p>One</p><p>Two</p>')).toBe('OneTwo');
|
||||
expect(extractText('<p>foo bar</p>')).toBe('foo bar');
|
||||
});
|
||||
|
||||
// XSS-shaped inputs: extractText must NOT execute, render, or expose the
|
||||
// payload as HTML. It is only required to return *some* string. The fact
|
||||
// that it exists is documented as a non-sanitiser; these tests prevent
|
||||
// silent regressions where the function might somehow leak a tag.
|
||||
describe('XSS-shaped input — never re-emits markup, even though this is not a sanitiser', () => {
|
||||
it('drops <script> and surfaces only its text content', () => {
|
||||
const out = extractText('<p>ok</p><script>alert(1)</script>');
|
||||
expect(out).not.toContain('<script>');
|
||||
expect(out).not.toContain('</script>');
|
||||
});
|
||||
|
||||
it('drops <svg/onload> markup', () => {
|
||||
const out = extractText('<svg/onload=alert(1)>');
|
||||
expect(out).not.toContain('<svg');
|
||||
expect(out).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('drops <iframe srcdoc=…> markup', () => {
|
||||
const out = extractText('<iframe srcdoc="<script>alert(1)</script>">');
|
||||
expect(out).not.toContain('<iframe');
|
||||
expect(out).not.toContain('srcdoc');
|
||||
});
|
||||
|
||||
it('drops <a href="javascript:…"> tag (text content may remain)', () => {
|
||||
const out = extractText('<a href="javascript:alert(1)">click</a>');
|
||||
expect(out).not.toContain('<a ');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainExcerpt', () => {
|
||||
it('returns full text when under the limit', () => {
|
||||
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
||||
});
|
||||
|
||||
it('truncates at the boundary with an ellipsis', () => {
|
||||
const html = '<p>' + 'a'.repeat(100) + '</p>';
|
||||
const out = plainExcerpt(html, 20);
|
||||
expect(out.length).toBeLessThanOrEqual(21);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('breaks at a word boundary when possible', () => {
|
||||
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
|
||||
expect(out).toBe('The quick brown…');
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/shared/utils/extractText.ts
Normal file
38
frontend/src/lib/shared/utils/extractText.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* **Not a sanitizer.** This module extracts visible text from a (presumed
|
||||
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
|
||||
* because the Geschichte body is sanitised against the OWASP allow-list
|
||||
* on the server before persistence, and via DOMPurify on render.
|
||||
*
|
||||
* Do not use these helpers to defend against XSS — `safeHtml()` in
|
||||
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
|
||||
* untrusted input that has not been sanitised does not protect against
|
||||
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip tags and return plain text. Uses DOMParser in the browser; on the
|
||||
* server it falls back to a regex that drops angle-bracket sequences.
|
||||
* The fallback is **not** a sanitiser — see module docstring.
|
||||
*/
|
||||
export function extractText(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
if (typeof DOMParser === 'function') {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip tags then truncate to `max` chars on a word boundary, appending an
|
||||
* ellipsis when truncated. Used for editorial story excerpts.
|
||||
*/
|
||||
export function plainExcerpt(html: string | null | undefined, max = 80): string {
|
||||
const text = extractText(html);
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
|
||||
}
|
||||
111
frontend/src/lib/shared/utils/hoverCardPosition.spec.ts
Normal file
111
frontend/src/lib/shared/utils/hoverCardPosition.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeHoverCardPosition,
|
||||
CARD_WIDTH_PX,
|
||||
CARD_HEIGHT_PX,
|
||||
CARD_GAP_PX,
|
||||
BOTTOM_BAND_RATIO,
|
||||
RIGHT_FLIP_THRESHOLD_PX
|
||||
} from './hoverCardPosition';
|
||||
|
||||
const makeRect = (overrides: Partial<DOMRect> = {}): DOMRect => {
|
||||
const base = { top: 100, left: 200, bottom: 120, right: 300, width: 100, height: 20 };
|
||||
const merged = { ...base, ...overrides };
|
||||
return {
|
||||
...merged,
|
||||
x: merged.left,
|
||||
y: merged.top,
|
||||
toJSON: () => merged
|
||||
} as DOMRect;
|
||||
};
|
||||
|
||||
const vp = { viewportWidth: 1440, viewportHeight: 900 };
|
||||
|
||||
describe('computeHoverCardPosition', () => {
|
||||
it('exports the spec constants used by the spec/CSS layer', () => {
|
||||
// Pin the values the design spec calls out — if these drift, the design spec
|
||||
// in #5329 needs to drift with them. Felix's PR review #2 (named constants).
|
||||
expect(CARD_WIDTH_PX).toBe(320);
|
||||
expect(CARD_HEIGHT_PX).toBe(180);
|
||||
expect(CARD_GAP_PX).toBe(6);
|
||||
expect(BOTTOM_BAND_RATIO).toBe(0.7);
|
||||
expect(RIGHT_FLIP_THRESHOLD_PX).toBe(300);
|
||||
});
|
||||
|
||||
describe('default placement (below-right)', () => {
|
||||
it('positions the card below the rect with a small gap', () => {
|
||||
const rect = makeRect({ top: 100, bottom: 120, left: 200 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.top).toBe(120 + CARD_GAP_PX);
|
||||
expect(result.left).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flip-up rule (Leonie #5329)', () => {
|
||||
it('flips up when the card would overflow the bottom edge', () => {
|
||||
// Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below
|
||||
const rect = makeRect({ top: 800, bottom: 850 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
||||
});
|
||||
|
||||
it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => {
|
||||
// rect.top is at 80% of viewport — fits below numerically, but poor UX
|
||||
const rect = makeRect({ top: 720, bottom: 740 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flip-left rule', () => {
|
||||
it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => {
|
||||
// vw - rect.left = 1440 - 1200 = 240 < 300, so flip
|
||||
const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 });
|
||||
const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 });
|
||||
// left = right - CARD_WIDTH = 1300 - 320 = 980
|
||||
expect(result.left).toBe(980);
|
||||
});
|
||||
|
||||
it('does not flip left when the rect has plenty of right-side room', () => {
|
||||
// vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip
|
||||
const rect = makeRect({ left: 200, right: 300 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.left).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewport clamping (Leonie FINDING-05)', () => {
|
||||
it('clamps left so the card never overflows the right edge', () => {
|
||||
// On a 320px viewport, even with flip the card width equals the viewport.
|
||||
// Without clamping the card would be at left=0 but extend to 320 — fine.
|
||||
// At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0.
|
||||
const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 });
|
||||
const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 });
|
||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||
expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('never returns a negative top or left', () => {
|
||||
const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.top).toBeGreaterThanOrEqual(0);
|
||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('position: fixed (viewport-relative coordinates)', () => {
|
||||
it('returns viewport-relative top — does not add scroll offset', () => {
|
||||
// getBoundingClientRect values are already viewport-relative; with position:fixed
|
||||
// we use them directly without adding scrollY.
|
||||
const rect = makeRect({ top: 100, bottom: 120 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.top).toBe(120 + CARD_GAP_PX);
|
||||
});
|
||||
|
||||
it('returns viewport-relative left — does not add scroll offset', () => {
|
||||
const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 });
|
||||
const result = computeHoverCardPosition(rect, vp);
|
||||
expect(result.left).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
frontend/src/lib/shared/utils/hoverCardPosition.ts
Normal file
67
frontend/src/lib/shared/utils/hoverCardPosition.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Pure positioning logic for the person-mention hover card.
|
||||
*
|
||||
* Pulled out of TranscriptionReadView so the four placement branches
|
||||
* (default, flip-up, flip-left, both) plus the viewport clamp are unit-testable
|
||||
* without DOM. Sara's PR-B2 review #6 (no test for computeCardPosition) and
|
||||
* Leonie's FINDING-05 (320px overflow) both land here.
|
||||
*/
|
||||
|
||||
/** Width of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
|
||||
export const CARD_WIDTH_PX = 320;
|
||||
|
||||
/** Min-height of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
|
||||
export const CARD_HEIGHT_PX = 180;
|
||||
|
||||
/** Gap between the mention rect and the card so they do not touch. */
|
||||
export const CARD_GAP_PX = 6;
|
||||
|
||||
/**
|
||||
* Mentions in the bottom 30% of the viewport flip the card up by default,
|
||||
* even if it would numerically fit below — keeping the eye-line stable
|
||||
* is more important than minimal travel (Leonie #5329).
|
||||
*/
|
||||
export const BOTTOM_BAND_RATIO = 0.7;
|
||||
|
||||
/**
|
||||
* Mentions within this distance of the right viewport edge flip the card
|
||||
* left so it stays fully visible.
|
||||
*/
|
||||
export const RIGHT_FLIP_THRESHOLD_PX = 300;
|
||||
|
||||
export type Viewport = {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
};
|
||||
|
||||
export type CardPosition = { top: number; left: number };
|
||||
|
||||
/**
|
||||
* Compute absolute-positioned top/left for the hover card, given a rect for
|
||||
* the mention anchor and the current viewport. Output is in document
|
||||
* coordinates (already includes scroll offsets).
|
||||
*/
|
||||
export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosition {
|
||||
let top = rect.bottom + CARD_GAP_PX;
|
||||
let left = rect.left;
|
||||
|
||||
const overflowsBottom = vp.viewportHeight - rect.bottom < CARD_HEIGHT_PX + CARD_GAP_PX;
|
||||
const inBottomBand = rect.top > vp.viewportHeight * BOTTOM_BAND_RATIO;
|
||||
if (overflowsBottom || inBottomBand) {
|
||||
top = rect.top - CARD_HEIGHT_PX - CARD_GAP_PX;
|
||||
}
|
||||
|
||||
if (vp.viewportWidth - rect.left < RIGHT_FLIP_THRESHOLD_PX) {
|
||||
left = rect.right - CARD_WIDTH_PX;
|
||||
}
|
||||
|
||||
// Clamp left so the card never extends past the right viewport edge
|
||||
// (FINDING-05: at 320px viewport the flip would otherwise produce a
|
||||
// negative left or right-side overflow).
|
||||
left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX);
|
||||
|
||||
return {
|
||||
top: Math.max(0, top),
|
||||
left: Math.max(0, left)
|
||||
};
|
||||
}
|
||||
36
frontend/src/lib/shared/utils/requiredFields.test.ts
Normal file
36
frontend/src/lib/shared/utils/requiredFields.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { countRequiredFilled } from './requiredFields';
|
||||
|
||||
describe('countRequiredFilled', () => {
|
||||
it('returns 0 when all three fields are empty', () => {
|
||||
expect(countRequiredFilled('', '', '')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 when only title is set', () => {
|
||||
expect(countRequiredFilled('Ein Brief', '', '')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when only dateIso is set', () => {
|
||||
expect(countRequiredFilled('', '1920-05-01', '')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when only senderId is set', () => {
|
||||
expect(countRequiredFilled('', '', 'person-uuid')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 when title and dateIso are set', () => {
|
||||
expect(countRequiredFilled('Ein Brief', '1920-05-01', '')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 2 when title and senderId are set', () => {
|
||||
expect(countRequiredFilled('Ein Brief', '', 'person-uuid')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 2 when dateIso and senderId are set', () => {
|
||||
expect(countRequiredFilled('', '1920-05-01', 'person-uuid')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 3 when all three fields are set', () => {
|
||||
expect(countRequiredFilled('Ein Brief', '1920-05-01', 'person-uuid')).toBe(3);
|
||||
});
|
||||
});
|
||||
3
frontend/src/lib/shared/utils/requiredFields.ts
Normal file
3
frontend/src/lib/shared/utils/requiredFields.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function countRequiredFilled(title: string, dateIso: string, senderId: string): number {
|
||||
return [title, dateIso, senderId].filter(Boolean).length;
|
||||
}
|
||||
47
frontend/src/lib/shared/utils/sanitize.spec.ts
Normal file
47
frontend/src/lib/shared/utils/sanitize.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeHtml } from './sanitize';
|
||||
|
||||
describe('safeHtml', () => {
|
||||
it('returns empty string for null/undefined/empty input', () => {
|
||||
expect(safeHtml(null)).toBe('');
|
||||
expect(safeHtml(undefined)).toBe('');
|
||||
expect(safeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
|
||||
const html =
|
||||
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
|
||||
'<h2>H2</h2><h3>H3</h3>' +
|
||||
'<ul><li>a</li></ul><ol><li>b</li></ol>';
|
||||
const result = safeHtml(html);
|
||||
expect(result).toContain('<strong>bold</strong>');
|
||||
expect(result).toContain('<em>italic</em>');
|
||||
expect(result).toContain('<br>');
|
||||
expect(result).toContain('<h2>H2</h2>');
|
||||
expect(result).toContain('<h3>H3</h3>');
|
||||
expect(result).toContain('<ul>');
|
||||
expect(result).toContain('<ol>');
|
||||
expect(result).toContain('<li>a</li>');
|
||||
});
|
||||
|
||||
it('strips <script> tags entirely', () => {
|
||||
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).not.toContain('alert');
|
||||
expect(result).toContain('<p>ok</p>');
|
||||
});
|
||||
|
||||
it('strips on* event-handler attributes', () => {
|
||||
const result = safeHtml('<p onclick="evil()">x</p>');
|
||||
expect(result).not.toContain('onclick');
|
||||
});
|
||||
|
||||
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
|
||||
const result = safeHtml(
|
||||
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
|
||||
);
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain('<iframe');
|
||||
});
|
||||
});
|
||||
17
frontend/src/lib/shared/utils/sanitize.ts
Normal file
17
frontend/src/lib/shared/utils/sanitize.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
|
||||
|
||||
/**
|
||||
* Render-side sanitiser for Geschichte body HTML. The backend already
|
||||
* sanitises with the OWASP allow-list on save, but we re-run on render
|
||||
* because the API can be called directly and stored content can pre-date
|
||||
* a tightening of the allow-list.
|
||||
*/
|
||||
export function safeHtml(raw: string | null | undefined): string {
|
||||
if (!raw) return '';
|
||||
return DOMPurify.sanitize(raw, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: []
|
||||
});
|
||||
}
|
||||
44
frontend/src/lib/shared/utils/sort.spec.ts
Normal file
44
frontend/src/lib/shared/utils/sort.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sortDocumentsByDate } from './sort';
|
||||
|
||||
const doc = (id: string, documentDate: string | null) =>
|
||||
({ id, documentDate }) as { id: string; documentDate: string | null };
|
||||
|
||||
describe('sortDocumentsByDate', () => {
|
||||
it('sorts DESC by default — newest first', () => {
|
||||
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
|
||||
const result = sortDocumentsByDate(docs, 'DESC');
|
||||
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('sorts ASC — oldest first', () => {
|
||||
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
|
||||
const result = sortDocumentsByDate(docs, 'ASC');
|
||||
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
it('places documents without a date last in DESC', () => {
|
||||
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
|
||||
const result = sortDocumentsByDate(docs, 'DESC');
|
||||
expect(result[0].id).toBe('b');
|
||||
expect(result.slice(1).map((d) => d.id)).toContain('a');
|
||||
expect(result.slice(1).map((d) => d.id)).toContain('c');
|
||||
});
|
||||
|
||||
it('places documents without a date last in ASC', () => {
|
||||
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
|
||||
const result = sortDocumentsByDate(docs, 'ASC');
|
||||
expect(result[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
|
||||
const original = [...docs];
|
||||
sortDocumentsByDate(docs, 'ASC');
|
||||
expect(docs).toEqual(original);
|
||||
});
|
||||
|
||||
it('returns an empty array unchanged', () => {
|
||||
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
|
||||
});
|
||||
});
|
||||
19
frontend/src/lib/shared/utils/sort.ts
Normal file
19
frontend/src/lib/shared/utils/sort.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type SortDir = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* Returns a new array of documents sorted by documentDate.
|
||||
* Documents without a date are always placed last, regardless of direction.
|
||||
*/
|
||||
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
|
||||
docs: T[],
|
||||
dir: SortDir
|
||||
): T[] {
|
||||
return [...docs].sort((a, b) => {
|
||||
const da = a.documentDate ?? '';
|
||||
const db = b.documentDate ?? '';
|
||||
if (!da && !db) return 0;
|
||||
if (!da) return 1;
|
||||
if (!db) return -1;
|
||||
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
|
||||
});
|
||||
}
|
||||
52
frontend/src/lib/shared/utils/time.spec.ts
Normal file
52
frontend/src/lib/shared/utils/time.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
const { relativeTime } = await import('./time');
|
||||
|
||||
function msAgo(ms: number, now: Date): string {
|
||||
return new Date(now.getTime() - ms).toISOString();
|
||||
}
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const now = new Date('2024-06-15T12:00:00.000Z');
|
||||
|
||||
it('returns "just now" for timestamps under 60 seconds ago', () => {
|
||||
const ts = msAgo(30_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_just_now());
|
||||
});
|
||||
|
||||
it('returns 1-minute label for exactly 1 minute ago', () => {
|
||||
const ts = msAgo(60_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 1 }));
|
||||
});
|
||||
|
||||
it('returns 59-minute label for exactly 59 minutes ago', () => {
|
||||
const ts = msAgo(59 * 60_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 59 }));
|
||||
});
|
||||
|
||||
it('returns 1-hour label for exactly 1 hour ago', () => {
|
||||
const ts = msAgo(60 * 60_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 1 }));
|
||||
});
|
||||
|
||||
it('returns 23-hour label for 23 hours ago', () => {
|
||||
const ts = msAgo(23 * 60 * 60_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 23 }));
|
||||
});
|
||||
|
||||
it('returns 1-day label for exactly 24 hours ago', () => {
|
||||
const ts = msAgo(24 * 60 * 60_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 1 }));
|
||||
});
|
||||
|
||||
it('returns 6-day label for 6 days ago', () => {
|
||||
const ts = msAgo(6 * 24 * 60 * 60_000, now);
|
||||
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 6 }));
|
||||
});
|
||||
|
||||
it('defaults now to current time when omitted', () => {
|
||||
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
expect(relativeTime(ts)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/shared/utils/time.ts
Normal file
12
frontend/src/lib/shared/utils/time.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export function relativeTime(isoString: string, now: Date = new Date()): string {
|
||||
const diff = now.getTime() - new Date(isoString).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return m.comment_time_just_now();
|
||||
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return m.comment_time_hours({ count: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return m.comment_time_days({ count: days });
|
||||
}
|
||||
Reference in New Issue
Block a user