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
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:
@@ -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') {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ function gameView(id: string): GameView {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user