feat(calendar): add application date integrations

This commit is contained in:
cesnimda
2026-08-31 22:24:38 +02:00
parent caf486dceb
commit 1152e05687
10 changed files with 547 additions and 2 deletions
@@ -0,0 +1,45 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { api } from "./api";
import ApplicationCalendarActions from "./components/ApplicationCalendarActions";
import { I18nProvider } from "./i18n/I18nProvider";
jest.mock("./api", () => ({
api: { get: jest.fn(), post: jest.fn() },
getApiErrorMessage: (_: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
window.localStorage.clear();
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: true }, microsoft: { connected: false, writable: false } } } as any);
mockedApi.post.mockResolvedValue({ data: { provider: "google", id: "event-1", webUrl: "https://calendar.google.test/event-1" } } as any);
});
afterEach(() => jest.clearAllMocks());
test("adds the selected application date to a writable connected calendar", async () => {
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
const googleButtons = await screen.findAllByRole("button", { name: "Google Calendar" });
fireEvent.click(googleButtons[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/calendar/jobs/42/events", { provider: "google", kind: "follow-up" }));
expect(await screen.findByText("The event was added to your calendar.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Open event" })).toHaveAttribute("href", "https://calendar.google.test/event-1");
});
test("keeps portable calendar download available without a provider connection", async () => {
mockedApi.get.mockResolvedValue({ data: { google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } } } as any);
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt={null} deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
expect(await screen.findByRole("button", { name: "Download .ics" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Google Calendar" })).not.toBeInTheDocument();
});
test("explains when an existing connection needs renewed calendar consent", async () => {
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: false }, microsoft: { connected: false, writable: false } } } as any);
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" /></I18nProvider>);
expect(await screen.findByText("Reconnect your calendar account to approve calendar access.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Reconnect" })).toHaveAttribute("href", "/settings");
});
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Stack, Typography } from "@mui/material";
import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined";
import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type ProviderStatus = { connected: boolean; writable: boolean };
type CalendarStatus = { google: ProviderStatus; microsoft: ProviderStatus };
type EventKind = "follow-up" | "deadline";
type CalendarEventResult = { provider: string; id?: string; webUrl?: string };
export default function ApplicationCalendarActions({ jobId, followUpAt, deadline }: { jobId: number; followUpAt?: string | null; deadline?: string | null }) {
const { t } = useI18n();
const [status, setStatus] = useState<CalendarStatus | null>(null);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [created, setCreated] = useState<CalendarEventResult | null>(null);
useEffect(() => {
let active = true;
api.get<CalendarStatus>("/calendar/status")
.then((response) => { if (active) setStatus(response.data); })
.catch(() => { if (active) setStatus({ google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } }); });
return () => { active = false; };
}, []);
const create = async (provider: "google" | "microsoft", kind: EventKind) => {
setBusy(`${provider}-${kind}`); setError(""); setCreated(null);
try {
const response = await api.post<CalendarEventResult>(`/calendar/jobs/${jobId}/events`, { provider, kind });
setCreated(response.data);
} catch (err) { setError(getApiErrorMessage(err, t("calendarCreateFailed"))); }
finally { setBusy(""); }
};
const download = async (kind: EventKind) => {
setBusy(`ics-${kind}`); setError("");
try {
const response = await api.get(`/calendar/jobs/${jobId}/events.ics`, { params: { kind }, responseType: "blob" });
const url = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = url; anchor.download = `jobjakt-${jobId}-${kind}.ics`; anchor.click();
URL.revokeObjectURL(url);
} catch (err) { setError(getApiErrorMessage(err, t("calendarDownloadFailed"))); }
finally { setBusy(""); }
};
const rows: Array<{ kind: EventKind; title: string }> = [
...(followUpAt ? [{ kind: "follow-up" as const, title: t("calendarFollowUp") }] : []),
...(deadline ? [{ kind: "deadline" as const, title: t("calendarDeadline") }] : []),
];
if (rows.length === 0) return <Alert severity="info">{t("calendarNoDates")}</Alert>;
if (!status) return <Stack direction="row" spacing={1} alignItems="center"><CircularProgress size={18} /><Typography variant="body2">{t("loading")}</Typography></Stack>;
return (
<Stack spacing={1.5}>
<Typography variant="body2" color="text.secondary">{t("calendarHelp")}</Typography>
{(status.google.connected && !status.google.writable) || (status.microsoft.connected && !status.microsoft.writable) ? (
<Alert severity="info" action={<Button color="inherit" size="small" href="/settings">{t("calendarReconnect")}</Button>}>{t("calendarPermissionNeeded")}</Alert>
) : null}
{error ? <Alert severity="error">{error}</Alert> : null}
{created ? (
<Alert severity="success" action={created.webUrl ? <Button color="inherit" size="small" href={created.webUrl} target="_blank" rel="noreferrer" endIcon={<OpenInNewIcon />}>{t("calendarOpen")}</Button> : undefined}>
{t("calendarCreated")}
</Alert>
) : null}
{rows.map((row) => (
<Box key={row.kind} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: "divider", borderRadius: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 750, flex: "1 1 180px" }}>{row.title}</Typography>
{status.google.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("google", row.kind)}>{t("calendarGoogle")}</Button> : null}
{status.microsoft.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("microsoft", row.kind)}>{t("calendarOutlook")}</Button> : null}
<Button size="small" startIcon={<DownloadOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void download(row.kind)}>{t("calendarDownload")}</Button>
</Box>
))}
</Stack>
);
}
+28
View File
@@ -278,6 +278,20 @@ export const translations = {
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.",
calendarTitle: "Calendar",
calendarHelp: "Add important application dates to a connected calendar, or download a portable calendar file.",
calendarFollowUp: "Application follow-up",
calendarDeadline: "Application deadline",
calendarGoogle: "Google Calendar",
calendarOutlook: "Outlook Calendar",
calendarDownload: "Download .ics",
calendarReconnect: "Reconnect",
calendarPermissionNeeded: "Reconnect your calendar account to approve calendar access.",
calendarCreated: "The event was added to your calendar.",
calendarOpen: "Open event",
calendarNoDates: "Set a follow-up date or application deadline to add it to a calendar.",
calendarCreateFailed: "The calendar event could not be created.",
calendarDownloadFailed: "The calendar file could not be downloaded.",
jobDiscoverySavedName: "Search name",
jobDiscoverySavedDefaultName: "Recent vacancies",
jobDiscoverySaveSearch: "Save search",
@@ -2680,6 +2694,20 @@ export const translations = {
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.",
calendarTitle: "Kalender",
calendarHelp: "Legg viktige søknadsdatoer til i en tilkoblet kalender, eller last ned en kalenderfil.",
calendarFollowUp: "Oppfølging av søknad",
calendarDeadline: "Søknadsfrist",
calendarGoogle: "Google Kalender",
calendarOutlook: "Outlook-kalender",
calendarDownload: "Last ned .ics",
calendarReconnect: "Koble til på nytt",
calendarPermissionNeeded: "Koble til kalenderkontoen på nytt for å godkjenne kalendertilgang.",
calendarCreated: "Hendelsen ble lagt til i kalenderen.",
calendarOpen: "Åpne hendelsen",
calendarNoDates: "Angi en oppfølgingsdato eller søknadsfrist for å legge den til i en kalender.",
calendarCreateFailed: "Kalenderhendelsen kunne ikke opprettes.",
calendarDownloadFailed: "Kalenderfilen kunne ikke lastes ned.",
jobDiscoverySavedName: "Navn på søket",
jobDiscoverySavedDefaultName: "Nylige stillinger",
jobDiscoverySaveSearch: "Lagre søk",
@@ -22,6 +22,7 @@ import { getApiErrorMessage } from "../api";
import Attachments from "../components/Attachments";
import Correspondence from "../components/Correspondence";
import ApplicationChecklist from "../components/ApplicationChecklist";
import ApplicationCalendarActions from "../components/ApplicationCalendarActions";
import {
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
} from "../components/ApplicationIntelligence";
@@ -387,6 +388,7 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
const panels = [
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
{ id: "calendar", title: t("calendarTitle"), content: <ApplicationCalendarActions jobId={jobId} followUpAt={overview.followUpAt} deadline={overview.deadline} /> },
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },