feat: sparser robot nudges, typed unread badge, lobby unread bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s

Three owner-requested polish changes:

- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
  flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
  still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
  when a chat message is unread, a softer amber (--warn) when only nudges are.
  Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
  the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
  your-turn and opponent-turn sections (finished keeps its order), reusing the
  existing unread_chat flag.

Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
This commit is contained in:
Ilia Denisov
2026-06-19 16:50:48 +02:00
parent c67a5d51f1
commit 6e77de4c1e
34 changed files with 331 additions and 86 deletions
+10 -4
View File
@@ -9,6 +9,7 @@
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
@@ -35,7 +36,7 @@
games = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat);
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
atGameLimit = list.atGameLimit;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
@@ -75,7 +76,7 @@
});
const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId));
const groups = $derived(groupGames(games, myId, app.chatUnread));
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
@@ -253,6 +254,7 @@
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
@@ -267,7 +269,7 @@
<span class="info">
<span class="who">
<span class="who-name">{opponents(g) || '—'}</span>
{#if app.chatUnread[g.id]}<span class="unread-dot"></span>{/if}
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span>
<span class="sub">{scoreline(g)}</span>
</span>
@@ -458,7 +460,8 @@
overflow: hidden;
text-overflow: ellipsis;
}
/* A small red dot beside the opponent name: this game has an unread chat entry. */
/* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread
message, a softer amber when only nudges are unread. */
.unread-dot {
flex: 0 0 auto;
width: 8px;
@@ -466,6 +469,9 @@
border-radius: 50%;
background: var(--danger);
}
.unread-dot.nudge {
background: var(--warn);
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);