feat(discovery): schedule saved-search alerts

This commit is contained in:
cesnimda
2026-08-31 22:12:52 +02:00
parent 19a89018a0
commit b7029faaec
16 changed files with 423 additions and 113 deletions
+6 -2
View File
@@ -275,7 +275,9 @@ export const translations = {
jobDiscoveryViewListing: "View listing",
jobDiscoverySaveTracker: "Save to tracker",
jobDiscoverySavedSearches: "Saved searches",
jobDiscoverySavedSearchesHelp: "Run a saved NAV search to see which vacancies are genuinely new since your last check.",
jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
jobDiscoveryAutomaticAlerts: "Automatic alerts",
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
jobDiscoverySavedName: "Search name",
jobDiscoverySavedDefaultName: "Recent vacancies",
jobDiscoverySaveSearch: "Save search",
@@ -2675,7 +2677,9 @@ export const translations = {
jobDiscoveryViewListing: "Vis stilling",
jobDiscoverySaveTracker: "Lagre i oversikten",
jobDiscoverySavedSearches: "Lagrede søk",
jobDiscoverySavedSearchesHelp: "Kjør et lagret NAV-søk for å se hvilke stillinger som faktisk er nye siden sist.",
jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
jobDiscoverySavedName: "Navn på søket",
jobDiscoverySavedDefaultName: "Nylige stillinger",
jobDiscoverySaveSearch: "Lagre søk",
+14
View File
@@ -93,3 +93,17 @@ test("runs a saved search, marks unseen vacancies, and dismisses them", async ()
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
});
test("lets the user pause automatic alerts for a saved search", async () => {
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 1, dismissedCount: 0 };
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
mockedApi.patch.mockResolvedValue({ data: { ...saved, isActive: false } } as any);
renderPage();
const toggle = await screen.findByRole("switch", { name: "Automatic alerts" });
expect(toggle).toBeChecked();
fireEvent.click(toggle);
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7", { isActive: false }));
await waitFor(() => expect(toggle).not.toBeChecked());
});
+17 -2
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import AddIcon from "@mui/icons-material/Add";
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
@@ -26,7 +26,7 @@ type DiscoveredJob = {
isNew?: boolean;
};
type SavedSearch = { id: number; name: string; query: string; location: string; lastRunAtUtc?: string; resultCount: number; dismissedCount: number };
type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; lastRunAtUtc?: string; resultCount: number; dismissedCount: number };
type SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> };
type SortOrder = "updated" | "deadline" | "title";
@@ -47,6 +47,7 @@ export default function JobDiscoveryPage() {
const [savedName, setSavedName] = useState("");
const [savingSearch, setSavingSearch] = useState(false);
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState<number | null>(null);
useEffect(() => {
let active = true;
@@ -102,6 +103,15 @@ export default function JobDiscoveryPage() {
} catch { setError(t("jobDiscoveryDeleteSavedFailed")); }
};
const toggleSavedSearchAlerts = async (saved: SavedSearch) => {
setUpdatingSavedSearchId(saved.id); setError("");
try {
const response = await api.patch<SavedSearch>(`/job-discovery/saved-searches/${saved.id}`, { isActive: !saved.isActive });
setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data : item));
} catch { setError(t("jobDiscoveryUpdateAlertsFailed")); }
finally { setUpdatingSavedSearchId(null); }
};
const dismissJob = async (jobId: string) => {
if (activeSavedSearchId === null) return;
try {
@@ -148,6 +158,11 @@ export default function JobDiscoveryPage() {
<Typography variant="caption" color="text.secondary">{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography>
</Box>
<Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} />
<FormControlLabel
sx={{ m: 0 }}
control={<Switch size="small" checked={saved.isActive} disabled={updatingSavedSearchId === saved.id} onChange={() => void toggleSavedSearchAlerts(saved)} />}
label={<Typography variant="body2">{t("jobDiscoveryAutomaticAlerts")}</Typography>}
/>
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={loading}>{t("jobDiscoveryRunSaved")}</Button>
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
</Box>