// Profile-edit validation, mirroring the backend (account/profile.go, // account/timezone.go) so the form can disable Save and flag fields before a round // trip. Pure and unit-tested. /** maxDisplayName caps the editable display name in runes. */ export const maxDisplayName = 32; /** maxAwayMinutes bounds the daily away window's length (12 h). */ export const maxAwayMinutes = 12 * 60; // Unicode letters joined by single space / "." / "_" separators, where a "." or "_" // may be followed by a single space. No leading separator and no adjacent separators // except " "; a single trailing "." is allowed (Stage 17). Same // rule as the Go displayNameRe. const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u; /** displayNameError returns true when the trimmed name is a valid display name. */ export function validDisplayName(raw: string): boolean { const name = raw.trim(); return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name); } // A pragmatic email check (the backend re-validates with net/mail). Rejects spaces // and requires a local part, an @, and a dotted domain. const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** validEmail reports whether email is a plausible address. */ export function validEmail(email: string): boolean { return emailRe.test(email.trim()); } /** toMinutes parses an "HH:MM" time-of-day into minutes since midnight, or null. */ export function toMinutes(hhmm: string): number | null { const m = /^(\d{2}):(\d{2})$/.exec(hhmm); if (!m) return null; const h = Number(m[1]); const min = Number(m[2]); if (h > 23 || min > 59) return null; return h * 60 + min; } /** awayDurationOk reports whether the away window (wrapping midnight) is <= 12 h. */ export function awayDurationOk(start: string, end: string): boolean { const s = toMinutes(start); const e = toMinutes(end); if (s === null || e === null) return false; let d = e - s; if (d < 0) d += 24 * 60; return d <= maxAwayMinutes; } /** The real-world set of unique UTC offsets, for the timezone dropdown. */ export const timezoneOffsets: string[] = [ '-12:00', '-11:00', '-10:00', '-09:30', '-09:00', '-08:00', '-07:00', '-06:00', '-05:00', '-04:00', '-03:30', '-03:00', '-02:00', '-01:00', '+00:00', '+01:00', '+02:00', '+03:00', '+03:30', '+04:00', '+04:30', '+05:00', '+05:30', '+05:45', '+06:00', '+06:30', '+07:00', '+08:00', '+08:45', '+09:00', '+09:30', '+10:00', '+10:30', '+11:00', '+12:00', '+12:45', '+13:00', '+14:00', ]; /** isOffsetZone reports whether a stored timezone is a "±HH:MM" offset. */ export function isOffsetZone(tz: string): boolean { return /^[+-]\d{2}:\d{2}$/.test(tz); } /** browserOffset returns the client's current UTC offset as "±HH:MM". */ export function browserOffset(): string { const mins = -new Date().getTimezoneOffset(); // getTimezoneOffset is minutes behind UTC const sign = mins < 0 ? '-' : '+'; const abs = Math.abs(mins); const hh = String(Math.floor(abs / 60)).padStart(2, '0'); const mm = String(abs % 60).padStart(2, '0'); return `${sign}${hh}:${mm}`; } /** Hour options "00".."23" for the away-window pickers. */ export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')); /** Minute options on a 10-minute step. */ export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50'];