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
+110
View File
@@ -0,0 +1,110 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { scannerApi, type ScannerAlarm } from '../../lib/api';
import { useNavigation } from '../../lib/navigation';
import { Modal } from '../ui/Modal';
import { PrimaryButton, SecondaryButton } from '../ui/form';
import { useToast } from '../Toast';
import { AlarmBell, SOURCE_LABELS, formatAge, playAlarmSound } from './scannerUi';
/*
* Alarm des Scanners: Treffer mit Alarm-Schlagwort melden sich mit Ton und
* Pop-up - egal, welche Ansicht offen ist (deshalb hängt der Wächter in App,
* nicht im Scanner-Dashboard; beim perinco-Scanner hörte man den Ton nur dort).
*
* Abgefragt wird jede Minute. Der Scanner läuft stündlich, ein Fund meldet
* sich also spätestens eine Minute nach dem Lauf. Quittieren gilt für alle
* Benutzer (serverseitig `alarm_ack`).
*/
const POLL_MS = 60_000;
export const AlarmWatcher: React.FC = () => {
const { toast } = useToast();
const { setView } = useNavigation();
const [alarms, setAlarms] = useState<ScannerAlarm[]>([]);
/** Welche Alarme schon geklingelt haben - neue klingeln erneut. */
const rung = useRef(new Set<number>());
const check = useCallback(async () => {
try {
const { alarms: list } = await scannerApi.alarms();
setAlarms(list);
if (list.some((alarm) => !rung.current.has(alarm.id))) {
list.forEach((alarm) => rung.current.add(alarm.id));
playAlarmSound();
}
} catch {
/* kein Netz oder Sitzung abgelaufen - beim nächsten Mal */
}
}, []);
useEffect(() => {
void check();
const timer = setInterval(() => void check(), POLL_MS);
return () => clearInterval(timer);
}, [check]);
if (alarms.length === 0) return null;
const acknowledge = async (goToScanner = false) => {
const ids = alarms.map((alarm) => alarm.id);
setAlarms([]);
try {
await scannerApi.ackAlarms(ids);
} catch (err) {
toast(err instanceof Error ? err.message : 'Quittieren fehlgeschlagen.', 'error');
}
if (goToScanner) setView('Scanner');
};
return (
<Modal
open
size="md"
onClose={() => void acknowledge()}
title={
<span className="flex items-center gap-2">
<AlarmBell className="h-5 w-5" />
{alarms.length === 1 ? 'Alarm-Treffer im Scanner' : `${alarms.length} Alarm-Treffer im Scanner`}
</span>
}
footer={
<>
<SecondaryButton type="button" onClick={() => void acknowledge()}>
Gesehen
</SecondaryButton>
<PrimaryButton type="button" onClick={() => void acknowledge(true)}>
Zum Scanner
</PrimaryButton>
</>
}
>
<ul className="max-h-[50vh] space-y-2 overflow-y-auto">
{alarms.map((alarm) => (
<li key={alarm.id} className="rounded-lg border border-red-200 bg-red-50/50 p-3">
<a
href={alarm.url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-ink-900 hover:text-brand-700"
>
{alarm.title} ↗
</a>
<p className="mt-0.5 text-xs text-ink-500">
{[alarm.company, alarm.location, SOURCE_LABELS[alarm.source]].filter(Boolean).join(' · ')} ·{' '}
{formatAge(alarm.published_at)}
</p>
<p className="mt-1 text-xs text-ink-600">
Agent <strong>{alarm.agent_name}</strong> · gefunden:{' '}
{alarm.hits.map((hit) => (
<span key={hit} className="mr-1 rounded-full border border-red-200 bg-surface px-1.5 py-0.5 text-red-700">
{hit}
</span>
))}
</p>
</li>
))}
</ul>
</Modal>
);
};