Scanner: Altersfenster "älter als / jünger als" (Tage)

Ersetzt die feste Auswahl bis/über 21 Tage durch zwei freie Felder,
einzeln oder kombiniert (z. B. älter 14, jünger 30). API: olderThan,
youngerThan; age=recent|old bleibt für Altaufrufe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 17:34:59 +02:00
co-authored by Claude Opus 5.5
parent 651ad2b954
commit 1e273ddd92
4 changed files with 54 additions and 17 deletions
+2 -2
View File
@@ -212,8 +212,8 @@ Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
## Scanner ## Scanner
Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna, Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna,
freelancermap) nach offenen Stellen in DACH – jedes Alter; im Dashboard filtert freelancermap) nach offenen Stellen in DACH – jedes Alter; im Dashboard grenzen
*Anzeigenalter* nach „bis 21 Tage“ oder „älter als 21 Tage“. Technik *Älter als* und *Jünger als* (Tage) das Alter ein, z. B. älter 14, jünger 30. Technik
siehe [12 Scanner](12-scanner.md). siehe [12 Scanner](12-scanner.md).
1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter 1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter
+5 -4
View File
@@ -4,9 +4,10 @@ Der Scanner durchsucht Jobbörsen nach offenen Stellen, die ICON besetzen kann
Festanstellungen ebenso wie Projekte. Region immer DACH. Eingeführt am 29.09.2026. Festanstellungen ebenso wie Projekte. Region immer DACH. Eingeführt am 29.09.2026.
Gesucht wird **ohne Altersgrenze** (Änderung vom 29.09.2026, vorher höchstens Gesucht wird **ohne Altersgrenze** (Änderung vom 29.09.2026, vorher höchstens
21 Tage): auch ältere Anzeigen kommen herein. Im Dashboard filtert 21 Tage): auch ältere Anzeigen kommen herein. Im Dashboard grenzen die Felder
**Anzeigenalter** nach „bis 21 Tage" bzw. „älter als 21 Tage", die Sortierung **Älter als** und **Jünger als** (Tage) das Alter frei ein – z. B. älter 14,
kennt „älteste Anzeige". jünger 30. Leer heißt keine Grenze; Anzeigen ohne Datum fallen heraus, sobald
eines der Felder gesetzt ist. Die Sortierung kennt „älteste Anzeige".
Menü: **Scanner** (Dashboard) mit dem Unterpunkt **Suchagenten**; Zeitplan und Menü: **Scanner** (Dashboard) mit dem Unterpunkt **Suchagenten**; Zeitplan und
API-Schlüssel unter **Einstellungen → Scanner** (nur Administratoren). API-Schlüssel unter **Einstellungen → Scanner** (nur Administratoren).
@@ -276,7 +277,7 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`.
| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | | POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht |
| PUT | `/agents/:id` | alle | ändern (gleicher Körper) | | PUT | `/agents/:id` | alle | ändern (gleicher Körper) |
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit | | DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit |
| GET | `/matches` | alle | Filter `hours` (gefunden), `age` (`recent`/`old`, Grenze 21 Tage ab Veröffentlichung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`agent`/`title`/`company`/`source`/`published`/`status`/`created`; `oldest` = `published` aufsteigend) mit `dir` (`asc`/`desc`), `limit` | | GET | `/matches` | alle | Filter `hours` (gefunden), `olderThan` / `youngerThan` (Tage ab Veröffentlichung, einzeln oder zusammen; `age=recent|old` = feste 21-Tage-Grenze, aus der ersten Fassung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`agent`/`title`/`company`/`source`/`published`/`status`/`created`; `oldest` = `published` aufsteigend) mit `dir` (`asc`/`desc`), `limit` |
| GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) | | GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) |
| POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer | | POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer |
| PATCH | `/matches/:id` | alle | `{ status }` | | PATCH | `/matches/:id` | alle | `{ status }` |
+15
View File
@@ -251,6 +251,21 @@ router.get('/matches', async (req, res, next) => {
); );
params.push(AGE_DAYS); params.push(AGE_DAYS);
} }
/*
* Freies Altersfenster in Tagen: `olderThan` = mindestens so alt,
* `youngerThan` = hoechstens so alt (z. B. aelter 14, juenger 30).
* Beide optional und mit `age` kombinierbar.
*/
const olderThan = Number(req.query.olderThan);
if (Number.isFinite(olderThan) && olderThan > 0) {
where.push('j.published_at < NOW() - INTERVAL ? DAY');
params.push(Math.round(olderThan));
}
const youngerThan = Number(req.query.youngerThan);
if (Number.isFinite(youngerThan) && youngerThan > 0) {
where.push('j.published_at >= NOW() - INTERVAL ? DAY');
params.push(Math.round(youngerThan));
}
if (req.query.alarm === '1') where.push('m.alarm = 1'); if (req.query.alarm === '1') where.push('m.alarm = 1');
/* /*
+32 -11
View File
@@ -65,8 +65,9 @@ export const ScannerDashboard: React.FC = () => {
const [showDiscarded, setShowDiscarded] = useState(false); const [showDiscarded, setShowDiscarded] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ key: 'known', dir: 'desc' }); const [sort, setSort] = useState<SortState>({ key: 'known', dir: 'desc' });
/** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */ /** Altersfenster der Anzeige in Tagen; leer = keine Grenze. */
const [age, setAge] = useState<'' | 'recent' | 'old'>(''); const [olderThan, setOlderThan] = useState('');
const [youngerThan, setYoungerThan] = useState('');
const [alarmOnly, setAlarmOnly] = useState(false); const [alarmOnly, setAlarmOnly] = useState(false);
const wasRunning = useRef(false); const wasRunning = useRef(false);
@@ -81,7 +82,8 @@ export const ScannerDashboard: React.FC = () => {
known: knownOnly ? 1 : undefined, known: knownOnly ? 1 : undefined,
status: showDiscarded ? 'alle' : undefined, status: showDiscarded ? 'alle' : undefined,
q: search.trim() || undefined, q: search.trim() || undefined,
age: age || undefined, olderThan: Number(olderThan) > 0 ? Number(olderThan) : undefined,
youngerThan: Number(youngerThan) > 0 ? Number(youngerThan) : undefined,
alarm: alarmOnly ? 1 : undefined, alarm: alarmOnly ? 1 : undefined,
sort: sort.key, sort: sort.key,
dir: sort.dir, dir: sort.dir,
@@ -91,7 +93,7 @@ export const ScannerDashboard: React.FC = () => {
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error'); toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error');
} }
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, age, alarmOnly, sort, toast]); }, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, olderThan, youngerThan, alarmOnly, sort, toast]);
const loadOverview = useCallback(async () => { const loadOverview = useCallback(async () => {
try { try {
@@ -175,7 +177,7 @@ export const ScannerDashboard: React.FC = () => {
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-sm text-ink-500"> <p className="text-sm text-ink-500">
Offene Stellen aus Jobbörsen in DACH – jedes Alter, gefiltert wird unten nach „Anzeigenalter“. Offene Stellen aus Jobbörsen in DACH – jedes Alter, eingrenzen unten mit „Älter als“ / „Jünger als“.
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs"> <div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span <span
@@ -272,12 +274,31 @@ export const ScannerDashboard: React.FC = () => {
))} ))}
</select> </select>
</Filter> </Filter>
<Filter label="Anzeigenalter" className="w-44"> <Filter label="Älter als (Tage)" className="w-28">
<select value={age} onChange={(event) => setAge(event.target.value as typeof age)} className={inputClass}> <input
<option value="">alle</option> type="number"
<option value="recent">bis 21 Tage</option> min={0}
<option value="old">älter als 21 Tage</option> value={olderThan}
</select> onChange={(event) => setOlderThan(event.target.value)}
placeholder="–"
title="Nur Anzeigen, die mindestens so viele Tage alt sind"
className={inputClass}
/>
</Filter>
<Filter label="Jünger als (Tage)" className="w-28">
<input
type="number"
min={0}
value={youngerThan}
onChange={(event) => setYoungerThan(event.target.value)}
placeholder="–"
title="Nur Anzeigen, die höchstens so viele Tage alt sind"
className={`${inputClass} ${
Number(olderThan) > 0 && Number(youngerThan) > 0 && Number(youngerThan) <= Number(olderThan)
? 'border-red-400'
: ''
}`}
/>
</Filter> </Filter>
<Filter label="Ab %" className="w-20"> <Filter label="Ab %" className="w-20">
<input <input