Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter

- Suche ohne 21-Tage-Grenze (BA ohne veroeffentlichtseit, Adzuna ohne
  max_days_old); Einstellung Höchstalter entfällt
- Dashboard: Filter Anzeigenalter (bis/älter als 21 Tage), Sortierung
  älteste Anzeige, Filter nur Alarm, Zeitraum heißt jetzt Gefunden
- Suchagent: Schlagwörter mit Alarm; Treffer mit alarm = 1 melden sich
  über den AlarmWatcher (App-weit, jede Minute) mit Ton und Pop-up,
  Quittieren gilt für alle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 13:28:29 +02:00
co-authored by Claude Opus 5.5
parent b34a1968df
commit 1ad774fdd2
16 changed files with 978 additions and 625 deletions
+53 -2
View File
@@ -78,12 +78,58 @@ export const formatAge = (value: string | null) => {
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';
tone?: 'brand' | 'red' | 'alarm';
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
const [draft, setDraft] = React.useState('');
@@ -99,12 +145,17 @@ export const TermInput: React.FC<{
};
const chip =
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
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"