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
@@ -14,6 +14,7 @@
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board';
import { badgeKind } from '../lib/unread';
import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
@@ -183,7 +184,7 @@
view = st;
syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat);
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
moves = hist.moves;
setCachedGame(id, st, hist.moves, draft);
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
@@ -670,7 +671,7 @@
};
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
// moving is already cleared server-side, so reconcile the unread flag from it.
seedChatUnread(id, r.game.unreadChat);
seedChatUnread(id, r.game.unreadChat, r.game.unreadMessages);
moves = [...moves, r.move];
// A committed move clears the actor's draft on the server, so clear the cached draft too;
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
@@ -1091,10 +1092,11 @@
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()}
{#if view}
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
@@ -1582,13 +1584,17 @@
color: var(--danger);
}
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
when the history is opened with unread present, rather than carrying a count badge. */
when the history is opened with unread present, rather than carrying a count badge. Red for an
unread message, a softer amber when only nudges are unread. */
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.unread-dot.nudge {
background: var(--warn);
}
.sbadge-dot {
position: absolute;
top: 4px;
+12 -2
View File
@@ -108,8 +108,13 @@ unreadChat():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
unreadMessages():boolean {
const offset = this.bb!.__offset(this.bb_pos, 32);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(14);
builder.startObject(15);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -180,12 +185,16 @@ static addUnreadChat(builder:flatbuffers.Builder, unreadChat:boolean) {
builder.addFieldInt8(13, +unreadChat, +false);
}
static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) {
builder.addFieldInt8(14, +unreadMessages, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean):flatbuffers.Offset {
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
@@ -201,6 +210,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addLastActivityUnix(builder, lastActivityUnix);
GameView.addVsAi(builder, vsAi);
GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages);
return GameView.endGameView(builder);
}
}
+19 -4
View File
@@ -63,6 +63,10 @@ export const app = $state<{
* game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
* raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
chatUnread: Record<string, boolean>;
/** Per-game flag: at least one unread entry is a real message (not just a nudge), so the dot
* can be coloured apart from the nudge-only case. Same lifecycle as chatUnread; nudge events
* raise only chatUnread, message events raise both. */
messageUnread: Record<string, boolean>;
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
@@ -93,6 +97,7 @@ export const app = $state<{
localeLocked: false,
notifications: 0,
chatUnread: {},
messageUnread: {},
feedbackReplyUnread: false,
staleInvite: false,
welcomeRedeem: false,
@@ -170,14 +175,18 @@ export function dismissWelcomeRedeem(): void {
}
/**
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
* stream raises unread (bumpChatUnread) rather than seeding it from a GameView.
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
* is a real message (the badge colour). The live-event GameView omits both, so the live stream
* raises them on receive rather than seeding from a GameView.
*/
export function seedChatUnread(gameId: string, unread: boolean): void {
export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void {
if ((app.chatUnread[gameId] ?? false) !== unread) {
app.chatUnread = { ...app.chatUnread, [gameId]: unread };
}
if ((app.messageUnread[gameId] ?? false) !== message) {
app.messageUnread = { ...app.messageUnread, [gameId]: message };
}
}
/**
@@ -192,6 +201,7 @@ export function seedChatUnread(gameId: string, unread: boolean): void {
export function markChatRead(gameId: string): void {
if (!app.chatUnread[gameId]) return;
app.chatUnread = { ...app.chatUnread, [gameId]: false };
if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false };
void gateway.markChatRead(gameId).catch(() => {});
}
@@ -262,6 +272,11 @@ function openStream(): void {
router.route.params.id === e.message.gameId;
if (!inComms) {
app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true };
// Only a real message colours the badge; a nudge delivered as a chat_message must not
// raise the message flag (the separate nudge event below also leaves it untouched).
if (e.message.kind !== 'nudge') {
app.messageUnread = { ...app.messageUnread, [e.message.gameId]: true };
}
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
}
} else if (e.kind === 'nudge') {
+2
View File
@@ -253,6 +253,7 @@ function decodeGameView(g: fb.GameView): GameView {
lastActivityUnix: Number(g.lastActivityUnix()),
vsAi: g.vsAi(),
unreadChat: g.unreadChat(),
unreadMessages: g.unreadMessages(),
seats,
};
}
@@ -849,6 +850,7 @@ function emptyGame(): GameView {
lastActivityUnix: 0,
vsAi: false,
unreadChat: false,
unreadMessages: false,
seats: [],
};
}
+1
View File
@@ -9,6 +9,7 @@ function gameView(id: string): GameView {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: 'active',
players: 2,
toMove: 0,
+1
View File
@@ -10,6 +10,7 @@ function gameView(moveCount: number, over = false): GameView {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
+1
View File
@@ -26,6 +26,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status,
players: 2,
toMove,
+24
View File
@@ -19,6 +19,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status,
players: 2,
toMove,
@@ -40,6 +41,7 @@ describe('groupGames', () => {
game('c', 'finished', 0, 100),
],
ME,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
@@ -57,12 +59,33 @@ describe('groupGames', () => {
game('f_old', 'finished', 0, 100),
],
ME,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
});
it('bumps unread games first in the active sections, leaving finished by activity', () => {
const g = groupGames(
[
game('y_read', 'active', 0, 50), // my turn, oldest, read
game('y_unread', 'active', 0, 200), // my turn, newest, unread -> bumped above y_read
game('t_read', 'active', 1, 200), // their turn, newest, read
game('t_unread', 'active', 1, 50), // their turn, oldest, unread -> bumped above t_read
game('f_unread', 'finished', 0, 100),
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
],
ME,
{ y_unread: true, t_unread: true, f_unread: true },
);
// Unread is the primary key, so it overrides the within-section activity order.
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_unread', 'y_read']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_unread', 't_read']);
// Finished ignores unread: still newest-first by activity.
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_unread']);
});
it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
@@ -76,6 +99,7 @@ describe('groupGames', () => {
game('open_wait', 'open', 1, 100), // the empty seat's turn
],
ME,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
+13 -6
View File
@@ -43,11 +43,16 @@ export interface LobbyGroups {
}
/**
* groupGames partitions games for myId into the three lobby sections and orders each: the
* your-turn games by ascending last activity (the longest-waiting first), the opponent-turn
* and finished games by descending last activity (the most recent first).
* groupGames partitions games for myId into the three lobby sections and orders each. In the two
* active sections a game with an unread notification (unread[g.id], any chat entry or nudge) sorts
* first, then by last activity: your-turn ascending (the longest-waiting first), opponent-turn
* descending (the most recent first). Finished games ignore unread and stay descending by activity.
*/
export function groupGames(games: GameView[], myId: string): LobbyGroups {
export function groupGames(
games: GameView[],
myId: string,
unread: Record<string, boolean>,
): LobbyGroups {
const yourTurn: GameView[] = [];
const theirTurn: GameView[] = [];
const finished: GameView[] = [];
@@ -56,8 +61,10 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups {
else if (isMyTurn(g, myId)) yourTurn.push(g);
else theirTurn.push(g);
}
yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix);
theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
const rank = (g: GameView) => (unread[g.id] ? 1 : 0);
yourTurn.sort((a, b) => rank(b) - rank(a) || a.lastActivityUnix - b.lastActivityUnix);
theirTurn.sort((a, b) => rank(b) - rank(a) || b.lastActivityUnix - a.lastActivityUnix);
finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
return { yourTurn, theirTurn, finished };
}
+2
View File
@@ -182,6 +182,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true,
unreadChat: false,
unreadMessages: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
@@ -214,6 +215,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false,
unreadChat: false,
unreadMessages: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
+3
View File
@@ -181,6 +181,7 @@ function activeGame(): MockGame {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: 'active',
players: 2,
toMove: 0,
@@ -218,6 +219,7 @@ function finishedG2(): MockGame {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: 'finished',
players: 2,
toMove: 0,
@@ -256,6 +258,7 @@ function finishedG3(): MockGame {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: 'finished',
players: 2,
toMove: 0,
+4
View File
@@ -51,6 +51,10 @@ export interface GameView {
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
* move result); the live-event GameView leaves it false (events bump unread instead). */
unreadChat: boolean;
/** Per-viewer flag: at least one unread entry is a real message (not just a nudge), so the
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
* unreadChat; the live-event GameView leaves it false. */
unreadMessages: boolean;
seats: Seat[];
}
+1
View File
@@ -21,6 +21,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status,
players: 2,
toMove: 0,
+1
View File
@@ -18,6 +18,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status,
players: seats.length,
toMove,
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { badgeKind } from './unread';
describe('badgeKind', () => {
it('shows nothing when nothing is unread', () => {
expect(badgeKind(false, false)).toBeNull();
// message without unread is inconsistent input; the unread guard still wins.
expect(badgeKind(false, true)).toBeNull();
});
it('is a nudge badge when only nudges are unread', () => {
expect(badgeKind(true, false)).toBe('nudge');
});
it('is a message badge when a real message is unread', () => {
expect(badgeKind(true, true)).toBe('message');
});
});
+17
View File
@@ -0,0 +1,17 @@
// Pure resolution of the per-game unread dot's kind, kept out of the .svelte components so it can
// be unit-tested. A game's dot is shown when anything is unread; its colour distinguishes a real
// chat message (the regular danger colour) from nudge-only unread (a softer nudge colour).
/** BadgeKind is the unread dot to show for a game, or null when nothing is unread. */
export type BadgeKind = 'message' | 'nudge' | null;
/**
* badgeKind picks the unread dot for a game from its two per-viewer flags: null when nothing is
* unread, 'message' when an unread real chat message is present (the regular colour), else 'nudge'
* when only nudges are unread (the softer colour). message implies unread, so a stray message=true
* with unread=false still shows nothing.
*/
export function badgeKind(unread: boolean, message: boolean): BadgeKind {
if (!unread) return null;
return message ? 'message' : 'nudge';
}
+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);