Files
scrabble-game/backend/internal/server/handlers_account.go
T
Ilia Denisov cbb485ebd6
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
feat(stats): show the best move word per game variant
Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.

- backend: new account_best_move table (PK account_id+variant) keeping the
  main word as JSON tiles {letter,value,blank}; captured at game finish in
  buildStats (blank flags taken from every placed blank — equivalent to the
  final board), upserted in the finish transaction and replaced only by a
  strictly higher-scoring play. Guest/honest-AI games still record nothing.
  GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
  (trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
  drops the maxWord card and adds the full-width best-move card (catalogue
  order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.

Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
2026-06-17 15:29:55 +02:00

171 lines
5.7 KiB
Go

package server
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/account"
)
// The /api/v1/user account handlers wire profile editing, email binding and the
// statistics read. They follow handlers_user.go: X-User-ID identity, a
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
// client sends the complete desired profile.
// updateProfileRequest is the full editable profile. away_start/away_end are
// "HH:MM" local-time bounds of the daily away window.
type updateProfileRequest struct {
DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
}
// statsDTO is a durable account's lifetime statistics (the derived games-played and
// win-rate are computed client-side). BestMoves breaks the best move down per variant,
// carrying the word itself; it is absent for an account with no recorded play and never
// lists a variant the account has not played.
type statsDTO struct {
Wins int `json:"wins"`
Losses int `json:"losses"`
Draws int `json:"draws"`
MaxGamePoints int `json:"max_game_points"`
MaxWordPoints int `json:"max_word_points"`
BestMoves []bestMoveDTO `json:"best_moves,omitempty"`
}
// bestMoveDTO is one variant's best play: the variant label, the play's total score and
// its main word as ordered tiles (letter, value, blank — value 0 for a blank), so the
// client renders it as game tiles without the variant's alphabet table.
type bestMoveDTO struct {
Variant string `json:"variant"`
Score int `json:"score"`
Word []account.BestMoveTile `json:"word"`
}
// parseAwayTime parses an "HH:MM" away-window bound.
func parseAwayTime(s string) (time.Time, bool) {
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
if err != nil {
return time.Time{}, false
}
return t, true
}
// handleUpdateProfile overwrites the caller's editable profile fields.
func (s *Server) handleUpdateProfile(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
awayStart, ok := parseAwayTime(req.AwayStart)
if !ok {
abortBadRequest(c, "away_start must be HH:MM")
return
}
awayEnd, ok := parseAwayTime(req.AwayEnd)
if !ok {
abortBadRequest(c, "away_end must be HH:MM")
return
}
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
DisplayName: req.DisplayName,
PreferredLanguage: req.PreferredLanguage,
TimeZone: req.TimeZone,
AwayStart: awayStart,
AwayEnd: awayEnd,
BlockChat: req.BlockChat,
BlockFriendRequests: req.BlockFriendRequests,
NotificationsInAppOnly: req.NotificationsInAppOnly,
})
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
}
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is
// the operator-set reason resolved to the account's language, empty when none was cited. It is
// the one payload a blocked client can still fetch, behind the suspension gate's exemption.
type blockStatusResponse struct {
Blocked bool `json:"blocked"`
Permanent bool `json:"permanent"`
Until string `json:"until,omitempty"`
Reason string `json:"reason,omitempty"`
}
// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the
// reason resolved to the account's language. The suspension gate exempts this route so a blocked
// client can render the terminal blocked screen.
func (s *Server) handleBlockStatus(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
resp := blockStatusResponse{Blocked: blocked}
if blocked {
resp.Permanent = susp.Permanent()
if susp.BlockedUntil != nil {
resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339)
}
// Resolve the reason snapshot to the account's interface language; fall back to English
// if the account row cannot be read for some reason.
lang := "en"
if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil {
lang = acc.PreferredLanguage
}
resp.Reason = susp.LocalizedReason(lang)
}
c.JSON(http.StatusOK, resp)
}
// handleStats returns the caller's lifetime statistics.
func (s *Server) handleStats(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
st, err := s.accounts.GetStats(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
var best []bestMoveDTO
if len(st.BestMoves) > 0 {
best = make([]bestMoveDTO, len(st.BestMoves))
for i, b := range st.BestMoves {
best[i] = bestMoveDTO{Variant: b.Variant, Score: b.Score, Word: b.Tiles}
}
}
c.JSON(http.StatusOK, statsDTO{
Wins: st.Wins,
Losses: st.Losses,
Draws: st.Draws,
MaxGamePoints: st.MaxGamePoints,
MaxWordPoints: st.MaxWordPoints,
BestMoves: best,
})
}