Skip to content

Commit 0766afc

Browse files
authored
Fix reset tooltips to mirror display mode (#297)
* fix: toggle reset tooltip mode * fix: localize reset date labels
1 parent 43516a1 commit 0766afc

8 files changed

Lines changed: 291 additions & 98 deletions

src/components/global-shortcut-section.test.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { fireEvent, render, screen } from "@testing-library/react"
1+
import { act, fireEvent, render, screen } from "@testing-library/react"
22
import userEvent from "@testing-library/user-event"
3-
import { describe, expect, it, vi } from "vitest"
3+
import { afterEach, describe, expect, it, vi } from "vitest"
44
import { GlobalShortcutSection } from "@/components/global-shortcut-section"
55

66
function renderSection(globalShortcut: string | null = null) {
@@ -20,6 +20,10 @@ async function startRecording() {
2020
}
2121

2222
describe("GlobalShortcutSection", () => {
23+
afterEach(() => {
24+
vi.useRealTimers()
25+
})
26+
2327
it("formats persisted shortcuts for display", () => {
2428
renderSection("CommandOrControl+Alt+Delete")
2529
expect(screen.getByText("Cmd + Opt + Delete")).toBeInTheDocument()
@@ -78,6 +82,20 @@ describe("GlobalShortcutSection", () => {
7882
expect(onGlobalShortcutChange).toHaveBeenCalledWith("CommandOrControl+A")
7983
})
8084

85+
it("records and saves Alt shortcuts", async () => {
86+
const { onGlobalShortcutChange } = renderSection()
87+
const textbox = await startRecording()
88+
89+
fireEvent.keyDown(textbox, { key: "Alt", code: "AltLeft" })
90+
fireEvent.keyDown(textbox, { key: "/", code: "Slash" })
91+
expect(screen.getByText("Opt + /")).toBeInTheDocument()
92+
93+
fireEvent.keyUp(textbox, { key: "/", code: "Slash" })
94+
fireEvent.keyUp(textbox, { key: "Alt", code: "AltLeft" })
95+
96+
expect(onGlobalShortcutChange).toHaveBeenCalledWith("Alt+Slash")
97+
})
98+
8199
it("does not save when only modifiers are pressed", async () => {
82100
const { onGlobalShortcutChange } = renderSection()
83101
const textbox = await startRecording()
@@ -123,6 +141,20 @@ describe("GlobalShortcutSection", () => {
123141
expect(screen.getByRole("textbox", { name: /Press keys/i })).toBeInTheDocument()
124142
})
125143

144+
it("focuses the recording textbox after starting", async () => {
145+
vi.useFakeTimers()
146+
renderSection()
147+
148+
fireEvent.click(screen.getByRole("button", { name: /Click to set/i }))
149+
const textbox = screen.getByRole("textbox", { name: /Press keys/i })
150+
151+
await act(async () => {
152+
vi.advanceTimersByTime(10)
153+
})
154+
155+
expect(textbox).toHaveFocus()
156+
})
157+
126158
it("cancels recording on blur without saving pending shortcut", async () => {
127159
const { onGlobalShortcutChange } = renderSection()
128160
const textbox = await startRecording()

src/components/provider-card.test.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,11 @@ vi.mock("@/components/ui/tooltip", () => ({
3232
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
3333
}))
3434

35-
function getEnglishOrdinalSuffix(day: number): string {
36-
const mod100 = day % 100
37-
if (mod100 >= 11 && mod100 <= 13) return "th"
38-
const mod10 = day % 10
39-
if (mod10 === 1) return "st"
40-
if (mod10 === 2) return "nd"
41-
if (mod10 === 3) return "rd"
42-
return "th"
43-
}
44-
45-
function formatOrdinalDate(date: Date): string {
46-
const monthText = new Intl.DateTimeFormat(undefined, {
35+
function formatMonthDay(date: Date): string {
36+
return new Intl.DateTimeFormat(undefined, {
4737
month: "short",
38+
day: "numeric",
4839
}).format(date)
49-
const day = date.getDate()
50-
return `${monthText} ${day}${getEnglishOrdinalSuffix(day)}`
5140
}
5241

5342
describe("ProviderCard", () => {
@@ -261,7 +250,15 @@ describe("ProviderCard", () => {
261250
/>
262251
)
263252
expect(screen.getByText("Resets in 1h 5m")).toBeInTheDocument()
264-
expect(screen.getByText(formatResetTooltipText("2026-02-02T01:05:00.000Z")!)).toBeInTheDocument()
253+
expect(
254+
screen.getByText(
255+
formatResetTooltipText({
256+
nowMs: now.getTime(),
257+
resetsAtIso: "2026-02-02T01:05:00.000Z",
258+
visibleMode: "relative",
259+
})!
260+
)
261+
).toBeInTheDocument()
265262
vi.useRealTimers()
266263
})
267264

@@ -355,7 +352,7 @@ describe("ProviderCard", () => {
355352
]}
356353
/>
357354
)
358-
expect(screen.getByText("Resets soon")).toBeInTheDocument()
355+
expect(screen.getAllByText("Resets soon")).toHaveLength(2)
359356
vi.useRealTimers()
360357
})
361358

@@ -380,7 +377,7 @@ describe("ProviderCard", () => {
380377
]}
381378
/>
382379
)
383-
expect(screen.getByText("Resets soon")).toBeInTheDocument()
380+
expect(screen.getAllByText("Resets soon")).toHaveLength(2)
384381
vi.useRealTimers()
385382
})
386383

@@ -410,7 +407,15 @@ describe("ProviderCard", () => {
410407
)
411408
const resetButton = screen.getByRole("button", { name: /^Resets today at / })
412409
expect(resetButton).toBeInTheDocument()
413-
expect(screen.getByText(formatResetTooltipText(resetsAt)!)).toBeInTheDocument()
410+
expect(
411+
screen.getByText(
412+
formatResetTooltipText({
413+
nowMs: now.getTime(),
414+
resetsAtIso: resetsAt,
415+
visibleMode: "absolute",
416+
})!
417+
)
418+
).toBeInTheDocument()
414419
fireEvent.click(resetButton)
415420
expect(onToggle).toHaveBeenCalledTimes(1)
416421
vi.useRealTimers()
@@ -447,7 +452,7 @@ describe("ProviderCard", () => {
447452
const now = new Date(2026, 1, 2, 10, 0, 0)
448453
const resetsAt = new Date(2026, 1, 5, 16, 0, 0)
449454
vi.setSystemTime(now)
450-
const dateText = formatOrdinalDate(resetsAt)
455+
const dateText = formatMonthDay(resetsAt)
451456
render(
452457
<ProviderCard
453458
name="Resets"
@@ -476,7 +481,7 @@ describe("ProviderCard", () => {
476481
const now = new Date(2026, 1, 2, 10, 0, 0)
477482
const resetsAt = new Date(2026, 1, 20, 16, 0, 0)
478483
vi.setSystemTime(now)
479-
const dateText = formatOrdinalDate(resetsAt)
484+
const dateText = formatMonthDay(resetsAt)
480485
render(
481486
<ProviderCard
482487
name="Resets"

src/components/provider-card.tsx

Lines changed: 11 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import type { ManifestLine, MetricLine, PluginLink } from "@/lib/plugin-types"
1414
import { groupLinesByType } from "@/lib/group-lines-by-type"
1515
import { clamp01, formatCountNumber, formatFixedPrecisionNumber } from "@/lib/utils"
1616
import { calculateDeficit, calculatePaceStatus, type PaceStatus } from "@/lib/pace-status"
17-
import { buildPaceDetailText, formatCompactDuration, formatDeficitText, formatRunsOutText, getPaceStatusText } from "@/lib/pace-tooltip"
18-
import { formatResetTooltipText } from "@/lib/reset-tooltip"
17+
import { buildPaceDetailText, formatDeficitText, formatRunsOutText, getPaceStatusText } from "@/lib/pace-tooltip"
18+
import { formatResetAbsoluteLabel, formatResetRelativeLabel, formatResetTooltipText } from "@/lib/reset-tooltip"
1919

2020
interface ProviderCardProps {
2121
name: string
@@ -40,61 +40,6 @@ const PACE_VISUALS: Record<PaceStatus, { dotClass: string }> = {
4040
behind: { dotClass: "bg-red-500" },
4141
}
4242

43-
44-
const RESET_SOON_THRESHOLD_MS = 5 * 60 * 1000
45-
46-
function formatResetIn(nowMs: number, resetsAtIso: string): string | null {
47-
const resetsAtMs = Date.parse(resetsAtIso)
48-
if (!Number.isFinite(resetsAtMs)) return null
49-
const deltaMs = resetsAtMs - nowMs
50-
if (deltaMs < RESET_SOON_THRESHOLD_MS) return "Resets soon"
51-
const durationText = formatCompactDuration(deltaMs)!
52-
return `Resets in ${durationText}`
53-
}
54-
55-
const RESET_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
56-
hour: "numeric",
57-
minute: "2-digit",
58-
})
59-
60-
const RESET_MONTH_FORMATTER = new Intl.DateTimeFormat(undefined, {
61-
month: "short",
62-
})
63-
64-
function getLocalDayIndex(timestampMs: number): number {
65-
const date = new Date(timestampMs)
66-
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
67-
}
68-
69-
function getEnglishOrdinalSuffix(day: number): string {
70-
const mod100 = day % 100
71-
if (mod100 >= 11 && mod100 <= 13) return "th"
72-
const mod10 = day % 10
73-
if (mod10 === 1) return "st"
74-
if (mod10 === 2) return "nd"
75-
if (mod10 === 3) return "rd"
76-
return "th"
77-
}
78-
79-
function formatMonthDayWithOrdinal(timestampMs: number): string {
80-
const date = new Date(timestampMs)
81-
const monthText = RESET_MONTH_FORMATTER.format(date)
82-
const day = date.getDate()
83-
return `${monthText} ${day}${getEnglishOrdinalSuffix(day)}`
84-
}
85-
86-
function formatResetAt(nowMs: number, resetsAtIso: string): string | null {
87-
const resetsAtMs = Date.parse(resetsAtIso)
88-
if (!Number.isFinite(resetsAtMs)) return null
89-
if (resetsAtMs - nowMs <= 0) return "Resets soon"
90-
const dayDiff = getLocalDayIndex(resetsAtMs) - getLocalDayIndex(nowMs)
91-
const timeText = RESET_TIME_FORMATTER.format(resetsAtMs)
92-
if (dayDiff <= 0) return `Resets today at ${timeText}`
93-
if (dayDiff === 1) return `Resets tomorrow at ${timeText}`
94-
const dateText = formatMonthDayWithOrdinal(resetsAtMs)
95-
return `Resets ${dateText} at ${timeText}`
96-
}
97-
9843
/** Colored dot indicator showing pace status */
9944
function PaceIndicator({
10045
status,
@@ -421,10 +366,16 @@ function MetricLineRenderer({
421366

422367
const resetLabel = line.resetsAt
423368
? resetTimerDisplayMode === "absolute"
424-
? formatResetAt(now, line.resetsAt)
425-
: formatResetIn(now, line.resetsAt)
369+
? formatResetAbsoluteLabel(now, line.resetsAt)
370+
: formatResetRelativeLabel(now, line.resetsAt)
371+
: null
372+
const resetTooltipText = line.resetsAt
373+
? formatResetTooltipText({
374+
nowMs: now,
375+
resetsAtIso: line.resetsAt,
376+
visibleMode: resetTimerDisplayMode,
377+
})
426378
: null
427-
const resetTooltipText = line.resetsAt ? formatResetTooltipText(line.resetsAt) : null
428379

429380
const secondaryText =
430381
resetLabel ??

src/components/side-nav.test.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { render, screen } from "@testing-library/react"
22
import userEvent from "@testing-library/user-event"
33
import { describe, expect, it, vi } from "vitest"
4+
import { openUrl } from "@tauri-apps/plugin-opener"
5+
import { invoke } from "@tauri-apps/api/core"
46

57
import { SideNav } from "@/components/side-nav"
68

@@ -12,6 +14,14 @@ vi.mock("@/hooks/use-dark-mode", () => ({
1214
useDarkMode: darkModeState.useDarkModeMock,
1315
}))
1416

17+
vi.mock("@tauri-apps/plugin-opener", () => ({
18+
openUrl: vi.fn(() => Promise.resolve()),
19+
}))
20+
21+
vi.mock("@tauri-apps/api/core", () => ({
22+
invoke: vi.fn(() => Promise.resolve()),
23+
}))
24+
1525
describe("SideNav", () => {
1626
it("calls onViewChange for Home and Settings", async () => {
1727
const onViewChange = vi.fn()
@@ -70,5 +80,14 @@ describe("SideNav", () => {
7080
const p2Style = screen.getByRole("img", { name: "P2" }).getAttribute("style") ?? ""
7181
expect(p2Style).toContain("rgb(255, 255, 255)")
7282
})
73-
})
7483

84+
it("opens the issues page and hides the panel from Help", async () => {
85+
const onViewChange = vi.fn()
86+
render(<SideNav activeView="home" onViewChange={onViewChange} plugins={[]} />)
87+
88+
await userEvent.click(screen.getByRole("button", { name: "Help" }))
89+
90+
expect(openUrl).toHaveBeenCalledWith("https://github.com/robinebers/openusage/issues")
91+
expect(invoke).toHaveBeenCalledWith("hide_panel")
92+
})
93+
})

src/hooks/use-now-ticker.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { renderHook, act } from "@testing-library/react"
2+
import { afterEach, describe, expect, it, vi } from "vitest"
3+
import { useNowTicker } from "./use-now-ticker"
4+
5+
describe("useNowTicker", () => {
6+
afterEach(() => {
7+
vi.useRealTimers()
8+
})
9+
10+
it("does not tick when disabled", () => {
11+
vi.useFakeTimers()
12+
vi.setSystemTime(new Date("2026-02-03T00:00:00.000Z"))
13+
14+
const { result } = renderHook(() => useNowTicker({ enabled: false, intervalMs: 1000 }))
15+
expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z"))
16+
17+
act(() => {
18+
vi.advanceTimersByTime(5_000)
19+
})
20+
21+
expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z"))
22+
})
23+
24+
it("stops immediately when stopAfterMs is non-positive", () => {
25+
vi.useFakeTimers()
26+
vi.setSystemTime(new Date("2026-02-03T00:00:00.000Z"))
27+
28+
const { result } = renderHook(() => useNowTicker({ intervalMs: 1000, stopAfterMs: 0 }))
29+
expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z"))
30+
31+
act(() => {
32+
vi.setSystemTime(new Date("2026-02-03T00:00:05.000Z"))
33+
vi.advanceTimersByTime(5_000)
34+
})
35+
36+
expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z"))
37+
})
38+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest"
2+
3+
describe("reset-tooltip mocked branches", () => {
4+
afterEach(() => {
5+
vi.doUnmock("@/lib/pace-tooltip")
6+
vi.resetModules()
7+
})
8+
9+
it("returns null when compact duration formatting is unavailable", async () => {
10+
vi.doMock("@/lib/pace-tooltip", () => ({
11+
formatCompactDuration: () => null,
12+
}))
13+
14+
const { formatResetRelativeLabel } = await import("@/lib/reset-tooltip")
15+
16+
expect(formatResetRelativeLabel(Date.parse("2026-02-03T11:29:00.000Z"), "2026-02-03T12:34:00.000Z")).toBeNull()
17+
})
18+
})

0 commit comments

Comments
 (0)