From 37d6a1b60049af046b7ef63f783a46b4e547bd94 Mon Sep 17 00:00:00 2001 From: Sory Kaba <131464999+SoryAK@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:38:02 -0400 Subject: [PATCH] Remove a role from Career History, one at a time or several selected, and flag identical records so the copies can go. Removing a role also removes its sites, details, facts, and photos on this machine, and unlinks its worklog entries. Stats count an identical record once, add up tenure without counting overlapping roles twice, and skip miles between roles held at the same time. Search matches site addresses, the counts follow the search, and an empty search says so on the map with a way to clear it. A place keeps its city when the street is abbreviated or the state carries a zip. Co-authored-by: Cursor --- core/career-history.test.ts | 250 ++++++++++++++++++++- core/career-history.ts | 279 +++++++++++++++++++---- core/index.ts | 8 + core/work-map.test.ts | 14 ++ core/work-map.ts | 10 + server/app.ts | 14 ++ server/work-map.test.ts | 48 ++++ server/work-map.ts | 61 +++++ web/src/CareerMap.tsx | 4 + web/src/GoogleWorkMap.tsx | 13 ++ web/src/RoleDetailPanel.tsx | 16 +- web/src/WorkMap.tsx | 429 ++++++++++++++++++++++++++++++++---- web/src/WorkMapCanvas.tsx | 14 +- web/src/WorkMapPreview.tsx | 12 +- web/src/work-map.css | 233 ++++++++++++++++---- 15 files changed, 1265 insertions(+), 140 deletions(-) diff --git a/core/career-history.test.ts b/core/career-history.test.ts index 9ec3f5f..2d157de 100644 --- a/core/career-history.test.ts +++ b/core/career-history.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it } from "vitest"; import { + careerHistoryCopyKey, + careerHistoryIdentityKey, classifyCareerHistorySection, + clusterCareerHistory, + flagIdenticalRoles, formatHistoryGist, + formatHistoryPlace, groupCareerHistory, presentCareerHistoryStats, searchCareerHistory, sortCareerHistory, + type CareerHistoryIdentity, type CareerHistoryItem, } from "./career-history"; function item( - overrides: Partial & Pick, -): CareerHistoryItem { + overrides: Partial & Pick, +): CareerHistoryIdentity { return { kind: "job", title: "Lead", @@ -137,6 +143,120 @@ describe("formatHistoryGist", () => { ), ).toBe("Jun 2024 – Present (2y 3m)"); }); + + it("keeps the city when the street uses an abbreviation and the state carries a zip", () => { + expect( + formatHistoryPlace({ + organization: "Giants Direct", + locationLabel: "67", + locations: [ + { + address: "235 N Reservoir St, Lancaster, PA 17602, USA", + }, + ], + }), + ).toBe("Lancaster, PA"); + expect( + formatHistoryPlace({ + organization: "Giants Direct", + locationLabel: "235 N Reservoir St, Lancaster, PA 17602", + }), + ).toBe("Lancaster, PA"); + }); +}); + +describe("clusterCareerHistory", () => { + it("keeps one lead for copies that share organization, title, and start month", () => { + const clusters = clusterCareerHistory([ + item({ + id: "copy-b", + organization: "Giants Direct", + title: "Technician", + startDate: "2024-06-15", + locationLabel: "67", + }), + item({ + id: "copy-a", + organization: "giants direct", + title: "Technician", + startDate: "2024-06-01", + locationLabel: "Lancaster, PA", + locations: [ + { + address: "235 N Reservoir St, Lancaster, PA 17602, USA", + latitude: 40.04, + longitude: -76.3, + }, + ], + }), + item({ + id: "other", + organization: "Sharp Packaging", + title: "Technician", + startDate: "2024-06-01", + }), + ]); + expect(clusters.map((cluster) => [cluster.lead.id, cluster.copies.length])).toEqual([ + ["copy-a", 2], + ["other", 1], + ]); + expect(careerHistoryCopyKey(clusters[0].lead)).toBe( + careerHistoryCopyKey(clusters[0].copies[1]), + ); + }); +}); + +describe("flagIdenticalRoles", () => { + it("flags records that match in dates, text, and places, and keeps the first", () => { + const shared = { + organization: "Giants Direct", + title: "Technician", + startDate: "2024-06-01", + description: "Maintained the robotic line.", + achievements: ["Replaced relays", "Tuned belts"], + locationLabel: "Lancaster", + locations: [ + { + address: "235 N Reservoir St, Lancaster, PA", + latitude: 40, + longitude: -76, + }, + ], + }; + const flags = flagIdenticalRoles([ + item({ id: "keep", ...shared }), + item({ + id: "same", + ...shared, + achievements: ["Tuned belts", "Replaced relays"], + startDate: "2024-06-20", + }), + item({ + id: "other-place", + ...shared, + locationLabel: "Philadelphia", + locations: [ + { + address: "Philadelphia, PA", + latitude: 39.9, + longitude: -75.1, + }, + ], + }), + ]); + expect(flags.get("keep")).toEqual({ count: 2, keeper: true }); + expect(flags.get("same")).toEqual({ count: 2, keeper: false }); + expect(flags.get("other-place")).toEqual({ count: 1, keeper: true }); + expect(careerHistoryIdentityKey(item({ id: "keep", ...shared }))).toBe( + careerHistoryIdentityKey( + item({ + id: "same", + ...shared, + description: " Maintained the robotic line. ", + }), + ), + ); + }); }); describe("presentCareerHistoryStats", () => { @@ -180,9 +300,95 @@ describe("presentCareerHistoryStats", () => { new Date(2026, 8, 1), ); expect(stats.roles).toBe(2); + expect( + presentCareerHistoryStats( + [ + item({ + id: "a", + organization: "Giants Direct", + title: "Technician", + description: "Same work", + }), + item({ + id: "b", + organization: "Giants Direct", + title: "Technician", + description: "Same work", + }), + item({ + id: "c", + organization: "Giants Direct", + title: "Technician", + description: "Different work", + }), + ], + new Date(2026, 8, 1), + ).roles, + ).toBe(2); expect(stats.cities).toBe(2); - expect(stats.tenure).toBe("10y 1m"); + expect(stats.tenure).toBe("8y 5m"); expect(stats.miles).toBeGreaterThan(60); + expect( + presentCareerHistoryStats( + [ + item({ + id: "open-ended", + isCurrent: false, + startDate: "2010-01-01", + endDate: "", + locationLabel: "Dayton, OH", + }), + ], + new Date(2026, 8, 1), + ).tenure, + ).toBe(""); + const overlapping = presentCareerHistoryStats( + [ + item({ + id: "media", + startDate: "2016-03-01", + locationLabel: "Media, PA", + locations: [ + { address: "Media, PA", latitude: 39.916, longitude: -75.388 }, + ], + }), + item({ + id: "allentown", + organization: "Sharp", + startDate: "2023-01-01", + locationLabel: "Allentown, PA", + locations: [ + { + address: "Allentown, PA", + latitude: 40.608, + longitude: -75.49, + }, + ], + }), + ], + new Date(2026, 8, 1), + ); + expect(overlapping.miles).toBe(0); + expect(overlapping.tenure).toBe("10y 6m"); + expect( + presentCareerHistoryStats( + [ + item({ + id: "giants", + organization: "Giants Direct", + locationLabel: "67", + locations: [ + { + address: "235 N Reservoir St, Lancaster, PA 17602, USA", + latitude: 40.04, + longitude: -76.3, + }, + ], + }), + ], + new Date(2026, 8, 1), + ).cities, + ).toBe(1); }); }); @@ -195,6 +401,44 @@ describe("searchCareerHistory", () => { expect(searchCareerHistory(items, "county").map((row) => row.id)).toEqual([ "trades", ]); + expect( + searchCareerHistory( + [ + item({ + id: "giants", + organization: "Giants Direct", + locationLabel: "67", + locations: [ + { + address: "235 N Reservoir St, Lancaster, PA 17602, USA", + latitude: 40.04, + longitude: -76.3, + }, + ], + }), + ], + "lancaster", + ).map((row) => row.id), + ).toEqual(["giants"]); + expect( + searchCareerHistory( + [ + item({ + id: "giants", + organization: "Giants Direct", + locationLabel: "67", + locations: [ + { + address: "235 N Reservoir St, Lancaster, PA 17602, USA", + latitude: 40.04, + longitude: -76.3, + }, + ], + }), + ], + "reservoir", + ).map((row) => row.id), + ).toEqual(["giants"]); }); }); diff --git a/core/career-history.ts b/core/career-history.ts index 43a550b..f8873fc 100644 --- a/core/career-history.ts +++ b/core/career-history.ts @@ -29,6 +29,11 @@ export type CareerHistoryGroup = { items: CareerHistoryItem[]; }; +export type CareerHistoryCluster = { + lead: CareerHistoryItem; + copies: CareerHistoryItem[]; +}; + export type CareerHistoryStats = { tenure: string; roles: number; @@ -67,7 +72,13 @@ export function searchCareerHistory( const needle = query.trim().toLowerCase(); if (!needle) return items; return items.filter((item) => - [item.organization, item.title, item.locationLabel, formatHistoryPlace(item)] + [ + item.organization, + item.title, + item.locationLabel, + formatHistoryPlace(item), + ...(item.locations ?? []).map((location) => location.address), + ] .join(" ") .toLowerCase() .includes(needle), @@ -93,17 +104,116 @@ export function groupCareerHistory( })).filter((section) => section.items.length > 0); } -export function presentCareerHistoryStats( +export function careerHistoryCopyKey(item: { + kind: string; + title: string; + organization: string; + startDate: string; +}): string { + const month = item.startDate.trim().match(/^(\d{4})-(\d{2})/); + const start = month + ? `${month[1]}-${month[2]}` + : item.startDate.trim().toLowerCase(); + return [ + classifyCareerHistorySection(item), + item.organization.trim().toLowerCase().replace(/\s+/g, " "), + item.title.trim().toLowerCase().replace(/\s+/g, " "), + start, + ].join("\0"); +} + +export function clusterCareerHistory( items: CareerHistoryItem[], +): CareerHistoryCluster[] { + const order: string[] = []; + const groups = new Map(); + for (const item of items) { + const key = careerHistoryCopyKey(item); + const group = groups.get(key); + if (group) group.push(item); + else { + groups.set(key, [item]); + order.push(key); + } + } + return order.map((key) => { + const group = groups.get(key) ?? []; + const lead = [...group].sort(byCopyRichness)[0]; + return { + lead, + copies: [lead, ...group.filter((item) => item.id !== lead.id)], + }; + }); +} + +export type CareerHistoryIdentity = CareerHistoryItem & { + description?: string; + achievements?: string[]; +}; + +export type IdenticalRoleFlag = { + count: number; + keeper: boolean; +}; + +export function careerHistoryIdentityKey(item: CareerHistoryIdentity): string { + const places = (item.locations ?? []) + .map((location) => normalizeHistoryText(location.address)) + .filter(Boolean) + .sort(); + const achievements = (item.achievements ?? []) + .map((achievement) => normalizeHistoryText(achievement)) + .filter(Boolean) + .sort(); + return [ + classifyCareerHistorySection(item), + normalizeHistoryText(item.organization), + normalizeHistoryText(item.title), + historyMonthKey(item.startDate), + item.isCurrent ? "current" : historyMonthKey(item.endDate), + normalizeHistoryText(item.description ?? ""), + achievements.join("|"), + places.length > 0 + ? places.join("|") + : normalizeHistoryText(item.locationLabel), + ].join("\0"); +} + +export function flagIdenticalRoles( + items: CareerHistoryIdentity[], +): Map { + const groups = new Map(); + const order: string[] = []; + for (const item of items) { + const key = careerHistoryIdentityKey(item); + const group = groups.get(key); + if (group) group.push(item.id); + else { + groups.set(key, [item.id]); + order.push(key); + } + } + const flags = new Map(); + for (const key of order) { + const ids = groups.get(key) ?? []; + ids.forEach((id, index) => { + flags.set(id, { count: ids.length, keeper: index === 0 }); + }); + } + return flags; +} + +export function presentCareerHistoryStats( + items: CareerHistoryIdentity[], now = new Date(), ): CareerHistoryStats { const work = items.filter( (item) => classifyCareerHistorySection(item) !== "school", ); return { - tenure: formatTenureMonths(careerSpanMonths(items, now)), - roles: work.length, - miles: Math.round(travelMiles(items)), + tenure: formatTenureMonths(workTenureMonths(items, now)), + roles: new Set(work.map((item) => careerHistoryIdentityKey(item))).size, + miles: Math.round(travelMiles(items, now)), cities: uniqueCities(items).size, }; } @@ -164,6 +274,9 @@ const STATE_ABBREV: Record = { const STREET_WORD = /\b(streets?|roads?|avenues?|boulevards?|drives?|lanes?|ways?|courts?|highways?|parkways?|circles?|places?|terraces?|trails?)\b/i; +const STREET_SUFFIX = + /\b(?:st|ave|blvd|dr|ln|rd|ct|hwy|pkwy|cir|pl|ter|trl|sq|pike|aly)\.?$/i; +const STATE_CODES = new Set(Object.values(STATE_ABBREV)); export function formatHistoryPlace(item: { organization: string; @@ -205,6 +318,12 @@ function compactPlace(raw: string, organization: string): string { if (!state) state = abbrev; continue; } + const postal = stateBeforePostal(part); + if (postal) { + if (!state) state = postal.state; + if (postal.city) cities.push(postal.city); + continue; + } if (/ county$/i.test(part)) { if (!county) county = part.replace(/ county$/i, "").trim(); continue; @@ -230,7 +349,27 @@ function isHouseNumber(value: string): boolean { } function isStreet(value: string): boolean { - return STREET_WORD.test(value); + const trimmed = value.trim(); + return STREET_WORD.test(trimmed) || STREET_SUFFIX.test(trimmed); +} + +function stateBeforePostal(part: string): { state: string; city: string } | null { + const match = part.trim().match(/^(.*?)(?:\s+)(\d{5}(?:-\d{4})?)$/); + if (!match) return null; + const head = match[1].trim(); + const direct = knownState(head); + if (direct) return { state: direct, city: "" }; + const words = head.split(/\s+/); + if (words.length < 2) return null; + const abbrev = knownState(words[words.length - 1] ?? ""); + if (!abbrev) return null; + return { state: abbrev, city: words.slice(0, -1).join(" ") }; +} + +function knownState(value: string): string | null { + const abbrev = stateAbbrev(value); + if (!abbrev || !STATE_CODES.has(abbrev)) return null; + return abbrev; } function stateAbbrev(value: string): string | null { @@ -283,7 +422,8 @@ export function tenureMonths( ): number { const start = parseYearMonth(startDate); if (!start) return 0; - const end = isCurrent || !endDate ? now : (parseYearMonth(endDate) ?? now); + const end = isCurrent ? now : parseYearMonth(endDate); + if (!end) return 0; return Math.max( 0, (end.getFullYear() - start.getFullYear()) * 12 + @@ -300,55 +440,93 @@ export function formatTenureMonths(months: number): string { return `${rest}m`; } -function careerSpanMonths(items: CareerHistoryItem[], now: Date): number { - const starts = items - .map((item) => parseYearMonth(item.startDate)) - .filter((value): value is Date => Boolean(value)); - if (starts.length === 0) return 0; - const earliest = starts.reduce((min, date) => (date < min ? date : min)); - const ends = items.map((item) => - item.isCurrent || !item.endDate - ? now - : (parseYearMonth(item.endDate) ?? now), - ); - const latest = ends.reduce((max, date) => (date > max ? date : max)); - return Math.max( - 0, - (latest.getFullYear() - earliest.getFullYear()) * 12 + - (latest.getMonth() - earliest.getMonth()), - ); +function workTenureMonths(items: CareerHistoryItem[], now: Date): number { + const intervals = items + .filter((item) => classifyCareerHistorySection(item) !== "school") + .flatMap((item) => { + const start = parseYearMonth(item.startDate); + const end = item.isCurrent ? now : parseYearMonth(item.endDate); + if (!start || !end || end < start) return []; + return [{ start: monthIndex(start), end: monthIndex(end) }]; + }) + .sort((a, b) => a.start - b.start || a.end - b.end); + let months = 0; + let cursor = Number.NEGATIVE_INFINITY; + for (const interval of intervals) { + const from = Math.max(interval.start, cursor); + if (interval.end > from) { + months += interval.end - from; + cursor = interval.end; + } + } + return months; +} + +function monthIndex(date: Date): number { + return date.getFullYear() * 12 + date.getMonth(); } function uniqueCities(items: CareerHistoryItem[]): Set { const cities = new Set(); for (const item of items) { - const place = formatHistoryPlace(item); - if (place) cities.add(place.toLowerCase()); + const sources = [ + ...(item.locations ?? []).map((location) => location.address), + item.locationLabel, + ]; + for (const raw of sources) { + const place = compactPlace(raw, item.organization); + if (place) cities.add(place.toLowerCase()); + } } return cities; } -function travelMiles(items: CareerHistoryItem[]): number { - const points = [...items] - .sort((a, b) => (a.startDate || "").localeCompare(b.startDate || "")) - .flatMap((item) => { - const location = item.locations?.[0]; - if ( - !location || - !Number.isFinite(location.latitude) || - !Number.isFinite(location.longitude) - ) { - return []; - } - return [location]; - }); +function travelMiles(items: CareerHistoryItem[], now: Date): number { + const stops = [...items] + .filter((item) => mappedPoint(item) !== null) + .sort((a, b) => (a.startDate || "").localeCompare(b.startDate || "")); let miles = 0; - for (let index = 1; index < points.length; index += 1) { - miles += haversineMiles(points[index - 1], points[index]); + for (let index = 1; index < stops.length; index += 1) { + const previous = stops[index - 1]; + const next = stops[index]; + if (!previous || !next || rolesOverlap(previous, next, now)) continue; + const from = mappedPoint(previous); + const to = mappedPoint(next); + if (!from || !to) continue; + miles += haversineMiles(from, to); } return miles; } +function mappedPoint(item: CareerHistoryItem): { + latitude: number; + longitude: number; +} | null { + const location = item.locations?.[0]; + if ( + !location || + !Number.isFinite(location.latitude) || + !Number.isFinite(location.longitude) + ) { + return null; + } + return location; +} + +function rolesOverlap( + left: CareerHistoryItem, + right: CareerHistoryItem, + now: Date, +): boolean { + const leftStart = parseYearMonth(left.startDate); + const rightStart = parseYearMonth(right.startDate); + if (!leftStart || !rightStart) return false; + const leftEnd = left.isCurrent ? now : parseYearMonth(left.endDate); + const rightEnd = right.isCurrent ? now : parseYearMonth(right.endDate); + if (!leftEnd || !rightEnd) return true; + return leftStart < rightEnd && rightStart < leftEnd; +} + function haversineMiles( from: { latitude: number; longitude: number }, to: { latitude: number; longitude: number }, @@ -384,6 +562,23 @@ function parseYearMonth(value: string): Date | null { return new Date(Number(match[1]), month - 1, 1); } +function normalizeHistoryText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function historyMonthKey(value: string): string { + const month = value.trim().match(/^(\d{4})-(\d{2})/); + return month ? `${month[1]}-${month[2]}` : normalizeHistoryText(value); +} + +function byCopyRichness(a: CareerHistoryItem, b: CareerHistoryItem): number { + const locations = (b.locations?.length ?? 0) - (a.locations?.length ?? 0); + if (locations !== 0) return locations; + const label = b.locationLabel.trim().length - a.locationLabel.trim().length; + if (label !== 0) return label; + return a.id.localeCompare(b.id); +} + function byHistoryRecency(a: CareerHistoryItem, b: CareerHistoryItem): number { if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1; return (b.startDate || "").localeCompare(a.startDate || ""); diff --git a/core/index.ts b/core/index.ts index cfb9934..56062d1 100644 --- a/core/index.ts +++ b/core/index.ts @@ -154,13 +154,20 @@ export { } from "./career-file"; export { CAREER_HISTORY_SECTIONS, + careerHistoryCopyKey, + careerHistoryIdentityKey, classifyCareerHistorySection, + clusterCareerHistory, + flagIdenticalRoles, formatHistoryGist, formatHistoryPlace, groupCareerHistory, presentCareerHistoryStats, searchCareerHistory, sortCareerHistory, + type CareerHistoryCluster, + type CareerHistoryIdentity, + type IdenticalRoleFlag, type CareerHistoryGroup, type CareerHistoryItem, type CareerHistoryStats, @@ -367,6 +374,7 @@ export { prepareLinkedEvidenceDelete, prepareWorkMapLocation, prepareWorkMapRoleCreate, + prepareWorkMapRoleDelete, nominatimAddressLine, presentWorkMapPlace, roleCoverPhoto, diff --git a/core/work-map.test.ts b/core/work-map.test.ts index 6825f64..2508709 100644 --- a/core/work-map.test.ts +++ b/core/work-map.test.ts @@ -14,6 +14,7 @@ import { prepareLinkedEvidenceDelete, prepareWorkMapLocation, prepareWorkMapRoleCreate, + prepareWorkMapRoleDelete, nominatimAddressLine, presentWorkMapPlace, publicationAllowsSnapshot, @@ -519,6 +520,19 @@ describe("Work Map role create", () => { }); }); +describe("Work Map role delete", () => { + it("requires a role id", () => { + expect(prepareWorkMapRoleDelete({ id: " role-1 " })).toEqual({ + ok: true, + value: { id: "role-1" }, + }); + expect(prepareWorkMapRoleDelete({ id: " " })).toEqual({ + ok: false, + error: "role-missing", + }); + }); +}); + describe("Linked Evidence", () => { it("accepts a review that credits work on the role", () => { expect( diff --git a/core/work-map.ts b/core/work-map.ts index 8e8436f..32a46d0 100644 --- a/core/work-map.ts +++ b/core/work-map.ts @@ -330,6 +330,16 @@ export function publicationNeedsAudienceConfirm(input: { ); } +export function prepareWorkMapRoleDelete(input: { + id?: unknown; +}): + | { ok: true; value: { id: string } } + | { ok: false; error: "role-missing" } { + const id = typeof input.id === "string" ? input.id.trim() : ""; + if (!id) return { ok: false, error: "role-missing" }; + return { ok: true, value: { id } }; +} + export function prepareWorkMapRoleCreate(input: { kind?: unknown; }): diff --git a/server/app.ts b/server/app.ts index 4be3abd..931e971 100644 --- a/server/app.ts +++ b/server/app.ts @@ -210,6 +210,7 @@ import { readWorkMap, readWorkMapSettings, removeWorkMapLocation, + removeWorkMapRole, removeWorkMapMedia, setWorkMapMediaPublic, saveWorkMapDetails, @@ -1026,6 +1027,19 @@ export function createApp(db: DatabaseSync, options: AppOptions = {}): Hono { } }); + app.delete("/api/work-map/roles/:id", (c) => { + const occupant = ensureOccupant(db); + try { + removeWorkMapRole(db, occupant.id, c.req.param("id"), uploadsDir); + return c.json({ ok: true }); + } catch (error) { + if (error instanceof WorkMapStoreError) { + return c.json({ error: error.code }, error.code === "role-missing" ? 404 : 400); + } + throw error; + } + }); + app.put("/api/work-map/roles/:id", async (c) => { const occupant = ensureOccupant(db); try { diff --git a/server/work-map.test.ts b/server/work-map.test.ts index cd4cd87..348f6da 100644 --- a/server/work-map.test.ts +++ b/server/work-map.test.ts @@ -553,6 +553,54 @@ describe("Work Map role create HTTP seam", () => { } }); + it("removes one role and leaves its copies", async () => { + const db = openDatabase(":memory:"); + const app = createApp(db); + try { + const first = await app.request("/api/work-map/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "job", title: "Technician", organization: "Giants Direct" }), + }); + const second = await app.request("/api/work-map/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "job", title: "Technician", organization: "Giants Direct" }), + }); + const removedId = ((await first.json()) as { role: { id: string } }).role.id; + const keptId = ((await second.json()) as { role: { id: string } }).role.id; + const located = await app.request(`/api/work-map/roles/${removedId}/locations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + label: "Plant", + address: "Lancaster, PA", + latitude: 40.04, + longitude: -76.3, + kind: "primary", + }), + }); + expect(located.status).toBe(201); + + const removed = await app.request(`/api/work-map/roles/${removedId}`, { + method: "DELETE", + }); + expect(removed.status).toBe(200); + const map = (await (await app.request("/api/work-map")).json()) as { + roles: Array<{ id: string; locations: unknown[] }>; + }; + expect(map.roles.map((role) => role.id)).toEqual([keptId]); + + const missing = await app.request(`/api/work-map/roles/${removedId}`, { + method: "DELETE", + }); + expect(missing.status).toBe(404); + await expect(missing.json()).resolves.toEqual({ error: "role-missing" }); + } finally { + db.close(); + } + }); + it("stores a role photo privately and serves it for the map", async () => { const db = openDatabase(":memory:"); const uploadsDir = mkdtempSync(join(tmpdir(), "proforna-role-photo-")); diff --git a/server/work-map.ts b/server/work-map.ts index 8039943..2a52107 100644 --- a/server/work-map.ts +++ b/server/work-map.ts @@ -24,6 +24,7 @@ import { type LinkedEvidence, type PreparedLinkedEvidence, prepareWorkMapRoleCreate, + prepareWorkMapRoleDelete, nominatimAddressLine, presentWorkMapPlace, type WorkMapLocation, @@ -324,6 +325,66 @@ export function updateWorkMapLocation( return persistWorkMapLocation(db, occupantId, roleId, locationId, input); } +export function removeWorkMapRole( + db: DatabaseSync, + occupantId: string, + roleId: string, + uploadsDir: string, +): void { + const prepared = prepareWorkMapRoleDelete({ id: roleId }); + if (!prepared.ok) throw new WorkMapStoreError(prepared.error); + const id = prepared.value.id; + requireRole(db, occupantId, id); + const files = db + .prepare( + `SELECT stored_name FROM work_history_media + WHERE work_history_id = ? AND occupant_id = ? AND stored_name IS NOT NULL`, + ) + .all(id, occupantId) as Array<{ stored_name: string }>; + db.exec("BEGIN"); + try { + db.prepare( + `DELETE FROM work_history_media + WHERE work_history_id = ? AND occupant_id = ?`, + ).run(id, occupantId); + db.prepare( + `DELETE FROM work_history_linked_evidence + WHERE work_history_id = ? AND occupant_id = ?`, + ).run(id, occupantId); + db.prepare( + `DELETE FROM work_history_locations + WHERE work_history_id = ? AND occupant_id = ?`, + ).run(id, occupantId); + db.prepare( + `DELETE FROM work_history_details + WHERE work_history_id = ? AND occupant_id = ?`, + ).run(id, occupantId); + db.prepare( + `DELETE FROM career_facts + WHERE occupant_id = ? AND subject_id = ?`, + ).run(occupantId, id); + db.prepare( + `UPDATE worklog_entries SET role_id = NULL + WHERE occupant_id = ? AND role_id = ?`, + ).run(occupantId, id); + const removed = db + .prepare(`DELETE FROM work_history WHERE id = ? AND occupant_id = ?`) + .run(id, occupantId); + if (removed.changes !== 1) throw new WorkMapStoreError("role-missing"); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + for (const file of files) { + try { + unlinkSync(join(uploadsDir, file.stored_name)); + } catch { + /* leftover file is harmless */ + } + } +} + export function removeWorkMapLocation( db: DatabaseSync, occupantId: string, diff --git a/web/src/CareerMap.tsx b/web/src/CareerMap.tsx index e2e4d21..ea82d4f 100644 --- a/web/src/CareerMap.tsx +++ b/web/src/CareerMap.tsx @@ -18,6 +18,7 @@ export function CareerMap({ onSelect, onMapClick, holdView = false, + emptyNote = null, suppressEmpty = false, overlays = [], selectedOverlayId = null, @@ -37,6 +38,7 @@ export function CareerMap({ onSelect: (id: string) => void; onMapClick?: (latitude: number, longitude: number) => void; holdView?: boolean; + emptyNote?: { title: string; detail: string } | null; suppressEmpty?: boolean; overlays?: MapSearchPin[]; selectedOverlayId?: string | null; @@ -68,6 +70,7 @@ export function CareerMap({ roles={roles} selectedId={selectedId} selectedOverlayId={selectedOverlayId} + emptyNote={emptyNote} suppressEmpty={suppressEmpty} fitKey={fitKey} viewKey={viewKey} @@ -92,6 +95,7 @@ export function CareerMap({ roles={roles} selectedId={selectedId} selectedOverlayId={selectedOverlayId} + emptyNote={emptyNote} suppressEmpty={suppressEmpty} fitKey={fitKey} viewKey={viewKey} diff --git a/web/src/GoogleWorkMap.tsx b/web/src/GoogleWorkMap.tsx index 16367e9..83bd342 100644 --- a/web/src/GoogleWorkMap.tsx +++ b/web/src/GoogleWorkMap.tsx @@ -24,6 +24,7 @@ export function GoogleWorkMap({ onSelect, onMapClick, holdView = false, + emptyNote = null, suppressEmpty = false, pinTheme, pinIcons, @@ -45,6 +46,7 @@ export function GoogleWorkMap({ onSelect: (id: string) => void; onMapClick?: (latitude: number, longitude: number) => void; holdView?: boolean; + emptyNote?: { title: string; detail: string } | null; suppressEmpty?: boolean; pinTheme: MapPinTheme; pinIcons: MapPinIcons; @@ -275,7 +277,18 @@ export function GoogleWorkMap({ {status === "ready" && points.length === 0 && overlays.length === 0 && + emptyNote && + !suppressEmpty ? ( +
+ {emptyNote.title} + {emptyNote.detail} +
+ ) : null} + {status === "ready" && + points.length === 0 && + overlays.length === 0 && anchors.length === 0 && + !emptyNote && !suppressEmpty ? (
Your history is ready to map. diff --git a/web/src/RoleDetailPanel.tsx b/web/src/RoleDetailPanel.tsx index d6219db..8a3b140 100644 --- a/web/src/RoleDetailPanel.tsx +++ b/web/src/RoleDetailPanel.tsx @@ -403,9 +403,7 @@ export function RoleDetailPanel({ Place
@@ -1372,6 +1370,18 @@ function CoverFile({ ); } +function storedPlace(role: WorkMapRole): string { + const label = role.locationLabel.trim(); + const site = + role.locations.find((location) => location.address.trim())?.address.trim() ?? + ""; + if (!label) return site; + if (site && (/^\d+[a-z]?$/i.test(label) || /^[A-Z]{2}\s+\d{5}/.test(label))) { + return site; + } + return label; +} + function historyItem(role: WorkMapRole): CareerHistoryItem { return { id: role.id, diff --git a/web/src/WorkMap.tsx b/web/src/WorkMap.tsx index a1d38cf..7b27d6c 100644 --- a/web/src/WorkMap.tsx +++ b/web/src/WorkMap.tsx @@ -4,6 +4,7 @@ import type { CareerFile } from "@core/career-file"; import { CAREER_HISTORY_SECTIONS, classifyCareerHistorySection, + flagIdenticalRoles, formatHistoryGist, formatHistoryPeriod, formatHistoryPlace, @@ -144,7 +145,16 @@ export function WorkMap({ useState(null); const [detailTab, setDetailTab] = useState("story"); const [homesOpen, setHomesOpen] = useState(false); + const [selecting, setSelecting] = useState(false); + const [checkedIds, setCheckedIds] = useState>(new Set()); + const [removeAsk, setRemoveAsk] = useState<{ + ids: string[]; + detail: string; + } | null>(null); + const [removing, setRemoving] = useState(false); const [importOpen, setImportOpen] = useState(false); + const [ledgerMenu, setLedgerMenu] = useState<"import" | "add" | null>(null); + const toolsRef = useRef(null); const [mapOverride, setMapOverride] = useState<"auto" | "show" | "hide">( "auto", ); @@ -153,6 +163,15 @@ export function WorkMap({ void load(); }, []); + useEffect(() => { + if (!data) return; + const live = new Set(data.roles.map((role) => role.id)); + setCheckedIds((current) => { + const next = new Set([...current].filter((id) => live.has(id))); + return next.size === current.size ? current : next; + }); + }, [data]); + useEffect(() => { void fetch("/api/maps") .then(async (response) => { @@ -166,6 +185,22 @@ export function WorkMap({ if (focusRoleId) setSelectedId(focusRoleId); }, [focusRoleId]); + useEffect(() => { + if (!ledgerMenu) return; + function onPointer(event: PointerEvent) { + if (!toolsRef.current?.contains(event.target as Node)) setLedgerMenu(null); + } + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") setLedgerMenu(null); + } + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [ledgerMenu]); + const focusTabUsed = useRef(false); useEffect(() => { const opensFocus = Boolean(focusTab) && selectedId === focusRoleId && !focusTabUsed.current; @@ -225,10 +260,49 @@ export function WorkMap({ ); }, [data, search, sort]); const groups = useMemo(() => groupCareerHistory(historyItems), [historyItems]); - const stats = useMemo( - () => presentCareerHistoryStats(data?.roles.map(asHistoryItem) ?? []), - [data], + const shownIds = useMemo(() => { + const ids: string[] = []; + for (const section of CAREER_HISTORY_SECTIONS) { + if (closedSections.has(section.key)) continue; + const items = groups.find((group) => group.key === section.key)?.items ?? []; + for (const item of items) ids.push(item.id); + } + return ids; + }, [groups, closedSections]); + const identicalFlags = useMemo(() => { + if (!data) return new Map(); + const byId = new Map(data.roles.map((role) => [role.id, role])); + const sorted = sortCareerHistory(data.roles.map(asHistoryItem), sort); + return flagIdenticalRoles( + sorted.map((item) => { + const role = byId.get(item.id); + return { + ...item, + description: role?.description ?? "", + achievements: role?.achievements ?? [], + }; + }), + ); + }, [data, sort]); + const identicalExtraIds = useMemo( + () => + [...identicalFlags.entries()] + .filter(([, flag]) => flag.count > 1 && !flag.keeper) + .map(([id]) => id), + [identicalFlags], ); + const stats = useMemo(() => { + const identities = (data?.roles ?? []).map((role) => ({ + ...asHistoryItem(role), + description: role.description, + achievements: role.achievements, + })); + const visibleIds = new Set(historyItems.map((item) => item.id)); + const counted = search.trim() + ? identities.filter((item) => visibleIds.has(item.id)) + : identities; + return presentCareerHistoryStats(counted); + }, [data, historyItems, search]); const visibleRoles = useMemo(() => { if (!data) return []; const byId = new Map(data.roles.map((role) => [role.id, role])); @@ -417,6 +491,74 @@ export function WorkMap({ } } + function stopSelecting() { + setSelecting(false); + setCheckedIds(new Set()); + setRemoveAsk(null); + } + + function toggleChecked(id: string) { + setRemoveAsk(null); + setCheckedIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function selectShown(ids: string[]) { + setRemoveAsk(null); + setCheckedIds((current) => { + const next = new Set(current); + const allOn = ids.length > 0 && ids.every((id) => next.has(id)); + for (const id of ids) { + if (allOn) next.delete(id); + else next.add(id); + } + return next; + }); + } + + function askRemove(ids: string[], detail: string) { + if (ids.length === 0) return; + setRemoveAsk({ ids, detail }); + } + + async function removeRoles(ids: string[]) { + setRemoving(true); + setMessage(""); + const failed: string[] = []; + for (const id of ids) { + const response = await fetch(`/api/work-map/roles/${id}`, { + method: "DELETE", + }); + if (!response.ok) failed.push(id); + } + setRemoving(false); + setRemoveAsk(null); + setCheckedIds((current) => { + const next = new Set(current); + for (const id of ids) { + if (!failed.includes(id)) next.delete(id); + } + return next; + }); + if (selectedId && ids.includes(selectedId) && !failed.includes(selectedId)) { + setSelectedId(null); + } + if (failed.length > 0) { + setMessage( + failed.length === ids.length + ? "Could not remove those roles." + : `Removed ${ids.length - failed.length}. ${failed.length} could not be removed.`, + ); + } else { + setMessage(ids.length === 1 ? "Role removed." : `${ids.length} roles removed.`); + } + await load(); + } + async function addRole(kind: CareerHistorySectionKey) { setCreatingKind(kind); setSettingsOpen(false); @@ -622,15 +764,86 @@ export function WorkMap({ {stats.cities}
-
+
setSearch(event.target.value)} placeholder="Search…" aria-label="Search career history" /> - + {ledgerMenu === "import" ? ( +
+ +
+ ) : null} +
+
+ + {ledgerMenu === "add" ? ( +
+ {CAREER_HISTORY_SECTIONS.map((section) => ( + + ))} +
+ ) : null} +
+
+ {removeAsk || selecting ? ( +
+ {removeAsk ? ( +
+

{removeAsk.detail}

+
+ + +
+
+ ) : selecting ? ( + <> + + {checkedIds.size === 0 + ? "Select roles" + : `${checkedIds.size} selected`} + + + {identicalExtraIds.length > 0 ? ( + + ) : null} + + + + ) : null} +
+ ) : null} {homesOpen ? ( {empty ? ( -

Import a resume, or add a role to a section.

+

Import a resume, or add a role.

) : null} {CAREER_HISTORY_SECTIONS.map((section) => { const items = @@ -680,47 +970,30 @@ export function WorkMap({ ({items.length}) - {open ? items.map((item) => ( - + item={item} + identical={(identicalFlags.get(item.id)?.count ?? 1) > 1} + selecting={selecting} + checked={checkedIds.has(item.id)} + onToggle={() => toggleChecked(item.id)} + onOpen={() => setSelectedId(item.id)} + /> )) : null} ); })} {!empty && historyItems.length === 0 ? ( -

No roles match that search.

+

+ No roles match that search. + +

) : null} @@ -758,6 +1031,14 @@ export function WorkMap({ roles={mapRoles} selectedId={timelineActive ? null : selectedId} settings={mapSettings} + emptyNote={ + search.trim() && historyItems.length === 0 + ? { + title: "No roles match that search.", + detail: "Clear the search to see every role.", + } + : null + } suppressEmpty={timelineActive} viewKey="history" /> @@ -1410,10 +1691,29 @@ function asTimelineRole(role: WorkMapRole): CareerTimelineRole { }; } -function sectionAddLabel(key: CareerHistorySectionKey): string { - if (key === "school") return "Add education"; - if (key === "internship") return "Add internship"; - return "Add job"; +function addKindLabel(key: CareerHistorySectionKey): string { + if (key === "school") return "Education"; + if (key === "internship") return "Internship"; + return "Job"; +} + +function ImportIcon() { + return ( + + ); +} + +function SelectIcon() { + return ( + + ); } function asHistoryItem(role: WorkMapRole): CareerHistoryItem { @@ -1434,6 +1734,51 @@ function asHistoryItem(role: WorkMapRole): CareerHistoryItem { }; } +function HistoryRow({ + item, + identical, + selecting, + checked, + onToggle, + onOpen, +}: { + item: CareerHistoryItem; + identical: boolean; + selecting: boolean; + checked: boolean; + onToggle: () => void; + onOpen: () => void; +}) { + const name = item.organization || item.title || "Untitled"; + const row = ( + + ); + if (!selecting) return row; + return ( +
+ + {row} +
+ ); +} + function HomesIcon() { return (
+ {emptyNote.title} + {emptyNote.detail} +
+ ) : null} + {points.length === 0 && + overlays.length === 0 && + anchors.length === 0 && + !emptyNote && + !suppressEmpty ? (
Your history is ready to map. Select a role and add its first work site. diff --git a/web/src/WorkMapPreview.tsx b/web/src/WorkMapPreview.tsx index e653690..779dc24 100644 --- a/web/src/WorkMapPreview.tsx +++ b/web/src/WorkMapPreview.tsx @@ -62,7 +62,10 @@ export function WorkMapPreview({ [history, search, sort], ); const groups = useMemo(() => groupCareerHistory(visible), [visible]); - const stats = useMemo(() => presentCareerHistoryStats(history), [history]); + const stats = useMemo( + () => presentCareerHistoryStats(search.trim() ? visible : history), + [history, search, visible], + ); const selected = viewSnapshot.roles.find((role) => role.id === selectedId) ?? null; const showMap = snapshot.sections.includes("map"); const withheldSite = snapshot.roles.some((role) => @@ -256,7 +259,12 @@ export function WorkMapPreview({ ))} {snapshot.roles.length > 0 && visible.length === 0 ? ( -

No roles match that search.

+

+ No roles match that search. + +

) : null} {snapshot.sections.includes("skills") && snapshot.skills.length > 0 ? (
diff --git a/web/src/work-map.css b/web/src/work-map.css index 1f1c964..01b1871 100644 --- a/web/src/work-map.css +++ b/web/src/work-map.css @@ -368,7 +368,8 @@ .history-publish { display: flex; min-width: 0; - flex: 1; + flex: 1 1 12rem; + flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 0.35rem; @@ -451,7 +452,7 @@ .work-map-stats span { margin: 0 0 0.15rem; color: var(--gold); - font-size: 0.58rem; + font-size: 0.7rem; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; @@ -463,20 +464,74 @@ font-size: 0.92rem; } -.work-map-import { - border: 1px solid rgba(167, 139, 113, 0.45); - border-radius: 999px; +.history-menu { + position: relative; + z-index: 4; + align-self: end; +} + +.history-tool { + display: grid; + width: 1.7rem; + height: 1.7rem; + place-items: center; + padding: 0; + border: 1px solid rgba(199, 164, 123, 0.32); + border-radius: 0.25rem; background: transparent; color: var(--gold-hover); - font: inherit; - font-size: 12px; - font-weight: 600; - padding: 0.45rem 0.8rem; - cursor: pointer; + font-size: 1rem; + line-height: 1; +} + +.history-tool svg { + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.history-tool:hover, +.history-tool[aria-expanded="true"], +.history-tool[aria-pressed="true"] { + border-color: var(--gold-hover); + background: rgba(199, 164, 123, 0.12); +} + +.history-menu-panel { + position: absolute; + z-index: 6; + top: calc(100% + 0.35rem); + left: 0; + min-width: 8.5rem; + padding: 0.25rem; + border: 1px solid rgba(199, 164, 123, 0.32); + border-radius: 0.4rem; + background: #14110e; +} + +.history-menu-panel button { + display: block; + width: 100%; + padding: 0.4rem 0.55rem; + border: 0; + border-radius: 0.2rem; + background: transparent; + color: var(--text); + font-size: 0.8rem; + text-align: left; +} + +.history-menu-panel button:hover { + background: rgba(199, 164, 123, 0.12); + color: var(--gold-hover); } .work-map-tools { display: flex; + flex-wrap: wrap; gap: 0.45rem; align-items: end; padding: 0 0.75rem 0.7rem; @@ -551,14 +606,71 @@ box-shadow: inset 1px 0 0 var(--gold-hover); } -.career-history-row.is-secondary { - opacity: 0.42; +.history-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin: 0.15rem 0.9rem 0.45rem; +} + +.history-actions > span { + margin-right: auto; + color: var(--wm-muted); + font-size: 0.75rem; } -.career-history-row.is-secondary:hover { - opacity: 0.78; +.history-actions button, +.history-confirm button { + padding: 0.32rem 0.6rem; + border: 1px solid rgba(199, 164, 123, 0.35); + border-radius: 0.25rem; + background: transparent; + color: var(--gold-hover); + font-size: 0.75rem; } +.history-confirm { + display: grid; + gap: 0.45rem; + width: 100%; +} + +.history-confirm p { + margin: 0; + color: var(--wm-muted); + font-size: 0.875rem; + line-height: 1.45; +} + +.history-confirm div { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.history-entry { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; +} + +.history-check { + display: grid; + place-items: center; + min-width: 2rem; + min-height: 2.4rem; + padding-left: 0.55rem; +} + +.history-check input { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--gold); +} + + .work-map-focus { display: flex; align-items: center; @@ -624,15 +736,19 @@ } .career-history-row mark { - padding: 0.1rem 0.4rem; - border: 1px solid rgba(52, 211, 153, 0.35); - border-radius: 999px; - background: rgba(16, 185, 129, 0.16); + padding: 0; + border: 0; + border-radius: 0; + background: transparent; color: #6ee7b7; - font-size: 0.58rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: none; +} + +.career-history-row mark.is-identical { + color: var(--wm-muted); } .history-cat-row { @@ -655,25 +771,6 @@ text-align: left; } -.history-add { - flex: 0 0 auto; - width: 1.7rem; - height: 1.7rem; - margin-right: 0.55rem; - padding: 0; - border: 1px solid rgba(199, 164, 123, 0.32); - border-radius: 0.25rem; - background: transparent; - color: var(--gold-hover); - font-size: 1.05rem; - line-height: 1; -} - -.history-add:hover { - border-color: var(--gold-hover); - background: rgba(199, 164, 123, 0.12); -} - .work-map-pin-icon { background: transparent; border: 0; @@ -701,14 +798,14 @@ .career-history-row b { margin-top: 0.2rem; color: var(--gold-light); - font-size: 0.69rem; + font-size: 0.8rem; font-weight: 450; } .career-history-row small { margin-top: 0.28rem; color: var(--wm-muted); - font-size: 0.67rem; + font-size: 0.8rem; font-style: normal; } @@ -941,6 +1038,28 @@ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.32); } +.history-search-miss { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + align-items: baseline; + margin: 0.75rem 0.9rem; + color: var(--wm-muted); + font-size: 0.875rem; +} + +.history-search-miss button { + padding: 0; + border: 0; + background: transparent; + color: var(--gold-hover); + font-size: 0.875rem; +} + +.history-search-miss button:hover { + color: var(--text); +} + .work-map-unmapped span { color: var(--wm-muted); font-size: 0.68rem; @@ -1527,7 +1646,7 @@ gap: 1rem; padding: 0.65rem 0; border-bottom: 1px solid rgba(199, 164, 123, 0.12); - font-size: 0.68rem; + font-size: 0.85rem; } .role-existing > p.role-form-note { @@ -1759,6 +1878,8 @@ @media (max-width: 760px) { .work-map { flex: none; + min-width: 0; + max-width: 100%; min-height: auto; } @@ -1771,11 +1892,15 @@ } .work-map-ledger { - max-height: 22rem; + max-height: none; border-right: 0; border-bottom: 1px solid var(--wm-line); } + .work-map-role-list { + min-height: 16rem; + } + .work-map-stage { height: 60vh; min-height: 30rem; @@ -1834,11 +1959,21 @@ } .work-map-float { - width: auto; - max-height: 46vh; + width: 100%; + max-height: none; order: 1; } + .work-map-sort { + min-width: 5.5rem; + flex: 1 1 8rem; + } + + .history-publish { + flex-basis: 100%; + justify-content: flex-start; + } + .role-sheet { min-height: 24rem; } @@ -1850,6 +1985,10 @@ } @media (max-width: 480px) { + .work-map-tools > input { + flex: 1 1 100%; + } + .work-map-stats { grid-template-columns: repeat(2, 1fr); }