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
+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']);