Skip to content

Commit 28fcd60

Browse files
authored
Merge pull request #21 from joe-brothers/fix-streak-display
2 parents ee4053d + 5d5d48d commit 28fcd60

10 files changed

Lines changed: 179 additions & 125 deletions

File tree

packages/client/src/managers/AuthStateManager.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EventEmitter } from "pixi.js";
2-
import type { PublicUser } from "@differ/shared";
2+
import type { DailyState, PublicUser } from "@differ/shared";
33
import { authApi } from "../network/rest";
44
import { getTurnstileToken } from "../network/turnstile";
55

@@ -8,6 +8,7 @@ import { getTurnstileToken } from "../network/turnstile";
88
export class AuthStateManager extends EventEmitter {
99
private user: PublicUser | null = null;
1010
private wins = 0;
11+
private daily: DailyState | null = null;
1112

1213
getUser(): PublicUser | null {
1314
return this.user;
@@ -17,16 +18,23 @@ export class AuthStateManager extends EventEmitter {
1718
return this.wins;
1819
}
1920

21+
// Today's daily-challenge state, bundled into /auth/me so the menu can
22+
// render played-state + streak without a second round trip.
23+
getDaily(): DailyState | null {
24+
return this.daily;
25+
}
26+
2027
isAuthenticated(): boolean {
2128
return this.user !== null;
2229
}
2330

2431
// Called on app start. Asks the server who we are based on the cookie.
2532
async tryRestore(): Promise<boolean> {
2633
try {
27-
const { user, wins } = await authApi.me();
34+
const { user, wins, daily } = await authApi.me();
2835
this.user = user;
2936
this.wins = wins;
37+
this.daily = daily;
3038
this.emit("authStateChanged");
3139
return true;
3240
} catch {
@@ -39,9 +47,10 @@ export class AuthStateManager extends EventEmitter {
3947
async refresh(): Promise<void> {
4048
if (!this.user) return;
4149
try {
42-
const { user, wins } = await authApi.me();
50+
const { user, wins, daily } = await authApi.me();
4351
this.user = user;
4452
this.wins = wins;
53+
this.daily = daily;
4554
this.emit("authStateChanged");
4655
} catch {
4756
// Network blip — keep cached values rather than logging out.
@@ -93,6 +102,7 @@ export class AuthStateManager extends EventEmitter {
93102
}
94103
this.user = null;
95104
this.wins = 0;
105+
this.daily = null;
96106
this.emit("authStateChanged");
97107
}
98108
}

packages/client/src/network/rest.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,32 +158,13 @@ export const leaderboardApi = {
158158
},
159159
};
160160

161-
export interface DailyTodayRes {
162-
date: string;
163-
played: boolean;
164-
result: {
165-
elapsedMs: number | null;
166-
foundCount: number;
167-
outcome: string;
168-
hintsUsed: number;
169-
} | null;
170-
streak: {
171-
current: number;
172-
longest: number;
173-
lastDailyDate: string | null;
174-
};
175-
}
176-
177161
export interface DailyStartRes {
178162
roomCode: string;
179163
wsUrl: string;
180164
date: string;
181165
}
182166

183167
export const dailyApi = {
184-
today(): Promise<DailyTodayRes> {
185-
return request<DailyTodayRes>("/daily/today");
186-
},
187168
start(): Promise<DailyStartRes> {
188169
return request<DailyStartRes>("/daily/start", { method: "POST" });
189170
},

packages/client/src/scenes/MainMenuScene.ts

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { COLORS } from "../constants";
55
import { game } from "../core/Game";
66
import { authState } from "../managers/AuthStateManager";
77
import { HtmlOverlay } from "../ui/HtmlOverlay";
8-
import { ApiError, authApi, dailyApi, type DailyTodayRes } from "../network/rest";
8+
import { ApiError, authApi } from "../network/rest";
99
import { evaluatePassword, PASSWORD_HINT } from "../managers/passwordStrength";
1010
import { createBetaBadge } from "../ui/pixiBetaBadge";
1111

@@ -30,7 +30,6 @@ export class MainMenuScene extends Container implements IScene {
3030
private footerText: Text | null = null;
3131
private upgradeOverlay: HtmlOverlay | null = null;
3232
private settingsOverlay: HtmlOverlay | null = null;
33-
private dailyToday: DailyTodayRes | null = null;
3433

3534
constructor(app: Application) {
3635
super();
@@ -51,21 +50,19 @@ export class MainMenuScene extends Container implements IScene {
5150
}
5251
this.createUserInfo();
5352
this.createFooter();
54-
// Pull fresh stats (wins) so the counter reflects games played in the
55-
// last session — fire-and-forget so the menu still draws instantly.
56-
void authState.refresh().then(() => this.refreshUserInfo());
57-
// Daily status — also fire-and-forget. Renders the button label as
58-
// "Played today" / "Play" and updates the streak line in the corner.
59-
void dailyApi
60-
.today()
61-
.then((res) => {
62-
this.dailyToday = res;
63-
this.refreshDailyButton();
64-
this.refreshStreak();
65-
})
66-
.catch(() => {
67-
/* leave the button in default "Daily Challenge" state */
68-
});
53+
// Pull fresh /auth/me (wins + daily state) so the counter, daily button,
54+
// and streak line reflect the latest session — fire-and-forget so the menu
55+
// still draws instantly. Daily is bundled into /auth/me (single round
56+
// trip) and the lazy streak reset runs server-side on that call.
57+
void authState.refresh().then(() => {
58+
this.refreshUserInfo();
59+
this.refreshDailyButton();
60+
this.refreshStreak();
61+
});
62+
// Render whatever cached state we already have so the corner labels
63+
// aren't blank during the in-flight refresh.
64+
this.refreshDailyButton();
65+
this.refreshStreak();
6966
}
7067

7168
private createTitle(): void {
@@ -171,7 +168,7 @@ export class MainMenuScene extends Container implements IScene {
171168
}
172169

173170
private isDailyDisabled(): boolean {
174-
return !!this.dailyToday?.played;
171+
return !!authState.getDaily()?.played;
175172
}
176173

177174
// (i) icon next to the Daily button. Hover surfaces a small tooltip card
@@ -335,9 +332,10 @@ export class MainMenuScene extends Container implements IScene {
335332

336333
private refreshDailyButton(): void {
337334
if (!this.dailyButton) return;
338-
const played = this.dailyToday?.played ?? false;
335+
const daily = authState.getDaily();
336+
const played = daily?.played ?? false;
339337
const el = this.dailyButton as Container & { __text: Text; __bg: Graphics };
340-
const ms = this.dailyToday?.result?.elapsedMs;
338+
const ms = daily?.result?.elapsedMs;
341339
if (played) {
342340
this.drawFilledButton(el.__bg, 250, 48, COLORS.surfaceMuted);
343341
el.__bg.stroke({ color: COLORS.border, width: 1 });
@@ -354,7 +352,7 @@ export class MainMenuScene extends Container implements IScene {
354352

355353
private refreshStreak(): void {
356354
if (!this.streakText) return;
357-
const cur = this.dailyToday?.streak.current ?? 0;
355+
const cur = authState.getDaily()?.streak.current ?? 0;
358356
this.streakText.text = cur > 0 ? `🔥 ${cur}-day streak` : "";
359357
}
360358

@@ -566,7 +564,7 @@ export class MainMenuScene extends Container implements IScene {
566564
this.addChild(this.winsText);
567565
nextY += 22;
568566

569-
// Streak — populated lazily by refreshStreak() once dailyApi.today()
567+
// Streak — populated lazily by refreshStreak() once authState.refresh()
570568
// resolves. Empty until then so the corner doesn't flash text.
571569
this.streakText = new Text({
572570
text: "",

packages/client/src/ui/react/GameCompleteModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, type CSSProperties, type ReactNode } from "react";
2+
import { dailyNumber } from "@differ/shared";
23
import { useUIStore, type OverlayModal } from "../store";
34
import { cardStyle, CSS, FONT_MONO, modalBackdropStyle } from "../styles";
45
import { Button } from "./Button";
@@ -44,7 +45,7 @@ function buildShareText(args: {
4445
// Timeouts and hint-assisted finishes don't get the badge — the LinkedIn
4546
// game-share style cue is meant to convey "no help, no shortcuts."
4647
const flawless = args.hintsUsed === 0 && args.elapsedSec != null ? " (Flawless ✨)" : "";
47-
return `Differ Daily ${args.date}${result}${flawless}\n${window.location.origin}`;
48+
return `Differ Daily ${args.date} (#${dailyNumber(args.date)}) ${result}${flawless}\n${window.location.origin}`;
4849
}
4950

5051
function DailyCompleteCard({

packages/server/src/auth/routes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import type { Env } from "../env.js";
1818
import { getDb } from "../db/client.js";
1919
import { gameParticipants, games, users } from "../db/schema.js";
20+
import { getDailyState } from "../daily/service.js";
2021
import { signToken, signTotpTicket, verifyTotpTicket } from "./jwt.js";
2122
import { hashPassword, verifyPassword, needsRehash } from "./password.js";
2223
import { requireAuth, type AuthEnv } from "./middleware.js";
@@ -238,9 +239,14 @@ protectedRoutes.get("/me", async (c) => {
238239
.get();
239240
wins = winsRow?.c ?? 0;
240241
}
242+
// Bundled with daily state so the menu can render on a single round trip.
243+
// The lazy streak reset (when lastDailyDate < yesterday) lives inside
244+
// getDailyState; calling it here means it kicks in on session start.
245+
const daily = await getDailyState(c.env.DB, claims.sub);
241246
return c.json({
242247
user: { userId: row.id, name: row.name, isGuest: row.isGuest === 1 },
243248
wins,
249+
daily,
244250
});
245251
});
246252

packages/server/src/daily/routes.ts

Lines changed: 1 addition & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { and, eq } from "drizzle-orm";
33
import type { Env } from "../env.js";
44
import { requireAuth, type AuthEnv } from "../auth/middleware.js";
55
import { getDb } from "../db/client.js";
6-
import { dailyAttempts, gameParticipants, games, userStats } from "../db/schema.js";
6+
import { dailyAttempts } from "../db/schema.js";
77
import { createGameRoom } from "../rooms/create.js";
88
import { utcDateKey } from "./service.js";
99

@@ -26,82 +26,6 @@ async function dailyRoomCode(date: string, userId: string): Promise<string> {
2626
return out;
2727
}
2828

29-
// GET /daily/today — status for the daily card on the main menu.
30-
// - playable: no attempt yet today
31-
// - already-played: returns the prior result (elapsedMs, foundCount)
32-
// Streak is included so the menu can render "Day N streak" without a
33-
// second round trip.
34-
dailyRoutes.get("/today", requireAuth, async (c) => {
35-
const userId = c.get("user").sub;
36-
const date = utcDateKey();
37-
const db = getDb(c.env.DB);
38-
39-
const [attemptRow] = await db
40-
.select({ gameId: dailyAttempts.gameId })
41-
.from(dailyAttempts)
42-
.where(and(eq(dailyAttempts.userId, userId), eq(dailyAttempts.date, date)))
43-
.limit(1);
44-
45-
const [statsRow] = await db
46-
.select({
47-
current: userStats.currentStreak,
48-
longest: userStats.longestStreak,
49-
last: userStats.lastDailyDate,
50-
})
51-
.from(userStats)
52-
.where(eq(userStats.userId, userId))
53-
.limit(1);
54-
55-
let result: {
56-
elapsedMs: number | null;
57-
foundCount: number;
58-
outcome: string;
59-
hintsUsed: number;
60-
} | null = null;
61-
if (attemptRow) {
62-
const [participant] = await db
63-
.select({
64-
elapsedMs: gameParticipants.elapsedMs,
65-
foundCount: gameParticipants.foundCount,
66-
outcome: gameParticipants.outcome,
67-
hintsUsed: gameParticipants.hintsUsed,
68-
})
69-
.from(gameParticipants)
70-
.where(
71-
and(eq(gameParticipants.gameId, attemptRow.gameId), eq(gameParticipants.userId, userId)),
72-
)
73-
.limit(1);
74-
if (participant) {
75-
result = participant;
76-
} else {
77-
// Guest path — daily_attempts exists, gameParticipants doesn't (D4).
78-
// Fall back to the games row so the share card still renders something.
79-
const [game] = await db
80-
.select({ endReason: games.endReason })
81-
.from(games)
82-
.where(eq(games.id, attemptRow.gameId))
83-
.limit(1);
84-
result = {
85-
elapsedMs: null,
86-
foundCount: 0,
87-
outcome: game?.endReason === "winner" ? "win" : "timeout",
88-
hintsUsed: 0,
89-
};
90-
}
91-
}
92-
93-
return c.json({
94-
date,
95-
played: !!attemptRow,
96-
result,
97-
streak: {
98-
current: statsRow?.current ?? 0,
99-
longest: statsRow?.longest ?? 0,
100-
lastDailyDate: statsRow?.last ?? null,
101-
},
102-
});
103-
});
104-
10529
// POST /daily/start — start (or resume) today's daily attempt.
10630
// - if already played today: 409 daily_already_played
10731
// - else: ensures a deterministic-coded GameRoom DO exists and returns the

0 commit comments

Comments
 (0)