{
expect(container.querySelector('[data-slot="progress-refreshing"]')).toBeNull()
})
+ it("draws usage spent unseen hatched, then fills over it", () => {
+ const { container, rerender } = render(
+
+ )
+ const fill = () => container.querySelector
('[role="progressbar"] > div')!
+ const hatch = () => container.querySelector('[data-slot="progress-unseen"]')
+ expect(fill().style.width).toBe("10%")
+ expect(hatch()?.style.left).toBe("10%")
+ expect(hatch()?.style.width).toBe("20%")
+ expect(hatch()).toHaveClass("animate-unseen")
+
+ rerender()
+ expect(fill().style.width).toBe("30%")
+ expect(hatch()).toHaveClass("opacity-0")
+ expect(hatch()).not.toHaveClass("animate-unseen")
+
+ // Showing what's left: the spent part is the piece the fill lost.
+ rerender()
+ expect(fill().style.width).toBe("50%")
+ expect(hatch()?.style.left).toBe("50%")
+ expect(hatch()?.style.width).toBe("20%")
+
+ rerender()
+ expect(fill().style.width).toBe("30%")
+ expect(hatch()).toBeNull()
+ })
+
it("renders separator orientations", () => {
const { rerender } = render()
expect(screen.getByRole("separator")).toBeInTheDocument()
diff --git a/src/hooks/use-unseen-usage.test.ts b/src/hooks/use-unseen-usage.test.ts
new file mode 100644
index 0000000..ddd0578
--- /dev/null
+++ b/src/hooks/use-unseen-usage.test.ts
@@ -0,0 +1,122 @@
+import { act, renderHook } from "@testing-library/react"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const { isVisibleMock } = vi.hoisted(() => ({ isVisibleMock: vi.fn() }))
+
+vi.mock("@tauri-apps/api/core", () => ({
+ isTauri: () => true,
+}))
+
+vi.mock("@tauri-apps/api/window", () => ({
+ getCurrentWindow: () => ({ isVisible: isVisibleMock }),
+}))
+
+import { UNSEEN_BLINK_MS, UNSEEN_SETTLE_MS, useUnseenUsage } from "@/hooks/use-unseen-usage"
+
+// Lets the OS visibility answer reach the hook.
+const settle = () =>
+ act(async () => {
+ for (let i = 0; i < 5; i++) await Promise.resolve()
+ })
+
+async function setPanel(open: boolean) {
+ isVisibleMock.mockResolvedValue(open)
+ act(() => {
+ window.dispatchEvent(new Event(open ? "focus" : "blur"))
+ })
+ await settle()
+}
+
+function advance(ms: number) {
+ act(() => vi.advanceTimersByTime(ms))
+}
+
+// Seen values outlive a mount (as they outlive a page switch), so every test has its own key.
+async function mount(key: string, used: number) {
+ const view = renderHook(({ used }) => useUnseenUsage(key, used), { initialProps: { used } })
+ await settle()
+ return view
+}
+
+describe("useUnseenUsage", () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ isVisibleMock.mockReset().mockResolvedValue(false)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it("shows what was spent while the panel was closed, blinks, fills, then counts it as seen", async () => {
+ const { result, rerender } = await mount("spent", 10)
+ expect(result.current).toBeNull()
+
+ rerender({ used: 25 })
+ expect(result.current).toEqual({ from: 10, settling: false })
+ advance(60 * 60_000)
+ expect(result.current).toEqual({ from: 10, settling: false })
+
+ await setPanel(true)
+ advance(UNSEEN_BLINK_MS - 1)
+ expect(result.current).toEqual({ from: 10, settling: false })
+ advance(1)
+ expect(result.current).toEqual({ from: 10, settling: true })
+ advance(UNSEEN_SETTLE_MS)
+ expect(result.current).toBeNull()
+
+ await setPanel(false)
+ await setPanel(true)
+ expect(result.current).toBeNull()
+ })
+
+ it("doesn't count a reset as spent usage", async () => {
+ const { result, rerender } = await mount("reset", 60)
+ rerender({ used: 5 })
+ expect(result.current).toBeNull()
+ rerender({ used: 8 })
+ expect(result.current).toEqual({ from: 5, settling: false })
+ })
+
+ it("counts a drop as a reset even when it stays above what was seen", async () => {
+ const { result, rerender } = await mount("reset-above-seen", 10)
+ rerender({ used: 60 })
+ expect(result.current).toEqual({ from: 10, settling: false })
+ rerender({ used: 30 })
+ expect(result.current).toBeNull()
+ rerender({ used: 35 })
+ expect(result.current).toEqual({ from: 30, settling: false })
+ })
+
+ it("keeps it for the next open when the panel closes mid-blink", async () => {
+ await setPanel(true)
+ const { result, rerender } = await mount("closed-early", 10)
+ rerender({ used: 20 })
+ advance(2_000)
+ await setPanel(false)
+ advance(10_000)
+ expect(result.current).toEqual({ from: 10, settling: false })
+
+ await setPanel(true)
+ advance(UNSEEN_BLINK_MS + UNSEEN_SETTLE_MS)
+ expect(result.current).toBeNull()
+ })
+
+ it("starts over when more is spent while it blinks", async () => {
+ await setPanel(true)
+ const { result, rerender } = await mount("more", 10)
+ rerender({ used: 20 })
+ advance(3_000)
+ rerender({ used: 30 })
+ advance(3_000)
+ expect(result.current).toEqual({ from: 10, settling: false })
+ advance(UNSEEN_BLINK_MS - 3_000 + UNSEEN_SETTLE_MS)
+ expect(result.current).toBeNull()
+ })
+
+ it("draws nothing without a key", async () => {
+ const { result } = renderHook(() => useUnseenUsage(null, 50))
+ await settle()
+ expect(result.current).toBeNull()
+ })
+})
diff --git a/src/hooks/use-unseen-usage.ts b/src/hooks/use-unseen-usage.ts
new file mode 100644
index 0000000..61c5754
--- /dev/null
+++ b/src/hooks/use-unseen-usage.ts
@@ -0,0 +1,88 @@
+import { useEffect, useReducer, useState, useSyncExternalStore } from "react"
+import { isTauri } from "@tauri-apps/api/core"
+import { getCurrentWindow } from "@tauri-apps/api/window"
+
+export const UNSEEN_BLINK_MS = 3_500
+export const UNSEEN_SETTLE_MS = 700
+
+/** Usage a bar gained while nobody looked: `from` is the used percent last seen in the open panel. */
+export type UnseenUsage = { from: number; settling: boolean }
+
+// Used percent each bar last showed in the open panel, by plugin + line. Page switches keep it; a
+// restart starts over from the first data.
+const seenUsed = new Map()
+// The last usage each bar got, seen or not: dropping below it is a reset, even when it stays above
+// what was seen.
+const latestUsed = new Map()
+
+// Asked from the OS: before its first show the hidden panel's webview reports itself visible.
+let panelOpen = false
+const listeners = new Set<() => void>()
+
+function checkPanel() {
+ getCurrentWindow()
+ .isVisible()
+ .then((open) => {
+ if (open === panelOpen) return
+ panelOpen = open
+ for (const listener of listeners) listener()
+ })
+ .catch((error) => console.error("Failed to read the panel's visibility:", error))
+}
+
+const PANEL_EVENTS = ["focus", "blur"] as const
+
+function subscribePanel(listener: () => void) {
+ if (listeners.size === 0 && isTauri()) {
+ for (const event of PANEL_EVENTS) window.addEventListener(event, checkPanel)
+ document.addEventListener("visibilitychange", checkPanel)
+ checkPanel()
+ }
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ if (listeners.size > 0) return
+ for (const event of PANEL_EVENTS) window.removeEventListener(event, checkPanel)
+ document.removeEventListener("visibilitychange", checkPanel)
+ }
+}
+
+/**
+ * The usage `key`'s bar gained since the open panel last showed it. It is drawn while the panel is
+ * hidden too, so opening the panel doesn't first show the new fill and then take it back; once the
+ * panel is open it blinks, then the fill grows over it and it counts as seen.
+ */
+export function useUnseenUsage(key: string | null, used: number): UnseenUsage | null {
+ const open = useSyncExternalStore(subscribePanel, () => panelOpen)
+ const [settling, setSettling] = useState(false)
+ // `seenUsed` lives outside React: re-render when it changes, even if `settling` ends where it began.
+ const [, markSeen] = useReducer((count: number) => count + 1, 0)
+ const seen = key === null ? undefined : seenUsed.get(key)
+ const latest = key === null ? undefined : latestUsed.get(key)
+ const isReset = latest !== undefined && used < latest
+ const from = seen !== undefined && used > seen && !isReset ? seen : null
+
+ useEffect(() => {
+ if (key === null) return
+ latestUsed.set(key, used)
+ if (from === null) {
+ // First data, or a reset: nothing was spent unseen, so it starts from here.
+ seenUsed.set(key, used)
+ return
+ }
+ setSettling(false)
+ if (!open) return
+ const settle = window.setTimeout(() => setSettling(true), UNSEEN_BLINK_MS)
+ const done = window.setTimeout(() => {
+ seenUsed.set(key, used)
+ setSettling(false)
+ markSeen()
+ }, UNSEEN_BLINK_MS + UNSEEN_SETTLE_MS)
+ return () => {
+ window.clearTimeout(settle)
+ window.clearTimeout(done)
+ }
+ }, [key, used, from, open])
+
+ return from === null ? null : { from, settling }
+}
diff --git a/src/index.css b/src/index.css
index 053bcb7..cb5aa1c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -248,7 +248,21 @@ body,
.animate-shimmer {
animation: shimmer 1.5s ease-in-out infinite;
}
+
+/* Usage spent while the panel was closed: hatched in the bar's color, blinking until it fills */
+.progress-unseen {
+ background-image: repeating-linear-gradient(-45deg, currentColor 0 2px, transparent 2px 4px);
+}
+@keyframes unseen-blink {
+ 50% {
+ opacity: 0.2;
+ }
+}
+.animate-unseen {
+ animation: unseen-blink 0.875s ease-in-out infinite;
+}
@media (prefers-reduced-motion: reduce) {
+ .animate-unseen,
.animate-shimmer,
.update-border-beam::before {
animation: none;