diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 6d8c72f..166a98b 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -98,45 +98,69 @@ public sealed class GmailController : ControllerBase [HttpPost("import")] public async Task Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) { - if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); - if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required."); - - var ownerUserId = GetRequiredOwnerUserId(); - var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken); - if (job is null) return NotFound("Job application not found."); - - var existing = await _db.Correspondences.FirstOrDefaultAsync( - x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId, - cancellationToken); - if (existing is not null) + try { - return Ok(existing); + if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); + if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required."); + + var ownerUserId = GetRequiredOwnerUserId(); + var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken); + if (job is null) return NotFound("Job application not found."); + + var existing = await _db.Correspondences.FirstOrDefaultAsync( + x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId, + cancellationToken); + if (existing is not null) + { + return Ok(existing); + } + + var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken); + var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); + var gmailAddress = me?.GmailAddress ?? string.Empty; + var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase); + var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now; + + var message = new Correspondence + { + JobApplicationId = request.JobApplicationId, + From = isMe ? "Me" : "Company", + Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(), + Channel = "Email", + ExternalMessageId = detail.Id, + Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText, + Date = messageDate, + }; + + _db.Correspondences.Add(message); + if (job.Company is not null) + { + job.Company.LastContactedAt = messageDate; + } + + if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value)) + { + var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}"; + job.ResponseReceived = true; + job.ResponseDate = messageDate; + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "ReplyReceived", + OldValue = oldResponse, + NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}", + Note = detail.Subject, + At = messageDate + }); + } + + await _db.SaveChangesAsync(cancellationToken); + return Ok(message); } - - var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken); - var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); - var gmailAddress = me?.GmailAddress ?? string.Empty; - var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase); - - var message = new Correspondence + catch (Exception ex) { - JobApplicationId = request.JobApplicationId, - From = isMe ? "Me" : "Company", - Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(), - Channel = "Email", - ExternalMessageId = detail.Id, - Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText, - Date = detail.Date?.LocalDateTime ?? DateTime.Now, - }; - - _db.Correspondences.Add(message); - if (job.Company is not null) - { - job.Company.LastContactedAt = DateTime.UtcNow; + return BadRequest(ex.Message); } - - await _db.SaveChangesAsync(cancellationToken); - return Ok(message); } private string GetRequiredOwnerUserId() diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index 8088f0b..469216a 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -236,8 +236,12 @@ export default function Correspondence({ jobId }: { jobId: number }) { }); await load(); toast("Email imported from Gmail.", "success"); - } catch { - toast("Failed to import Gmail message.", "error"); + } catch (error: any) { + const message = + error?.response?.data && typeof error.response.data === "string" + ? error.response.data + : "Failed to import Gmail message."; + toast(message, "error"); } finally { setImportingMessageId(null); } @@ -303,8 +307,8 @@ export default function Correspondence({ jobId }: { jobId: number }) { sx={{ display: "block", mt: 0.75, color: "text.secondary" }} > {isMe ? "Me" : "Company"} - {m.channel ? ` · ${m.channel}` : ""} - {m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""} + {m.channel ? ` · ${m.channel}` : ""} + {m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 563bb33..6949cab 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -110,7 +110,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { - + setTab(v)} sx={{ mb: 2 }}> diff --git a/job-tracker-ui/src/components/JobFlowBar.tsx b/job-tracker-ui/src/components/JobFlowBar.tsx index 1c03004..e945af5 100644 --- a/job-tracker-ui/src/components/JobFlowBar.tsx +++ b/job-tracker-ui/src/components/JobFlowBar.tsx @@ -1,60 +1,141 @@ import React, { useMemo } from "react"; -import { Box, Tooltip, Typography, useTheme } from "@mui/material"; +import { Box, Chip, Typography, useTheme } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import WorkOutlineIcon from "@mui/icons-material/WorkOutline"; -import ForumIcon from "@mui/icons-material/Forum"; import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead"; +import ForumIcon from "@mui/icons-material/Forum"; import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; import CloseIcon from "@mui/icons-material/Close"; -import { alpha } from "@mui/material/styles"; - import { JobApplication } from "../types"; +type HistoryEvent = { + id: number; + type: string; + oldValue?: string; + newValue?: string; + note?: string; + at: string; +}; + +type FlowItem = { + key: string; + label: string; + at: Date; + color: string; + icon: React.ReactNode; +}; + function normalizeStatus(status?: string): string { if (!status) return "Applied"; if (status === "Interviewing") return "Interview"; return status; } -function isTerminalRejected(status: string) { - return status === "Rejected" || status === "Ghosted"; +function parseResponseDate(value?: string): Date | null { + if (!value) return null; + const parts = value.split(":"); + const raw = parts.length > 1 ? parts.slice(1).join(":") : ""; + const d = new Date(raw); + return Number.isNaN(+d) ? null : d; } -export default function JobFlowBar({ job }: { job: JobApplication | null }) { +function firstStatusChange(history: HistoryEvent[], status: string): Date | null { + const match = history.find((item) => item.type === "StatusChanged" && normalizeStatus(item.newValue) === status); + if (!match) return null; + const d = new Date(match.at); + return Number.isNaN(+d) ? null : d; +} + +function firstResponse(history: HistoryEvent[], job: JobApplication): Date | null { + if (job.responseDate) { + const d = new Date(job.responseDate); + if (!Number.isNaN(+d)) return d; + } + + const explicit = history.find((item) => item.type === "ReplyReceived"); + if (explicit) { + const d = new Date(explicit.at); + if (!Number.isNaN(+d)) return d; + } + + const updated = history.find((item) => item.type === "ResponseUpdated"); + if (!updated) return null; + return parseResponseDate(updated.newValue) ?? new Date(updated.at); +} + +export default function JobFlowBar({ job, history = [] }: { job: JobApplication | null; history?: HistoryEvent[] }) { const theme = useTheme(); - const steps = useMemo( - () => - [ - { key: "applied" as const, label: "Applied", color: theme.palette.info.main, icon: }, - { key: "interview" as const, label: "Interview", color: theme.palette.primary.main, icon: }, - { key: "reply" as const, label: "Reply", color: theme.palette.success.main, icon: }, - { key: "offer" as const, label: "Offer", color: theme.palette.success.dark, icon: }, - { key: "outcome" as const, label: "Outcome", color: theme.palette.text.secondary, icon: }, - ] as const, - [theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.success.dark, theme.palette.text.secondary], - ); - if (!job) return null; + const items = useMemo(() => { + if (!job) return [] as FlowItem[]; - const status = normalizeStatus(job.status); - const reply = Boolean(job.responseReceived); - const rejected = isTerminalRejected(status); + const appliedAt = new Date(job.dateApplied); + const replyAt = firstResponse(history, job); + const interviewAt = firstStatusChange(history, "Interview") || (normalizeStatus(job.status) === "Interview" ? replyAt ?? appliedAt : null); + const offerAt = firstStatusChange(history, "Offer") || (normalizeStatus(job.status) === "Offer" ? replyAt ?? interviewAt ?? appliedAt : null); + const rejectedLabel = normalizeStatus(job.status) === "Ghosted" ? "Ghosted" : "Rejected"; + const rejectedAt = firstStatusChange(history, "Rejected") + || firstStatusChange(history, "Ghosted") + || ((normalizeStatus(job.status) === "Rejected" || normalizeStatus(job.status) === "Ghosted") ? replyAt ?? appliedAt : null); - const activeIndex = (() => { - if (rejected) return 4; - if (status === "Offer") return 3; - if (reply) return 2; - if (status === "Interview") return 1; - return 0; - })(); + const next: FlowItem[] = [ + { + key: "applied", + label: "Applied", + at: appliedAt, + color: theme.palette.info.main, + icon: , + }, + ]; - const breakIndex = rejected ? activeIndex : null; - const visibleSteps = breakIndex !== null ? steps.slice(0, breakIndex + 1) : steps; + if (replyAt) { + next.push({ + key: "reply", + label: "Reply", + at: replyAt, + color: theme.palette.success.main, + icon: , + }); + } - const segCount = visibleSteps.length; - const segWidth = `${100 / segCount}%`; + if (interviewAt) { + next.push({ + key: "interview", + label: "Interview", + at: interviewAt, + color: theme.palette.primary.main, + icon: , + }); + } + + if (offerAt) { + next.push({ + key: "offer", + label: "Offer", + at: offerAt, + color: theme.palette.success.dark, + icon: , + }); + } else if (rejectedAt) { + next.push({ + key: normalizeStatus(job.status).toLowerCase(), + label: rejectedLabel, + at: rejectedAt, + color: theme.palette.error.main, + icon: , + }); + } + + return next + .filter((item) => !Number.isNaN(+item.at)) + .sort((a, b) => +a.at - +b.at) + .filter((item, index, arr) => index === arr.findIndex((other) => other.key === item.key)); + }, [history, job, theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.dark, theme.palette.success.main]); + + if (!job || items.length === 0) return null; return ( @@ -65,70 +146,35 @@ export default function JobFlowBar({ job }: { job: JobApplication | null }) { - {visibleSteps.map((s, idx) => { - const filled = idx <= activeIndex && breakIndex === null; - const terminalFilled = breakIndex !== null && idx < breakIndex; - const isBreak = breakIndex !== null && idx === breakIndex; - - const bg = filled || terminalFilled ? s.color : alpha(s.color, 0.12); - const fg = filled || terminalFilled ? "#fff" : alpha(theme.palette.text.primary, 0.6); - - const title = (() => { - if (isBreak) return `${status} (stopped here)`; - if (idx === 2 && reply) return "Reply received"; - return s.label; - })(); - - return ( - - - {isBreak ? ( - - ) : ( - - {s.icon} - {s.key === "outcome" && rejected ? status : s.label} - - )} - - {isBreak ? ( - - {status} - - ) : null} - - - ); - })} + {items.map((item, index) => ( + + + {index < items.length - 1 ? ( + + ? + + ) : null} + + ))} ); diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index 20905b8..44c1e40 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -133,6 +133,7 @@ export default function KanbanBoard() { border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`, background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.55)" : "rgba(255,255,255,0.8)", backdropFilter: "blur(8px)", + color: theme.palette.text.primary, }} > @@ -142,6 +143,7 @@ export default function KanbanBoard() { { e.stopPropagation(); setMenuJobId(j.id);