Files
scrabble-game/ui/src/lib/profileValidation.ts
T
Ilia Denisov b15fd30c4f
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Stage 17 (contour round 4a): quick fixes
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
2026-06-06 14:08:40 +02:00

81 lines
3.3 KiB
TypeScript

// 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 "<dot|underscore> <space>"; 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'];