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); }