From acbb2d8254c18573b622715273571dfeb0217355 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 22:12:59 +0200 Subject: [PATCH] Stage 8 polish: profile validation, finished-game UI, badge + Safari fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-review follow-up on the Stage 8 branch: - Friend code is copyable (📋 + toast). The lobby notification badge is fixed — it had inherited the hamburger-bar style — into a proper round count dot. - Safari: min-width:0 on flex text inputs (friend code, profile, chat) so they shrink instead of pushing the adjacent button off-screen. - Profile editing is validated on both the UI and the backend: display-name format (letters joined by single space/./_ separators, no leading/trailing/adjacent separators, <=32 runes), a UTC-offset timezone picker (account.ResolveZone parses ±HH:MM or a legacy IANA name), a 10-minute away grid capped at 12h (wrap-aware), and email format; Save is disabled and invalid fields red-bordered until valid. Language stays in Settings. - In a game, an "add to friends" menu item flips to a disabled "request sent"; chat send/nudge became ⬆️/🛎️ icon buttons. - A finished game drops its last-word highlight, hides Check word / Drop game, disables zoom, and draws an inert (greyed) footer instead of hiding it. Tests: account validators (name/away/zone), UI profileValidation, e2e for the finished-game footer/menu and the copy control. Docs (PLAN, ARCHITECTURE, FUNCTIONAL +ru, UI_DESIGN) updated for the display-name rule, UTC-offset timezone and the 12h away window. --- PLAN.md | 12 ++ backend/internal/account/profile.go | 60 ++++++++- backend/internal/account/timezone.go | 56 ++++++++ backend/internal/account/validate_test.go | 84 ++++++++++++ backend/internal/game/timeout.go | 16 +-- backend/internal/inttest/social_test.go | 4 +- backend/internal/robot/strategy.go | 15 +-- docs/ARCHITECTURE.md | 14 +- docs/FUNCTIONAL.md | 8 +- docs/FUNCTIONAL_ru.md | 10 +- docs/UI_DESIGN.md | 18 ++- ui/e2e/social.spec.ts | 15 ++- ui/src/components/Menu.svelte | 11 +- ui/src/game/Chat.svelte | 13 +- ui/src/game/Game.svelte | 58 ++++---- ui/src/lib/i18n/en.ts | 3 + ui/src/lib/i18n/ru.ts | 3 + ui/src/lib/profileValidation.test.ts | 48 +++++++ ui/src/lib/profileValidation.ts | 79 +++++++++++ ui/src/screens/Friends.svelte | 37 +++++- ui/src/screens/Profile.svelte | 153 ++++++++++++++++------ 21 files changed, 602 insertions(+), 115 deletions(-) create mode 100644 backend/internal/account/timezone.go create mode 100644 backend/internal/account/validate_test.go create mode 100644 ui/src/lib/profileValidation.test.ts create mode 100644 ui/src/lib/profileValidation.ts diff --git a/PLAN.md b/PLAN.md index 0474fbf..4787efe 100644 --- a/PLAN.md +++ b/PLAN.md @@ -583,6 +583,18 @@ Open details: deployment target/host; dashboards; load expectations. REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}` and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow change (the Go and UI workflows already cover the new code). + - **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the + lobby notification badge fixed (it had inherited the hamburger-bar style) and made + a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit + validation on both UI and backend** — display-name format (letters + single + `␠`/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone` + parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid + capped at **12 h** (wrap-aware), email format — with Save disabled and invalid + fields red-bordered while any field is invalid; language stays in Settings; in a + game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge + became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check + word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab + bar) instead of hiding it. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 1a9fde0..814cca5 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "regexp" "strings" "time" "unicode/utf8" @@ -16,8 +17,18 @@ import ( "scrabble/backend/internal/postgres/jet/backend/table" ) -// maxDisplayName caps a display name's length in runes. -const maxDisplayName = 64 +// maxDisplayName caps an editable display name's length in runes (the column itself +// is unbounded; auto-provisioned platform names bypass this editor validation). +const maxDisplayName = 32 + +// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). +const maxAwayWindow = 12 * time.Hour + +// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters +// joined by single space / "." / "_" separators, where a "." or "_" may be followed +// by a single space. No leading or trailing separator and no two adjacent separators, +// except " ". So "Name_P. Last" is valid, "Name P._Last" is not. +var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`) // ErrInvalidProfile is returned when a profile update carries an unacceptable // field (an unknown language, an invalid timezone, or an over-long display name). @@ -46,12 +57,15 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage) } tz := strings.TrimSpace(p.TimeZone) - if _, err := time.LoadLocation(tz); err != nil { - return Account{}, fmt.Errorf("%w: time_zone %q: %v", ErrInvalidProfile, p.TimeZone, err) + if !validZone(tz) { + return Account{}, fmt.Errorf("%w: time_zone %q", ErrInvalidProfile, p.TimeZone) } - name := strings.TrimSpace(p.DisplayName) - if utf8.RuneCountInString(name) > maxDisplayName { - return Account{}, fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName) + name, err := ValidateDisplayName(p.DisplayName) + if err != nil { + return Account{}, err + } + if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil { + return Account{}, err } stmt := table.Accounts.UPDATE( @@ -74,3 +88,35 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate } return modelToAccount(row), nil } + +// ValidateDisplayName trims surrounding whitespace and checks the editable +// display-name length (<= maxDisplayName runes) and format (displayNameRe), +// returning the cleaned name or ErrInvalidProfile. It is exported so the gateway +// boundary could reuse it; the UI mirrors the same rule. +func ValidateDisplayName(raw string) (string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return "", fmt.Errorf("%w: display name is empty", ErrInvalidProfile) + } + if utf8.RuneCountInString(name) > maxDisplayName { + return "", fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName) + } + if !displayNameRe.MatchString(name) { + return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile) + } + return name, nil +} + +// validateAwayWindow checks that the daily away window's duration, wrapping across +// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means +// "no away time" and is allowed. +func validateAwayWindow(start, end time.Time) error { + mins := (end.Hour()*60 + end.Minute()) - (start.Hour()*60 + start.Minute()) + if mins < 0 { + mins += 24 * 60 + } + if time.Duration(mins)*time.Minute > maxAwayWindow { + return fmt.Errorf("%w: away window exceeds %s", ErrInvalidProfile, maxAwayWindow) + } + return nil +} diff --git a/backend/internal/account/timezone.go b/backend/internal/account/timezone.go new file mode 100644 index 0000000..3158ab1 --- /dev/null +++ b/backend/internal/account/timezone.go @@ -0,0 +1,56 @@ +package account + +import ( + "regexp" + "strconv" + "time" +) + +// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the +// Stage 8 profile editor stores (an offset dropdown rather than an IANA name). +var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`) + +// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting +// ok=false when name is not a well-formed offset within ±14:00. +func parseOffsetZone(name string) (*time.Location, bool) { + m := offsetZoneRe.FindStringSubmatch(name) + if m == nil { + return nil, false + } + h, _ := strconv.Atoi(m[2]) + min, _ := strconv.Atoi(m[3]) + if h > 14 || min > 59 || (h == 14 && min > 0) { + return nil, false + } + secs := h*3600 + min*60 + if m[1] == "-" { + secs = -secs + } + return time.FixedZone(name, secs), true +} + +// ResolveZone resolves a stored timezone — a fixed "±HH:MM" offset or an IANA name — +// to a *time.Location, falling back to UTC when it is empty or unrecognised, so a +// bad profile value never breaks the turn-timeout sweeper or the robot's sleep. +func ResolveZone(name string) *time.Location { + if name == "" { + return time.UTC + } + if loc, ok := parseOffsetZone(name); ok { + return loc + } + if loc, err := time.LoadLocation(name); err == nil { + return loc + } + return time.UTC +} + +// validZone reports whether name is an acceptable timezone for a profile update — +// either a "±HH:MM" offset or a loadable IANA location. +func validZone(name string) bool { + if _, ok := parseOffsetZone(name); ok { + return true + } + _, err := time.LoadLocation(name) + return err == nil +} diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go new file mode 100644 index 0000000..1e8c978 --- /dev/null +++ b/backend/internal/account/validate_test.go @@ -0,0 +1,84 @@ +package account + +import ( + "strings" + "testing" + "time" +) + +func TestValidateDisplayName(t *testing.T) { + cases := map[string]struct { + in string + want string + ok bool + }{ + "plain": {"Kaya", "Kaya", true}, + "cyrillic": {"Кая", "Кая", true}, + "dot underscore mix": {"Name_P. Last", "Name_P. Last", true}, + "single dot": {"Mr.Smith", "Mr.Smith", true}, + "dot then space": {"Mr. Smith", "Mr. Smith", true}, + "trim surrounding": {" Kaya ", "Kaya", true}, + "adjacent specials": {"Name P._Last", "", false}, + "two spaces": {"Name Last", "", false}, + "leading special": {"_Name", "", false}, + "trailing special": {"Name.", "", false}, + "digit rejected": {"Name2", "", false}, + "blank": {" ", "", false}, + "too long": {strings.Repeat("a", 33), "", false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := ValidateDisplayName(tc.in) + if tc.ok != (err == nil) || (tc.ok && got != tc.want) { + t.Fatalf("ValidateDisplayName(%q) = (%q, err=%v), want (%q, ok=%v)", tc.in, got, err, tc.want, tc.ok) + } + }) + } +} + +func TestValidateAwayWindow(t *testing.T) { + hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) } + cases := map[string]struct { + start, end time.Time + ok bool + }{ + "8h overnight": {hm(22, 0), hm(6, 0), true}, + "12h exact": {hm(0, 0), hm(12, 0), true}, + "13h daytime": {hm(8, 0), hm(21, 0), false}, + "zero window": {hm(7, 0), hm(7, 0), true}, + "13h wrap": {hm(20, 0), hm(9, 0), false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if err := validateAwayWindow(tc.start, tc.end); tc.ok != (err == nil) { + t.Fatalf("validateAwayWindow = %v, want ok=%v", err, tc.ok) + } + }) + } +} + +func TestResolveAndValidZone(t *testing.T) { + offsetOf := func(name string) int { + _, off := time.Date(2024, 1, 1, 12, 0, 0, 0, ResolveZone(name)).Zone() + return off + } + if got := offsetOf("+03:00"); got != 3*3600 { + t.Errorf("+03:00 offset = %d, want 10800", got) + } + if got := offsetOf("-05:30"); got != -(5*3600 + 30*60) { + t.Errorf("-05:30 offset = %d", got) + } + if ResolveZone("nonsense-zone") != time.UTC { + t.Error("unknown zone should resolve to UTC") + } + for _, ok := range []string{"+05:45", "-12:00", "+14:00", "Europe/Moscow", "UTC"} { + if !validZone(ok) { + t.Errorf("validZone(%q) = false, want true", ok) + } + } + for _, bad := range []string{"+15:00", "03:00", "+3:00", "nope", "+05:99"} { + if validZone(bad) { + t.Errorf("validZone(%q) = true, want false", bad) + } + } +} diff --git a/backend/internal/game/timeout.go b/backend/internal/game/timeout.go index 9c74c1d..637e9d3 100644 --- a/backend/internal/game/timeout.go +++ b/backend/internal/game/timeout.go @@ -5,6 +5,8 @@ import ( "time" "go.uber.org/zap" + + "scrabble/backend/internal/account" ) // effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline @@ -57,17 +59,11 @@ func minutesOfDay(t time.Time) int { return t.Hour()*60 + t.Minute() } -// loadLocation resolves an IANA timezone name, falling back to UTC when it is -// empty or unknown (so a bad profile value never breaks the sweeper). +// loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset), +// falling back to UTC when it is empty or unknown (so a bad profile value never +// breaks the sweeper). It defers to account.ResolveZone, the single source of truth. func loadLocation(name string) *time.Location { - if name == "" { - return time.UTC - } - loc, err := time.LoadLocation(name) - if err != nil { - return time.UTC - } - return loc + return account.ResolveZone(name) } // SweepTimeouts auto-resigns every active game whose current turn has exceeded diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 42f5900..5de9c3d 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -84,7 +84,7 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) { // Toggle: the addressee does not accept friend requests. a, b := provisionAccount(t), provisionAccount(t) - if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil { + if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil { t.Fatalf("set toggle: %v", err) } if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) { @@ -257,7 +257,7 @@ func TestChatPostListAndBlocks(t *testing.T) { if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil { t.Fatalf("post 2: %v", err) } - if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil { + if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil { t.Fatalf("set block_chat: %v", err) } if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 { diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index b828ea9..7301670 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -6,6 +6,7 @@ import ( "math" "time" + "scrabble/backend/internal/account" "scrabble/backend/internal/engine" ) @@ -136,17 +137,11 @@ func asleep(opponentTZ string, drift time.Duration, now time.Time) bool { return h >= sleepStartHour && h < sleepEndHour } -// loadLocation resolves an IANA timezone name, falling back to UTC when it is -// empty or unknown (so a bad opponent profile never breaks the driver). +// loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset), +// falling back to UTC when it is empty or unknown (so a bad opponent profile never +// breaks the driver). It defers to account.ResolveZone. func loadLocation(name string) *time.Location { - if name == "" { - return time.UTC - } - loc, err := time.LoadLocation(name) - if err != nil { - return time.UTC - } - return loc + return account.ResolveZone(name) } // selectMove chooses the robot's action given the ranked candidate plays, the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6152aaf..b13e9bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -298,11 +298,15 @@ requires (there is no DM surface; chat is per-game). the opponent may nudge **once per hour per game**; it is not allowed on one's own turn. The platform-native delivery is wired with the gateway / platform side-service (Stage 6 / 8). -- **Profile**: `preferred_language` (en/ru), display name, email - (confirm-code binding, see §4), **timezone** (drives the away window and the - robot's sleep; user-editable), the daily **away window** and the block toggles — - all editable through `account.UpdateProfile`. Linked platform accounts and merge - are Stage 11. +- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email + (confirm-code binding, see §4), **timezone**, the daily **away window** and the + block toggles — all editable through `account.UpdateProfile`, which validates them + (Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_` + separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a + fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone` + for the sweeper and the robot's sleep (a fixed offset trades DST for a simple + picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform + accounts and merge are Stage 11. ## 9. Persistence diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 88a2b79..ee0bb43 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -88,9 +88,11 @@ existing friendship). Per-game chat is for quick reactions: messages are short even disguised. Nudge the player whose turn is awaited at most once per hour (the nudge is part of the game chat); the out-of-app push is delivered via the platform. -### Profile & settings *(Stage 4)* -Edit language (en/ru), display name, timezone, the daily away window and the block -toggles, and bind an email by confirm-code: the backend emails a short code that, +### Profile & settings *(Stage 4 / 8)* +Edit the display name (letters joined by single space / "." / "_" separators, up to +32 characters), the timezone (chosen as a UTC offset), the daily away window (on a +10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind +an email by confirm-code: the backend emails a short code that, once entered, attaches the email to the account (an email already confirmed by another account cannot be taken — that is a merge, a later stage). Linked platform accounts and merge arrive in Stage 11. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index d5598b2..fcf1ca0 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -90,10 +90,12 @@ session-токен; backend сопоставляет его с внутренн соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий push доставляется через платформу. -### Профиль и настройки *(Stage 4)* -Редактирование языка (en/ru), отображаемого имени, таймзоны, суточного окна -отсутствия (away) и переключателей блокировок, а также привязка email по -confirm-коду: backend шлёт на почту короткий код, и после ввода email +### Профиль и настройки *(Stage 4 / 8)* +Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» / +«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия +(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и +переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на +почту короткий код, и после ввода email привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и слияние появятся в Stage 11. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 82c5051..0f566f0 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -95,13 +95,23 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. -- **Profile editing** (`screens/Profile.svelte`): an inline form (display name, timezone, - the away-window time pickers, block toggles) and an email-binding sub-flow (enter email - → enter the confirm code). Interface language stays in **Settings** (it writes through - to the account for durable users). +- **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a + **UTC-offset** timezone dropdown (defaulting to the browser's offset), the away + window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an + email-binding sub-flow (enter email → enter the confirm code on a numeric field). + Invalid fields show a **red border** (no message) and **Save stays disabled** until + every field is valid. Interface language stays in **Settings** (it writes through to + the account for durable users). +- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or + the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of + overflowing in Safari. - **History / GCG**: the in-game slide-down history gains the running total per move; *Export GCG* shares or downloads the `.gcg` file and appears only once the game is finished. +- **Finished game**: the board keeps no last-word highlight and no zoom; the menu drops + *Check word* and *Drop game*; and the footer (rack + tab bar) is **drawn but inert** + (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat + send / nudge are the ⬆️ / 🛎️ icons. ## Caveat diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index cc16c91..a21174c 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -19,9 +19,10 @@ test('friends: issue a code, accept an incoming request, redeem a code', async ( await loginLobby(page); await openFriends(page); - // Issue a one-time code — it is shown to share. + // Issue a one-time code — it is shown to share, with a copy control. await page.getByRole('button', { name: /Show my code/i }).click(); await expect(page.getByTestId('friend-code')).toContainText('246813'); + await expect(page.getByRole('button', { name: 'Copy' })).toBeVisible(); // The seeded incoming request (Rick) can be accepted; the requests section clears. await expect(page.getByText('Friend requests')).toBeVisible(); @@ -73,3 +74,15 @@ test('GCG export is hidden for an active game', async ({ page }) => { await page.locator('.burger').first().click(); await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0); }); + +test('finished game draws an inert footer and trims the live-only menu', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya + await expect(page.locator('[data-cell]').first()).toBeVisible(); + // The footer (tab bar) is drawn but its controls are disabled in a finished game. + await expect(page.locator('.tab').first()).toBeDisabled(); + // The menu drops Check word and Drop game once the game is over. + await page.locator('.burger').first().click(); + await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); +}); diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte index cbdca83..f4a59f5 100644 --- a/ui/src/components/Menu.svelte +++ b/ui/src/components/Menu.svelte @@ -6,6 +6,7 @@ label: string; onclick: () => void; badge?: number; + disabled?: boolean; } let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props(); let open = $state(false); @@ -27,7 +28,7 @@
(open = false)}>
@@ -95,18 +95,21 @@ } .input input { flex: 1; + min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); } - .input button { - padding: 10px 12px; + .iconbtn { + flex: 0 0 auto; + padding: 8px 12px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: var(--radius-sm); - font-weight: 600; + font-size: 1.25rem; + line-height: 1; } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f37f4a2..93c2e06 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -66,7 +66,7 @@ // Highlight the last word with a dark tile bg; while placing, only the pending tiles // are highlighted. It flashes when the opponent just moved and it is now our turn. const highlight = $derived( - placement.pending.length > 0 || !lastPlay + placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active') ? new Set() : new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)), ); @@ -378,9 +378,13 @@ } } + let requested = $state(new Set()); + const noop = () => {}; + async function addFriend(accountId: string) { try { await gateway.friendRequest(accountId); + requested = new Set([...requested, accountId]); showToast(t('friends.requestSent')); } catch (e) { handleError(e); @@ -391,15 +395,21 @@ view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [], ); + // In a finished game the menu drops Check word and Drop game, gains Export GCG, and + // an "add to friends" item flips to a disabled "request sent" once tapped. const menuItems = $derived([ { label: t('game.history'), onclick: () => (historyOpen = true) }, { label: t('game.chat'), onclick: openChat }, - { label: t('game.checkWord'), onclick: openCheck }, + ...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]), ...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []), ...(!app.profile?.isGuest - ? opponents.map((s) => ({ label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) })) + ? opponents.map((s) => + requested.has(s.accountId) + ? { label: t('game.requestSent'), onclick: noop, disabled: true } + : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }, + ) : []), - { label: t('game.dropGame'), onclick: () => (resignOpen = true) }, + ...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]), ]); @@ -454,7 +464,7 @@ locale={app.locale} {focus} oncell={onCell} - ontogglezoom={() => (zoomed = !zoomed)} + ontogglezoom={() => { if (!gameOver) zoomed = !zoomed; }} /> @@ -471,28 +481,28 @@ - {#if !gameOver} -
-
- -
- {#if placement.pending.length > 0} - - {#snippet trigger()}🏁{/snippet} - {#snippet popover(close)} - - - {/snippet} - - {/if} + +
+
+
- {/if} + {#if !gameOver && placement.pending.length > 0} + + {#snippet trigger()}🏁{/snippet} + {#snippet popover(close)} + + + {/snippet} + + {/if} +
{:else}

{t('common.loading')}

{/if} {#snippet tabbar()} - {#if view && !gameOver} + {#if view} {/snippet} - @@ -697,6 +707,10 @@ align-items: stretch; padding: 0 var(--pad) 6px; } + .rack-row.inert { + pointer-events: none; + opacity: 0.55; + } .rack-wrap { flex: 1; min-width: 0; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index cfb4497..1a260d3 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -178,6 +178,8 @@ export const en = { 'friends.enterCode': 'Have a code? Add a friend', 'friends.codePlaceholder': '6-digit code', 'friends.redeem': 'Add', + 'friends.copy': 'Copy', + 'friends.codeCopied': 'Code copied.', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', @@ -212,6 +214,7 @@ export const en = { 'game.exportGcg': 'Export GCG', 'game.gcgActiveOnly': 'Available once the game is finished.', + 'game.requestSent': 'Request sent', 'time.minutes': '{n} min', 'time.hours': '{n} h', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c46f94b..97d9893 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -179,6 +179,8 @@ export const ru: Record = { 'friends.enterCode': 'Есть код? Добавить друга', 'friends.codePlaceholder': 'Код из 6 цифр', 'friends.redeem': 'Добавить', + 'friends.copy': 'Копировать', + 'friends.codeCopied': 'Код скопирован.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', @@ -213,6 +215,7 @@ export const ru: Record = { 'game.exportGcg': 'Экспорт GCG', 'game.gcgActiveOnly': 'Доступно после завершения игры.', + 'game.requestSent': 'Запрос отправлен', 'time.minutes': '{n} мин', 'time.hours': '{n} ч', diff --git a/ui/src/lib/profileValidation.test.ts b/ui/src/lib/profileValidation.test.ts new file mode 100644 index 0000000..91e6fbe --- /dev/null +++ b/ui/src/lib/profileValidation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { awayDurationOk, browserOffset, isOffsetZone, validDisplayName, validEmail } from './profileValidation'; + +describe('validDisplayName', () => { + it.each([ + ['Kaya', true], + ['Кая', true], + ['Name_P. Last', true], + ['Mr.Smith', true], + ['Mr. Smith', true], + [' Kaya ', true], + ['Name P._Last', false], + ['Name Last', false], + ['_Name', false], + ['Name.', false], + ['Name2', false], + ['', false], + ['a'.repeat(33), false], + ])('%s -> %s', (name, ok) => { + expect(validDisplayName(name)).toBe(ok); + }); +}); + +describe('validEmail', () => { + it('accepts a normal address', () => expect(validEmail('you@example.com')).toBe(true)); + it('rejects a missing domain', () => expect(validEmail('you@')).toBe(false)); + it('rejects spaces', () => expect(validEmail('a b@x.com')).toBe(false)); +}); + +describe('awayDurationOk', () => { + it.each([ + ['22:00', '06:00', true], + ['00:00', '12:00', true], + ['08:00', '21:00', false], + ['07:00', '07:00', true], + ['20:00', '09:00', false], + ])('%s-%s -> %s', (s, e, ok) => expect(awayDurationOk(s, e)).toBe(ok)); +}); + +describe('timezone helpers', () => { + it('detects offset zones', () => { + expect(isOffsetZone('+03:00')).toBe(true); + expect(isOffsetZone('Europe/Moscow')).toBe(false); + }); + it('formats the browser offset as ±HH:MM', () => { + expect(browserOffset()).toMatch(/^[+-]\d{2}:\d{2}$/); + }); +}); diff --git a/ui/src/lib/profileValidation.ts b/ui/src/lib/profileValidation.ts new file mode 100644 index 0000000..95bd4ab --- /dev/null +++ b/ui/src/lib/profileValidation.ts @@ -0,0 +1,79 @@ +// 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/trailing separator and no adjacent +// separators except " ". 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']; diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index b0261ad..eaec81e 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -67,6 +67,16 @@ function codeTime(unix: number): string { return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + + async function copyCode() { + if (!code) return; + try { + await navigator.clipboard.writeText(code.code); + showToast(t('friends.codeCopied')); + } catch { + // Clipboard may be unavailable (insecure context); leave the code on screen. + } + } @@ -88,7 +98,10 @@
{#if code}
- {code.code} +
+ + +
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} @@ -167,6 +180,7 @@ } .codein { flex: 1; + min-width: 0; padding: 10px 12px; border: 1px solid var(--border); background: var(--surface); @@ -184,10 +198,31 @@ flex-direction: column; gap: 4px; } + .coderow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } .codeval { font-size: 1.8rem; font-weight: 700; letter-spacing: 0.3em; + background: none; + border: none; + color: var(--text); + padding: 0; + cursor: pointer; + text-align: left; + font-family: inherit; + } + .copy { + flex: 0 0 auto; + background: none; + border: none; + font-size: 1.4rem; + padding: 4px; + cursor: pointer; } .codehint { font-size: 0.8rem; diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index af1c039..7be02d6 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -3,35 +3,70 @@ import { app, handleError, logout, showToast } from '../lib/app.svelte'; import { gateway } from '../lib/gateway'; import { t } from '../lib/i18n/index.svelte'; - import type { ProfileUpdate } from '../lib/model'; + import { + awayDurationOk, + awayHours, + awayMinutes, + browserOffset, + isOffsetZone, + timezoneOffsets, + validDisplayName, + validEmail, + } from '../lib/profileValidation'; let editing = $state(false); - let form = $state(blankForm()); + let dn = $state(''); + let tz = $state('+00:00'); + let startH = $state('00'); + let startM = $state('00'); + let endH = $state('07'); + let endM = $state('00'); + let blockChat = $state(false); + let blockFriendRequests = $state(false); let emailInput = $state(''); let codeInput = $state(''); let emailSent = $state(false); - function blankForm(): ProfileUpdate { - const p = app.profile; - return { - displayName: p?.displayName ?? '', - preferredLanguage: p?.preferredLanguage ?? 'en', - timeZone: p?.timeZone ?? 'UTC', - awayStart: p?.awayStart ?? '00:00', - awayEnd: p?.awayEnd ?? '07:00', - blockChat: p?.blockChat ?? false, - blockFriendRequests: p?.blockFriendRequests ?? false, - }; + function defaultTz(): string { + const b = browserOffset(); + return timezoneOffsets.includes(b) ? b : '+00:00'; + } + function splitTime(hhmm: string): [string, string] { + const m = /^(\d{2}):(\d{2})$/.exec(hhmm); + if (!m) return ['00', '00']; + return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00']; } function startEdit() { - form = blankForm(); + const p = app.profile!; + dn = p.displayName; + tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz(); + [startH, startM] = splitTime(p.awayStart); + [endH, endM] = splitTime(p.awayEnd); + blockChat = p.blockChat; + blockFriendRequests = p.blockFriendRequests; editing = true; } + const awayStart = $derived(`${startH}:${startM}`); + const awayEnd = $derived(`${endH}:${endM}`); + const nameOk = $derived(validDisplayName(dn)); + const awayOk = $derived(awayDurationOk(awayStart, awayEnd)); + const formValid = $derived(nameOk && awayOk); + const emailOk = $derived(validEmail(emailInput)); + async function save() { + if (!formValid) return; try { - app.profile = await gateway.profileUpdate(form); + app.profile = await gateway.profileUpdate({ + displayName: dn.trim(), + preferredLanguage: app.profile!.preferredLanguage, // language lives in Settings + timeZone: tz, + awayStart, + awayEnd, + blockChat, + blockFriendRequests, + }); editing = false; showToast(t('profile.saved')); } catch (e) { @@ -40,12 +75,11 @@ } async function requestEmail() { - const email = emailInput.trim(); - if (!email) return; + if (!emailOk) return; try { - await gateway.emailBindRequest(email); + await gateway.emailBindRequest(emailInput.trim()); emailSent = true; - showToast(t('profile.emailSent', { email })); + showToast(t('profile.emailSent', { email: emailInput.trim() })); } catch (e) { handleError(e); } @@ -75,37 +109,45 @@
{ e.preventDefault(); void save(); }}> -
+
{t('profile.awayWindow')}
- - + {t('profile.from')} + + : + +
+
+ {t('profile.to')} + + : +

{t('profile.awayHint')}

- +
{:else}
-
{t('profile.language')}
-
{p.preferredLanguage}
{t('profile.timezone')}
{p.timeZone}
{t('profile.awayWindow')}
@@ -123,12 +165,23 @@

{t('profile.bindEmail')}

{#if !emailSent}
- - + 0 && !emailOk} + bind:value={emailInput} + placeholder={t('login.emailPlaceholder')} + type="email" + /> +
{:else}
- +
{/if} @@ -183,26 +236,33 @@ flex-direction: column; gap: 14px; } - .edit label { + .edit > label { display: flex; flex-direction: column; gap: 4px; font-size: 0.9rem; color: var(--text-muted); } - .edit input:not([type]), - .edit input[type='time'] { + .edit input:not([type='checkbox']), + .edit select { + min-width: 0; padding: 9px 11px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: var(--radius-sm); } + .invalid { + border-color: var(--danger, #c0392b) !important; + } .away { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; margin: 0; + display: flex; + flex-direction: column; + gap: 8px; } .away legend { color: var(--text-muted); @@ -211,10 +271,16 @@ } .times { display: flex; - gap: 12px; + align-items: center; + gap: 8px; } - .times label { - flex: 1; + .tlabel { + min-width: 2.5em; + color: var(--text-muted); + font-size: 0.85rem; + } + .colon { + font-weight: 700; } .check { flex-direction: row !important; @@ -226,6 +292,9 @@ display: flex; gap: 10px; } + .btn:disabled { + opacity: 0.5; + } .emailbox h3 { margin: 0 0 8px; font-size: 0.95rem; @@ -237,12 +306,17 @@ } .addrow input { flex: 1; + min-width: 0; padding: 9px 11px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: var(--radius-sm); } + .addrow input.codein { + letter-spacing: 0.3em; + font-size: 1.1rem; + } .btn { align-self: flex-start; padding: 9px 14px; @@ -258,6 +332,9 @@ color: var(--text); border-radius: var(--radius-sm); } + .ghost:disabled { + opacity: 0.5; + } .logout { margin-top: 8px; align-self: flex-start;