WFDASH-WEBAPP/src/lib/user-color.ts
SJCoetz a18ab0f19f feat: add quote email tracking, user color coding, and improved attachments
Add comprehensive quote management and UI improvements:
- Add `email_received_at` field to quotes collection, TypeScript types, and setup script
- Update quote attachments schema to mark `kind` optional for legacy attachments, add `uploaded_at` field, and improve documentation comments
- Create `/api/v1/users/lookup` server endpoint to resolve user IDs to display names
- Add stable user ID → color mapping for visual grouping of claimed quotes
- Enhance quotes list page: reorder tab filters, show claimer names, color-code rows per claimant
- Update quote detail page to display claimer info with user icon
- Expand DataTable component to support per-row custom class and style props
- Overhaul QuoteEmailCard: add proper HTML/plain text body sanitization/linkification, inline PDF/image previews, improved metadata display, and fixed auth-aware attachment previews via blob URLs
2026-06-22 15:15:00 +02:00

85 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Stable user-id → color mapping for visual grouping in lists (e.g.
* colour every row a quoter claimed so the team can see at a glance
* who owns what).
*
* The mapping is deterministic — the same user id always resolves to
* the same colour across reloads and across users. We use a 32-bit
* FNV-1a hash so the palette selection is well-distributed without
* importing a hash library.
*
* The palette is hand-tuned for accessibility: every colour is
* distinct at a glance AND pairs with a matching tinted background
* (5–10 % opacity) so the row highlight reads well in both light and
* dark mode.
*/
export interface UserColor {
/** Foreground accent — left border, dot, chip text. */
accent: string;
/** Background tint — used for row highlight, ~6–10% opacity. */
tint: string;
/** Solid chip background — used for the user-name pill. */
chip: string;
/** Foreground that reads on `chip`. */
chipFg: string;
}
// Curated palette of 12 visually distinct hues. Going past 12 starts to
// cycle back to colours too close to neighbours. If the team grows past
// that, append more entries rather than re-ordering — existing user
// colour assignments must stay stable.
const PALETTE: UserColor[] = [
{ accent: "#2563eb", tint: "rgba(37, 99, 235, 0.10)", chip: "rgba(37, 99, 235, 0.18)", chipFg: "#1d4ed8" }, // blue
{ accent: "#dc2626", tint: "rgba(220, 38, 38, 0.10)", chip: "rgba(220, 38, 38, 0.18)", chipFg: "#b91c1c" }, // red
{ accent: "#16a34a", tint: "rgba(22, 163, 74, 0.10)", chip: "rgba(22, 163, 74, 0.18)", chipFg: "#15803d" }, // green
{ accent: "#ea580c", tint: "rgba(234, 88, 12, 0.10)", chip: "rgba(234, 88, 12, 0.18)", chipFg: "#c2410c" }, // orange
{ accent: "#9333ea", tint: "rgba(147, 51, 234, 0.10)", chip: "rgba(147, 51, 234, 0.18)", chipFg: "#7e22ce" }, // purple
{ accent: "#0891b2", tint: "rgba(8, 145, 178, 0.10)", chip: "rgba(8, 145, 178, 0.18)", chipFg: "#0e7490" }, // cyan
{ accent: "#db2777", tint: "rgba(219, 39, 119, 0.10)", chip: "rgba(219, 39, 119, 0.18)", chipFg: "#be185d" }, // pink
{ accent: "#ca8a04", tint: "rgba(202, 138, 4, 0.10)", chip: "rgba(202, 138, 4, 0.18)", chipFg: "#a16207" }, // amber
{ accent: "#0d9488", tint: "rgba(13, 148, 136, 0.10)", chip: "rgba(13, 148, 136, 0.18)", chipFg: "#0f766e" }, // teal
{ accent: "#7c3aed", tint: "rgba(124, 58, 237, 0.10)", chip: "rgba(124, 58, 237, 0.18)", chipFg: "#6d28d9" }, // violet
{ accent: "#65a30d", tint: "rgba(101, 163, 13, 0.10)", chip: "rgba(101, 163, 13, 0.18)", chipFg: "#4d7c0f" }, // lime
{ accent: "#475569", tint: "rgba(71, 85, 105, 0.10)", chip: "rgba(71, 85, 105, 0.18)", chipFg: "#334155" }, // slate
];
/**
* FNV-1a 32-bit hash. Returns an unsigned 32-bit int. Fast enough for
* per-row colour assignment; no need for crypto strength.
*/
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
// Multiply by FNV prime, keeping within 32 bits via `>>> 0`.
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h >>> 0;
}
/**
* Resolve a user id (PB record id) to a stable colour. Returns `null`
* for empty input so callers can short-circuit.
*/
export function userColor(userId: string | null | undefined): UserColor | null {
if (!userId) return null;
const idx = fnv1a(userId) % PALETTE.length;
return PALETTE[idx];
}
/**
* Best-effort display name for a user. Prefers `name` (the PB auth
* collection's human-readable field), falls back to the local-part of
* `email`, then to "User".
*/
export function userDisplayName(
user: { name?: string; email?: string } | null | undefined
): string {
if (!user) return "—";
const name = (user.name || "").trim();
if (name) return name;
const email = (user.email || "").trim();
if (!email) return "—";
return email.split("@")[0] || email;
}