Files
scrabble-game/ui/src/lib/profileValidation.ts
T
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

88 lines
3.7 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;
/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every
* rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */
export const maxDisplayNameSpecials = 5;
/** 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. 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();
const chars = [...name];
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length;
return specials <= maxDisplayNameSpecials;
}
// 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'];