diff --git a/frontend/__tests__/utils/streak.spec.ts b/frontend/__tests__/utils/streak.spec.ts new file mode 100644 index 000000000000..ccfa3dc2da74 --- /dev/null +++ b/frontend/__tests__/utils/streak.spec.ts @@ -0,0 +1,245 @@ +import type { Mode } from "@monkeytype/schemas/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SnapshotResult } from "../../src/ts/constants/default-snapshot"; +import { + getStreakExtraText, + getStreakIndicatorState, + getStreakHoverText, + hasClaimedStreakToday, +} from "../../src/ts/utils/streak"; + +function resultAt(timestamp: number): SnapshotResult { + return { timestamp } as SnapshotResult; +} + +describe("streak utils", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("hides the header indicator when signed out", () => { + expect(getStreakIndicatorState(undefined, undefined)).toEqual({ + show: false, + claimedToday: false, + hoverText: "", + }); + }); + + it("shows a hollow flame with a zero label before the first streak claim", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 0, + maxStreak: 0, + }, + undefined, + ); + + expect(state).toMatchObject({ + show: true, + claimedToday: false, + label: "0", + hoverText: "Longest streak: 0 days", + }); + }); + + it("does not claim a zero streak even if a stale last result is present", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 0, + maxStreak: 0, + }, + resultAt(new Date("2026-06-03T09:00:00Z").getTime()), + ); + + expect(state).toMatchObject({ + show: true, + claimedToday: false, + label: "0", + hoverText: "Longest streak: 0 days", + }); + }); + + it("shows a solid flame and one-day label after the first saved result", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 1, + maxStreak: 1, + }, + resultAt(new Date("2026-06-03T09:00:00Z").getTime()), + ); + + expect(state.show).toBe(true); + expect(state.claimedToday).toBe(true); + expect(state.label).toBe("1"); + expect(state.hoverText).toContain("Longest streak: 1 day"); + expect(state.hoverText).toContain("Claimed today: yes"); + }); + + it("uses the snapshot streak timestamp before result history loads", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 1, + maxStreak: 1, + streakLastResultTimestamp: new Date("2026-06-03T09:00:00Z").getTime(), + }, + undefined, + ); + + expect(state.claimedToday).toBe(true); + expect(state.label).toBe("1"); + expect(state.hoverText).toContain("Claimed today: yes"); + }); + + it("prefers the snapshot streak timestamp over a stale cached result", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 365, + maxStreak: 365, + streakLastResultTimestamp: new Date("2026-06-02T12:00:00Z").getTime(), + }, + resultAt(new Date("2026-06-03T09:00:00Z").getTime()), + ); + + expect(state.claimedToday).toBe(false); + expect(state.label).toBe("365"); + expect(state.hoverText).toContain("Claimed today: no"); + }); + + it("updates a long streak from unclaimed to claimed after today's result save", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const yesterday = new Date("2026-06-02T12:00:00Z").getTime(); + + const beforeToday = getStreakIndicatorState( + { + streak: 365, + maxStreak: 365, + }, + resultAt(yesterday), + ); + const afterToday = getStreakIndicatorState( + { + streak: 366, + maxStreak: 366, + }, + resultAt(new Date("2026-06-03T00:00:00Z").getTime()), + ); + + expect(beforeToday.show).toBe(true); + expect(beforeToday.claimedToday).toBe(false); + expect(beforeToday.label).toBe("365"); + expect(beforeToday.hoverText).toContain("Claimed today: no"); + + expect(afterToday.show).toBe(true); + expect(afterToday.claimedToday).toBe(true); + expect(afterToday.label).toBe("366"); + expect(afterToday.hoverText).toContain("Longest streak: 366 days"); + expect(afterToday.hoverText).toContain("Claimed today: yes"); + }); + + it("omits account-only extra text unless opted in", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const yesterday = new Date("2026-06-02T12:00:00Z").getTime(); + + expect(getStreakHoverText({ maxStreak: 30 })).toBe( + "Longest streak: 30 days", + ); + expect( + getStreakHoverText({ + maxStreak: 30, + lastResult: resultAt(yesterday), + }), + ).toBe("Longest streak: 30 days"); + expect( + getStreakHoverText({ + maxStreak: 30, + lastResult: resultAt(yesterday), + showExtraText: true, + }), + ).toContain("Claimed today: no"); + }); + + it("describes a streak at risk after yesterday's last result", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 5, + maxStreak: 10, + }, + resultAt(new Date("2026-06-02T12:00:00Z").getTime()), + ); + + expect(state.claimedToday).toBe(false); + expect(state.label).toBe("5"); + expect(state.hoverText).toContain("Claimed today: no"); + expect(state.hoverText).toContain("Streak lost in:"); + }); + + it("shows a zero label when the current streak is already lost", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const state = getStreakIndicatorState( + { + streak: 7, + maxStreak: 12, + }, + resultAt(new Date("2026-06-01T12:00:00Z").getTime()), + ); + + expect(state.claimedToday).toBe(false); + expect(state.label).toBe("0"); + expect(state.hoverText).toContain("Longest streak: 12 days"); + expect(state.hoverText).toContain("Streak lost "); + }); + + it("describes lost streaks from the actual reset time", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T20:00:00Z")); + const extraText = getStreakExtraText( + resultAt(new Date("2026-06-01T12:00:00Z").getTime()), + undefined, + ); + + expect(extraText).toContain("Streak lost 20 hours ago"); + }); + + it("uses the streak hour offset when checking today's claim", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T01:00:00Z")); + const lateYesterdayUtc = resultAt( + new Date("2026-06-02T23:30:00Z").getTime(), + ); + + expect(hasClaimedStreakToday(lateYesterdayUtc, undefined)).toBe(false); + expect(hasClaimedStreakToday(lateYesterdayUtc, 6)).toBe(true); + }); + + it("describes an already lost streak without the timezone hint when offset is set", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-03T12:00:00Z")); + const extraText = getStreakExtraText( + resultAt(new Date("2026-06-01T12:00:00Z").getTime()), + 2, + ); + + expect(extraText).toContain("Streak lost "); + expect(extraText).toContain("(+2 offset)"); + expect(extraText).toContain( + "It will be removed from your profile on the next result save", + ); + expect(extraText).not.toContain("If the streak reset time"); + }); +}); diff --git a/frontend/src/ts/components/layout/header/Nav.tsx b/frontend/src/ts/components/layout/header/Nav.tsx index a020e2abe0a2..87d7ea347861 100644 --- a/frontend/src/ts/components/layout/header/Nav.tsx +++ b/frontend/src/ts/components/layout/header/Nav.tsx @@ -33,6 +33,7 @@ import { NotificationBubble } from "../../common/NotificationBubble"; import { User } from "../../common/User"; import { AccountMenu } from "./AccountMenu"; import { AccountXpBar } from "./AccountXpBar"; +import { StreakIndicator } from "./StreakIndicator"; export function Nav(): JSXElement { const [getAccountMenuOpen, setAccountMenuOpen] = createSignal(false); @@ -150,6 +151,7 @@ export function Nav(): JSXElement { router-link />
+ + + ); +} diff --git a/frontend/src/ts/components/pages/profile/UserDetails.tsx b/frontend/src/ts/components/pages/profile/UserDetails.tsx index 473fb2cd820e..e61e443ae4f1 100644 --- a/frontend/src/ts/components/pages/profile/UserDetails.tsx +++ b/frontend/src/ts/components/pages/profile/UserDetails.tsx @@ -3,18 +3,10 @@ import { UserProfile, UserProfileDetails, } from "@monkeytype/schemas/users"; -import { - getCurrentDayTimestamp, - isToday as dateIsToday, - isYesterday as dateIsYesterday, -} from "@monkeytype/util/date-and-time"; -import { isSafeNumber } from "@monkeytype/util/numbers"; import { differenceInDays } from "date-fns/differenceInDays"; import { formatDate } from "date-fns/format"; -import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict"; import { createEffect, createSignal, For, JSXElement, Show } from "solid-js"; -import { Snapshot } from "../../../constants/default-snapshot"; import { addFriend, isFriend } from "../../../db"; import * as UserReportModal from "../../../modals/user-report"; import { bp } from "../../../states/breakpoints"; @@ -24,11 +16,13 @@ import { showNoticeNotification, showErrorNotification, } from "../../../states/notifications"; -import { getLastResult, getSnapshot } from "../../../states/snapshot"; +import { getSnapshot } from "../../../states/snapshot"; +import { getStreakIndicatorState } from "../../../states/streak"; import { cn } from "../../../utils/cn"; import { secondsToString } from "../../../utils/date-and-time"; import { formatXp, getXpDetails } from "../../../utils/levels"; import { formatTypingStatsRatio } from "../../../utils/misc"; +import { formatStreak, getStreakHoverText } from "../../../utils/streak"; import { AutoShrink } from "../../common/AutoShrink"; import { Balloon, BalloonProps } from "../../common/Balloon"; import { Bar } from "../../common/Bar"; @@ -220,52 +214,17 @@ function AvatarAndName(props: { return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`; }; - const formatStreak = (length: number) => - `${length} ${length === 1 ? "day" : "days"}`; - - const extraStreakText = () => { - if (!props.isAccountPage) return ""; - let hoverText = ""; - - const lastResult = getLastResult(); - if (lastResult === undefined) return ""; - - const streakOffset = (props.profile as Snapshot).streakHourOffset; - - const dayInMilis = 1000 * 60 * 60 * 24; - - let target = getCurrentDayTimestamp(streakOffset) + dayInMilis; - if (target < Date.now()) { - target += dayInMilis; - } - const timeDif = formatDistanceToNowStrict(target); - - if (lastResult !== undefined) { - //check if the last result is from today - const isToday = dateIsToday(lastResult.timestamp, streakOffset); - const isYesterday = dateIsYesterday(lastResult.timestamp, streakOffset); + const currentStreak = () => + props.isAccountPage + ? Number(getStreakIndicatorState().label ?? 0) + : (props.profile.streak ?? 0); - const offsetString = isSafeNumber(streakOffset) - ? `(${streakOffset > 0 ? "+" : ""}${streakOffset} offset)` - : ""; - - if (isToday) { - hoverText += `\nClaimed today: yes`; - hoverText += `\nCome back in: ${timeDif} ${offsetString}`; - } else if (isYesterday) { - hoverText += `\nClaimed today: no`; - hoverText += `\nStreak lost in: ${timeDif} ${offsetString}`; - } else { - hoverText += `\nStreak lost ${timeDif} ${offsetString} ago`; - hoverText += `\nIt will be removed from your profile on the next result save`; - } - - if (streakOffset === undefined) { - hoverText += `\n\nIf the streak reset time doesn't line up with your timezone, you can change it in Account Settings > Account > Set streak hour offset.`; - } - } - return hoverText; - }; + const streakHoverText = () => + props.isAccountPage + ? getStreakIndicatorState().hoverText + : getStreakHoverText({ + maxStreak: props.profile.maxStreak, + }); const balloonPosition = (): BalloonProps["position"] => bp().md ? "right" : "up"; @@ -332,15 +291,15 @@ function AvatarAndName(props: { Joined {formatDate(props.profile.addedAt ?? 0, "dd MMM yyyy")} - 1}> + 0}> - Current streak {formatStreak(props.profile.streak)} + Current streak {formatStreak(currentStreak())} diff --git a/frontend/src/ts/constants/default-snapshot.ts b/frontend/src/ts/constants/default-snapshot.ts index 74f192019a34..e80428427896 100644 --- a/frontend/src/ts/constants/default-snapshot.ts +++ b/frontend/src/ts/constants/default-snapshot.ts @@ -68,6 +68,7 @@ export type Snapshot = Omit< streak: number; maxStreak: number; isPremium: boolean; + streakLastResultTimestamp?: number; streakHourOffset?: number; xp: number; testActivity?: ModifiableTestActivityCalendar; @@ -104,6 +105,7 @@ const defaultSnap = { inboxUnreadSize: 0, streak: 0, maxStreak: 0, + streakLastResultTimestamp: undefined, streakHourOffset: undefined, allTimeLbs: { time: { diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 5e3cc3d9fd05..797f38228425 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -149,6 +149,8 @@ export async function initSnapshot(): Promise { snap.inboxUnreadSize = userData.inboxUnreadSize ?? 0; snap.streak = userData?.streak?.length ?? 0; snap.maxStreak = userData?.streak?.maxLength ?? 0; + snap.streakLastResultTimestamp = + userData?.streak?.lastResultTimestamp ?? undefined; snap.isPremium = userData?.isPremium ?? false; snap.allTimeLbs = userData.allTimeLbs; @@ -334,6 +336,7 @@ export function saveLocalResult(data: SaveLocalResultData): void { if (data.result !== undefined) { void insertLocalResult({ result: data.result }); setLastResult(data.result); + snapshot.streakLastResultTimestamp = data.result.timestamp; if (snapshot.testActivity !== undefined) { snapshot.testActivity.increment(new Date(data.result.timestamp)); } diff --git a/frontend/src/ts/states/streak.ts b/frontend/src/ts/states/streak.ts new file mode 100644 index 000000000000..bd277d09334c --- /dev/null +++ b/frontend/src/ts/states/streak.ts @@ -0,0 +1,22 @@ +import { createEffect, createSignal } from "solid-js"; + +import type { StreakIndicatorState } from "../utils/streak"; +import { getStreakIndicatorState as getStreakIndicatorStateFromSnapshot } from "../utils/streak"; +import { getLastResult, getSnapshot } from "./snapshot"; + +const [getNow, setNow] = createSignal(Date.now()); +export const [getStreakIndicatorState, setStreakIndicatorState] = + createSignal( + getStreakIndicatorStateFromSnapshot(undefined, undefined), + ); + +window.setInterval(() => { + setNow(Date.now()); +}, 60_000); + +createEffect(() => { + getNow(); + setStreakIndicatorState( + getStreakIndicatorStateFromSnapshot(getSnapshot(), getLastResult()), + ); +}); diff --git a/frontend/src/ts/utils/streak.ts b/frontend/src/ts/utils/streak.ts new file mode 100644 index 000000000000..d0c041720aea --- /dev/null +++ b/frontend/src/ts/utils/streak.ts @@ -0,0 +1,157 @@ +import type { Mode } from "@monkeytype/schemas/shared"; +import { + getCurrentDayTimestamp, + getStartOfDayTimestamp, + MILISECONDS_IN_HOUR, + MILLISECONDS_IN_DAY, + isToday as dateIsToday, + isYesterday as dateIsYesterday, +} from "@monkeytype/util/date-and-time"; +import { isSafeNumber } from "@monkeytype/util/numbers"; +import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict"; + +import type { SnapshotResult } from "../constants/default-snapshot"; + +type StreakLastResult = Pick, "timestamp">; + +type StreakSnapshot = { + streak: number; + maxStreak: number; + streakLastResultTimestamp?: number; + streakHourOffset?: number; +}; + +export type StreakIndicatorState = { + show: boolean; + claimedToday: boolean; + label?: string; + hoverText: string; +}; + +export function formatStreak(length: number): string { + return `${length} ${length === 1 ? "day" : "days"}`; +} + +export function hasClaimedStreakToday( + lastResult: StreakLastResult | undefined, + streakHourOffset: number | undefined, +): boolean { + return ( + lastResult !== undefined && + dateIsToday(lastResult.timestamp, streakHourOffset) + ); +} + +export function hasLostStreak( + lastResult: StreakLastResult | undefined, + streakHourOffset: number | undefined, +): boolean { + return ( + lastResult !== undefined && + !dateIsToday(lastResult.timestamp, streakHourOffset) && + !dateIsYesterday(lastResult.timestamp, streakHourOffset) + ); +} + +export function getStreakExtraText( + lastResult: StreakLastResult | undefined, + streakHourOffset: number | undefined, +): string { + if (lastResult === undefined) return ""; + + let hoverText = ""; + let target = getCurrentDayTimestamp(streakHourOffset) + MILLISECONDS_IN_DAY; + if (target < Date.now()) { + target += MILLISECONDS_IN_DAY; + } + const timeDif = formatDistanceToNowStrict(target); + const isToday = dateIsToday(lastResult.timestamp, streakHourOffset); + const isYesterday = dateIsYesterday(lastResult.timestamp, streakHourOffset); + const offsetString = isSafeNumber(streakHourOffset) + ? ` (${streakHourOffset > 0 ? "+" : ""}${streakHourOffset} offset)` + : ""; + + if (isToday) { + hoverText += `\nClaimed today: yes`; + hoverText += `\nCome back in: ${timeDif}${offsetString}`; + } else if (isYesterday) { + hoverText += `\nClaimed today: no`; + hoverText += `\nStreak lost in: ${timeDif}${offsetString}`; + } else { + const offsetMilis = (streakHourOffset ?? 0) * MILISECONDS_IN_HOUR; + const lostAt = + getStartOfDayTimestamp(lastResult.timestamp, offsetMilis) + + MILLISECONDS_IN_DAY * 2; + const lostTimeDif = formatDistanceToNowStrict(lostAt); + hoverText += `\nStreak lost ${lostTimeDif}${offsetString} ago`; + hoverText += `\nIt will be removed from your profile on the next result save`; + } + + if (streakHourOffset === undefined) { + hoverText += `\n\nIf the streak reset time doesn't line up with your timezone, you can change it in Account Settings > Account > Set streak hour offset.`; + } + + return hoverText; +} + +export function getStreakHoverText(args: { + maxStreak: number; + lastResult?: StreakLastResult; + streakHourOffset?: number; + showExtraText?: boolean; +}): string { + return `Longest streak: ${formatStreak(args.maxStreak)}${ + args.showExtraText === true + ? getStreakExtraText(args.lastResult, args.streakHourOffset) + : "" + }`; +} + +export function getStreakLastResult( + snapshot: StreakSnapshot, + lastResult: SnapshotResult | undefined, +): StreakLastResult | undefined { + const timestamp = snapshot.streakLastResultTimestamp ?? lastResult?.timestamp; + + if (timestamp === undefined || timestamp <= 0) { + return undefined; + } + + return { timestamp }; +} + +export function getStreakIndicatorState( + snapshot: StreakSnapshot | undefined, + lastResult: SnapshotResult | undefined, +): StreakIndicatorState { + if (snapshot === undefined) { + return { + show: false, + claimedToday: false, + hoverText: "", + }; + } + + const hasActiveStreak = snapshot.streak > 0; + const streakLastResult = getStreakLastResult(snapshot, lastResult); + const claimedToday = + hasActiveStreak && + hasClaimedStreakToday(streakLastResult, snapshot.streakHourOffset); + const lostStreak = + hasActiveStreak && + hasLostStreak(streakLastResult, snapshot.streakHourOffset); + + const label = lostStreak ? "0" : `${snapshot.streak}`; + + return { + show: true, + claimedToday, + label, + hoverText: getStreakHoverText({ + maxStreak: snapshot.maxStreak, + lastResult: hasActiveStreak ? streakLastResult : undefined, + streakHourOffset: snapshot.streakHourOffset, + showExtraText: hasActiveStreak, + }), + }; +}