chore(M001/S01): auto-commit after complete-slice

This commit is contained in:
2026-03-24 12:27:04 +01:00
parent 9f03d123d0
commit 13d4e29336
22 changed files with 970 additions and 118 deletions
@@ -36,6 +36,7 @@ import {
GmailImportThreadResult,
GmailJobMatchesResponse,
GmailStatus,
GmailThreadRefreshResult,
JobApplication,
} from "../types";
import { useDialogActions } from "../dialogs";
@@ -115,8 +116,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
const [gmailQuery, setGmailQuery] = useState("");
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
const [linkedThreadRefresh, setLinkedThreadRefresh] = useState<GmailThreadRefreshResult | null>(null);
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
const autoRefreshKeyRef = useRef<string | null>(null);
const load = useCallback(async () => {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
@@ -153,6 +157,47 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}
}, [jobId, toast]);
const linkedThreadIds = useMemo(
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
[messages],
);
const refreshLinkedThreads = useCallback(async (mode: "auto" | "manual" = "manual") => {
if (!gmailStatus?.connected || linkedThreadIds.length === 0) return null;
try {
setLinkedThreadRefreshLoading(true);
const res = await api.post<GmailThreadRefreshResult>("/gmail/refresh-linked-threads", { jobApplicationId: jobId });
setLinkedThreadRefresh(res.data);
await load();
await loadGmailStatus();
if (importOpen && importTab === 1) {
await loadGmailMatches(gmailQuery);
}
if (mode === "manual") {
if (res.data.imported > 0) {
toast(`Imported ${res.data.imported} new Gmail message${res.data.imported === 1 ? "" : "s"}.`, "success");
} else if (res.data.hasLinkedThreads) {
toast("Linked Gmail threads are already current.", "success");
} else {
toast("This job does not have any linked Gmail threads yet.", "success");
}
} else if (res.data.imported > 0) {
toast(`Linked Gmail threads imported ${res.data.imported} new message${res.data.imported === 1 ? "" : "s"}.`, "success");
}
return res.data;
} catch (error: any) {
if (mode === "manual") {
toast(getApiErrorMessage(error, "Failed to refresh linked Gmail threads."), "error");
}
return null;
} finally {
setLinkedThreadRefreshLoading(false);
}
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, toast]);
useEffect(() => {
void load();
}, [load]);
@@ -164,9 +209,20 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}, [messages.length]);
useEffect(() => {
if (!importOpen) return;
void loadGmailStatus();
}, [importOpen, loadGmailStatus]);
}, [loadGmailStatus]);
useEffect(() => {
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
autoRefreshKeyRef.current = null;
return;
}
const refreshKey = `${jobId}:${linkedThreadIds.join("|")}`;
if (autoRefreshKeyRef.current === refreshKey) return;
autoRefreshKeyRef.current = refreshKey;
void refreshLinkedThreads("auto");
}, [gmailStatus?.connected, jobId, linkedThreadIds, refreshLinkedThreads]);
useEffect(() => {
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
@@ -261,6 +317,7 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
await api.delete("/gmail/connection");
setGmailStatus({ connected: false });
setGmailMatches(null);
setLinkedThreadRefresh(null);
toast(t("googleUnlinked"), "success");
} catch (error) {
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
@@ -391,6 +448,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? (
<>
{linkedThreadIds.length > 0 ? (
<Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
{linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"}
</Button>
) : null}
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
</>
@@ -419,6 +481,22 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
</Box>
) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
{linkedThreadIds.length > 0 ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 0
? `Last refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
: linkedThreadRefresh.hasLinkedThreads
? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
: "No linked Gmail threads yet"}
/>
) : null}
</Box>
) : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
{gmailMatchesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>