data holders: private tracker of every organisation holding my address, email, phone or IBAN
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
dataholders schema (orgs + identity) with migration and personal_app grants, idempotent CSV import (upsert on name+login, never wipes, keeps site edits and ticks), owner-only /trackers/data-holders with search, filters, pagination, inline edit/add and a moving checklist per identifier
This commit is contained in:
@@ -29,7 +29,8 @@ export default defineConfig({
|
|||||||
'hardware',
|
'hardware',
|
||||||
'planes',
|
'planes',
|
||||||
'wildlife',
|
'wildlife',
|
||||||
'trains'
|
'trains',
|
||||||
|
'dataholders'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
83
drizzle/20261025160958_dataholders/migration.sql
Normal file
83
drizzle/20261025160958_dataholders/migration.sql
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
CREATE SCHEMA "dataholders";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dataholders"."identity" (
|
||||||
|
"kind" text PRIMARY KEY,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"since" date NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "identity_kind_check" CHECK ("kind" IN ('address','email','phone','iban'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dataholders"."orgs" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "dataholders"."orgs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"category" text DEFAULT 'Other' NOT NULL,
|
||||||
|
"login" text,
|
||||||
|
"source" text,
|
||||||
|
"domains" text,
|
||||||
|
"import_key" text,
|
||||||
|
"holds_address" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_email" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_phone" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_iban" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_bsn" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_card" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_dob" boolean DEFAULT false NOT NULL,
|
||||||
|
"holds_id_doc" boolean DEFAULT false NOT NULL,
|
||||||
|
"data_held" text,
|
||||||
|
"address_on_file" text,
|
||||||
|
"address_status" text DEFAULT 'unknown' NOT NULL,
|
||||||
|
"follows_brp" boolean DEFAULT false NOT NULL,
|
||||||
|
"email_on_file" text,
|
||||||
|
"phone_on_file" text,
|
||||||
|
"action" text DEFAULT 'update' NOT NULL,
|
||||||
|
"priority" text DEFAULT 'should' NOT NULL,
|
||||||
|
"how_to_change" text,
|
||||||
|
"link" text,
|
||||||
|
"evidence" text,
|
||||||
|
"notes" text,
|
||||||
|
"address_updated_on" date,
|
||||||
|
"email_updated_on" date,
|
||||||
|
"phone_updated_on" date,
|
||||||
|
"iban_updated_on" date,
|
||||||
|
"last_checked" date,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"edited_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "orgs_name_check" CHECK (length(btrim("name")) > 0),
|
||||||
|
CONSTRAINT "orgs_address_status_check" CHECK ("address_status" IN ('confirmed','likely','unknown')),
|
||||||
|
CONSTRAINT "orgs_action_check" CHECK ("action" IN ('update','close','check','none')),
|
||||||
|
CONSTRAINT "orgs_priority_check" CHECK ("priority" IN ('must','should','nice'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "orgs_import_key_key" ON "dataholders"."orgs" ("import_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "orgs_category_idx" ON "dataholders"."orgs" ("category");--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['orgs','identity'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON dataholders.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Privacy (same as health): app role only, NOT granted to personal_ro, and not registered
|
||||||
|
-- in meta.tables, so the generic list/table GUI never shows it.
|
||||||
|
REVOKE ALL ON SCHEMA dataholders FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA dataholders TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA dataholders TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA dataholders TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA dataholders
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA dataholders
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
29091
drizzle/20261025160958_dataholders/snapshot.json
Normal file
29091
drizzle/20261025160958_dataholders/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "bun scripts/seed-catalog.ts",
|
"db:seed": "bun scripts/seed-catalog.ts",
|
||||||
"cars:import": "bun scripts/cars-import.ts",
|
"cars:import": "bun scripts/cars-import.ts",
|
||||||
|
"dataholders:import": "bun scripts/dataholders-import.ts",
|
||||||
"cars:photos": "bun scripts/cars-photos.ts",
|
"cars:photos": "bun scripts/cars-photos.ts",
|
||||||
"cars:refresh": "scripts/cars-refresh.sh",
|
"cars:refresh": "scripts/cars-refresh.sh",
|
||||||
"planes:import": "bun scripts/planes-import.ts",
|
"planes:import": "bun scripts/planes-import.ts",
|
||||||
|
|||||||
61
scripts/dataholders-import.ts
Normal file
61
scripts/dataholders-import.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Import organisations that hold data about Stan into the private `dataholders` schema.
|
||||||
|
* Idempotent: re-run it whenever the CSV grows. Never deletes rows, never touches checklist
|
||||||
|
* ticks or archive flags, and only fills empty fields on rows edited on the site.
|
||||||
|
*
|
||||||
|
* bun scripts/dataholders-import.ts data.csv # import
|
||||||
|
* bun scripts/dataholders-import.ts --dry-run data.csv # validate and show what would change
|
||||||
|
* bun scripts/dataholders-import.ts --address "Street 1, 1234 AB City" --since 2026-10-15 data.csv
|
||||||
|
*
|
||||||
|
* Columns (header names, any order): name, category, data_held, address_on_file,
|
||||||
|
* email_on_file, phone_on_file, action, how_to_change, link, auto_from_brp, priority, status,
|
||||||
|
* evidence, notes, last_checked, source, login. A `password` column is refused.
|
||||||
|
* Uses DB_URL (the app role on prod, personal_dev on dev).
|
||||||
|
*/
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import {
|
||||||
|
importOrgs,
|
||||||
|
mapCsvRow,
|
||||||
|
parseCsv,
|
||||||
|
seedIdentity
|
||||||
|
} from '../src/lib/server/dataholders-import';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const opt = (name: string) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i >= 0 ? args.splice(i, 2)[1] : undefined;
|
||||||
|
};
|
||||||
|
const address = opt('--address');
|
||||||
|
const since = opt('--since');
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const files = args.filter((a) => !a.startsWith('--'));
|
||||||
|
if (files.length !== 1) {
|
||||||
|
console.error(
|
||||||
|
'usage: bun scripts/dataholders-import.ts [--dry-run] [--address A --since YYYY-MM-DD] <orgs.csv>'
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const records = parseCsv(await readFile(files[0], 'utf8'));
|
||||||
|
if (records.length && Object.keys(records[0]).some((k) => /pass(word)?|secret|otp|totp/i.test(k))) {
|
||||||
|
console.error('Refusing: the CSV has a password/secret column. Remove it first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const rows = records.map(mapCsvRow).filter((r) => r !== null);
|
||||||
|
console.log(`Valid: ${rows.length} of ${records.length} rows`);
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
|
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
if (address && !dryRun)
|
||||||
|
await seedIdentity(sql, [
|
||||||
|
{ kind: 'address', value: address, since: since ?? new Date().toISOString().slice(0, 10) }
|
||||||
|
]);
|
||||||
|
const r = await importOrgs(sql, rows, { dryRun });
|
||||||
|
console.log(
|
||||||
|
`${dryRun ? '[dry run] ' : ''}${r.inserted} inserted, ${r.updated} updated, ${r.unchanged} unchanged, ` +
|
||||||
|
`${r.linkedLogin} logins attached to existing orgs` +
|
||||||
|
(r.duplicates.length ? `; duplicate keys in CSV (last wins): ${r.duplicates.join(', ')}` : '')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
48
src/lib/dataholders.test.ts
Normal file
48
src/lib/dataholders.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isDue, matchesCurrent, type OrgLike } from './dataholders';
|
||||||
|
|
||||||
|
const base: OrgLike = {
|
||||||
|
archived: false,
|
||||||
|
action: 'update',
|
||||||
|
holdsAddress: true,
|
||||||
|
holdsEmail: true,
|
||||||
|
holdsPhone: false,
|
||||||
|
holdsIban: false,
|
||||||
|
addressOnFile: 'Houtenstraat 15, 2801 WD Gouda',
|
||||||
|
emailOnFile: 'old@example.com',
|
||||||
|
phoneOnFile: null,
|
||||||
|
addressUpdatedOn: null,
|
||||||
|
emailUpdatedOn: null,
|
||||||
|
phoneUpdatedOn: null,
|
||||||
|
ibanUpdatedOn: null
|
||||||
|
};
|
||||||
|
const address = {
|
||||||
|
kind: 'address',
|
||||||
|
value: 'Van Baerlestraat 12, 2802 GJ Gouda',
|
||||||
|
since: '2026-10-15'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('data holders', () => {
|
||||||
|
it('matches addresses on street and number', () => {
|
||||||
|
expect(matchesCurrent('Van Baerlestraat 12 (Mar 2025 mail)', address.value, 'address')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(matchesCurrent('Houtenstraat 15', address.value, 'address')).toBe(false);
|
||||||
|
});
|
||||||
|
it('is due until ticked on or after the change', () => {
|
||||||
|
expect(isDue(base, 'address', address)).toBe(true);
|
||||||
|
expect(isDue({ ...base, addressUpdatedOn: '2026-10-01' }, 'address', address)).toBe(true);
|
||||||
|
expect(isDue({ ...base, addressUpdatedOn: '2026-10-20' }, 'address', address)).toBe(false);
|
||||||
|
});
|
||||||
|
it('skips archived, no-action, not-held and unset identifiers', () => {
|
||||||
|
expect(isDue({ ...base, archived: true }, 'address', address)).toBe(false);
|
||||||
|
expect(isDue({ ...base, action: 'none' }, 'address', address)).toBe(false);
|
||||||
|
expect(isDue({ ...base, holdsAddress: false }, 'address', address)).toBe(false);
|
||||||
|
expect(isDue(base, 'address', undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
it('treats an org with the new address on file as done', () => {
|
||||||
|
expect(isDue({ ...base, addressOnFile: 'Van Baerlestraat 12' }, 'address', address)).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/lib/dataholders.ts
Normal file
78
src/lib/dataholders.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Shared (client + server) bits of the private data-holders tracker: which identifiers we
|
||||||
|
// track, and when an org still has an old value ("due" in the move checklist).
|
||||||
|
|
||||||
|
export const identityKinds = ['address', 'email', 'phone', 'iban'] as const;
|
||||||
|
export type IdentityKind = (typeof identityKinds)[number];
|
||||||
|
export const isIdentityKind = (v: unknown): v is IdentityKind =>
|
||||||
|
typeof v === 'string' && (identityKinds as readonly string[]).includes(v);
|
||||||
|
|
||||||
|
export const identityLabels: Record<IdentityKind, string> = {
|
||||||
|
address: 'Address',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Phone',
|
||||||
|
iban: 'IBAN'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = ['update', 'close', 'check', 'none'] as const;
|
||||||
|
export const priorities = ['must', 'should', 'nice'] as const;
|
||||||
|
export const addressStatuses = ['confirmed', 'likely', 'unknown'] as const;
|
||||||
|
export const priorityLabels = { must: 'Must-do', should: 'Should-do', nice: 'Nice-to-have' };
|
||||||
|
|
||||||
|
export type Identity = { kind: string; value: string; since: string };
|
||||||
|
|
||||||
|
export type OrgLike = {
|
||||||
|
archived: boolean;
|
||||||
|
action: string;
|
||||||
|
holdsAddress: boolean;
|
||||||
|
holdsEmail: boolean;
|
||||||
|
holdsPhone: boolean;
|
||||||
|
holdsIban: boolean;
|
||||||
|
addressOnFile: string | null;
|
||||||
|
emailOnFile: string | null;
|
||||||
|
phoneOnFile: string | null;
|
||||||
|
addressUpdatedOn: string | null;
|
||||||
|
emailUpdatedOn: string | null;
|
||||||
|
phoneUpdatedOn: string | null;
|
||||||
|
ibanUpdatedOn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const holds = (o: OrgLike, kind: IdentityKind) =>
|
||||||
|
({ address: o.holdsAddress, email: o.holdsEmail, phone: o.holdsPhone, iban: o.holdsIban })[kind];
|
||||||
|
|
||||||
|
export const updatedOn = (o: OrgLike, kind: IdentityKind) =>
|
||||||
|
({
|
||||||
|
address: o.addressUpdatedOn,
|
||||||
|
email: o.emailUpdatedOn,
|
||||||
|
phone: o.phoneUpdatedOn,
|
||||||
|
iban: o.ibanUpdatedOn
|
||||||
|
})[kind];
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^a-z0-9@.]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** Does the value on file already match the current one? Addresses match on street + number. */
|
||||||
|
export function matchesCurrent(onFile: string | null, current: string, kind: IdentityKind) {
|
||||||
|
if (!onFile || !current) return false;
|
||||||
|
const head = kind === 'address' ? current.split(',')[0] : current;
|
||||||
|
return norm(onFile).includes(norm(head));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Still has the old value: holds it, not archived/no-action, not ticked since the change. */
|
||||||
|
export function isDue(o: OrgLike, kind: IdentityKind, current: Identity | undefined): boolean {
|
||||||
|
if (!current || o.archived || o.action === 'none' || !holds(o, kind)) return false;
|
||||||
|
const ticked = updatedOn(o, kind);
|
||||||
|
if (ticked && ticked >= current.since) return false;
|
||||||
|
const onFile = {
|
||||||
|
address: o.addressOnFile,
|
||||||
|
email: o.emailOnFile,
|
||||||
|
phone: o.phoneOnFile,
|
||||||
|
iban: null
|
||||||
|
}[kind];
|
||||||
|
if (kind === 'email' && onFile)
|
||||||
|
return !onFile.split(/[;,]\s*/).every((e) => matchesCurrent(e, current.value, kind));
|
||||||
|
return !matchesCurrent(onFile, current.value, kind);
|
||||||
|
}
|
||||||
@@ -176,6 +176,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
|||||||
external: false,
|
external: false,
|
||||||
private: true
|
private: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'holders',
|
||||||
|
subtitle: 'private: who has my address, email, phone and bank details, and a moving checklist',
|
||||||
|
href: '/trackers/data-holders',
|
||||||
|
external: false,
|
||||||
|
private: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'energy',
|
name: 'energy',
|
||||||
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
||||||
|
|||||||
321
src/lib/server/dataholders-import.ts
Normal file
321
src/lib/server/dataholders-import.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
// Importer for the private data-holders tracker (scripts/dataholders-import.ts).
|
||||||
|
// Idempotent and re-runnable: rows are matched on a stable import key (normalised name +
|
||||||
|
// login), nothing is ever deleted, checklist ticks and archive flags are never touched, and
|
||||||
|
// rows edited on the site (edited_at set) only get their empty fields filled.
|
||||||
|
import type { Sql } from 'postgres';
|
||||||
|
|
||||||
|
export type CsvRecord = Record<string, string>;
|
||||||
|
|
||||||
|
/** RFC 4180 CSV (quoted fields, "" escapes, newlines inside quotes). */
|
||||||
|
export function parseCsv(text: string): CsvRecord[] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = '';
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (ch === '"' && text[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i++;
|
||||||
|
} else if (ch === '"') quoted = false;
|
||||||
|
else field += ch;
|
||||||
|
} else if (ch === '"') quoted = true;
|
||||||
|
else if (ch === ',') {
|
||||||
|
row.push(field);
|
||||||
|
field = '';
|
||||||
|
} else if (ch === '\n' || ch === '\r') {
|
||||||
|
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = '';
|
||||||
|
} else field += ch;
|
||||||
|
}
|
||||||
|
if (field || row.length) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
const [header, ...body] = rows.filter((r) => r.some((c) => c.trim()));
|
||||||
|
if (!header) return [];
|
||||||
|
const keys = header.map((h) =>
|
||||||
|
h
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^\ufeff/, '')
|
||||||
|
);
|
||||||
|
return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normKey = (s: string | null | undefined) =>
|
||||||
|
(s ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9@.]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
export const importKey = (name: string, login: string | null) =>
|
||||||
|
`${normKey(name)}|${normKey(login)}`;
|
||||||
|
|
||||||
|
const CATEGORY: Record<string, string> = {
|
||||||
|
hosting: 'Domains / hosting',
|
||||||
|
'hosting / domains': 'Domains / hosting',
|
||||||
|
payroll: 'Employer',
|
||||||
|
'government / business': 'Business',
|
||||||
|
'business admin': 'Business'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgRow = {
|
||||||
|
name: string;
|
||||||
|
category: string | null;
|
||||||
|
login: string | null;
|
||||||
|
source: string | null;
|
||||||
|
domains: string | null;
|
||||||
|
holdsAddress: boolean;
|
||||||
|
holdsEmail: boolean;
|
||||||
|
holdsPhone: boolean;
|
||||||
|
holdsIban: boolean;
|
||||||
|
holdsBsn: boolean;
|
||||||
|
holdsCard: boolean;
|
||||||
|
holdsDob: boolean;
|
||||||
|
holdsIdDoc: boolean;
|
||||||
|
dataHeld: string | null;
|
||||||
|
addressOnFile: string | null;
|
||||||
|
addressStatus: 'confirmed' | 'likely' | 'unknown' | null;
|
||||||
|
followsBrp: boolean;
|
||||||
|
emailOnFile: string | null;
|
||||||
|
phoneOnFile: string | null;
|
||||||
|
action: 'update' | 'close' | 'check' | 'none' | null;
|
||||||
|
priority: 'must' | 'should' | 'nice' | null;
|
||||||
|
howToChange: string | null;
|
||||||
|
link: string | null;
|
||||||
|
evidence: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
lastChecked: string | null;
|
||||||
|
/** Only applied to new rows (CSV status "Done"). */
|
||||||
|
addressUpdatedOn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blank = (v: string | undefined) => (v && v.trim() ? v.trim() : null);
|
||||||
|
const truthy = (v: string | undefined) => /^(true|yes|1|y)$/i.test(v?.trim() ?? '');
|
||||||
|
const isoDate = (v: string | undefined) =>
|
||||||
|
v && /^\d{4}-\d{2}-\d{2}$/.test(v.trim()) ? v.trim() : null;
|
||||||
|
|
||||||
|
/** Split the free-text "address on file" into the address itself and how sure we are. */
|
||||||
|
export function parseAddress(raw: string | null): {
|
||||||
|
address: string | null;
|
||||||
|
status: OrgRow['addressStatus'];
|
||||||
|
followsBrp: boolean;
|
||||||
|
note: string | null;
|
||||||
|
} {
|
||||||
|
if (!raw || /^none( held)?$/i.test(raw))
|
||||||
|
return { address: null, status: null, followsBrp: false, note: null };
|
||||||
|
if (/^follows brp/i.test(raw))
|
||||||
|
return { address: null, status: 'unknown', followsBrp: true, note: null };
|
||||||
|
const paren = raw.match(/\(([^)]*)\)/)?.[1] ?? null;
|
||||||
|
if (/^unknown/i.test(raw))
|
||||||
|
return {
|
||||||
|
address: null,
|
||||||
|
status: 'unknown',
|
||||||
|
followsBrp: false,
|
||||||
|
note: paren ? `Address: ${paren}` : null
|
||||||
|
};
|
||||||
|
if (/^still previous owner/i.test(raw))
|
||||||
|
return { address: null, status: 'confirmed', followsBrp: false, note: `Address: ${raw}` };
|
||||||
|
let status: OrgRow['addressStatus'] = 'unknown';
|
||||||
|
let rest = raw;
|
||||||
|
if (/^likely\s+/i.test(rest)) {
|
||||||
|
status = 'likely';
|
||||||
|
rest = rest.replace(/^likely\s+/i, '');
|
||||||
|
} else if (/seen in mail|\bmail\)/i.test(rest)) status = 'confirmed';
|
||||||
|
const [main, ...more] = rest.split(';');
|
||||||
|
let address = main.replace(/\s*\([^)]*\)\s*/g, ' ').trim();
|
||||||
|
let note = [paren && !/^seen in mail$/i.test(paren) ? paren : null, ...more.map((m) => m.trim())]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ');
|
||||||
|
const forDomain = address.match(/^(.*?)\s+for\s+(\S+)$/);
|
||||||
|
if (forDomain) {
|
||||||
|
address = forDomain[1];
|
||||||
|
note = [note, `for ${forDomain[2]}`].filter(Boolean).join('; ');
|
||||||
|
}
|
||||||
|
if (/^old address$/i.test(address)) address = '';
|
||||||
|
return {
|
||||||
|
address: address || null,
|
||||||
|
status,
|
||||||
|
followsBrp: false,
|
||||||
|
note: note ? `Address: ${note}` : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCsvRow(r: CsvRecord): OrgRow | null {
|
||||||
|
const name = blank(r.name);
|
||||||
|
if (!name) return null;
|
||||||
|
const held = r.data_held ?? '';
|
||||||
|
// Placeholders like "(address/postcode stored as login)" are not a real login.
|
||||||
|
const rawLogin = blank(r.login);
|
||||||
|
const login = rawLogin && /^\(.*\)$/.test(rawLogin) ? null : rawLogin;
|
||||||
|
const email = blank(r.email_on_file);
|
||||||
|
const addr = parseAddress(blank(r.address_on_file));
|
||||||
|
const cat = blank(r.category);
|
||||||
|
const action = blank(r.action)?.toLowerCase() ?? null;
|
||||||
|
const prio = blank(r.priority)?.toLowerCase() ?? '';
|
||||||
|
const status = blank(r.status)?.toLowerCase() ?? '';
|
||||||
|
const notes = [blank(r.notes), addr.note].filter(Boolean).join(' ');
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
category: cat ? (CATEGORY[cat.toLowerCase()] ?? cat) : null,
|
||||||
|
login,
|
||||||
|
source: blank(r.source),
|
||||||
|
domains: blank(r.domains),
|
||||||
|
holdsAddress: /address|delivery|location|postcode|lease/i.test(held) || !!addr.address,
|
||||||
|
holdsEmail: /e-?mail/i.test(held) || !!email || !!login?.includes('@'),
|
||||||
|
holdsPhone: /phone|mobile/i.test(held),
|
||||||
|
holdsIban: /\biban\b|linked bank/i.test(held),
|
||||||
|
holdsBsn: /\bbsn\b|tax id/i.test(held),
|
||||||
|
holdsCard: /\bcard\b/i.test(held),
|
||||||
|
holdsDob: /date of birth|birth ?date|\bdob\b/i.test(held),
|
||||||
|
holdsIdDoc: /id document|id check|passport|photo/i.test(held) || /\bID\b/.test(held),
|
||||||
|
dataHeld: blank(held),
|
||||||
|
addressOnFile: addr.address,
|
||||||
|
addressStatus: addr.status,
|
||||||
|
followsBrp: truthy(r.auto_from_brp) || addr.followsBrp,
|
||||||
|
emailOnFile: email,
|
||||||
|
phoneOnFile: blank(r.phone_on_file),
|
||||||
|
action:
|
||||||
|
status === 'not needed'
|
||||||
|
? 'none'
|
||||||
|
: action && ['update', 'close', 'check', 'none'].includes(action)
|
||||||
|
? (action as OrgRow['action'])
|
||||||
|
: null,
|
||||||
|
priority: prio.startsWith('must')
|
||||||
|
? 'must'
|
||||||
|
: prio.startsWith('should')
|
||||||
|
? 'should'
|
||||||
|
: prio.startsWith('nice')
|
||||||
|
? 'nice'
|
||||||
|
: null,
|
||||||
|
howToChange: blank(r.how_to_change),
|
||||||
|
link: blank(r.link),
|
||||||
|
evidence: blank(r.evidence),
|
||||||
|
notes: notes || null,
|
||||||
|
lastChecked: isoDate(r.last_checked),
|
||||||
|
addressUpdatedOn: status === 'done' ? (isoDate(r.last_checked) ?? null) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Existing = {
|
||||||
|
id: number;
|
||||||
|
import_key: string | null;
|
||||||
|
name: string;
|
||||||
|
login: string | null;
|
||||||
|
edited_at: Date | null;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
const TEXT = [
|
||||||
|
'category',
|
||||||
|
'source',
|
||||||
|
'domains',
|
||||||
|
'data_held',
|
||||||
|
'address_on_file',
|
||||||
|
'email_on_file',
|
||||||
|
'phone_on_file',
|
||||||
|
'how_to_change',
|
||||||
|
'link',
|
||||||
|
'evidence',
|
||||||
|
'notes',
|
||||||
|
'last_checked'
|
||||||
|
] as const;
|
||||||
|
const ENUM = ['address_status', 'action', 'priority'] as const;
|
||||||
|
const FLAGS = [
|
||||||
|
'holds_address',
|
||||||
|
'holds_email',
|
||||||
|
'holds_phone',
|
||||||
|
'holds_iban',
|
||||||
|
'holds_bsn',
|
||||||
|
'holds_card',
|
||||||
|
'holds_dob',
|
||||||
|
'holds_id_doc',
|
||||||
|
'follows_brp'
|
||||||
|
] as const;
|
||||||
|
const snake = (k: string) => k.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||||
|
|
||||||
|
export async function importOrgs(sql: Sql, rows: OrgRow[], { dryRun = false } = {}) {
|
||||||
|
const result = {
|
||||||
|
inserted: 0,
|
||||||
|
updated: 0,
|
||||||
|
unchanged: 0,
|
||||||
|
linkedLogin: 0,
|
||||||
|
duplicates: [] as string[]
|
||||||
|
};
|
||||||
|
const byKey = new Map<string, OrgRow>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = importKey(r.name, r.login);
|
||||||
|
if (byKey.has(key)) result.duplicates.push(r.name);
|
||||||
|
byKey.set(key, r);
|
||||||
|
}
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
const existing = await tx<Existing[]>`select * from dataholders.orgs`;
|
||||||
|
const index = new Map(existing.filter((e) => e.import_key).map((e) => [e.import_key!, e]));
|
||||||
|
for (const [key, r] of byKey) {
|
||||||
|
const values = Object.fromEntries(Object.entries(r).map(([k, v]) => [snake(k), v])) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
let row = index.get(key);
|
||||||
|
// A login appearing for an org that so far had none: attach it instead of duplicating.
|
||||||
|
if (!row && r.login) {
|
||||||
|
const nameKey = importKey(r.name, null);
|
||||||
|
const bare = existing.filter(
|
||||||
|
(e) =>
|
||||||
|
!e.login &&
|
||||||
|
(e.import_key === nameKey || (!e.import_key && importKey(e.name, null) === nameKey))
|
||||||
|
);
|
||||||
|
if (bare.length === 1) {
|
||||||
|
row = bare[0];
|
||||||
|
result.linkedLogin++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!row) {
|
||||||
|
const insert: Record<string, unknown> = { name: r.name, login: r.login, import_key: key };
|
||||||
|
for (const c of [...TEXT, ...FLAGS, 'address_updated_on'])
|
||||||
|
if (values[c] !== null && values[c] !== undefined) insert[c] = values[c];
|
||||||
|
for (const c of ENUM) if (values[c]) insert[c] = values[c];
|
||||||
|
if (!dryRun) await tx`insert into dataholders.orgs ${tx(insert)}`;
|
||||||
|
result.inserted++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const edited = !!row.edited_at;
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
const set = (c: string, v: unknown) => {
|
||||||
|
const cur =
|
||||||
|
row![c] instanceof Date ? (row![c] as Date).toISOString().slice(0, 10) : row![c];
|
||||||
|
if (v !== cur) patch[c] = v;
|
||||||
|
};
|
||||||
|
if (row.import_key !== key) set('import_key', key);
|
||||||
|
if (!row.login && r.login) set('login', r.login);
|
||||||
|
if (!edited && r.name !== row.name) set('name', r.name);
|
||||||
|
for (const c of [...TEXT, ...ENUM]) {
|
||||||
|
const v = values[c];
|
||||||
|
if (v === null || v === undefined || v === '') continue; // never wipe
|
||||||
|
if (!edited || row[c] === null || row[c] === '') set(c, v);
|
||||||
|
}
|
||||||
|
for (const c of FLAGS) if (values[c] && !row[c]) set(c, true);
|
||||||
|
if (!Object.keys(patch).length) {
|
||||||
|
result.unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!dryRun) await tx`update dataholders.orgs set ${tx(patch)} where id = ${row.id}`;
|
||||||
|
result.updated++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the current identity values once (never overwrites what the site set). */
|
||||||
|
export async function seedIdentity(
|
||||||
|
sql: Sql,
|
||||||
|
values: { kind: string; value: string; since: string }[]
|
||||||
|
) {
|
||||||
|
for (const v of values)
|
||||||
|
await sql`insert into dataholders.identity ${sql(v)} on conflict (kind) do nothing`;
|
||||||
|
}
|
||||||
79
src/lib/server/db/schema/dataholders.ts
Normal file
79
src/lib/server/db/schema/dataholders.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { boolean, check, date, index, integer, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { dataholdersSchema } from './schemas';
|
||||||
|
import { timestamps, timestamptz } from './helpers';
|
||||||
|
|
||||||
|
// Private: every organisation that holds data about Stan (owner only, see requireOwner).
|
||||||
|
// Whenever the address, email, phone or IBAN changes, set the new value in
|
||||||
|
// dataholders.identity and work down the orgs that still have the old one (the
|
||||||
|
// /trackers/data-holders checklist). Never stores passwords: `login` is a username or email.
|
||||||
|
|
||||||
|
export const dataholderOrgs = dataholdersSchema.table(
|
||||||
|
'orgs',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
category: text('category').notNull().default('Other'),
|
||||||
|
/** Username or email used to log in. Never a password. */
|
||||||
|
login: text('login'),
|
||||||
|
/** Where the row came from, e.g. "Daisy checklist", "1Password". */
|
||||||
|
source: text('source'),
|
||||||
|
/** Websites/domains of the account (from 1Password). */
|
||||||
|
domains: text('domains'),
|
||||||
|
/** Stable import key (normalised name + login). Set by the importer, never by edits. */
|
||||||
|
importKey: text('import_key'),
|
||||||
|
holdsAddress: boolean('holds_address').notNull().default(false),
|
||||||
|
holdsEmail: boolean('holds_email').notNull().default(false),
|
||||||
|
holdsPhone: boolean('holds_phone').notNull().default(false),
|
||||||
|
holdsIban: boolean('holds_iban').notNull().default(false),
|
||||||
|
holdsBsn: boolean('holds_bsn').notNull().default(false),
|
||||||
|
holdsCard: boolean('holds_card').notNull().default(false),
|
||||||
|
holdsDob: boolean('holds_dob').notNull().default(false),
|
||||||
|
holdsIdDoc: boolean('holds_id_doc').notNull().default(false),
|
||||||
|
/** Free-text description of everything they hold (the "other" bucket). */
|
||||||
|
dataHeld: text('data_held'),
|
||||||
|
/** The address they have, as last seen (often an old one). */
|
||||||
|
addressOnFile: text('address_on_file'),
|
||||||
|
addressStatus: text('address_status').notNull().default('unknown'),
|
||||||
|
/** Gets the address from the BRP automatically (government bodies). */
|
||||||
|
followsBrp: boolean('follows_brp').notNull().default(false),
|
||||||
|
emailOnFile: text('email_on_file'),
|
||||||
|
phoneOnFile: text('phone_on_file'),
|
||||||
|
action: text('action').notNull().default('update'),
|
||||||
|
priority: text('priority').notNull().default('should'),
|
||||||
|
howToChange: text('how_to_change'),
|
||||||
|
link: text('link'),
|
||||||
|
evidence: text('evidence'),
|
||||||
|
notes: text('notes'),
|
||||||
|
addressUpdatedOn: date('address_updated_on', { mode: 'string' }),
|
||||||
|
emailUpdatedOn: date('email_updated_on', { mode: 'string' }),
|
||||||
|
phoneUpdatedOn: date('phone_updated_on', { mode: 'string' }),
|
||||||
|
ibanUpdatedOn: date('iban_updated_on', { mode: 'string' }),
|
||||||
|
lastChecked: date('last_checked', { mode: 'string' }),
|
||||||
|
/** Closed accounts stay in the list (archived), so their history is kept. */
|
||||||
|
archived: boolean('archived').notNull().default(false),
|
||||||
|
/** Set on any edit from the site; the importer then only fills empty fields. */
|
||||||
|
editedAt: timestamptz('edited_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('orgs_import_key_key').on(t.importKey),
|
||||||
|
index('orgs_category_idx').on(t.category),
|
||||||
|
check('orgs_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||||
|
check('orgs_address_status_check', sql`${t.addressStatus} IN ('confirmed','likely','unknown')`),
|
||||||
|
check('orgs_action_check', sql`${t.action} IN ('update','close','check','none')`),
|
||||||
|
check('orgs_priority_check', sql`${t.priority} IN ('must','should','nice')`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The current value of each identifier, and since when. Orgs ticked before `since` are due. */
|
||||||
|
export const dataholderIdentity = dataholdersSchema.table(
|
||||||
|
'identity',
|
||||||
|
{
|
||||||
|
kind: text('kind').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
since: date('since', { mode: 'string' }).notNull(),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [check('identity_kind_check', sql`${t.kind} IN ('address','email','phone','iban')`)]
|
||||||
|
);
|
||||||
@@ -25,3 +25,4 @@ export * from './wildlife';
|
|||||||
export * from './trains';
|
export * from './trains';
|
||||||
export * from './music';
|
export * from './music';
|
||||||
export * from './hardware';
|
export * from './hardware';
|
||||||
|
export * from './dataholders';
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ export const hardwareSchema = pgSchema('hardware');
|
|||||||
export const planesSchema = pgSchema('planes');
|
export const planesSchema = pgSchema('planes');
|
||||||
export const wildlifeSchema = pgSchema('wildlife');
|
export const wildlifeSchema = pgSchema('wildlife');
|
||||||
export const trainsSchema = pgSchema('trains');
|
export const trainsSchema = pgSchema('trains');
|
||||||
|
export const dataholdersSchema = pgSchema('dataholders');
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
|
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/data-holders')).toBe(true);
|
||||||
|
expect(isOwnerWritePath('/trackers/data-holders')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/houses')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/houses')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/houses')).toBe(true);
|
expect(isOwnerWritePath('/trackers/houses')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const ownerOnlyPrefixes = [
|
|||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
'/trackers/groceries',
|
'/trackers/groceries',
|
||||||
'/trackers/houses',
|
'/trackers/houses',
|
||||||
|
'/trackers/data-holders',
|
||||||
'/trackers/books/files',
|
'/trackers/books/files',
|
||||||
'/lists/db',
|
'/lists/db',
|
||||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||||
@@ -47,6 +48,7 @@ export const ownerWritePrefixes = [
|
|||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
'/trackers/groceries',
|
'/trackers/groceries',
|
||||||
'/trackers/houses',
|
'/trackers/houses',
|
||||||
|
'/trackers/data-holders',
|
||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/planes',
|
'/trackers/planes',
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
|||||||
ink: WHITE,
|
ink: WHITE,
|
||||||
paths: ['M3 10.5 12 3l9 7.5', 'M5 9.5V20h14V9.5', 'M10 20v-6h4v6']
|
paths: ['M3 10.5 12 3l9 7.5', 'M5 9.5V20h14V9.5', 'M10 20v-6h4v6']
|
||||||
},
|
},
|
||||||
|
holders: {
|
||||||
|
from: '#38bdf8',
|
||||||
|
to: '#6d28d9',
|
||||||
|
ink: WHITE,
|
||||||
|
// id card
|
||||||
|
paths: [
|
||||||
|
'M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
|
||||||
|
c(9, 10.5, 2),
|
||||||
|
'M5.8 16a3.4 3.4 0 0 1 6.4 0',
|
||||||
|
'M15 10h4M15 14h3'
|
||||||
|
]
|
||||||
|
},
|
||||||
energy: {
|
energy: {
|
||||||
from: '#fde047',
|
from: '#fde047',
|
||||||
to: '#eab308',
|
to: '#eab308',
|
||||||
|
|||||||
10
src/routes/trackers/data-holders/+layout.server.ts
Normal file
10
src/routes/trackers/data-holders/+layout.server.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Owner only (private personal data): everyone else gets a plain 404. Form actions skip
|
||||||
|
// this load, so +page.server.ts checks again, and hooks.server.ts blocks the prefix too.
|
||||||
|
export const load: LayoutServerLoad = (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
return {};
|
||||||
|
};
|
||||||
185
src/routes/trackers/data-holders/+page.server.ts
Normal file
185
src/routes/trackers/data-holders/+page.server.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import { dataholderIdentity, dataholderOrgs } from '#lib/server/db/schema/index.js';
|
||||||
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
|
import { importKey } from '#lib/server/dataholders-import.js';
|
||||||
|
import {
|
||||||
|
actions as orgActions,
|
||||||
|
addressStatuses,
|
||||||
|
isIdentityKind,
|
||||||
|
priorities
|
||||||
|
} from '#lib/dataholders.js';
|
||||||
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
const today = () =>
|
||||||
|
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam' }).format(new Date());
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const [orgs, identity] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(dataholderOrgs)
|
||||||
|
.orderBy(
|
||||||
|
sql`case ${dataholderOrgs.priority} when 'must' then 0 when 'should' then 1 else 2 end`,
|
||||||
|
asc(sql`lower(${dataholderOrgs.name})`)
|
||||||
|
),
|
||||||
|
db.select().from(dataholderIdentity)
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
today: today(),
|
||||||
|
orgs,
|
||||||
|
identity: identity.map(({ kind, value, since }) => ({ kind, value, since })),
|
||||||
|
seo: { title: 'Data holders', description: 'Private.', index: false }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const str = (form: FormData, key: string, max = 2000) => {
|
||||||
|
const v = String(form.get(key) ?? '').trim();
|
||||||
|
return v ? v.slice(0, max) : null;
|
||||||
|
};
|
||||||
|
const flag = (form: FormData, key: string) => form.get(key) === 'on' || form.get(key) === 'true';
|
||||||
|
const day = (form: FormData, key: string) => {
|
||||||
|
const v = str(form, key, 10);
|
||||||
|
return v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null;
|
||||||
|
};
|
||||||
|
const oneOf = <T extends string>(list: readonly T[], v: string | null, fallback: T): T =>
|
||||||
|
v && (list as readonly string[]).includes(v) ? (v as T) : fallback;
|
||||||
|
const orgId = (form: FormData) => {
|
||||||
|
const id = Number(form.get('id'));
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
save: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const name = str(form, 'name', 200);
|
||||||
|
if (!name) return fail(400, { message: 'Name is required' });
|
||||||
|
const link = str(form, 'link', 500);
|
||||||
|
if (link && !/^https?:\/\//i.test(link))
|
||||||
|
return fail(400, { message: 'Link must start with http(s)://' });
|
||||||
|
const login = str(form, 'login', 200);
|
||||||
|
const values = {
|
||||||
|
name,
|
||||||
|
category: str(form, 'category', 80) ?? 'Other',
|
||||||
|
login,
|
||||||
|
source: str(form, 'source', 120),
|
||||||
|
domains: str(form, 'domains', 500),
|
||||||
|
holdsAddress: flag(form, 'holds_address'),
|
||||||
|
holdsEmail: flag(form, 'holds_email'),
|
||||||
|
holdsPhone: flag(form, 'holds_phone'),
|
||||||
|
holdsIban: flag(form, 'holds_iban'),
|
||||||
|
holdsBsn: flag(form, 'holds_bsn'),
|
||||||
|
holdsCard: flag(form, 'holds_card'),
|
||||||
|
holdsDob: flag(form, 'holds_dob'),
|
||||||
|
holdsIdDoc: flag(form, 'holds_id_doc'),
|
||||||
|
followsBrp: flag(form, 'follows_brp'),
|
||||||
|
dataHeld: str(form, 'data_held'),
|
||||||
|
addressOnFile: str(form, 'address_on_file', 300),
|
||||||
|
addressStatus: oneOf(addressStatuses, str(form, 'address_status'), 'unknown'),
|
||||||
|
emailOnFile: str(form, 'email_on_file', 300),
|
||||||
|
phoneOnFile: str(form, 'phone_on_file', 60),
|
||||||
|
action: oneOf(orgActions, str(form, 'action'), 'update'),
|
||||||
|
priority: oneOf(priorities, str(form, 'priority'), 'should'),
|
||||||
|
howToChange: str(form, 'how_to_change'),
|
||||||
|
link,
|
||||||
|
evidence: str(form, 'evidence', 300),
|
||||||
|
notes: str(form, 'notes'),
|
||||||
|
addressUpdatedOn: day(form, 'address_updated_on'),
|
||||||
|
emailUpdatedOn: day(form, 'email_updated_on'),
|
||||||
|
phoneUpdatedOn: day(form, 'phone_updated_on'),
|
||||||
|
ibanUpdatedOn: day(form, 'iban_updated_on'),
|
||||||
|
archived: flag(form, 'archived'),
|
||||||
|
editedAt: new Date()
|
||||||
|
};
|
||||||
|
const id = orgId(form);
|
||||||
|
if (id) {
|
||||||
|
await db.update(dataholderOrgs).set(values).where(eq(dataholderOrgs.id, id));
|
||||||
|
return { saved: id };
|
||||||
|
}
|
||||||
|
// New rows claim their import key when it is free, so a later import of the same org
|
||||||
|
// fills its empty fields instead of adding a duplicate.
|
||||||
|
const key = importKey(name, login);
|
||||||
|
const [taken] = await db
|
||||||
|
.select({ id: dataholderOrgs.id })
|
||||||
|
.from(dataholderOrgs)
|
||||||
|
.where(eq(dataholderOrgs.importKey, key));
|
||||||
|
const [row] = await db
|
||||||
|
.insert(dataholderOrgs)
|
||||||
|
.values({ ...values, source: values.source ?? 'Manual', importKey: taken ? null : key })
|
||||||
|
.returning({ id: dataholderOrgs.id });
|
||||||
|
return { saved: row.id, added: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Checklist tick: the org now has the current value (or the account is closed). */
|
||||||
|
tick: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const id = orgId(form);
|
||||||
|
const kind = form.get('kind');
|
||||||
|
if (!id || !isIdentityKind(kind)) return fail(400, { message: 'Invalid tick' });
|
||||||
|
const done = form.get('done') === 'true';
|
||||||
|
const close = form.get('close') === 'true';
|
||||||
|
const [current] = await db
|
||||||
|
.select()
|
||||||
|
.from(dataholderIdentity)
|
||||||
|
.where(eq(dataholderIdentity.kind, kind));
|
||||||
|
const date = done ? today() : null;
|
||||||
|
const patch: Partial<typeof dataholderOrgs.$inferInsert> = { editedAt: new Date() };
|
||||||
|
if (kind === 'address') patch.addressUpdatedOn = date;
|
||||||
|
if (kind === 'email') patch.emailUpdatedOn = date;
|
||||||
|
if (kind === 'phone') patch.phoneUpdatedOn = date;
|
||||||
|
if (kind === 'iban') patch.ibanUpdatedOn = date;
|
||||||
|
if (done && current && !close) {
|
||||||
|
if (kind === 'address')
|
||||||
|
Object.assign(patch, { addressOnFile: current.value, addressStatus: 'confirmed' });
|
||||||
|
if (kind === 'email') patch.emailOnFile = current.value;
|
||||||
|
if (kind === 'phone') patch.phoneOnFile = current.value;
|
||||||
|
}
|
||||||
|
if (close) patch.archived = done;
|
||||||
|
await db.update(dataholderOrgs).set(patch).where(eq(dataholderOrgs.id, id));
|
||||||
|
return { ticked: id };
|
||||||
|
},
|
||||||
|
|
||||||
|
archive: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const id = orgId(form);
|
||||||
|
if (!id) return fail(400, { message: 'Invalid org' });
|
||||||
|
await db
|
||||||
|
.update(dataholderOrgs)
|
||||||
|
.set({ archived: form.get('archived') === 'true', editedAt: new Date() })
|
||||||
|
.where(eq(dataholderOrgs.id, id));
|
||||||
|
return { archived: id };
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const id = orgId(form);
|
||||||
|
if (!id) return fail(400, { message: 'Invalid org' });
|
||||||
|
await db.delete(dataholderOrgs).where(eq(dataholderOrgs.id, id));
|
||||||
|
return { deleted: id };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set the current address/email/phone/IBAN; every org ticked before `since` becomes due. */
|
||||||
|
identity: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const kind = form.get('kind');
|
||||||
|
const value = str(form, 'value', 300);
|
||||||
|
const since = day(form, 'since') ?? today();
|
||||||
|
if (!isIdentityKind(kind)) return fail(400, { message: 'Unknown identifier' });
|
||||||
|
if (!value) {
|
||||||
|
await db.delete(dataholderIdentity).where(eq(dataholderIdentity.kind, kind));
|
||||||
|
return { identity: kind };
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.insert(dataholderIdentity)
|
||||||
|
.values({ kind, value, since })
|
||||||
|
.onConflictDoUpdate({ target: dataholderIdentity.kind, set: { value, since } });
|
||||||
|
return { identity: kind };
|
||||||
|
}
|
||||||
|
};
|
||||||
907
src/routes/trackers/data-holders/+page.svelte
Normal file
907
src/routes/trackers/data-holders/+page.svelte
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
|
import { Button } from '#lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '#lib/components/ui/input/index.js';
|
||||||
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||||
|
import {
|
||||||
|
identityKinds,
|
||||||
|
identityLabels,
|
||||||
|
isDue,
|
||||||
|
matchesCurrent,
|
||||||
|
priorityLabels,
|
||||||
|
updatedOn,
|
||||||
|
type IdentityKind
|
||||||
|
} from '#lib/dataholders.js';
|
||||||
|
import Lock from 'lucide-svelte/icons/lock';
|
||||||
|
import MapPin from 'lucide-svelte/icons/map-pin';
|
||||||
|
import Mail from 'lucide-svelte/icons/mail';
|
||||||
|
import Phone from 'lucide-svelte/icons/phone';
|
||||||
|
import Landmark from 'lucide-svelte/icons/landmark';
|
||||||
|
import Fingerprint from 'lucide-svelte/icons/fingerprint';
|
||||||
|
import CreditCard from 'lucide-svelte/icons/credit-card';
|
||||||
|
import IdCard from 'lucide-svelte/icons/id-card';
|
||||||
|
import Cake from 'lucide-svelte/icons/cake';
|
||||||
|
import Search from 'lucide-svelte/icons/search';
|
||||||
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
|
import Pencil from 'lucide-svelte/icons/pencil';
|
||||||
|
import Archive from 'lucide-svelte/icons/archive';
|
||||||
|
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||||
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
|
import Check from 'lucide-svelte/icons/check';
|
||||||
|
import ListChecks from 'lucide-svelte/icons/list-checks';
|
||||||
|
import List from 'lucide-svelte/icons/list';
|
||||||
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
type Org = (typeof data.orgs)[number];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const kindIcon = { address: MapPin, email: Mail, phone: Phone, iban: Landmark };
|
||||||
|
const kindColour: Record<IdentityKind, [string, string]> = {
|
||||||
|
address: ['#fb923c', '#ea580c'],
|
||||||
|
email: ['#60a5fa', '#2563eb'],
|
||||||
|
phone: ['#34d399', '#059669'],
|
||||||
|
iban: ['#a78bfa', '#7c3aed']
|
||||||
|
};
|
||||||
|
const holdIcons = [
|
||||||
|
{ key: 'holdsAddress', label: 'Address', icon: MapPin, colour: '#f97316' },
|
||||||
|
{ key: 'holdsEmail', label: 'Email', icon: Mail, colour: '#3b82f6' },
|
||||||
|
{ key: 'holdsPhone', label: 'Phone', icon: Phone, colour: '#10b981' },
|
||||||
|
{ key: 'holdsIban', label: 'IBAN / bank', icon: Landmark, colour: '#8b5cf6' },
|
||||||
|
{ key: 'holdsBsn', label: 'BSN / tax id', icon: Fingerprint, colour: '#ef4444' },
|
||||||
|
{ key: 'holdsCard', label: 'Card', icon: CreditCard, colour: '#eab308' },
|
||||||
|
{ key: 'holdsIdDoc', label: 'ID document / photo', icon: IdCard, colour: '#06b6d4' },
|
||||||
|
{ key: 'holdsDob', label: 'Date of birth', icon: Cake, colour: '#ec4899' }
|
||||||
|
] as const;
|
||||||
|
const holdFields = [
|
||||||
|
['holds_address', 'holdsAddress', 'Address'],
|
||||||
|
['holds_email', 'holdsEmail', 'Email'],
|
||||||
|
['holds_phone', 'holdsPhone', 'Phone'],
|
||||||
|
['holds_iban', 'holdsIban', 'IBAN'],
|
||||||
|
['holds_bsn', 'holdsBsn', 'BSN'],
|
||||||
|
['holds_card', 'holdsCard', 'Card'],
|
||||||
|
['holds_id_doc', 'holdsIdDoc', 'ID doc'],
|
||||||
|
['holds_dob', 'holdsDob', 'Birth date'],
|
||||||
|
['follows_brp', 'followsBrp', 'Follows BRP']
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const identity = $derived(Object.fromEntries(data.identity.map((i) => [i.kind, i])));
|
||||||
|
|
||||||
|
// View state.
|
||||||
|
let mode = $state<'list' | 'checklist'>('list');
|
||||||
|
let kind = $state<IdentityKind>('address');
|
||||||
|
let query = $state('');
|
||||||
|
let category = $state('');
|
||||||
|
let status = $state('');
|
||||||
|
let showArchived = $state(false);
|
||||||
|
let page = $state(1);
|
||||||
|
let editing = $state<number | 'new' | null>(null);
|
||||||
|
let editingIdentity = $state<IdentityKind | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const dueCount = $derived(
|
||||||
|
Object.fromEntries(
|
||||||
|
identityKinds.map((k) => [k, data.orgs.filter((o) => isDue(o, k, identity[k])).length])
|
||||||
|
) as Record<IdentityKind, number>
|
||||||
|
);
|
||||||
|
const doneSince = (o: Org, k: IdentityKind) => {
|
||||||
|
const d = updatedOn(o, k);
|
||||||
|
return !!d && !!identity[k] && d >= identity[k].since;
|
||||||
|
};
|
||||||
|
const onFile = (o: Org, k: IdentityKind) =>
|
||||||
|
({ address: o.addressOnFile, email: o.emailOnFile, phone: o.phoneOnFile, iban: null })[k];
|
||||||
|
const isCurrent = (o: Org, k: IdentityKind) =>
|
||||||
|
!!identity[k] && matchesCurrent(onFile(o, k), identity[k].value, k);
|
||||||
|
|
||||||
|
const categories = $derived.by(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const o of data.orgs)
|
||||||
|
if (showArchived || !o.archived) counts[o.category] = (counts[o.category] ?? 0) + 1;
|
||||||
|
return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const needle = $derived(query.trim().toLowerCase());
|
||||||
|
const matchesQuery = (o: Org) =>
|
||||||
|
!needle ||
|
||||||
|
[o.name, o.login, o.domains, o.category, o.notes, o.dataHeld, o.addressOnFile, o.emailOnFile]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((v) => v!.toLowerCase().includes(needle));
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
if (mode === 'checklist') {
|
||||||
|
const rows = data.orgs.filter(
|
||||||
|
(o) =>
|
||||||
|
(isDue(o, kind, identity[kind]) || doneSince(o, kind)) &&
|
||||||
|
matchesQuery(o) &&
|
||||||
|
(!category || o.category === category)
|
||||||
|
);
|
||||||
|
// Still to do first (already priority-sorted by the server), ticked ones at the end.
|
||||||
|
return [
|
||||||
|
...rows.filter((o) => !doneSince(o, kind)),
|
||||||
|
...rows.filter((o) => doneSince(o, kind))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return data.orgs.filter((o) => {
|
||||||
|
if (o.archived && !showArchived) return false;
|
||||||
|
if (category && o.category !== category) return false;
|
||||||
|
if (!matchesQuery(o)) return false;
|
||||||
|
switch (status) {
|
||||||
|
case 'old':
|
||||||
|
return isDue(o, 'address', identity.address);
|
||||||
|
case 'old-email':
|
||||||
|
return isDue(o, 'email', identity.email);
|
||||||
|
case 'confirmed':
|
||||||
|
case 'likely':
|
||||||
|
case 'unknown':
|
||||||
|
return o.holdsAddress && o.addressStatus === status && !isCurrent(o, 'address');
|
||||||
|
case 'current':
|
||||||
|
return o.holdsAddress && isCurrent(o, 'address');
|
||||||
|
case 'todo':
|
||||||
|
return o.action !== 'none';
|
||||||
|
case 'archived':
|
||||||
|
return o.archived;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const pages = $derived(Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)));
|
||||||
|
const shown = $derived(filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
|
||||||
|
const checklistTotal = $derived(
|
||||||
|
dueCount[kind] + data.orgs.filter((o) => doneSince(o, kind)).length
|
||||||
|
);
|
||||||
|
const checklistDone = $derived(data.orgs.filter((o) => doneSince(o, kind)).length);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Any filter change jumps back to the first page.
|
||||||
|
void [mode, kind, needle, category, status, showArchived];
|
||||||
|
page = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openChecklist(k: IdentityKind) {
|
||||||
|
kind = k;
|
||||||
|
mode = 'checklist';
|
||||||
|
editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit: SubmitFunction =
|
||||||
|
() =>
|
||||||
|
async ({ result, update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
if (result.type === 'success') {
|
||||||
|
error = null;
|
||||||
|
editing = null;
|
||||||
|
editingIdentity = null;
|
||||||
|
} else
|
||||||
|
error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
|
||||||
|
};
|
||||||
|
const quiet: SubmitFunction =
|
||||||
|
() =>
|
||||||
|
async ({ update }) =>
|
||||||
|
update({ reset: false, invalidateAll: true });
|
||||||
|
|
||||||
|
const fmt = (d: string | null | undefined) =>
|
||||||
|
d
|
||||||
|
? new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
const host = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).host.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const statusLabel = (o: Org) => {
|
||||||
|
if (isCurrent(o, 'address')) return { text: 'current', cls: 'ok' };
|
||||||
|
if (o.followsBrp && !o.addressOnFile) return { text: 'follows BRP', cls: 'info' };
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
confirmed: { text: 'old (confirmed)', cls: 'bad' },
|
||||||
|
likely: { text: 'likely old', cls: 'warn' },
|
||||||
|
unknown: { text: 'unknown', cls: 'muted' }
|
||||||
|
}[o.addressStatus] ?? { text: o.addressStatus, cls: 'muted' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet tileIcon(k: IdentityKind, size = 'size-9')}
|
||||||
|
{@const Icon = kindIcon[k]}
|
||||||
|
<span
|
||||||
|
class="tile-icon grid {size} shrink-0 place-items-center rounded-xl text-white"
|
||||||
|
style="background: linear-gradient(135deg, {kindColour[k][0]}, {kindColour[k][1]})"
|
||||||
|
><Icon size={18} aria-hidden="true" /></span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet holdsBadges(o: Org)}
|
||||||
|
<span class="flex flex-wrap items-center gap-1">
|
||||||
|
{#each holdIcons as h (h.key)}
|
||||||
|
{#if o[h.key]}
|
||||||
|
{@const Icon = h.icon}
|
||||||
|
<span
|
||||||
|
class="hold"
|
||||||
|
title={h.label}
|
||||||
|
style="color: {h.colour}; background: color-mix(in oklab, {h.colour} 14%, transparent)"
|
||||||
|
><Icon size={12} aria-label={h.label} /></span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet editor(o: Org | null)}
|
||||||
|
<form method="POST" action="?/save" use:enhance={submit} class="editor grid gap-3">
|
||||||
|
{#if o}<input type="hidden" name="id" value={o.id} />{/if}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label class="field sm:col-span-2"
|
||||||
|
>Organisation *<Input name="name" value={o?.name ?? ''} required maxlength={200} /></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>Category<Input
|
||||||
|
name="category"
|
||||||
|
value={o?.category ?? (category || '')}
|
||||||
|
list="dh-categories"
|
||||||
|
placeholder="Other"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>Login (username or email, never a password)<Input
|
||||||
|
name="login"
|
||||||
|
value={o?.login ?? ''}
|
||||||
|
autocomplete="off"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="field">Link<Input name="link" type="url" value={o?.link ?? ''} /></label>
|
||||||
|
<label class="field">Domains<Input name="domains" value={o?.domains ?? ''} /></label>
|
||||||
|
</div>
|
||||||
|
<fieldset class="flex flex-wrap gap-x-4 gap-y-2 text-sm">
|
||||||
|
<legend class="mb-1 text-xs text-[var(--site-muted)]">What they hold</legend>
|
||||||
|
{#each holdFields as [name, key, label] (name)}
|
||||||
|
<label class="flex items-center gap-1.5"
|
||||||
|
><input type="checkbox" {name} checked={o ? o[key] : name === 'holds_email'} />
|
||||||
|
{label}</label
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
<label class="field"
|
||||||
|
>Other data they hold<Textarea name="data_held" rows={2} value={o?.dataHeld ?? ''} /></label
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label class="field sm:col-span-2"
|
||||||
|
>Address on file<Input name="address_on_file" value={o?.addressOnFile ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>How sure<NativeSelect name="address_status" value={o?.addressStatus ?? 'unknown'}>
|
||||||
|
<option value="confirmed">confirmed</option>
|
||||||
|
<option value="likely">likely</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>Email on file<Input name="email_on_file" value={o?.emailOnFile ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>Phone on file<Input name="phone_on_file" value={o?.phoneOnFile ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="field">Source<Input name="source" value={o?.source ?? ''} /></label>
|
||||||
|
<label class="field"
|
||||||
|
>Action<NativeSelect name="action" value={o?.action ?? 'update'}>
|
||||||
|
<option value="update">update</option>
|
||||||
|
<option value="close">close account</option>
|
||||||
|
<option value="check">check</option>
|
||||||
|
<option value="none">nothing to do</option>
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>Priority<NativeSelect name="priority" value={o?.priority ?? 'should'}>
|
||||||
|
<option value="must">Must-do</option>
|
||||||
|
<option value="should">Should-do</option>
|
||||||
|
<option value="nice">Nice-to-have</option>
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="field">Evidence<Input name="evidence" value={o?.evidence ?? ''} /></label>
|
||||||
|
</div>
|
||||||
|
<label class="field"
|
||||||
|
>How to change it<Textarea
|
||||||
|
name="how_to_change"
|
||||||
|
rows={2}
|
||||||
|
value={o?.howToChange ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="field">Notes<Textarea name="notes" rows={2} value={o?.notes ?? ''} /></label>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-4">
|
||||||
|
{#each identityKinds as k (k)}
|
||||||
|
<label class="field"
|
||||||
|
>{identityLabels[k]} updated<Input
|
||||||
|
type="date"
|
||||||
|
name="{k}_updated_on"
|
||||||
|
value={o ? (updatedOn(o, k) ?? '') : ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label class="flex items-center gap-1.5 text-sm"
|
||||||
|
><input type="checkbox" name="archived" checked={o?.archived ?? false} /> Archived (account closed)</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if error}<p class="self-center text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||||
|
<Button type="button" variant="outline" onclick={() => (editing = null)}>Cancel</Button>
|
||||||
|
<Button type="submit">{o ? 'Save' : 'Add'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if o}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm(`Delete ${o.name} for good? Archiving keeps it.`)) cancel();
|
||||||
|
return submit({} as never);
|
||||||
|
}}
|
||||||
|
class="mt-2 text-right"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={o.id} />
|
||||||
|
<button class="text-xs text-[var(--site-muted)] underline hover:text-red-500" type="submit"
|
||||||
|
>Delete for good</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<datalist id="dh-categories">
|
||||||
|
{#each categories as [c] (c)}<option value={c}></option>{/each}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">data holders</h1>
|
||||||
|
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
||||||
|
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-[var(--site-muted)]">
|
||||||
|
Every organisation that has my address, email, phone or bank details. When one of them
|
||||||
|
changes, set the new value below and work down the checklist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
editing = 'new';
|
||||||
|
mode = 'list';
|
||||||
|
}}><Plus size={16} /> Add organisation</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current identifiers: each tile opens the checklist for it. -->
|
||||||
|
<section class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" aria-label="Current details">
|
||||||
|
{#each identityKinds as k (k)}
|
||||||
|
{@const current = identity[k]}
|
||||||
|
<div class="tile relative" class:active={mode === 'checklist' && kind === k}>
|
||||||
|
{#if editingIdentity === k}
|
||||||
|
<form method="POST" action="?/identity" use:enhance={submit} class="grid gap-2">
|
||||||
|
<input type="hidden" name="kind" value={k} />
|
||||||
|
<label class="field"
|
||||||
|
>New {identityLabels[k].toLowerCase()}<Input
|
||||||
|
name="value"
|
||||||
|
value={current?.value ?? ''}
|
||||||
|
autocomplete="off"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="field"
|
||||||
|
>Since<Input type="date" name="since" value={current?.since ?? data.today} /></label
|
||||||
|
>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editingIdentity = null)}>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button size="sm" type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stretched flex w-full items-start gap-3 text-left"
|
||||||
|
onclick={() => openChecklist(k)}
|
||||||
|
>
|
||||||
|
{@render tileIcon(k)}
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block text-xs text-[var(--site-muted)]">{identityLabels[k]}</span>
|
||||||
|
<span class="block truncate font-medium" title={current?.value}
|
||||||
|
>{current?.value ?? 'not set'}</span
|
||||||
|
>
|
||||||
|
<span class="block text-xs text-[var(--site-muted)]">
|
||||||
|
{#if current}
|
||||||
|
{dueCount[k]} still to update · since {fmt(current.since)}
|
||||||
|
{:else}
|
||||||
|
set it to get a checklist
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon absolute top-2 right-2 z-10"
|
||||||
|
aria-label={`Change ${identityLabels[k]}`}
|
||||||
|
onclick={() => (editingIdentity = k)}><Pencil size={14} /></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<div class="seg" role="tablist" aria-label="View">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'list'}
|
||||||
|
class:on={mode === 'list'}
|
||||||
|
onclick={() => (mode = 'list')}><List size={14} /> All</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'checklist'}
|
||||||
|
class:on={mode === 'checklist'}
|
||||||
|
onclick={() => (mode = 'checklist')}><ListChecks size={14} /> Checklist</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if mode === 'checklist'}
|
||||||
|
<NativeSelect aria-label="Checklist for" bind:value={kind}>
|
||||||
|
{#each identityKinds as k (k)}<option value={k}
|
||||||
|
>{identityLabels[k]} ({dueCount[k]} to do)</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
{:else}
|
||||||
|
<NativeSelect aria-label="Status" bind:value={status}>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="old">Still has my old address</option>
|
||||||
|
<option value="old-email">Still has my old email</option>
|
||||||
|
<option value="confirmed">Old address, confirmed</option>
|
||||||
|
<option value="likely">Old address, likely</option>
|
||||||
|
<option value="unknown">Address unknown</option>
|
||||||
|
<option value="current">Has my current address</option>
|
||||||
|
<option value="todo">Has something to do</option>
|
||||||
|
<option value="archived">Archived only</option>
|
||||||
|
</NativeSelect>
|
||||||
|
{/if}
|
||||||
|
<label class="search relative min-w-[14rem] flex-1">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
class="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-[var(--site-muted)]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
bind:value={query}
|
||||||
|
type="search"
|
||||||
|
placeholder="Search name, login, domain, notes…"
|
||||||
|
class="pl-8"
|
||||||
|
aria-label="Search"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if mode === 'list'}
|
||||||
|
<label class="flex items-center gap-1.5 text-sm text-[var(--site-muted)]"
|
||||||
|
><input type="checkbox" bind:checked={showArchived} /> Show archived</label
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5 flex flex-wrap gap-1.5" aria-label="Categories">
|
||||||
|
<button type="button" class="chip" class:on={!category} onclick={() => (category = '')}
|
||||||
|
>All <small>{data.orgs.filter((o) => showArchived || !o.archived).length}</small></button
|
||||||
|
>
|
||||||
|
{#each categories as [c, n] (c)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:on={category === c}
|
||||||
|
onclick={() => (category = category === c ? '' : c)}>{c} <small>{n}</small></button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === 'checklist'}
|
||||||
|
{#if !identity[kind]}
|
||||||
|
<p class="panel text-sm">
|
||||||
|
Set your current {identityLabels[kind].toLowerCase()} in the tile above first; every organisation
|
||||||
|
that holds it then shows up here until you tick it.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-4 flex items-center gap-3">
|
||||||
|
{@render tileIcon(kind, 'size-7')}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>Moving {identityLabels[kind].toLowerCase()} to <b>{identity[kind].value}</b></span
|
||||||
|
>
|
||||||
|
<span class="tabular-nums text-[var(--site-muted)]"
|
||||||
|
>{checklistDone} / {checklistTotal}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="bar mt-1">
|
||||||
|
<span
|
||||||
|
style="width: {checklistTotal
|
||||||
|
? (checklistDone / checklistTotal) * 100
|
||||||
|
: 0}%; background: linear-gradient(90deg, {kindColour[kind][0]}, {kindColour[
|
||||||
|
kind
|
||||||
|
][1]})"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editing === 'new'}
|
||||||
|
<div class="panel mb-3">{@render editor(null)}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="mb-2 text-xs text-[var(--site-muted)]">
|
||||||
|
{filtered.length} organisation{filtered.length === 1 ? '' : 's'}{pages > 1
|
||||||
|
? ` · page ${page} of ${pages}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
{#each shown as o (o.id)}
|
||||||
|
{@const st = statusLabel(o)}
|
||||||
|
{@const done = mode === 'checklist' && doneSince(o, kind)}
|
||||||
|
<li class="row" class:archived={o.archived} class:done>
|
||||||
|
{#if editing === o.id}
|
||||||
|
{@render editor(o)}
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
{#if mode === 'checklist'}
|
||||||
|
<form method="POST" action="?/tick" use:enhance={quiet}>
|
||||||
|
<input type="hidden" name="id" value={o.id} />
|
||||||
|
<input type="hidden" name="kind" value={kind} />
|
||||||
|
<input type="hidden" name="done" value={done ? 'false' : 'true'} />
|
||||||
|
<input type="hidden" name="close" value={o.action === 'close' ? 'true' : 'false'} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="tick"
|
||||||
|
class:checked={done}
|
||||||
|
aria-label={done
|
||||||
|
? `Undo ${o.name}`
|
||||||
|
: `Mark ${o.name} as ${o.action === 'close' ? 'closed' : 'updated'}`}
|
||||||
|
title={done
|
||||||
|
? 'Undo'
|
||||||
|
: o.action === 'close'
|
||||||
|
? 'Mark account closed'
|
||||||
|
: 'Mark updated'}
|
||||||
|
style="--k: {kindColour[kind][1]}"
|
||||||
|
>
|
||||||
|
{#if done}<Check size={16} />{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span class="font-medium">{o.name}</span>
|
||||||
|
{#if o.link}<a class="link" href={o.link} target="_blank" rel="noopener noreferrer"
|
||||||
|
>{host(o.link)} <ExternalLink size={11} /></a
|
||||||
|
>{/if}
|
||||||
|
<span class="badge">{o.category}</span>
|
||||||
|
{#if o.priority === 'must'}<span class="badge must">{priorityLabels.must}</span
|
||||||
|
>{/if}
|
||||||
|
{#if o.action === 'close'}<span class="badge warn">close account</span>{/if}
|
||||||
|
{#if o.action === 'check'}<span class="badge info">check</span>{/if}
|
||||||
|
{#if o.archived}<span class="badge muted">archived</span>{/if}
|
||||||
|
{#if done}<span class="badge ok"
|
||||||
|
>{o.action === 'close' ? 'closed' : 'updated'} {fmt(updatedOn(o, kind))}</span
|
||||||
|
>{/if}
|
||||||
|
{@render holdsBadges(o)}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-[var(--site-muted)]">
|
||||||
|
{#if o.login}<span title="Login">{o.login}</span>{/if}
|
||||||
|
{#if o.holdsAddress}
|
||||||
|
<span>
|
||||||
|
<span class="dot {st.cls}"></span>
|
||||||
|
{o.addressOnFile ?? 'address'} · {st.text}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if o.emailOnFile && o.emailOnFile !== o.login}<span>{o.emailOnFile}</span>{/if}
|
||||||
|
{#if o.phoneOnFile}<span>{o.phoneOnFile}</span>{/if}
|
||||||
|
{#if o.source}<span>via {o.source}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if (mode === 'checklist' && !done) || o.notes}
|
||||||
|
<div class="mt-1 text-xs">
|
||||||
|
{#if mode === 'checklist' && o.howToChange}<p>{o.howToChange}</p>{/if}
|
||||||
|
{#if o.notes}<p class="text-[var(--site-muted)]">{o.notes}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<button
|
||||||
|
class="icon"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${o.name}`}
|
||||||
|
onclick={() => {
|
||||||
|
editing = o.id;
|
||||||
|
error = null;
|
||||||
|
}}><Pencil size={14} /></button
|
||||||
|
>
|
||||||
|
<form method="POST" action="?/archive" use:enhance={quiet}>
|
||||||
|
<input type="hidden" name="id" value={o.id} />
|
||||||
|
<input type="hidden" name="archived" value={o.archived ? 'false' : 'true'} />
|
||||||
|
<button
|
||||||
|
class="icon"
|
||||||
|
type="submit"
|
||||||
|
aria-label={o.archived ? `Restore ${o.name}` : `Archive ${o.name}`}
|
||||||
|
title={o.archived ? 'Restore' : 'Archive (account closed)'}
|
||||||
|
>
|
||||||
|
{#if o.archived}<ArchiveRestore size={14} />{:else}<Archive size={14} />{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="panel text-sm text-[var(--site-muted)]">
|
||||||
|
{mode === 'checklist' && identity[kind]
|
||||||
|
? 'Nothing left to update. 🎉'
|
||||||
|
: 'No organisations match.'}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if pages > 1}
|
||||||
|
<nav class="mt-4 flex items-center justify-center gap-1" aria-label="Pages">
|
||||||
|
<button
|
||||||
|
class="icon"
|
||||||
|
type="button"
|
||||||
|
disabled={page === 1}
|
||||||
|
onclick={() => (page -= 1)}
|
||||||
|
aria-label="Previous page"><ChevronLeft size={16} /></button
|
||||||
|
>
|
||||||
|
{#each Array.from({ length: pages }, (_, i) => i + 1) as p (p)}
|
||||||
|
<button type="button" class="chip" class:on={p === page} onclick={() => (page = p)}
|
||||||
|
>{p}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
class="icon"
|
||||||
|
type="button"
|
||||||
|
disabled={page === pages}
|
||||||
|
onclick={() => (page += 1)}
|
||||||
|
aria-label="Next page"><ChevronRight size={16} /></button
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel,
|
||||||
|
.row,
|
||||||
|
.tile {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
|
||||||
|
}
|
||||||
|
.row.archived {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.row.done {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
transition:
|
||||||
|
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.tile:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 24px -14px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
.tile.active {
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
.tile :global(.tile-icon) {
|
||||||
|
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
.tile:hover :global(.tile-icon) {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
.stretched::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition:
|
||||||
|
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.chip.on {
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--background);
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
.chip small {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.seg button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.seg button.on {
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.icon:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.icon:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.hold {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.badge.must {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.badge.warn {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.badge.info {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
.badge.ok {
|
||||||
|
border-color: #10b981;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-right: 0.15rem;
|
||||||
|
background: #a1a1aa;
|
||||||
|
}
|
||||||
|
.dot.ok {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
.dot.bad {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
.dot.warn {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
.dot.info {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.tick {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
border: 2px solid color-mix(in oklab, var(--foreground) 30%, transparent);
|
||||||
|
color: white;
|
||||||
|
transition:
|
||||||
|
transform 120ms ease,
|
||||||
|
background-color 120ms ease,
|
||||||
|
border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.tick:hover {
|
||||||
|
border-color: var(--k);
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
.tick.checked {
|
||||||
|
background: var(--k);
|
||||||
|
border-color: var(--k);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
transition: width 300ms ease;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tile,
|
||||||
|
.chip,
|
||||||
|
.tick {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.tile:hover,
|
||||||
|
.chip:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user