From 33ac4b963bdc4b0fec549dc2873c51789cf96c54 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Apr 2026 12:03:49 +0200 Subject: [PATCH] Optimize workspace and daily-loop surfaces --- .../src/components/DashboardView.tsx | 181 +++++++++------ .../src/components/JobDetailsDialog.tsx | 213 ++++++++++-------- job-tracker-ui/src/components/JobTable.tsx | 128 ++++++----- .../job-workspace/useJobWorkspaceBaseData.ts | 128 +++++++++++ .../job-workspace/useWorkspaceTabCache.ts | 24 ++ job-tracker-ui/src/hooks/useViewResource.ts | 14 +- job-tracker-ui/src/types.ts | 10 + 7 files changed, 474 insertions(+), 224 deletions(-) create mode 100644 job-tracker-ui/src/components/job-workspace/useJobWorkspaceBaseData.ts create mode 100644 job-tracker-ui/src/components/job-workspace/useWorkspaceTabCache.ts diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index e59c5a8..f799268 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { @@ -135,20 +135,22 @@ export default function DashboardView() { const [months, setMonths] = useState<6 | 12 | 24>(12); const [prefs, setPrefs] = useState(() => loadPrefs()); const [prefsAnchor, setPrefsAnchor] = useState(null); - const summaryResource = useViewResource( - async () => { - const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([ - api.get("/jobapplications/stats"), - api.get("/jobapplications/analytics-overview"), - api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }), - ]); + const loadSummary = useCallback(async () => { + const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([ + api.get("/jobapplications/stats"), + api.get("/jobapplications/analytics-overview"), + api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }), + ]); - return { - stats: statsResponse.data, - overview: overviewResponse.data, - reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [], - }; - }, + return { + stats: statsResponse.data, + overview: overviewResponse.data, + reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [], + }; + }, []); + + const summaryResource = useViewResource( + loadSummary, { initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] }, errorMessage: "Unable to load dashboard summary data right now.", @@ -156,21 +158,23 @@ export default function DashboardView() { }, ); - const trendsResource = useViewResource( - async () => { - const params = { months }; - const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([ - api.get("/jobapplications/analytics", { params }), - api.get("/jobapplications/tags", { params: { limit: 10, ...params } }), - api.get("/jobapplications/tag-trends", { params: { months, limit: 5 } }), - ]); + const loadTrends = useCallback(async () => { + const params = { months }; + const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([ + api.get("/jobapplications/analytics", { params }), + api.get("/jobapplications/tags", { params: { limit: 10, ...params } }), + api.get("/jobapplications/tag-trends", { params: { months, limit: 5 } }), + ]); - return { - analytics: analyticsResponse.data ?? [], - tags: tagsResponse.data ?? [], - tagTrends: trendsResponse.data, - }; - }, + return { + analytics: analyticsResponse.data ?? [], + tags: tagsResponse.data ?? [], + tagTrends: trendsResponse.data, + }; + }, [months]); + + const trendsResource = useViewResource( + loadTrends, { initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null }, errorMessage: "Unable to load dashboard trends right now.", @@ -185,25 +189,54 @@ export default function DashboardView() { const tags = trendsResource.data.tags; const tagTrends = trendsResource.data.tagTrends; - const appliedValues = analytics.map((x) => x.applied); - const responseValues = analytics.map((x) => x.responses); - const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860; - const chartHeight = isMobile ? 210 : 250; - const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight); - const responsePath = buildLinePath(responseValues, chartWidth, chartHeight); - const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main]; - const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0; - const topSource = overview?.responseRateBySource?.[0]; - const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length; + const tagColors = useMemo( + () => [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main], + [theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main], + ); - const metricCards = [ + const summaryView = useMemo(() => { + const topSource = overview?.responseRateBySource?.[0]; + const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length; + + return { + funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0, + topSource, + missingCvCount, + priorityJobs: reminderJobs.slice(0, 5), + }; + }, [overview, reminderJobs]); + + const trendsView = useMemo(() => { + const appliedValues = analytics.map((x) => x.applied); + const responseValues = analytics.map((x) => x.responses); + const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860; + const chartHeight = isMobile ? 210 : 250; + const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0); + const totalResponses = responseValues.reduce((sum, value) => sum + value, 0); + const maxTagCount = Math.max(...tags.map((item) => item.count), 1); + + return { + appliedValues, + responseValues, + chartWidth, + chartHeight, + appliedPath: buildLinePath(appliedValues, chartWidth, chartHeight), + responsePath: buildLinePath(responseValues, chartWidth, chartHeight), + totalApplied, + totalResponses, + responseRate: totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0, + maxTagCount, + }; + }, [analytics, isMobile, tags]); + + const metricCards = useMemo(() => ([ { label: t("dashboardActiveApplications"), value: stats?.active ?? 0, sub: t("dashboardCurrentlyInProgress"), icon: , tone: theme.palette.primary.main, - spark: appliedValues, + spark: trendsView.appliedValues, }, { label: t("dashboardApplied30Days"), @@ -211,7 +244,7 @@ export default function DashboardView() { sub: t("dashboardNewApplications"), icon: , tone: theme.palette.success.main, - spark: appliedValues.slice(-6), + spark: trendsView.appliedValues.slice(-6), }, { label: t("dashboardMedianFirstResponse"), @@ -219,7 +252,7 @@ export default function DashboardView() { sub: t("dashboardDaysUntilFirstReply"), icon: , tone: theme.palette.info.main, - spark: responseValues, + spark: trendsView.responseValues, }, { label: t("dashboardResponsesLogged"), @@ -227,30 +260,28 @@ export default function DashboardView() { sub: t("dashboardAcrossActiveJobs"), icon: , tone: theme.palette.warning.main, - spark: responseValues.slice(-6), + spark: trendsView.responseValues.slice(-6), }, - ]; + ]), [overview?.medianDaysToFirstResponse, overview?.totalResponses, stats?.active, stats?.appliedLast30Days, t, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, trendsView.appliedValues, trendsView.responseValues]); - const togglePref = (key: keyof Prefs) => { - const next = { ...prefs, [key]: !prefs[key] }; - setPrefs(next); - savePrefs(next); - }; + const togglePref = useCallback((key: keyof Prefs) => { + setPrefs((current) => { + const next = { ...current, [key]: !current[key] }; + savePrefs(next); + return next; + }); + }, []); - const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0); - const totalResponses = responseValues.reduce((sum, value) => sum + value, 0); - const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0; - const priorityJobs = reminderJobs.slice(0, 5); - const getReminderAction = (job: ReminderJob) => getWorkflowAction(job, { + const getReminderAction = useCallback((job: ReminderJob) => getWorkflowAction(job, { packageWork: t("jobTablePackageWork"), followUp: t("jobTableFollowUp"), interviewPrep: t("jobTableInterviewStage"), readiness: t("jobTableReadiness"), - }); + }), [t]); - const openReminderJob = (job: ReminderJob) => { + const openReminderJob = useCallback((job: ReminderJob) => { navigate(buildWorkflowPath(job)); - }; + }, [navigate]); return ( @@ -287,9 +318,9 @@ export default function DashboardView() { - - - + + + @@ -378,27 +409,27 @@ export default function DashboardView() { {t("dashboardMonthlyApplicationsResponses")} - - + + - - + + {[0.2, 0.4, 0.6, 0.8].map((tick) => ( ))} - {responsePath ? : null} - {appliedPath ? : null} + {trendsView.responsePath ? : null} + {trendsView.appliedPath ? : null} {analytics.map((point) => ( @@ -418,7 +449,7 @@ export default function DashboardView() { {t("dashboardResponseSources")} {(overview?.funnel ?? []).map((item) => { - const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0; + const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0; return ( @@ -444,10 +475,10 @@ export default function DashboardView() { - {topSource?.label ?? t("dashboardResponseSources")} - {topSource ? `${topSource.rate}%` : "—"} + {summaryView.topSource?.label ?? t("dashboardResponseSources")} + {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"} - {topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")} + {summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")} @@ -459,11 +490,11 @@ export default function DashboardView() { {t("remindersTitle")} {t("remindersSubtitle")} - {priorityJobs.length === 0 ? ( + {summaryView.priorityJobs.length === 0 ? ( {t("remindersNothing")} ) : ( - {priorityJobs.map((job) => { + {summaryView.priorityJobs.map((job) => { const action = getReminderAction(job); return ( diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 4397b13..1b5d144 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -28,26 +28,12 @@ import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; import JobFlowBar from "./JobFlowBar"; import { useI18n } from "../i18n/I18nProvider"; - -type AttachmentItem = { - id: number; - fileName: string; - uploadDate: string; - fileType: string; - fileSize: number; - purpose?: string | null; - useForAi: boolean; -}; +import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData"; +import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache"; type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview"; type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold"; -type PackageWorkspaceState = { - coverLetter: string; - applicationAnswer: string; - recruiterMessage: string; -}; - type TailoredCvPreviewResponse = { templateId: string; html: string; @@ -91,20 +77,6 @@ function copyLines(items: string[]) { const APPLICATION_ANSWER_START = "<<>>"; const APPLICATION_ANSWER_END = "<<>>"; -function extractApplicationAnswerDraft(notes?: string | null) { - const value = (notes ?? "").trim(); - if (!value) return ""; - - const startIndex = value.indexOf(APPLICATION_ANSWER_START); - const endIndex = value.indexOf(APPLICATION_ANSWER_END); - if (startIndex >= 0 && endIndex > startIndex) { - return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim(); - } - - const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i); - return legacyMatch?.[1]?.trim() ?? ""; -} - function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) { const trimmedNotes = (notes ?? "").trim(); const trimmedDraft = draft.trim(); @@ -156,11 +128,41 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); + const followUpCache = useWorkspaceTabCache(); + const candidateFitCache = useWorkspaceTabCache(); + const focusPlanCache = useWorkspaceTabCache(); + const interviewPrepCache = useWorkspaceTabCache(); + const readinessCache = useWorkspaceTabCache(); + const tailoredDraftCache = useWorkspaceTabCache(); + + const { + job, + setJob, + tab, + setTab, + history, + isAdmin, + jobAttachments, + selectedAttachmentIds, + setSelectedAttachmentIds, + profileAvatarImageDataUrl, + packageWorkspace, + setPackageWorkspace, + savedPackageWorkspace, + setSavedPackageWorkspace, + packageGeneratedAt, + setPackageGeneratedAt, + draftRecipient, + setDraftRecipient, + followUpMode, + setFollowUpMode, + } = useJobWorkspaceBaseData({ + open, + jobId, + initialTab, + initialFollowUpMode, + }); - const [job, setJob] = useState(null); - const [tab, setTab] = useState(0); - const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]); - const [isAdmin, setIsAdmin] = useState(false); const [followUpDraft, setFollowUpDraft] = useState(null); const [loadingDraft, setLoadingDraft] = useState(false); const [sendingDraft, setSendingDraft] = useState(false); @@ -174,8 +176,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false); const [readiness, setReadiness] = useState(null); const [loadingReadiness, setLoadingReadiness] = useState(false); - const [jobAttachments, setJobAttachments] = useState([]); - const [selectedAttachmentIds, setSelectedAttachmentIds] = useState([]); const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false); const [generatingPackage, setGeneratingPackage] = useState(false); const [applicationPackage, setApplicationPackage] = useState(null); @@ -189,14 +189,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [tailoredCvPreview, setTailoredCvPreview] = useState(null); const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false); const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false); - const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState(null); const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState(null); const [useProfilePhoto, setUseProfilePhoto] = useState(true); - const [packageWorkspace, setPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); - const [savedPackageWorkspace, setSavedPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); - const [packageGeneratedAt, setPackageGeneratedAt] = useState(null); - const [draftRecipient, setDraftRecipient] = useState(""); - const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply"); const [draftReloadToken, setDraftReloadToken] = useState(0); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); @@ -204,60 +198,43 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, useEffect(() => { if (!open || !jobId) return; - setTab(Math.max(0, Math.min(9, initialTab))); setFollowUpDraft(null); setCandidateFit(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); setApplicationPackage(null); - setJobAttachments([]); - setSelectedAttachmentIds([]); - setPackageGeneratedAt(null); setTailoredCvDraft(emptyTailoredCvDraft()); setSavedTailoredCvDraft(emptyTailoredCvDraft()); setTailoredCvPreview(null); - setProfileAvatarImageDataUrl(null); setCustomPhotoDataUrl(null); setUseProfilePhoto(true); - setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); - setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); - api.get(`/jobapplications/${jobId}`).then((r) => { - setJob(r.data); - const savedWorkspace = { - coverLetter: r.data.coverLetterText ?? "", - applicationAnswer: extractApplicationAnswerDraft(r.data.notes), - recruiterMessage: r.data.recruiterMessageDraft ?? "", - }; - setSavedPackageWorkspace(savedWorkspace); - setPackageWorkspace(savedWorkspace); - setDraftRecipient(r.data.company?.recruiterEmail ?? ""); - setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply")); - }); - api.get(`/attachments/${jobId}`).then((r) => { - const items = Array.isArray(r.data) ? r.data : []; - setJobAttachments(items); - const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id); - setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id)); - }).catch(() => { - setJobAttachments([]); - setSelectedAttachmentIds([]); - }); - api.get(`/auth/me`).then((r) => { - setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); - setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null); - }).catch(() => { - setIsAdmin(false); - setProfileAvatarImageDataUrl(null); - }); - api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); - }, [open, jobId, initialTab, initialFollowUpMode]); + setDraftReloadToken(0); + setDraftSubject(""); + setDraftBody(""); + followUpCache.clearCached(); + candidateFitCache.clearCached(); + focusPlanCache.clearCached(); + interviewPrepCache.clearCached(); + readinessCache.clearCached(); + tailoredDraftCache.clearCached(); + }, [open, jobId, followUpCache, candidateFitCache, focusPlanCache, interviewPrepCache, readinessCache, tailoredDraftCache]); useEffect(() => { if (!open || !jobId || tab !== 3) return; + const cacheKey = `${jobId}:tailored-cv-draft`; + const cached = tailoredDraftCache.getCached(cacheKey); + if (cached) { + const normalized = normalizeTailoredCvDraft(cached); + setTailoredCvDraft(normalized); + setSavedTailoredCvDraft(normalized); + return; + } + setLoadingTailoredCvDraft(true); api.get(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => { const normalized = normalizeTailoredCvDraft(r.data); + tailoredDraftCache.setCached(cacheKey, normalized); setTailoredCvDraft(normalized); setSavedTailoredCvDraft(normalized); }).catch(() => { @@ -265,35 +242,75 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setTailoredCvDraft(empty); setSavedTailoredCvDraft(empty); }).finally(() => setLoadingTailoredCvDraft(false)); - }, [open, jobId, tab]); + }, [open, jobId, tab, tailoredDraftCache]); useEffect(() => { if (!open || !jobId || tab !== 4) return; + const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`; + const cached = followUpCache.getCached(cacheKey); + if (cached) { + setFollowUpDraft(cached); + setDraftSubject(cached.subject); + setDraftBody(cached.body); + return; + } + setLoadingDraft(true); api.get(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { + followUpCache.setCached(cacheKey, r.data); setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); - }, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]); + }, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]); useEffect(() => { if (!open || !jobId || tab !== 5 || candidateFit) return; + const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`; + const cached = candidateFitCache.getCached(cacheKey); + if (cached) { + setCandidateFit(cached); + return; + } + setLoadingCandidateFit(true); - api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); - }, [open, jobId, tab, candidateFit, selectedAttachmentCsv]); + api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { + candidateFitCache.setCached(cacheKey, r.data); + setCandidateFit(r.data); + }).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); + }, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]); useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; + const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`; + const cached = focusPlanCache.getCached(cacheKey); + if (cached) { + setFocusPlan(cached); + return; + } + setLoadingFocusPlan(true); - api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); - }, [open, jobId, tab, focusPlan, selectedAttachmentCsv]); + api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { + focusPlanCache.setCached(cacheKey, r.data); + setFocusPlan(r.data); + }).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); + }, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]); useEffect(() => { if (!open || !jobId || tab !== 7 || interviewPrep) return; + const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`; + const cached = interviewPrepCache.getCached(cacheKey); + if (cached) { + setInterviewPrep(cached); + return; + } + setLoadingInterviewPrep(true); - api.get(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); - }, [open, jobId, tab, interviewPrep, selectedAttachmentCsv]); + api.get(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { + interviewPrepCache.setCached(cacheKey, r.data); + setInterviewPrep(r.data); + }).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); + }, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]); useEffect(() => { setFollowUpDraft(null); @@ -304,9 +321,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, useEffect(() => { if (!open || !jobId || tab !== 8 || readiness) return; + const cacheKey = `${jobId}:readiness`; + const cached = readinessCache.getCached(cacheKey); + if (cached) { + setReadiness(cached); + return; + } + setLoadingReadiness(true); - api.get(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false)); - }, [open, jobId, tab, readiness]); + api.get(`/jobapplications/${jobId}/readiness`).then((r) => { + readinessCache.setCached(cacheKey, r.data); + setReadiness(r.data); + }).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false)); + }, [open, jobId, tab, readiness, readinessCache]); const tags: string[] = (() => { const raw = job?.tags; @@ -392,6 +419,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, renderOptions: normalized.renderOptions, status: normalized.status, }); + tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized); setTailoredCvDraft(normalized); setSavedTailoredCvDraft(normalized); setJob((prev) => prev ? { @@ -399,6 +427,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, tailoredCvText: normalized.renderedText, tailoredCvUpdatedAt: new Date().toISOString(), } : prev); + readinessCache.clearCached(); setReadiness(null); toast("Tailored CV draft saved.", "success"); } catch (error: any) { @@ -422,6 +451,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setGeneratingTailoredCvDraft(true); const res = await api.post(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } }); const normalized = normalizeTailoredCvDraft(res.data); + tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized); setTailoredCvDraft(normalized); setSavedTailoredCvDraft(normalized); setJob((prev) => prev ? { @@ -519,6 +549,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, notes: nextNotes, } : prev); setSavedPackageWorkspace({ ...packageWorkspace }); + readinessCache.clearCached(); + interviewPrepCache.clearCached(); setReadiness(null); setInterviewPrep(null); toast("Application package saved to this job.", "success"); @@ -578,6 +610,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }), api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }), ]); + candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data); + focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, focusRes.data); setCandidateFit(fitRes.data); setFocusPlan(focusRes.data); } catch { @@ -1007,6 +1041,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, try { await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null }); setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev); + readinessCache.clearCached(); setReadiness(null); toast(t("jobDetailsFollowUpSent"), "success"); } catch (error: any) { diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 862ccbc..4970332 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { @@ -61,6 +61,26 @@ interface PagedResult { pageSize: number; } +type RowActionSignal = { + label: string; + detail: string; + onClick: () => void; + variant: "contained" | "outlined"; + color: "warning" | "primary"; +}; + +type JobRowViewModel = { + job: JobApplication; + toneName: string; + overview: string; + tags: string[]; + actionSignals: RowActionSignal[]; + primaryAction: RowActionSignal | null; + appliedDateLabel: string; + isSelected: boolean; + isExpanded: boolean; +}; + export type JobTableColumns = { status: boolean; dateApplied: boolean; @@ -107,6 +127,13 @@ function statusTone(status: string): string { } } +function generateOverview(job: JobApplication): string { + if (job.fullSummary) return job.fullSummary; + if (job.shortSummary) return job.shortSummary; + const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + return src.length > 220 ? `${src.slice(0, 220)}...` : src; +} + export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) { const theme = useTheme(); const isMobile = useMediaQuery("(max-width:767.95px)"); @@ -210,7 +237,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return jobs.filter((job) => needsWorkflowWork(job)); }, [jobs, readinessFilter]); - const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(job.id)); + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id)); const toggleSelectAll = (checked: boolean) => { setSelectedIds(checked ? filteredJobs.map((job) => job.id) : []); @@ -262,7 +291,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => { if (selectedIds.length === 0) return; - const selectedJobs = jobs.filter((job) => selectedIds.includes(job.id)); + const selectedJobs = jobs.filter((job) => selectedIdSet.has(job.id)); if (action === "delete" && !(await confirmDelete(selectedJobs))) return; try { await Promise.all(selectedIds.map((id) => { @@ -278,45 +307,38 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col } }; - const generateOverview = (job: JobApplication) => { - if (job.fullSummary) return job.fullSummary; - if (job.shortSummary) return job.shortSummary; - const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); - return src.length > 220 ? `${src.slice(0, 220)}...` : src; - }; - - const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, { - packageWork: t("jobTablePackageWork"), - followUp: t("jobTableFollowUp"), - interviewPrep: t("jobTableInterviewStage"), - readiness: t("jobTableReadiness"), - }); - - const getActionSignals = (job: JobApplication) => { - const action = buildWorkflowActionDetail(job); - if (!action || job.isDeleted) return []; - - return [{ - label: action.label, - detail: action.detail, - onClick: () => navigate(action.path), - variant: action.key === "follow-up" ? "contained" as const : "outlined" as const, - color: action.key === "follow-up" ? "warning" as const : "primary" as const, - }]; - }; - - const getPrimaryAction = (job: JobApplication) => { - const action = buildWorkflowActionDetail(job); + const buildWorkflowActionSignal = useCallback((job: JobApplication): RowActionSignal | null => { + const action = getWorkflowAction(job, { + packageWork: t("jobTablePackageWork"), + followUp: t("jobTableFollowUp"), + interviewPrep: t("jobTableInterviewStage"), + readiness: t("jobTableReadiness"), + }); if (!action || job.isDeleted) return null; return { label: action.label, detail: action.detail, onClick: () => navigate(action.path), - variant: action.key === "follow-up" ? "contained" as const : "outlined" as const, - color: action.key === "follow-up" ? "warning" as const : "primary" as const, + variant: action.key === "follow-up" ? "contained" : "outlined", + color: action.key === "follow-up" ? "warning" : "primary", }; - }; + }, [navigate, t]); + + const rowModels = useMemo(() => filteredJobs.map((job) => { + const actionSignal = buildWorkflowActionSignal(job); + return { + job, + toneName: statusTone(job.status), + overview: generateOverview(job), + tags: parseTags(job.tags), + actionSignals: actionSignal ? [actionSignal] : [], + primaryAction: actionSignal, + appliedDateLabel: new Date(job.dateApplied).toLocaleDateString(), + isSelected: selectedIdSet.has(job.id), + isExpanded: expanded.includes(job.id), + }; + }), [buildWorkflowActionSignal, expanded, filteredJobs, selectedIdSet]); const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const visibleDesktopColumns = 4 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl); @@ -498,11 +520,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {jobsResource.loading ? {t("loading")} : null} - {!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => { - const toneName = statusTone(job.status); - const primaryAction = getPrimaryAction(job); - const actionSignals = getActionSignals(job); - const tags = parseTags(job.tags).slice(0, 6); + {!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, overview, primaryAction, appliedDateLabel, isSelected }) => { + const compactTags = tags.slice(0, 6); return ( - toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> + toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> {job.company?.name ?? t("jobTableCompany")} @@ -550,7 +569,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {columns.dateApplied ? ( {t("jobTableDateApplied")} - {new Date(job.dateApplied).toLocaleDateString()} + {appliedDateLabel} ) : null} {columns.daysSince ? ( @@ -576,16 +595,16 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ) : null} - {tags.length > 0 ? ( + {compactTags.length > 0 ? ( - {tags.map((tag) => )} + {compactTags.map((tag) => )} ) : null} {t("jobTableOverview")} - {generateOverview(job) || t("jobTableNoSummaryYet")} + {overview || t("jobTableNoSummaryYet")} @@ -647,17 +666,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {jobsResource.loading ? {t("loading")} : null} - {!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => { - const open = expanded.includes(job.id); - const toneName = statusTone(job.status); + {!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, primaryAction, appliedDateLabel, overview, tags, isSelected, isExpanded }) => { const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main; - const primaryAction = getPrimaryAction(job); - const actionSignals = getActionSignals(job); + const detailTags = tags.slice(0, 8); return ( - toggleSelected(job.id, e.target.checked)} /> - toggleExpanded(job.id)}>{open ? : } + toggleSelected(job.id, e.target.checked)} /> + toggleExpanded(job.id)}>{isExpanded ? : } {job.company?.name ?? ""} @@ -679,7 +695,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {columns.status ? : null} - {columns.dateApplied ? {new Date(job.dateApplied).toLocaleDateString()} : null} + {columns.dateApplied ? {appliedDateLabel} : null} {columns.daysSince ? {job.daysSince} : null} {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} @@ -708,13 +724,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - + {t("jobTableLocation")}{job.location ?? "-"} {t("addJobModalSalary")}{job.salary ?? "-"} {t("settingsColumnJobUrl")}{job.jobUrl ? {t("jobTableOpenListing")} : "-"} - {t("jobTableSkills")}{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : {t("jobTableNoTags")}} - {t("jobTableOverview")}{generateOverview(job) || t("jobTableNoSummaryYet")} + {t("jobTableSkills")}{detailTags.length ? detailTags.map((tag) => ) : {t("jobTableNoTags")}} + {t("jobTableOverview")}{overview || t("jobTableNoSummaryYet")} diff --git a/job-tracker-ui/src/components/job-workspace/useJobWorkspaceBaseData.ts b/job-tracker-ui/src/components/job-workspace/useJobWorkspaceBaseData.ts new file mode 100644 index 0000000..676a04c --- /dev/null +++ b/job-tracker-ui/src/components/job-workspace/useJobWorkspaceBaseData.ts @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; + +import { api } from "../../api"; +import { AttachmentItem, JobApplication } from "../../types"; + +type PackageWorkspaceState = { + coverLetter: string; + applicationAnswer: string; + recruiterMessage: string; +}; + +const APPLICATION_ANSWER_START = "<<>>"; +const APPLICATION_ANSWER_END = "<<>>"; + +function extractApplicationAnswerDraft(notes?: string | null) { + const value = (notes ?? "").trim(); + if (!value) return ""; + + const startIndex = value.indexOf(APPLICATION_ANSWER_START); + const endIndex = value.indexOf(APPLICATION_ANSWER_END); + if (startIndex >= 0 && endIndex > startIndex) { + return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim(); + } + + const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i); + return legacyMatch?.[1]?.trim() ?? ""; +} + +export function useJobWorkspaceBaseData({ + open, + jobId, + initialTab, + initialFollowUpMode, +}: { + open: boolean; + jobId: number | null; + initialTab: number; + initialFollowUpMode?: string; +}) { + const [job, setJob] = useState(null); + const [tab, setTab] = useState(0); + const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]); + const [isAdmin, setIsAdmin] = useState(false); + const [jobAttachments, setJobAttachments] = useState([]); + const [selectedAttachmentIds, setSelectedAttachmentIds] = useState([]); + const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState(null); + const [packageWorkspace, setPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); + const [savedPackageWorkspace, setSavedPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); + const [packageGeneratedAt, setPackageGeneratedAt] = useState(null); + const [draftRecipient, setDraftRecipient] = useState(""); + const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply"); + + useEffect(() => { + if (!open || !jobId) return; + + setTab(Math.max(0, Math.min(9, initialTab))); + setJob(null); + setHistory([]); + setIsAdmin(false); + setJobAttachments([]); + setSelectedAttachmentIds([]); + setProfileAvatarImageDataUrl(null); + setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); + setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); + setPackageGeneratedAt(null); + + void api.get(`/jobapplications/${jobId}`).then((r) => { + setJob(r.data); + const savedWorkspace = { + coverLetter: r.data.coverLetterText ?? "", + applicationAnswer: extractApplicationAnswerDraft(r.data.notes), + recruiterMessage: r.data.recruiterMessageDraft ?? "", + }; + setSavedPackageWorkspace(savedWorkspace); + setPackageWorkspace(savedWorkspace); + setDraftRecipient(r.data.company?.recruiterEmail ?? ""); + setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply")); + }); + + void api.get(`/attachments/${jobId}`).then((r) => { + const items = Array.isArray(r.data) ? r.data : []; + setJobAttachments(items); + const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id); + setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id)); + }).catch(() => { + setJobAttachments([]); + setSelectedAttachmentIds([]); + }); + + void api.get(`/auth/me`).then((r) => { + setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); + setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null); + }).catch(() => { + setIsAdmin(false); + setProfileAvatarImageDataUrl(null); + }); + + void api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); + }, [open, jobId, initialTab, initialFollowUpMode]); + + return { + job, + setJob, + tab, + setTab, + history, + setHistory, + isAdmin, + jobAttachments, + setJobAttachments, + selectedAttachmentIds, + setSelectedAttachmentIds, + profileAvatarImageDataUrl, + packageWorkspace, + setPackageWorkspace, + savedPackageWorkspace, + setSavedPackageWorkspace, + packageGeneratedAt, + setPackageGeneratedAt, + draftRecipient, + setDraftRecipient, + followUpMode, + setFollowUpMode, + }; +} + +export { extractApplicationAnswerDraft }; +export type { PackageWorkspaceState }; diff --git a/job-tracker-ui/src/components/job-workspace/useWorkspaceTabCache.ts b/job-tracker-ui/src/components/job-workspace/useWorkspaceTabCache.ts new file mode 100644 index 0000000..1c694f9 --- /dev/null +++ b/job-tracker-ui/src/components/job-workspace/useWorkspaceTabCache.ts @@ -0,0 +1,24 @@ +import { useCallback, useMemo, useRef } from "react"; + +export function useWorkspaceTabCache() { + const cacheRef = useRef(new Map()); + + const getCached = useCallback((key: string) => cacheRef.current.get(key), []); + const setCached = useCallback((key: string, value: T) => { + cacheRef.current.set(key, value); + }, []); + const clearCached = useCallback((prefix?: string) => { + if (!prefix) { + cacheRef.current.clear(); + return; + } + + for (const key of Array.from(cacheRef.current.keys())) { + if (key.startsWith(prefix)) { + cacheRef.current.delete(key); + } + } + }, []); + + return useMemo(() => ({ getCached, setCached, clearCached }), [getCached, setCached, clearCached]); +} diff --git a/job-tracker-ui/src/hooks/useViewResource.ts b/job-tracker-ui/src/hooks/useViewResource.ts index 179b52f..de6aee9 100644 --- a/job-tracker-ui/src/hooks/useViewResource.ts +++ b/job-tracker-ui/src/hooks/useViewResource.ts @@ -1,4 +1,4 @@ -import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getApiErrorMessage } from "../api"; @@ -64,12 +64,18 @@ export function useViewResource( const [refreshing, setRefreshing] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); + const hasLoadedRef = useRef(hasLoaded); + + useEffect(() => { + hasLoadedRef.current = hasLoaded; + }, [hasLoaded]); const reload = useCallback(async () => { if (!enabled) return; - setLoading((current) => !hasLoaded && current); - setRefreshing(hasLoaded); + const alreadyLoaded = hasLoadedRef.current; + setLoading(!alreadyLoaded); + setRefreshing(alreadyLoaded); try { const next = await load(); setData(next); @@ -82,7 +88,7 @@ export function useViewResource( setLoading(false); setRefreshing(false); } - }, [enabled, errorMessage, hasLoaded, load]); + }, [enabled, errorMessage, load]); useEffect(() => { if (!enabled) { diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index bc02b2c..0623420 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -225,6 +225,16 @@ export interface CorrespondenceMessage { attachmentMetadataJson?: string | null; } +export interface AttachmentItem { + id: number; + fileName: string; + uploadDate: string; + fileType: string; + fileSize: number; + purpose?: string | null; + useForAi: boolean; +} + export interface GmailJobMatchReason { label: string; value: string;