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
+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';
}