From 5ed5b340a523c9e2b81635cf875a303a91bc7ad9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 21 Mar 2026 21:04:04 +0100 Subject: [PATCH] Feature: Remove message, Upgrade: pull better job data, add dedicated status section to job applications --- .../Controllers/CorrespondenceController.cs | 12 + .../Controllers/JobApplicationsController.cs | 7 +- .../Services/JobEnrichmentHostedService.cs | 2 +- .../Services/JobImport/SkillTagger.cs | 57 +++-- .../src/components/Correspondence.tsx | 28 +- .../src/components/EditJobDialog.tsx | 242 ++++++++---------- tools/summarizer/app.py | 45 +++- 7 files changed, 220 insertions(+), 173 deletions(-) diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index c9816c8..b236d38 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -67,5 +67,17 @@ namespace JobTrackerApi.Controllers return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message); } + + + [HttpDelete("{id:int}")] + public async Task Delete([FromRoute] int id, CancellationToken cancellationToken) + { + var message = await _db.Correspondences.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + if (message is null) return NotFound(); + + _db.Correspondences.Remove(message); + await _db.SaveChangesAsync(cancellationToken); + return NoContent(); + } } } diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index efc3b54..92d6c4e 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -576,7 +576,7 @@ namespace JobTrackerApi.Controllers // Generate and persist a short summary at creation time to avoid repeated model calls. try { - var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 80, 20); + var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60); job.ShortSummary = shortSum; } catch @@ -626,7 +626,8 @@ namespace JobTrackerApi.Controllers string? CoverLetterText, string? JobUrl, DateTime? DateApplied, - DateTime? FeedbackRequestedAt + DateTime? FeedbackRequestedAt, + DateTime? StatusChangedAt ); [HttpPut("{id:int}")] @@ -686,7 +687,7 @@ namespace JobTrackerApi.Controllers Type = "StatusChanged", OldValue = oldStatus, NewValue = job.Status, - At = DateTime.Now + At = request.StatusChangedAt ?? DateTime.Now }); } diff --git a/JobTrackerApi/Services/JobEnrichmentHostedService.cs b/JobTrackerApi/Services/JobEnrichmentHostedService.cs index be549c1..1c4d4f4 100644 --- a/JobTrackerApi/Services/JobEnrichmentHostedService.cs +++ b/JobTrackerApi/Services/JobEnrichmentHostedService.cs @@ -58,7 +58,7 @@ public sealed class JobEnrichmentHostedService : BackgroundService { try { - var shortSummary = await summarizer.SummarizeAsync(sourceText, 80, 20); + var shortSummary = await summarizer.SummarizeAsync(sourceText, 160, 60); if (!string.IsNullOrWhiteSpace(shortSummary)) { job.ShortSummary = shortSummary; diff --git a/JobTrackerApi/Services/JobImport/SkillTagger.cs b/JobTrackerApi/Services/JobImport/SkillTagger.cs index 24d29da..c1c867a 100644 --- a/JobTrackerApi/Services/JobImport/SkillTagger.cs +++ b/JobTrackerApi/Services/JobImport/SkillTagger.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -7,29 +7,52 @@ namespace JobTrackerApi.Services.JobImport; public static class SkillTagger { - private static readonly (string Tag, Regex Pattern)[] Patterns = + private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns = { - ("C#", new Regex(@"\bC#\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - (".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("Docker", new Regex(@"\bDocker\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("Azure", new Regex(@"\bAzure\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("AWS", new Regex(@"\bAWS\b|\bAmazon Web Services\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("React", new Regex(@"\bReact\b|\bReact\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("TypeScript", new Regex(@"\bTypeScript\b|\bTS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("SQL", new Regex(@"\bSQL\b|\bPostgreSQL\b|\bMySQL\b|\bSQLite\b|\bMS\s*SQL\b|\bT-?SQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), - ("Kubernetes", new Regex(@"\bKubernetes\b|\bK8s\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)), + ("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6), + (".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6), + ("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6), + ("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("TypeScript", new Regex(@"\bTypeScript\b|\bTS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("React", new Regex(@"\bReact\b|\bReact\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("Node.js", new Regex(@"\bNode\b|\bNode\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("SQL", new Regex(@"\bSQL\b|\bPostgreSQL\b|\bMySQL\b|\bSQLite\b|\bMS\s*SQL\b|\bT-?SQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("Docker", new Regex(@"\bDocker\b|\bcontainers?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("Kubernetes", new Regex(@"\bKubernetes\b|\bK8s\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("Azure", new Regex(@"\bAzure\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("AWS", new Regex(@"\bAWS\b|\bAmazon Web Services\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5), + ("CI/CD", new Regex(@"\bCI/CD\b|continuous integration|continuous delivery|continuous deployment", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4), + ("REST APIs", new Regex(@"\bREST\b|RESTful|API development|web services", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4), + ("GraphQL", new Regex(@"\bGraphQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4), + ("Testing", new Regex(@"\bunit tests?\b|\bintegration tests?\b|\btesting\b|\bTDD\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4), + ("Agile", new Regex(@"\bAgile\b|\bScrum\b|\bKanban\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3), + ("Communication", new Regex(@"communication skills?|communicate effectively|stakeholder management", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3), + ("Collaboration", new Regex(@"collaborat|cross-functional|team player|work closely with", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3), + ("Problem Solving", new Regex(@"problem solving|solve complex|analytical|troubleshoot", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3), + ("Leadership", new Regex(@"leadership|mentor|coaching|leading teams?", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3), + ("Ownership", new Regex(@"ownership|self-starter|take initiative|proactive", RegexOptions.IgnoreCase | RegexOptions.Compiled), 3), + ("Adaptability", new Regex(@"adaptable|flexible|fast-paced|ambiguity", RegexOptions.IgnoreCase | RegexOptions.Compiled), 2), + ("Attention to Detail", new Regex(@"attention to detail|detail-oriented|quality-focused", RegexOptions.IgnoreCase | RegexOptions.Compiled), 2), }; public static string[] Detect(string? description) { if (string.IsNullOrWhiteSpace(description)) return Array.Empty(); - var tags = new List(capacity: 8); - foreach (var (tag, pattern) in Patterns) + + var matches = new List<(string Tag, int Score)>(); + foreach (var (tag, pattern, weight) in Patterns) { - if (pattern.IsMatch(description)) tags.Add(tag); + if (pattern.IsMatch(description)) matches.Add((tag, weight)); } - return tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + return matches + .GroupBy(x => x.Tag, StringComparer.OrdinalIgnoreCase) + .Select(g => new { Tag = g.Key, Score = g.Max(x => x.Score) }) + .OrderByDescending(x => x.Score) + .ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase) + .Take(12) + .Select(x => x.Tag) + .ToArray(); } } - diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index 152b60b..ba24a2c 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -4,6 +4,7 @@ import { Box, Button, Chip, + IconButton, CircularProgress, Dialog, DialogActions, @@ -22,6 +23,7 @@ import { Typography, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import { api } from "../api"; import { useToast } from "../toast"; @@ -231,6 +233,17 @@ export default function Correspondence({ jobId }: { jobId: number }) { } }; + + const deleteMessage = async (messageId: number) => { + try { + await api.delete(`/correspondence/${messageId}`); + await load(); + toast("Message removed.", "success"); + } catch { + toast("Failed to remove message.", "error"); + } + }; + const importGmailMessage = async (messageId: string) => { try { setImportingMessageId(messageId); @@ -303,11 +316,16 @@ export default function Correspondence({ jobId }: { jobId: number }) { {m.content} - - {isMe ? "Me" : "Company"} - {m.channel ? ` - ${m.channel}` : ""} - {m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} - + + + {isMe ? "Me" : "Company"} + {m.channel ? ` - ${m.channel}` : ""} + {m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} + + void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> + + + ); diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index ee53828..f39a243 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -11,7 +11,9 @@ import { FormControlLabel, Checkbox, MenuItem, + Paper, TextField, + Typography, } from "@mui/material"; import { api } from "../api"; @@ -56,6 +58,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) const [company, setCompany] = useState(null); const [jobTitle, setJobTitle] = useState(""); const [status, setStatus] = useState("Applied"); + const [initialStatus, setInitialStatus] = useState("Applied"); + const [statusChangedAt, setStatusChangedAt] = useState(() => new Date().toISOString().slice(0, 10)); const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10)); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); @@ -86,6 +90,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setCompany(j.company ?? null); setJobTitle(j.jobTitle ?? ""); setStatus(j.status ?? "Applied"); + setInitialStatus(j.status ?? "Applied"); + setStatusChangedAt(new Date().toISOString().slice(0, 10)); setDateApplied(toDateInputValue(j.dateApplied)); setLocation(j.location ?? ""); setSalary(j.salary ?? ""); @@ -121,6 +127,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) jobTitle: jobTitle.trim(), companyId: company.id, status, + statusChangedAt: status !== initialStatus ? statusChangedAt || null : null, responseReceived, responseDate: responseReceived && responseDate ? responseDate : null, location: location.trim() || null, @@ -154,154 +161,107 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) Edit job - - c.name} - value={company} - onChange={(_, v) => setCompany(v)} - renderInput={(params) => } - /> + + + Application details + + c.name} + value={company} + onChange={(_, v) => setCompany(v)} + renderInput={(params) => } + /> + setJobTitle(e.target.value)} /> + setDateApplied(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setJobUrl(e.target.value)} /> + + - setStatus(e.target.value)}> - {STATUS_OPTIONS.map((s) => ( - - {s} - - ))} - + + Status update + + setStatus(e.target.value)}> + {STATUS_OPTIONS.map((s) => ( + {s} + ))} + + setStatusChangedAt(e.target.value)} + InputLabelProps={{ shrink: true }} + helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."} + /> + + setResponseReceived(e.target.checked)} />} + label="Reply received" + /> + + setResponseDate(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setNextAction(e.target.value)} /> + setFollowUpAt(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + - setJobTitle(e.target.value)} /> + + Role details + + setLocation(e.target.value)} /> + setSalary(e.target.value)} /> + setDeadline(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setDescriptionLanguage(e.target.value)} /> + + + + setNotes(e.target.value)} multiline rows={4} sx={{ gridColumn: "1 / -1" }} /> + setDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} /> + setTranslatedDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} /> + setCoverLetterText(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} /> + + - setDateApplied(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - setLocation(e.target.value)} /> - - setSalary(e.target.value)} /> - - setNextAction(e.target.value)} /> - - setFollowUpAt(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - setJobUrl(e.target.value)} - sx={{ gridColumn: "1 / -1" }} - /> - - setNotes(e.target.value)} - multiline - rows={4} - sx={{ gridColumn: "1 / -1" }} - /> - - setDescription(e.target.value)} - multiline - rows={6} - sx={{ gridColumn: "1 / -1" }} - /> - - setTranslatedDescription(e.target.value)} - multiline - rows={6} - sx={{ gridColumn: "1 / -1" }} - /> - - setDescriptionLanguage(e.target.value)} - /> - - setDeadline(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - - - - - setCoverLetterText(e.target.value)} - multiline - rows={6} - sx={{ gridColumn: "1 / -1" }} - /> - - setResponseReceived(e.target.checked)} />} - label="Response received" - /> - - setResponseDate(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - - setHasResume(e.target.checked)} />} - label="Resume" - /> - setHasCoverLetter(e.target.checked)} />} - label="Cover letter" - /> - setHasPortfolio(e.target.checked)} />} - label="Portfolio" - /> - setHasOtherAttachment(e.target.checked)} />} - label="Other attachment" - /> - + + Attachments checklist + + setHasResume(e.target.checked)} />} label="Resume" /> + setHasCoverLetter(e.target.checked)} />} label="Cover letter" /> + setHasPortfolio(e.target.checked)} />} label="Portfolio" /> + setHasOtherAttachment(e.target.checked)} />} label="Other attachment" /> + + - + ); diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index 78099bf..1fe51f6 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -65,6 +65,21 @@ _TECH = [ ] + +_SOFT = [ + "communication", + "collaboration", + "teamwork", + "problem solving", + "leadership", + "mentoring", + "ownership", + "initiative", + "adaptability", + "stakeholder management", + "detail oriented", +] + def _strip_html(text: str) -> str: # Good enough for job descriptions pasted from the web. text = re.sub(r"<\s*br\s*/?>", "\n", text, flags=re.IGNORECASE) @@ -131,10 +146,14 @@ def _role_focused_excerpt(text: str) -> dict: nice = _extract_bullets(nice_lines, max_items=5) tech_found = [] + soft_found = [] low = cleaned.lower() for t in _TECH: if t in low: tech_found.append(t) + for s in _SOFT: + if s in low: + soft_found.append(s) # Fallback: pick bullet-like lines anywhere if sections are missing. if not responsibilities and not requirements: @@ -160,6 +179,7 @@ def _role_focused_excerpt(text: str) -> dict: "requirements": requirements, "nice": nice, "tech": tech_found, + "soft": soft_found, } @@ -192,26 +212,39 @@ async def summarize(req: SummarizeRequest): lines = ["Role summary:", summary] + if info["requirements"]: + lines.append("") + lines.append("What they need from you:") + for x in info["requirements"][:7]: + lines.append(f"- {x}") + if info["responsibilities"]: lines.append("") - lines.append("Key responsibilities:") + lines.append("What you would be doing:") for x in info["responsibilities"][:6]: lines.append(f"- {x}") - if info["requirements"]: + if info["nice"]: lines.append("") - lines.append("Key requirements:") - for x in info["requirements"][:6]: + lines.append("Nice to have:") + for x in info["nice"][:5]: lines.append(f"- {x}") if info["tech"]: - # Keep this short; it's just a hint based on keyword matches. uniq = [] for t in info["tech"]: if t not in uniq: uniq.append(t) lines.append("") - lines.append("Tech keywords: " + ", ".join(uniq[:14])) + lines.append("Relevant hard skills: " + ", ".join(uniq[:14])) + + if info["soft"]: + uniq_soft = [] + for s in info["soft"]: + if s not in uniq_soft: + uniq_soft.append(s) + lines.append("") + lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8])) out = "\n".join(lines).strip() cache[key] = out