Seit dem Wegfall der Altersgrenze lieferten BA und StepStone nur die relevantesten Anzeigen aller Jahre - fünf Läufe lang 0 neue Treffer. - BA: erst veroeffentlichtseit=7 (bis 200), dann ohne Grenze (bis 200) - StepStone: sort=2 Seiten 1-2 plus Relevanz Seite 1 (action=sort_publish hatte keine Wirkung) - Dashboard liest DB-Zeitstempel als UTC (vorher 2 h zu früh) Erster Lauf danach: 481 statt 298 Anzeigen, 40 neue Treffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
7.1 KiB
TypeScript
200 lines
7.1 KiB
TypeScript
import React from 'react';
|
||
import type { ScannerMatchStatus, ScannerSourceId } from '../../lib/api';
|
||
|
||
/* Gemeinsame Beschriftungen und Bausteine von Dashboard und Suchagenten. */
|
||
|
||
export const SOURCE_LABELS: Record<ScannerSourceId, string> = {
|
||
ba: 'Agentur für Arbeit',
|
||
stepstone: 'StepStone',
|
||
karriere: 'karriere.at',
|
||
adzuna: 'Adzuna',
|
||
freelancermap: 'freelancermap',
|
||
};
|
||
|
||
export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', 'freelancermap'];
|
||
|
||
export const STATUS_LABELS: Record<ScannerMatchStatus, string> = {
|
||
neu: 'Neu',
|
||
gesehen: 'Gesehen',
|
||
in_bearbeitung: 'In Bearbeitung',
|
||
verworfen: 'Verworfen',
|
||
};
|
||
|
||
export const STATUS_TONES: Record<ScannerMatchStatus, string> = {
|
||
neu: 'border-sky-200 bg-sky-50 text-sky-700',
|
||
gesehen: 'border-amber-200 bg-amber-50 text-amber-700',
|
||
in_bearbeitung: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||
verworfen: 'border-ink-200 bg-ink-50 text-ink-500',
|
||
};
|
||
|
||
export const StatusBadge: React.FC<{ status: ScannerMatchStatus }> = ({ status }) => (
|
||
<span
|
||
className={`inline-flex rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide ${STATUS_TONES[status]}`}
|
||
>
|
||
{STATUS_LABELS[status]}
|
||
</span>
|
||
);
|
||
|
||
/** Quadrat mit der Trefferquote - Farbe nach Güte. */
|
||
export const ScoreBox: React.FC<{ score: number }> = ({ score }) => {
|
||
const tone =
|
||
score >= 75
|
||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||
: score >= 50
|
||
? 'border-amber-500 bg-amber-50 text-amber-700'
|
||
: 'border-ink-300 bg-ink-50 text-ink-600';
|
||
return (
|
||
<span
|
||
className={`inline-flex h-9 w-11 items-center justify-center rounded-md border-2 text-xs font-bold tabular-nums ${tone}`}
|
||
>
|
||
{score}%
|
||
</span>
|
||
);
|
||
};
|
||
|
||
/*
|
||
* Zeitstempel aus der Datenbank ("2026-09-29 11:18:31") sind UTC - Server und
|
||
* MariaDB laufen in UTC. Ohne das "Z" las der Browser sie als Ortszeit und
|
||
* zeigte Funde zwei Stunden zu früh an (13:18 erschien als 11:18).
|
||
*/
|
||
const parseDbDate = (value: string) =>
|
||
new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value.replace(' ', 'T')}Z`);
|
||
|
||
export const formatDateTime = (value: string | null) => {
|
||
if (!value) return '–';
|
||
const date = parseDbDate(value);
|
||
if (Number.isNaN(date.getTime())) return '–';
|
||
return date.toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
/** "vor 3 Tagen" - fürs Alter einer Anzeige genauer als ein Datum. */
|
||
export const formatAge = (value: string | null) => {
|
||
if (!value) return 'unbekannt';
|
||
const date = parseDbDate(value);
|
||
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
|
||
if (Number.isNaN(days)) return 'unbekannt';
|
||
if (days <= 0) return 'heute';
|
||
if (days === 1) return 'gestern';
|
||
return `vor ${days} Tagen`;
|
||
};
|
||
|
||
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||
|
||
const BELL_ICON =
|
||
'M12 22a2.5 2.5 0 0 0 2.5-2.5h-5A2.5 2.5 0 0 0 12 22Zm7-5V11a7 7 0 0 0-5.5-6.8V3a1.5 1.5 0 0 0-3 0v1.2A7 7 0 0 0 5 11v6l-2 2v1h18v-1l-2-2Z';
|
||
|
||
/** Glocke für Treffer mit Alarm-Schlagwort. */
|
||
export const AlarmBell: React.FC<{ className?: string }> = ({ className = 'h-4 w-4' }) => (
|
||
<span title="Alarm-Schlagwort" className="inline-flex text-red-600">
|
||
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden="true">
|
||
<path d={BELL_ICON} />
|
||
</svg>
|
||
</span>
|
||
);
|
||
|
||
/**
|
||
* Kurzer Zweiklang über WebAudio - ohne Audiodatei.
|
||
*
|
||
* Browser spielen Ton erst, nachdem auf der Seite einmal geklickt oder getippt
|
||
* wurde (Autoplay-Regel). Klappt es nicht, bleibt es still; das Pop-up kommt
|
||
* trotzdem.
|
||
*/
|
||
export function playAlarmSound() {
|
||
try {
|
||
const AudioContextClass =
|
||
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||
if (!AudioContextClass) return;
|
||
const context = new AudioContextClass();
|
||
const tone = (frequency: number, start: number) => {
|
||
const oscillator = context.createOscillator();
|
||
const gain = context.createGain();
|
||
oscillator.type = 'sine';
|
||
oscillator.frequency.value = frequency;
|
||
gain.gain.setValueAtTime(0.0001, context.currentTime + start);
|
||
gain.gain.exponentialRampToValueAtTime(0.3, context.currentTime + start + 0.02);
|
||
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + start + 0.35);
|
||
oscillator.connect(gain).connect(context.destination);
|
||
oscillator.start(context.currentTime + start);
|
||
oscillator.stop(context.currentTime + start + 0.4);
|
||
};
|
||
tone(880, 0);
|
||
tone(1175, 0.18);
|
||
// Nicht geschlossene Kontexte zählen gegen das Browserlimit.
|
||
setTimeout(() => void context.close().catch(() => {}), 1000);
|
||
} catch {
|
||
/* kein Ton möglich - das Pop-up reicht */
|
||
}
|
||
}
|
||
|
||
/** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
|
||
export const TermInput: React.FC<{
|
||
value: string[];
|
||
onChange: (value: string[]) => void;
|
||
placeholder?: string;
|
||
tone?: 'brand' | 'red' | 'alarm';
|
||
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
|
||
const [draft, setDraft] = React.useState('');
|
||
|
||
const add = (text: string) => {
|
||
// Anführungszeichen gehören nicht zum Begriff ("Interim" -> Interim).
|
||
const terms = text
|
||
.split(',')
|
||
.map((term) => term.trim().replace(/^["'„“”‚‘’«»]+|["'„“”‚‘’«»]+$/g, '').trim())
|
||
.filter(Boolean)
|
||
.filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase()));
|
||
if (terms.length) onChange([...value, ...terms]);
|
||
setDraft('');
|
||
};
|
||
|
||
const chip =
|
||
tone === 'red'
|
||
? 'border-red-200 bg-red-50 text-red-700'
|
||
: tone === 'alarm'
|
||
? 'border-amber-300 bg-amber-50 text-amber-800'
|
||
: 'border-brand-200 bg-brand-50 text-brand-700';
|
||
|
||
return (
|
||
<div className="flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-ink-200 bg-surface px-2 py-1.5">
|
||
{value.map((term) => (
|
||
<span key={term} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${chip}`}>
|
||
{tone === 'alarm' && <AlarmBell className="h-3 w-3" />}
|
||
{term}
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(value.filter((item) => item !== term))}
|
||
className="opacity-60 hover:opacity-100"
|
||
aria-label={`${term} entfernen`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<input
|
||
value={draft}
|
||
onChange={(event) => {
|
||
const text = event.target.value;
|
||
if (text.includes(',')) add(text);
|
||
else setDraft(text);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
add(draft);
|
||
} else if (event.key === 'Backspace' && !draft && value.length) {
|
||
onChange(value.slice(0, -1));
|
||
}
|
||
}}
|
||
onBlur={() => draft.trim() && add(draft)}
|
||
placeholder={value.length ? '' : placeholder}
|
||
className="min-w-[140px] flex-1 border-0 bg-transparent px-1 py-0.5 text-sm text-ink-900 placeholder:text-ink-400 focus:outline-none focus:ring-0"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|