diff --git a/src-tauri/src/local_usage.rs b/src-tauri/src/local_usage.rs index 653e7c59..8dc2b8b5 100644 --- a/src-tauri/src/local_usage.rs +++ b/src-tauri/src/local_usage.rs @@ -130,6 +130,12 @@ fn build_snapshot( let last7_tokens: i64 = last7.iter().map(|day| day.total_tokens).sum(); let last7_input: i64 = last7.iter().map(|day| day.input_tokens).sum(); let last7_cached: i64 = last7.iter().map(|day| day.cached_input_tokens).sum(); + let last30_tokens: i64 = days + .iter() + .rev() + .take(30) + .map(|day| day.total_tokens) + .sum(); let average_daily_tokens = if last7.is_empty() { 0 @@ -171,7 +177,7 @@ fn build_snapshot( days, totals: LocalUsageTotals { last7_days_tokens: last7_tokens, - last30_days_tokens: total_tokens, + last30_days_tokens: last30_tokens, average_daily_tokens, cache_hit_rate_percent, peak_day, @@ -803,6 +809,36 @@ mod tests { assert_eq!(snapshot.totals.last30_days_tokens, 11); } + #[test] + fn build_snapshot_uses_last_30_days_tokens_only() { + let day_keys = make_day_keys(40); + let mut daily: HashMap = HashMap::new(); + for (index, day_key) in day_keys.iter().enumerate() { + let input_tokens = (index as i64) + 1; + daily.insert( + day_key.clone(), + DailyTotals { + input: input_tokens, + cached: 0, + output: 0, + agent_ms: 0, + agent_runs: 0, + }, + ); + } + + let snapshot = build_snapshot(0, day_keys.clone(), daily, HashMap::new()); + let expected_last30: i64 = day_keys + .iter() + .rev() + .take(30) + .enumerate() + .map(|(offset, _)| (40 - offset) as i64) + .sum(); + + assert_eq!(snapshot.totals.last30_days_tokens, expected_last30); + } + #[test] fn resolve_sessions_roots_includes_workspace_overrides() { let mut workspaces = HashMap::new(); diff --git a/src/App.tsx b/src/App.tsx index 85490fbf..d92b99d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import "./styles/base.css"; import "./styles/buttons.css"; import "./styles/sidebar.css"; import "./styles/home.css"; +import "./styles/usage-details-modal.css"; import "./styles/workspace-home.css"; import "./styles/main.css"; import "./styles/messages.css"; @@ -78,6 +79,7 @@ import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceS import { useLocalUsage } from "./features/home/hooks/useLocalUsage"; import { useGitHubPanelController } from "./features/app/hooks/useGitHubPanelController"; import { useSettingsModalState } from "./features/app/hooks/useSettingsModalState"; +import { useUsageDetailsModalState } from "./features/app/hooks/useUsageDetailsModalState"; import { usePersistComposerSettings } from "./features/app/hooks/usePersistComposerSettings"; import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath"; import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController"; @@ -252,6 +254,11 @@ function MainApp() { openSettings, closeSettings, } = useSettingsModalState(); + const { + usageDetailsOpen, + openUsageDetails, + closeUsageDetails, + } = useUsageDetailsModalState(); const composerInputRef = useRef(null); const { @@ -1687,6 +1694,7 @@ function MainApp() { usageWorkspaceId, usageWorkspaceOptions, onUsageWorkspaceChange: setUsageWorkspaceId, + onOpenUsageDetails: openUsageDetails, onSelectHomeThread: (workspaceId, threadId) => { exitDiffView(); selectWorkspace(workspaceId); @@ -2146,6 +2154,10 @@ function MainApp() { onCancelDictationDownload: dictationModel.cancel, onRemoveDictationModel: dictationModel.remove, }} + usageDetailsOpen={usageDetailsOpen} + usageDays={localUsageSnapshot?.days ?? []} + usageMetric={usageMetric} + onCloseUsageDetails={closeUsageDetails} /> ); diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index 80174f97..fc1b5c79 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -4,6 +4,7 @@ import type { SettingsViewProps } from "../../settings/components/SettingsView"; import { useRenameThreadPrompt } from "../../threads/hooks/useRenameThreadPrompt"; import { useClonePrompt } from "../../workspaces/hooks/useClonePrompt"; import { useWorktreePrompt } from "../../workspaces/hooks/useWorktreePrompt"; +import { UsageDetailsModal } from "../../home/components/UsageDetailsModal"; const RenameThreadPrompt = lazy(() => import("../../threads/components/RenameThreadPrompt").then((module) => ({ @@ -49,6 +50,10 @@ type AppModalsProps = { onCloseSettings: () => void; SettingsViewComponent: ComponentType; settingsProps: Omit; + usageDetailsOpen: boolean; + usageDays: { day: string; totalTokens: number; agentTimeMs?: number | null }[]; + usageMetric: "tokens" | "time"; + onCloseUsageDetails: () => void; }; export const AppModals = memo(function AppModals({ @@ -73,6 +78,10 @@ export const AppModals = memo(function AppModals({ onCloseSettings, SettingsViewComponent, settingsProps, + usageDetailsOpen, + usageDays, + usageMetric, + onCloseUsageDetails, }: AppModalsProps) { return ( <> @@ -131,6 +140,14 @@ export const AppModals = memo(function AppModals({ /> )} + {usageDetailsOpen && ( + + )} ); }); diff --git a/src/features/app/hooks/useUsageDetailsModalState.ts b/src/features/app/hooks/useUsageDetailsModalState.ts new file mode 100644 index 00000000..3c5f730e --- /dev/null +++ b/src/features/app/hooks/useUsageDetailsModalState.ts @@ -0,0 +1,20 @@ +import { useCallback, useState } from "react"; + +export function useUsageDetailsModalState() { + const [usageDetailsOpen, setUsageDetailsOpen] = useState(false); + + const openUsageDetails = useCallback(() => { + setUsageDetailsOpen(true); + }, []); + + const closeUsageDetails = useCallback(() => { + setUsageDetailsOpen(false); + }, []); + + return { + usageDetailsOpen, + openUsageDetails, + closeUsageDetails, + setUsageDetailsOpen, + }; +} diff --git a/src/features/home/components/Home.test.tsx b/src/features/home/components/Home.test.tsx index 407c7544..2add5653 100644 --- a/src/features/home/components/Home.test.tsx +++ b/src/features/home/components/Home.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Home } from "./Home"; const baseProps = { @@ -18,8 +18,13 @@ const baseProps = { usageWorkspaceOptions: [], onUsageWorkspaceChange: vi.fn(), onSelectThread: vi.fn(), + onOpenUsageDetails: vi.fn(), }; +afterEach(() => { + cleanup(); +}); + describe("Home", () => { it("renders latest agent runs and lets you open a thread", () => { const onSelectThread = vi.fn(); @@ -99,4 +104,43 @@ describe("Home", () => { expect(screen.getByText("Runs")).toBeTruthy(); expect(screen.getByText("Peak day")).toBeTruthy(); }); + + it("opens the usage details modal when clicking More", () => { + const onOpenUsageDetails = vi.fn(); + render( + , + ); + + const [moreButton] = screen.getAllByRole("button", { + name: "More usage details", + }); + fireEvent.click(moreButton); + expect(onOpenUsageDetails).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index d9664021..022adc1e 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -34,6 +34,7 @@ type HomeProps = { usageWorkspaceOptions: UsageWorkspaceOption[]; onUsageWorkspaceChange: (workspaceId: string | null) => void; onSelectThread: (workspaceId: string, threadId: string) => void; + onOpenUsageDetails: () => void; }; export function Home({ @@ -51,6 +52,7 @@ export function Home({ usageWorkspaceOptions, onUsageWorkspaceChange, onSelectThread, + onOpenUsageDetails, }: HomeProps) { const formatCompactNumber = (value: number | null | undefined) => { if (value === null || value === undefined) { @@ -464,6 +466,14 @@ export function Home({ )}
+
{last7Days.map((day) => { const value = diff --git a/src/features/home/components/UsageDetailsModal.test.tsx b/src/features/home/components/UsageDetailsModal.test.tsx new file mode 100644 index 00000000..b85b419d --- /dev/null +++ b/src/features/home/components/UsageDetailsModal.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { UsageDetailsModal } from "./UsageDetailsModal"; + +const baseProps = { + isOpen: true, + days: [], + usageMetric: "tokens" as const, + onClose: vi.fn(), +}; + +afterEach(() => { + cleanup(); +}); + +describe("UsageDetailsModal", () => { + it("renders empty state when no data is available", () => { + render(); + + expect(screen.getByText("Usage details")).toBeTruthy(); + expect(screen.getByText("Select a range")).toBeTruthy(); + expect(screen.getByText("No usage data for this range.")).toBeTruthy(); + }); + + it("calls onClose when clicking the close button", () => { + const onClose = vi.fn(); + render(); + + const [closeButton] = screen.getAllByRole("button", { + name: "Close usage details", + }); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose on Escape", () => { + const onClose = vi.fn(); + render(); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders usage bars for the active range", async () => { + render( + , + ); + + const items = await screen.findAllByRole("listitem"); + expect(items).toHaveLength(1); + }); +}); diff --git a/src/features/home/components/UsageDetailsModal.tsx b/src/features/home/components/UsageDetailsModal.tsx new file mode 100644 index 00000000..e3997a97 --- /dev/null +++ b/src/features/home/components/UsageDetailsModal.tsx @@ -0,0 +1,520 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import X from "lucide-react/dist/esm/icons/x"; + +type UsageMetric = "tokens" | "time"; + +type LocalUsageDay = { + day: string; + totalTokens: number; + agentTimeMs?: number | null; + agentRuns?: number | null; +}; + +type UsageDetailsModalProps = { + isOpen: boolean; + days: LocalUsageDay[]; + usageMetric: UsageMetric; + onClose: () => void; +}; + +type UsageTabId = "usage"; + +type UsageTab = { + id: UsageTabId; + label: string; +}; + +const TABS: UsageTab[] = [{ id: "usage", label: "Usage" }]; + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +const formatIsoDate = (date: Date) => { + const year = date.getFullYear(); + const month = pad2(date.getMonth() + 1); + const day = pad2(date.getDate()); + return `${year}-${month}-${day}`; +}; + +const parseIsoDate = (value: string) => { + const [year, month, day] = value.split("-").map(Number); + if (!year || !month || !day) { + return null; + } + const date = new Date(year, month - 1, day); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; +}; + +const addDays = (date: Date, offset: number) => { + const next = new Date(date); + next.setDate(next.getDate() + offset); + return next; +}; + +const diffDays = (start: Date, end: Date) => { + const startMidnight = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const endMidnight = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + const diffMs = endMidnight.getTime() - startMidnight.getTime(); + return Math.floor(diffMs / 86400000) + 1; +}; + +const getMonthStart = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); + +const getMonthDays = (date: Date) => { + const year = date.getFullYear(); + const month = date.getMonth(); + return new Date(year, month + 1, 0).getDate(); +}; + +const shiftMonth = (date: Date, offset: number) => + new Date(date.getFullYear(), date.getMonth() + offset, 1); + +const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +const isBetween = (value: Date, start: Date, end: Date) => { + const startTime = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime(); + const endTime = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime(); + const valueTime = new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + return valueTime >= startTime && valueTime <= endTime; +}; + +const sortByDay = (items: LocalUsageDay[]) => + [...items].sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + +const formatDayLabel = (value: string) => { + const [year, month, day] = value.split("-").map(Number); + if (!year || !month || !day) { + return value; + } + const date = new Date(year, month - 1, day); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(date); +}; + +const formatCount = (value: number | null | undefined) => { + if (value === null || value === undefined) { + return "--"; + } + return new Intl.NumberFormat().format(value); +}; + +const formatDuration = (valueMs: number | null | undefined) => { + if (valueMs === null || valueMs === undefined) { + return "--"; + } + const totalSeconds = Math.max(0, Math.round(valueMs / 1000)); + const totalMinutes = Math.floor(totalSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (totalMinutes > 0) { + return `${totalMinutes}m`; + } + return `${totalSeconds}s`; +}; + +export function UsageDetailsModal({ + isOpen, + days, + usageMetric, + onClose, +}: UsageDetailsModalProps) { + const closeRef = useRef(null); + const popoverRef = useRef(null); + const initRef = useRef(false); + const sortedDays = useMemo(() => sortByDay(days), [days]); + const minDateValue = sortedDays.length ? sortedDays[0].day : null; + const maxDateValue = sortedDays.length ? sortedDays[sortedDays.length - 1].day : null; + const minDate = useMemo( + () => (minDateValue ? parseIsoDate(minDateValue) : null), + [minDateValue], + ); + const maxDate = useMemo( + () => (maxDateValue ? parseIsoDate(maxDateValue) : null), + [maxDateValue], + ); + + const [activeTab, setActiveTab] = useState("usage"); + const [rangeStart, setRangeStart] = useState(null); + const [rangeEnd, setRangeEnd] = useState(null); + const [draftStart, setDraftStart] = useState(null); + const [draftEnd, setDraftEnd] = useState(null); + const [pickerMonth, setPickerMonth] = useState(null); + const [isPickerOpen, setIsPickerOpen] = useState(false); + + useEffect(() => { + if (!isOpen) { + initRef.current = false; + setIsPickerOpen(false); + return; + } + if (!maxDate || initRef.current) { + return; + } + const end = maxDate; + const start = addDays(end, -6); + const clampedStart = + minDate && start < minDate ? minDate : start; + setRangeStart(clampedStart); + setRangeEnd(end); + setDraftStart(clampedStart); + setDraftEnd(end); + setPickerMonth(getMonthStart(end)); + initRef.current = true; + }, [isOpen, maxDate, minDate]); + + useEffect(() => { + if (!isOpen) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + useEffect(() => { + if (isOpen) { + closeRef.current?.focus(); + } + }, [isOpen]); + + useEffect(() => { + if (!isPickerOpen) { + return; + } + const handleClick = (event: MouseEvent) => { + if (!popoverRef.current) { + return; + } + if (!popoverRef.current.contains(event.target as Node)) { + setIsPickerOpen(false); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [isPickerOpen]); + + const rangeLabel = (() => { + if (!rangeStart || !rangeEnd) { + return "Select a range"; + } + const formatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "2-digit", + }); + return `${formatter.format(rangeStart)} - ${formatter.format(rangeEnd)}`; + })(); + + const isRangeValid = Boolean(rangeStart && rangeEnd && rangeStart <= rangeEnd); + const activePreset = rangeStart && rangeEnd ? diffDays(rangeStart, rangeEnd) : null; + + const filteredDays = useMemo(() => { + if (!isRangeValid || !rangeStart || !rangeEnd) { + return [] as LocalUsageDay[]; + } + const startIso = formatIsoDate(rangeStart); + const endIso = formatIsoDate(rangeEnd); + return sortedDays.filter((day) => day.day >= startIso && day.day <= endIso); + }, [isRangeValid, rangeStart, rangeEnd, sortedDays]); + + const maxUsageValue = Math.max( + 1, + ...filteredDays.map((day) => + usageMetric === "tokens" ? day.totalTokens : day.agentTimeMs ?? 0, + ), + ); + + const handlePresetClick = (daysCount: number) => { + if (!maxDate) { + return; + } + const end = rangeEnd ?? maxDate; + const start = addDays(end, -(daysCount - 1)); + const clampedStart = + minDate && start < minDate ? minDate : start; + setRangeStart(clampedStart); + setRangeEnd(end); + setDraftStart(clampedStart); + setDraftEnd(end); + }; + + const handleTogglePicker = () => { + if (!maxDate) { + return; + } + const start = rangeStart ?? maxDate; + const end = rangeEnd ?? maxDate; + setDraftStart(start); + setDraftEnd(end); + setPickerMonth(getMonthStart(end)); + setIsPickerOpen((prev) => !prev); + }; + + const handleDayClick = (date: Date) => { + if (!maxDate) { + return; + } + const clamped = + minDate && date < minDate ? minDate : date > maxDate ? maxDate : date; + if (!draftStart || (draftStart && draftEnd)) { + setDraftStart(clamped); + setDraftEnd(null); + return; + } + if (clamped < draftStart) { + setDraftStart(clamped); + return; + } + setDraftEnd(clamped); + }; + + const handleApplyPicker = () => { + if (!draftStart || !draftEnd) { + return; + } + setRangeStart(draftStart); + setRangeEnd(draftEnd); + setIsPickerOpen(false); + }; + + const handleCancelPicker = () => { + setDraftStart(rangeStart); + setDraftEnd(rangeEnd); + setIsPickerOpen(false); + }; + + if (!isOpen) { + return null; + } + + const monthToRender = pickerMonth ?? (rangeEnd ?? maxDate ?? new Date()); + const monthStart = getMonthStart(monthToRender); + const monthDays = getMonthDays(monthToRender); + const monthStartWeekday = monthStart.getDay(); + const monthLabel = new Intl.DateTimeFormat(undefined, { + month: "long", + year: "numeric", + }).format(monthStart); + + return ( +
+
+
+
+
Usage details
+ +
+
+ +
+ {activeTab === "usage" && ( +
+
+
+ + {isPickerOpen && ( +
+
+
+ {monthLabel} +
+ + +
+
+
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => ( + {day} + ))} +
+
+ {Array.from({ length: monthStartWeekday }).map((_, index) => ( + + ))} + {Array.from({ length: monthDays }).map((_, index) => { + const dayNumber = index + 1; + const date = new Date( + monthStart.getFullYear(), + monthStart.getMonth(), + dayNumber, + ); + const isDisabled = + (minDate ? date < minDate : false) || + (maxDate ? date > maxDate : false); + const isStart = draftStart ? isSameDay(date, draftStart) : false; + const isEnd = draftEnd ? isSameDay(date, draftEnd) : false; + const inRange = + draftStart && draftEnd + ? isBetween(date, draftStart, draftEnd) + : false; + return ( + + ); + })} +
+
+ + +
+
+
+ )} +
+
+ {[1, 7, 30].map((daysCount) => ( + + ))} +
+
+ {!isRangeValid && ( +
Start date must be before end date.
+ )} +
+
+ {filteredDays.length === 0 ? ( +
No usage data for this range.
+ ) : ( +
+
+ {filteredDays.map((day) => { + const value = + usageMetric === "tokens" + ? day.totalTokens + : day.agentTimeMs ?? 0; + const height = Math.max( + 6, + Math.round((value / maxUsageValue) * 100), + ); + const tooltip = + usageMetric === "tokens" + ? `${formatDayLabel(day.day)} · ${formatCount(day.totalTokens)} tokens` + : `${formatDayLabel(day.day)} · ${formatDuration(day.agentTimeMs ?? 0)} agent time`; + return ( +
+ + {formatDayLabel(day.day)} +
+ ); + })} +
+
+ )} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/features/home/hooks/useLocalUsage.ts b/src/features/home/hooks/useLocalUsage.ts index 2c60ebb8..88fe8caf 100644 --- a/src/features/home/hooks/useLocalUsage.ts +++ b/src/features/home/hooks/useLocalUsage.ts @@ -15,6 +15,7 @@ const emptyState: LocalUsageState = { }; const REFRESH_INTERVAL_MS = 5 * 60 * 1000; +const SNAPSHOT_DAYS = 90; export function useLocalUsage(enabled: boolean, workspacePath: string | null) { const [state, setState] = useState(emptyState); @@ -40,7 +41,7 @@ export function useLocalUsage(enabled: boolean, workspacePath: string | null) { const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; setState((prev) => ({ ...prev, isLoading: true, error: null })); - return localUsageSnapshot(30, workspaceRef.current ?? undefined) + return localUsageSnapshot(SNAPSHOT_DAYS, workspaceRef.current ?? undefined) .then((snapshot) => { if (requestIdRef.current !== requestId || !enabledRef.current) { return; diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index c10dfb20..f30273af 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -191,6 +191,7 @@ type LayoutNodesOptions = { usageWorkspaceOptions: Array<{ id: string; label: string }>; onUsageWorkspaceChange: (workspaceId: string | null) => void; onSelectHomeThread: (workspaceId: string, threadId: string) => void; + onOpenUsageDetails: () => void; activeWorkspace: WorkspaceInfo | null; activeParentWorkspace: WorkspaceInfo | null; worktreeLabel: string | null; @@ -643,6 +644,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { usageWorkspaceOptions={options.usageWorkspaceOptions} onUsageWorkspaceChange={options.onUsageWorkspaceChange} onSelectThread={options.onSelectHomeThread} + onOpenUsageDetails={options.onOpenUsageDetails} /> ); diff --git a/src/styles/home.css b/src/styles/home.css index fa499e52..02a67652 100644 --- a/src/styles/home.css +++ b/src/styles/home.css @@ -266,6 +266,36 @@ var(--surface-card); border: 1px solid var(--border-subtle); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); + position: relative; + isolation: isolate; +} + +.home-usage-more { + position: absolute; + top: 10px; + right: 12px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-strong); + color: var(--text-stronger); + font-size: 12px; + letter-spacing: 0.02em; + opacity: 0; + transform: translateY(-4px); + transition: opacity 140ms ease, transform 140ms ease, background 140ms ease; + z-index: 3; + pointer-events: auto; +} + +.home-usage-chart-card:hover .home-usage-more, +.home-usage-more:focus-visible { + opacity: 1; + transform: translateY(0); +} + +.home-usage-more:hover { + background: var(--surface-card); } .home-usage-chart { @@ -274,6 +304,8 @@ align-items: end; gap: 8px; height: 120px; + position: relative; + z-index: 1; } .home-usage-bar { diff --git a/src/styles/usage-details-modal.css b/src/styles/usage-details-modal.css new file mode 100644 index 00000000..7d5ca9d5 --- /dev/null +++ b/src/styles/usage-details-modal.css @@ -0,0 +1,323 @@ +.usage-overlay { + z-index: 30; +} + +.usage-window { + width: min(900px, 92vw); + height: min(620px, 84vh); +} + +.usage-content { + padding: 20px 24px; + overflow: visible; + display: flex; + flex-direction: column; +} + +.usage-panel { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; +} + +.usage-panel-scroll { + overflow-y: auto; + min-height: 0; + flex: 1; +} + +.usage-range-row { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; + align-items: center; +} + +.usage-range-popover { + position: relative; + display: inline-flex; + align-items: center; +} + +.usage-range-trigger { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-radius: 14px; + border: 1px solid var(--border-subtle); + background: var(--surface-card); + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + min-height: 40px; +} + +.usage-range-trigger.is-open { + border-color: rgba(99, 186, 255, 0.55); + box-shadow: 0 0 0 2px rgba(99, 186, 255, 0.18); +} + +.usage-range-label { + white-space: nowrap; +} + +.usage-range-chevron { + font-size: 12px; + opacity: 0.7; +} + +.usage-picker-popover { + position: absolute; + top: calc(100% + 12px); + left: 0; + z-index: 5; + min-width: 320px; +} + +.usage-picker { + padding: 18px 20px; + border-radius: 18px; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--surface-card-strong) 75%, transparent); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.12); + backdrop-filter: blur(22px) saturate(1.1); + -webkit-backdrop-filter: blur(22px) saturate(1.1); +} + +.usage-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.usage-picker-title { + font-size: 16px; + font-weight: 600; + color: var(--text-strong); +} + +.usage-picker-nav { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.usage-nav-button { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-strong); + font-size: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.usage-weekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.usage-weekdays span { + text-align: center; +} + +.usage-days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; +} + +.usage-day { + height: 36px; + border-radius: 10px; + border: 1px solid transparent; + background: var(--surface-card-muted); + color: var(--text-strong); + font-size: 14px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.usage-day.is-in-range { + background: color-mix(in srgb, rgba(99, 186, 255, 0.2), var(--surface-card)); +} + +.usage-day.is-selected { + background: rgba(124, 165, 210, 0.9); + color: #0e1218; + border-color: rgba(99, 186, 255, 0.6); +} + +.usage-day.is-blank { + pointer-events: none; + background: transparent; +} + +.usage-day:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.usage-picker-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 16px; +} + +.usage-action { + padding: 8px 14px; + border-radius: 12px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-strong); + font-size: 13px; +} + +.usage-action.primary { + border-color: rgba(99, 186, 255, 0.55); + background: color-mix(in srgb, rgba(62, 156, 255, 0.25), var(--surface-card)); +} + +.usage-quick { + display: inline-flex; + gap: 8px; +} + +.usage-quick-button { + padding: 6px 12px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-muted); + font-size: 12px; + font-weight: 600; +} + +.usage-quick-button.is-active { + border-color: rgba(99, 186, 255, 0.6); + color: var(--text-strong); + background: color-mix(in srgb, rgba(62, 156, 255, 0.2), var(--surface-card)); +} + +.usage-range-error { + font-size: 12px; + color: #ff8f8f; +} + +.usage-chart-card { + position: relative; + border-radius: 16px; + border: 1px solid var(--border-subtle); + background: var(--surface-card); + padding: 12px 8px; + overflow: visible; +} + +.usage-chart-scroll { + overflow-x: auto; + overflow-y: hidden; + padding: 6px 8px; + margin-left: -8px; + margin-right: -8px; + -webkit-overflow-scrolling: touch; +} + +.usage-chart-track { + display: flex; + gap: 10px; + align-items: flex-end; + height: 160px; + padding-top: 24px; +} + +.usage-bar { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 6px; + height: 100%; + position: relative; + width: 52px; + flex: 0 0 auto; + border-radius: 0; + background: transparent; + z-index: 1; + overflow: visible; +} + +.usage-bar-fill { + width: 100%; + border-radius: 0; + background: linear-gradient(180deg, rgba(99, 186, 255, 0.95), rgba(78, 132, 255, 0.65)); + box-shadow: 0 8px 16px rgba(30, 100, 200, 0.25); +} + +.usage-bar-label { + font-size: 10px; + color: var(--text-muted); + text-align: center; + white-space: nowrap; +} + +.usage-bar::after { + content: attr(data-value); + position: absolute; + bottom: 100%; + left: 50%; + transform: translate(-50%, -8px); + background: var(--surface-popover); + color: var(--text-stronger); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 4px 8px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; + z-index: 3; +} + +.usage-bar:hover::after { + opacity: 1; +} + +.usage-empty { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +@media (max-width: 840px) { + .usage-window { + width: min(720px, 94vw); + height: min(560px, 90vh); + } + + .settings-body { + grid-template-columns: 1fr; + } + + .settings-sidebar { + flex-direction: row; + flex-wrap: wrap; + border-right: none; + border-bottom: 1px solid var(--border-muted); + } +}