/** * Presentation helpers for the profile picker. * * Pure, and separate from the component, because this is the half worth testing * and a component cannot be unit-tested without mounting it. * * Note what is *not* here: nothing decides whether a profile is locked, whether * a PIN is correct, or how many attempts remain. Those come from the backend as * an opaque `unlockMethod` and an `UnlockOutcome`. A child's profile is simply * one with no PIN — the app models no roles and infers no ages. * * TRACES: UR-082, UR-083 | DR-276 */ import type { Profile } from "$lib/api/bindings"; /** * Order tiles as people expect to find them: whoever used the device last is * leftmost, and profiles that have never been used sort after those that have. * * TRACES: UR-082 | DR-276 */ export function orderProfiles(profiles: Profile[]): Profile[] { return [...profiles].sort((a, b) => { if (a.lastUsedAt && b.lastUsedAt) return b.lastUsedAt.localeCompare(a.lastUsedAt); if (a.lastUsedAt) return -1; if (b.lastUsedAt) return 1; return a.username.localeCompare(b.username); }); } /** * Initials for a tile with no avatar. At most two letters, because three fills * a circle badly at tile size. * * TRACES: UR-082 | DR-276 */ export function initialsFor(username: string): string { const words = username .trim() .split(/[\s._-]+/) .filter(Boolean); if (words.length === 0) return "?"; if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); return (words[0][0] + words[words.length - 1][0]).toUpperCase(); } /** * A stable tile colour per profile, so a family learns to recognise their tile * by colour before they read the name. Derived from the user id rather than the * name, so renaming does not move someone's colour. * * TRACES: UR-082 | DR-276 */ const TILE_COLOURS = ["#7b68ee", "#00a4dc", "#e8734a", "#3ba55d", "#d95f8e", "#c9a227"] as const; export function tileColour(userId: string): string { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = (hash * 31 + userId.charCodeAt(i)) >>> 0; } return TILE_COLOURS[hash % TILE_COLOURS.length]; } /** * How long a lockout has left, phrased for a person rather than a log line. * * TRACES: UR-083 | DR-276 */ export function lockoutMessage(until: string, now: Date = new Date()): string { const remainingMs = new Date(until).getTime() - now.getTime(); if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Try again now"; const minutes = Math.ceil(remainingMs / 60000); if (minutes <= 1) return "Try again in less than a minute"; return `Try again in ${minutes} minutes`; }