Skip to content

Commit f4a3f57

Browse files
committed
Standalone application tracker at /tracker with GDPR integration
- /tracker route: vault-gated encrypted application tracking - Add/edit/delete applications with company, position, platform, status - Status flow: Applied, Screening, Interview, Offer, Rejected, Withdrawn - Desktop table view, mobile card view, search/filter by company - Stats panel: total, by status, this week/month - GDPR actions: Generate DSAR (Article 15), Request Erasure (Article 17) - GDPR buttons labeled for EU users, universal tracker for everyone - Migrated legacy gdpr-applications to shared tracker namespace - TrackerApplicationRecord extends ApplicationRecord with positionTitle, status - Audit events: application_created, application_updated, application_deleted - Nav entry between Scan and Audit with clipboard icon - Fourth feature card on landing page - Privacy note: all data encrypted locally, never sent to any server
1 parent d99f535 commit f4a3f57

10 files changed

Lines changed: 1074 additions & 19 deletions

File tree

apps/web/src/app.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ button {
325325
inset: auto 0 0;
326326
z-index: 30;
327327
display: grid;
328-
grid-template-columns: repeat(5, minmax(0, 1fr));
328+
grid-template-columns: repeat(6, minmax(0, 1fr));
329329
gap: 0.5rem;
330330
padding:
331331
0.75rem 0.75rem

apps/web/src/lib/components/AppShell.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import {
3+
ClipboardList,
34
FileText,
45
Radar,
56
ScanSearch,
@@ -25,6 +26,7 @@
2526
const navItems: NavItem[] = [
2627
{ href: '/resumes', label: 'Resumes', icon: FileText },
2728
{ href: '/scan', label: 'Scan', icon: ScanSearch },
29+
{ href: '/tracker', label: 'Tracker', icon: ClipboardList },
2830
{ href: '/audit', label: 'Audit', icon: Radar },
2931
{ href: '/settings', label: 'Settings', icon: Settings },
3032
{ href: '/attack-mode', label: 'Attack Mode', icon: ShieldAlert, tone: 'danger' },

apps/web/src/lib/resume-vault.ts

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { appendEntry, getEntries, initAudit, type AuditEvent } from '@shieldcv/audit';
2-
import type { ApplicationRecord } from '@shieldcv/compliance';
2+
import type { ApplicationRecord, TrackerApplicationRecord } from '@shieldcv/compliance';
33
import { createBlankResume, normalizeResumeDocument, type ResumeDocument } from '@shieldcv/resume';
44
import { EncryptedStore } from '@shieldcv/storage';
55
import { writable } from 'svelte/store';
@@ -8,6 +8,7 @@ import { ATTACK_MODE_AUDIT_DATABASE_NAME, ATTACK_MODE_AUDIT_FLAG } from '$lib/de
88
const DATABASE_NAME = 'shieldcv-local-vault';
99
const RESUME_NAMESPACE = 'resume';
1010
const GDPR_APPLICATION_NAMESPACE = 'gdpr-applications';
11+
const TRACKER_APPLICATION_NAMESPACE = 'tracker';
1112
export const MAX_RESUME_SIZE = 500_000;
1213
export type VaultLockReason = 'manual' | 'inactivity';
1314

@@ -71,6 +72,53 @@ function fireAndForgetAudit(event: AuditEvent, details: string): void {
7172
});
7273
}
7374

75+
function normalizeTrackerApplication(record: ApplicationRecord | TrackerApplicationRecord): TrackerApplicationRecord {
76+
return {
77+
...record,
78+
positionTitle:
79+
'positionTitle' in record && typeof record.positionTitle === 'string' ? record.positionTitle : '',
80+
status:
81+
'status' in record &&
82+
typeof record.status === 'string' &&
83+
['applied', 'screening', 'interview', 'offer', 'rejected', 'withdrawn'].includes(record.status)
84+
? record.status
85+
: 'applied',
86+
};
87+
}
88+
89+
async function migrateLegacyGdprApplications(store: EncryptedStore): Promise<void> {
90+
const trackerIds = await store.list(TRACKER_APPLICATION_NAMESPACE);
91+
const legacyIds = await store.list(GDPR_APPLICATION_NAMESPACE);
92+
93+
if (legacyIds.length === 0) {
94+
return;
95+
}
96+
97+
const trackerIdSet = new Set(trackerIds);
98+
99+
await Promise.all(
100+
legacyIds.map(async (id) => {
101+
if (trackerIdSet.has(id)) {
102+
await store.delete(GDPR_APPLICATION_NAMESPACE, id);
103+
return;
104+
}
105+
106+
const legacyRecord = await store.get<ApplicationRecord>(GDPR_APPLICATION_NAMESPACE, id);
107+
108+
if (!legacyRecord) {
109+
return;
110+
}
111+
112+
await store.put(
113+
TRACKER_APPLICATION_NAMESPACE,
114+
legacyRecord.id,
115+
normalizeTrackerApplication(legacyRecord),
116+
);
117+
await store.delete(GDPR_APPLICATION_NAMESPACE, id);
118+
}),
119+
);
120+
}
121+
74122
export async function unlockVault(passphrase: string): Promise<void> {
75123
vaultStatus.set('unlocking');
76124

@@ -164,27 +212,83 @@ export async function deleteResume(id: string): Promise<void> {
164212
fireAndForgetAudit('resume_deleted', `Deleted resume ${id}`);
165213
}
166214

167-
export async function listGdprApplications(): Promise<ApplicationRecord[]> {
215+
export async function listGdprApplications(): Promise<TrackerApplicationRecord[]> {
216+
const records = await listTrackerApplications();
217+
return records;
218+
}
219+
220+
export async function saveGdprApplication(record: ApplicationRecord): Promise<ApplicationRecord> {
221+
return saveTrackerApplication(normalizeTrackerApplication(record));
222+
}
223+
224+
export async function deleteGdprApplication(id: string): Promise<void> {
225+
await deleteTrackerApplication(id);
226+
}
227+
228+
export async function listTrackerApplications(): Promise<TrackerApplicationRecord[]> {
168229
const store = await resumeStore();
169-
const ids = await store.list(GDPR_APPLICATION_NAMESPACE);
230+
await migrateLegacyGdprApplications(store);
231+
const ids = await store.list(TRACKER_APPLICATION_NAMESPACE);
170232
const records = await Promise.all(
171-
ids.map((id) => store.get<ApplicationRecord>(GDPR_APPLICATION_NAMESPACE, id)),
233+
ids.map((id) => store.get<TrackerApplicationRecord>(TRACKER_APPLICATION_NAMESPACE, id)),
172234
);
173235

174236
return records
175-
.filter((record): record is ApplicationRecord => record !== undefined)
237+
.filter((record): record is TrackerApplicationRecord => record !== undefined)
238+
.map((record) => normalizeTrackerApplication(record))
176239
.sort((left, right) => right.dateApplied.localeCompare(left.dateApplied));
177240
}
178241

179-
export async function saveGdprApplication(record: ApplicationRecord): Promise<ApplicationRecord> {
242+
export async function saveTrackerApplication(
243+
record: TrackerApplicationRecord,
244+
): Promise<TrackerApplicationRecord> {
180245
const store = await resumeStore();
181-
await store.put(GDPR_APPLICATION_NAMESPACE, record.id, record);
246+
await migrateLegacyGdprApplications(store);
247+
await store.put(TRACKER_APPLICATION_NAMESPACE, record.id, normalizeTrackerApplication(record));
182248
return record;
183249
}
184250

185-
export async function deleteGdprApplication(id: string): Promise<void> {
251+
export async function deleteTrackerApplication(id: string): Promise<void> {
252+
const store = await resumeStore();
253+
await migrateLegacyGdprApplications(store);
254+
await store.delete(TRACKER_APPLICATION_NAMESPACE, id);
255+
}
256+
257+
export async function createTrackerApplication(
258+
record: TrackerApplicationRecord,
259+
): Promise<TrackerApplicationRecord> {
260+
const normalized = normalizeTrackerApplication(record);
261+
await saveTrackerApplication(normalized);
262+
fireAndForgetAudit(
263+
'application_created',
264+
`Created application for ${normalized.company} - ${normalized.positionTitle} on ${normalized.platform}.`,
265+
);
266+
return normalized;
267+
}
268+
269+
export async function updateTrackerApplication(
270+
record: TrackerApplicationRecord,
271+
): Promise<TrackerApplicationRecord> {
272+
const normalized = normalizeTrackerApplication(record);
273+
await saveTrackerApplication(normalized);
274+
fireAndForgetAudit(
275+
'application_updated',
276+
`Updated application for ${normalized.company} - ${normalized.positionTitle} on ${normalized.platform}.`,
277+
);
278+
return normalized;
279+
}
280+
281+
export async function removeTrackerApplication(id: string): Promise<void> {
186282
const store = await resumeStore();
187-
await store.delete(GDPR_APPLICATION_NAMESPACE, id);
283+
await migrateLegacyGdprApplications(store);
284+
const existing = await store.get<TrackerApplicationRecord>(TRACKER_APPLICATION_NAMESPACE, id);
285+
await store.delete(TRACKER_APPLICATION_NAMESPACE, id);
286+
fireAndForgetAudit(
287+
'application_deleted',
288+
existing
289+
? `Deleted application for ${existing.company} - ${existing.positionTitle} on ${existing.platform}.`
290+
: `Deleted application ${id}.`,
291+
);
188292
}
189293

190294
function clearBrowserStorage(): void {

apps/web/src/routes/+page.svelte

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
ArrowRight,
44
ChartNoAxesColumn,
5+
ClipboardList,
56
ExternalLink,
67
ScanSearch,
78
Shield,
@@ -38,6 +39,14 @@
3839
linkLabel: 'Enter Attack Mode',
3940
icon: ShieldAlert,
4041
},
42+
{
43+
title: 'Application Tracker',
44+
description:
45+
'Track every job application in one encrypted place. Know which companies have your data. Exercise your GDPR rights with one click.',
46+
href: '/tracker',
47+
linkLabel: 'Start tracking',
48+
icon: ClipboardList,
49+
},
4150
] as const;
4251
4352
const securityProperties = [

apps/web/src/routes/audit/+page.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onMount } from 'svelte';
33
import {
44
AlertTriangle,
5+
BriefcaseBusiness,
56
Download,
67
FileText,
78
Search,
@@ -59,6 +60,10 @@
5960
return { colorClass: 'audit-event--resume', icon: FileText, label: event.replaceAll('_', ' ') };
6061
}
6162
63+
if (event.startsWith('application_')) {
64+
return { colorClass: 'audit-event--resume', icon: BriefcaseBusiness, label: event.replaceAll('_', ' ') };
65+
}
66+
6267
if (event.startsWith('scan_')) {
6368
return { colorClass: 'audit-event--scan', icon: Search, label: event.replaceAll('_', ' ') };
6469
}

apps/web/src/routes/scan/+page.svelte

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@
2828
} from '$lib/ats-match';
2929
import { ATS_DEMO_JOB_DESCRIPTION } from '$lib/fixtures/demo-job-description';
3030
import {
31-
deleteGdprApplication,
31+
createTrackerApplication,
3232
isVaultUnlocked,
3333
listGdprApplications,
3434
listResumes,
35-
saveGdprApplication,
35+
removeTrackerApplication,
3636
unlockVault,
37+
updateTrackerApplication,
3738
vaultStatus,
3839
} from '$lib/resume-vault';
3940
import type { ResumeDocument } from '@shieldcv/resume';
@@ -44,6 +45,7 @@
4445
GdprEducationContent,
4546
PhiFinding,
4647
ScanResult,
48+
TrackerApplicationRecord,
4749
} from '@shieldcv/compliance';
4850
4951
type AiModule = typeof import('@shieldcv/ai');
@@ -98,7 +100,7 @@
98100
let cmmcScannedLabel = '';
99101
100102
let gdprEducation: GdprEducationContent | null = null;
101-
let applications: ApplicationRecord[] = [];
103+
let applications: TrackerApplicationRecord[] = [];
102104
let platformOptions: string[] = [];
103105
let gdprPlatform = 'LinkedIn';
104106
let gdprCompany = '';
@@ -560,14 +562,14 @@
560562
return;
561563
}
562564
563-
const record = compliance?.createApplicationRecord(gdprPlatform, gdprCompany);
565+
const record = compliance?.createTrackerApplicationRecord(gdprPlatform, gdprCompany, '');
564566
565567
if (!record) {
566568
return;
567569
}
568570
569571
const appliedAt = new Date(`${gdprDateApplied}T00:00:00.000Z`).toISOString();
570-
await saveGdprApplication({
572+
await createTrackerApplication({
571573
...record,
572574
dateApplied: appliedAt,
573575
});
@@ -577,7 +579,7 @@
577579
copyStatus = `Saved ${gdprPlatform} application for ${applications[0]?.company ?? 'company'}.`;
578580
}
579581
580-
async function generateGdprEmail(record: ApplicationRecord, kind: 'dsar' | 'erasure') {
582+
async function generateGdprEmail(record: TrackerApplicationRecord, kind: 'dsar' | 'erasure') {
581583
if (!compliance) {
582584
return;
583585
}
@@ -594,7 +596,7 @@
594596
);
595597
596598
const now = new Date().toISOString();
597-
const updatedRecord: ApplicationRecord =
599+
const updatedRecord: TrackerApplicationRecord =
598600
kind === 'dsar'
599601
? {
600602
...record,
@@ -608,7 +610,7 @@
608610
erasureDate: now,
609611
};
610612
611-
await saveGdprApplication(updatedRecord);
613+
await updateTrackerApplication(updatedRecord);
612614
applications = await listGdprApplications();
613615
614616
if (kind === 'dsar') {
@@ -621,7 +623,7 @@
621623
}
622624
623625
async function removeApplication(id: string) {
624-
await deleteGdprApplication(id);
626+
await removeTrackerApplication(id);
625627
applications = await listGdprApplications();
626628
copyStatus = 'Removed application from the encrypted GDPR tracker.';
627629
}

0 commit comments

Comments
 (0)