diff --git a/src/__test__/unit/chart-utils.test.ts b/src/__test__/unit/chart-utils.test.ts index ed99eff84..7efc34c91 100644 --- a/src/__test__/unit/chart-utils.test.ts +++ b/src/__test__/unit/chart-utils.test.ts @@ -1,7 +1,5 @@ -import { - calculateYAxisMax, - transformMetrics, -} from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' +import { transformMetrics } from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' +import { calculateAxisMax } from '@/lib/utils/chart' import { ClientTeamMetric } from '@/types/sandboxes.types' import { describe, expect, it } from 'vitest' @@ -14,29 +12,44 @@ describe('team-metrics-chart-utils', () => { { x: 3, y: 60 }, ] // max = 100, scale = 1.25 → 125 → snap to 150 - expect(calculateYAxisMax(data, 1.25)).toBe(150) + expect( + calculateAxisMax( + data.map((d) => d.y), + 1.25 + ) + ).toBe(150) }) it('should use custom scale factor', () => { const data = [{ x: 1, y: 100 }] // max = 100, scale = 1.5 → 150 - expect(calculateYAxisMax(data, 1.5)).toBe(150) + expect( + calculateAxisMax( + data.map((d) => d.y), + 1.5 + ) + ).toBe(150) }) it('should snap to nice values for different ranges', () => { // small values < 10 - expect(calculateYAxisMax([{ x: 1, y: 5 }], 1.5)).toBe(8) // 7.5 → ceil to 8 + expect(calculateAxisMax([5], 1.5)).toBe(8) // 7.5 → ceil to 8 // values 10-100 - expect(calculateYAxisMax([{ x: 1, y: 50 }], 1.5)).toBe(80) // 75 → snap to 80 + expect(calculateAxisMax([50], 1.5)).toBe(80) // 75 → snap to 80 // values 100-1000 - expect(calculateYAxisMax([{ x: 1, y: 500 }], 1.5)).toBe(750) // 750 → snap to 750 + expect(calculateAxisMax([500], 1.5)).toBe(750) // 750 → snap to 750 }) it('should return default for empty data', () => { const data: Array<{ x: number; y: number }> = [] - expect(calculateYAxisMax(data, 1.25)).toBe(1) + expect( + calculateAxisMax( + data.map((d) => d.y), + 1.25 + ) + ).toBe(1) }) }) diff --git a/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx b/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx index 8b80e413e..aa2d2c6ac 100644 --- a/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx +++ b/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx @@ -1,9 +1,8 @@ -import { CostCard } from '@/features/dashboard/usage/cost-card' -import { RAMCard } from '@/features/dashboard/usage/ram-card' -import { SandboxesCard } from '@/features/dashboard/usage/sandboxes-card' -import { VCPUCard } from '@/features/dashboard/usage/vcpu-card' +import { UsageChartsProvider } from '@/features/dashboard/usage/usage-charts-context' +import { UsageMetricChart } from '@/features/dashboard/usage/usage-metric-chart' import { resolveTeamIdInServerComponent } from '@/lib/utils/server' -import { CatchErrorBoundary } from '@/ui/error' +import { getUsage } from '@/server/usage/get-usage' +import ErrorBoundary from '@/ui/error' import Frame from '@/ui/frame' export default async function UsagePage({ @@ -14,44 +13,57 @@ export default async function UsagePage({ const { teamIdOrSlug } = await params const teamId = await resolveTeamIdInServerComponent(teamIdOrSlug) - return ( - - - - - ) -} + ) + } -function UsagePageContent({ teamId }: { teamId: string }) { return ( - - - - - + +
+
+ +
+ + + + +
+ +
+
+
) } diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 4a99f5b76..5e0f7c929 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -7,6 +7,9 @@ import micromatch from 'micromatch' export interface DashboardLayoutConfig { title: string type: 'default' | 'custom' + custom?: { + includeHeaderBottomStyles: boolean + } } const DASHBOARD_LAYOUT_CONFIGS: Record = { @@ -24,7 +27,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record = { }, '/dashboard/*/usage': { title: 'Usage', - type: 'default', + type: 'custom', + custom: { + includeHeaderBottomStyles: true, + }, }, '/dashboard/*/members': { title: 'Members', diff --git a/src/features/dashboard/billing/credits-content.tsx b/src/features/dashboard/billing/credits-content.tsx index c68d0ba45..ba7f5f603 100644 --- a/src/features/dashboard/billing/credits-content.tsx +++ b/src/features/dashboard/billing/credits-content.tsx @@ -1,4 +1,4 @@ -import { getUsageThroughReactCache } from '@/server/usage/get-usage' +import { getUsage } from '@/server/usage/get-usage' import ErrorTooltip from '@/ui/error-tooltip' import { AlertTriangle } from 'lucide-react' @@ -7,9 +7,10 @@ export default async function BillingCreditsContent({ }: { teamId: string }) { - const res = await getUsageThroughReactCache({ + const res = await getUsage({ teamId, }) + if (!res?.data || res.serverError) { return ( +
= { concurrent: { id: 'concurrent-sandboxes', @@ -25,21 +21,4 @@ export const CHART_CONFIGS: Record = { }, } -// echarts static configuration that never changes -export const STATIC_ECHARTS_CONFIG = { - backgroundColor: 'transparent', - animation: false, - toolbox: { - id: 'toolbox', - show: true, - iconStyle: { opacity: 0 }, - showTitle: false, - feature: { - dataZoom: { - yAxisIndex: 'none', - }, - }, - }, -} as const - export const LIVE_PADDING_MULTIPLIER = 1 diff --git a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx index 01af40f83..2586c722f 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx +++ b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx @@ -1,31 +1,26 @@ 'use client' import { useCssVars } from '@/lib/hooks/use-css-vars' +import { calculateAxisMax } from '@/lib/utils/chart' import { EChartsOption, MarkPointComponentOption, SeriesOption } from 'echarts' import ReactEChartsCore from 'echarts-for-react/lib/core' import { LineChart } from 'echarts/charts' import { AxisPointerComponent, - DataZoomComponent, + BrushComponent, GridComponent, MarkLineComponent, MarkPointComponent, - ToolboxComponent, TooltipComponent, } from 'echarts/components' import * as echarts from 'echarts/core' import { CanvasRenderer } from 'echarts/renderers' import { useTheme } from 'next-themes' import { memo, useCallback, useMemo, useRef } from 'react' -import { - CHART_CONFIGS, - LIVE_PADDING_MULTIPLIER, - STATIC_ECHARTS_CONFIG, -} from './constants' +import { CHART_CONFIGS, LIVE_PADDING_MULTIPLIER } from './constants' import type { TeamMetricsChartProps } from './types' import { buildSeriesData, - calculateYAxisMax, createLimitLine, createLiveIndicators, createSplitLineInterval, @@ -39,8 +34,7 @@ echarts.use([ LineChart, GridComponent, TooltipComponent, - ToolboxComponent, - DataZoomComponent, + BrushComponent, MarkPointComponent, MarkLineComponent, AxisPointerComponent, @@ -69,10 +63,12 @@ function TeamMetricsChart({ // use refs for callbacks to avoid re-creating chart options const onTooltipValueChangeRef = useRef(onTooltipValueChange) const onHoverEndRef = useRef(onHoverEnd) + const onZoomEndRef = useRef(onZoomEnd) // keep refs up to date onTooltipValueChangeRef.current = onTooltipValueChange onHoverEndRef.current = onHoverEnd + onZoomEndRef.current = onZoomEnd const config = CHART_CONFIGS[type] @@ -89,7 +85,6 @@ function TeamMetricsChart({ config.areaToVar, '--stroke', '--fg-tertiary', - '--bg-inverted', '--font-mono', '--accent-error-highlight', '--accent-error-bg', @@ -101,7 +96,6 @@ function TeamMetricsChart({ const areaTo = cssVars[config.areaToVar] || '#000' const stroke = cssVars['--stroke'] || '#000' const fgTertiary = cssVars['--fg-tertiary'] || '#666' - const bgInverted = cssVars['--bg-inverted'] || '#fff' const fontMono = cssVars['--font-mono'] || 'monospace' const errorHighlight = cssVars['--accent-error-highlight'] || '#f00' const errorBg = cssVars['--accent-error-bg'] || '#fee' @@ -131,29 +125,44 @@ function TeamMetricsChart({ [] ) - const handleZoom = useCallback( + const handleBrushEnd = useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any (params: any) => { - if (onZoomEnd && params.batch?.[0]) { - const { startValue, endValue } = params.batch[0] - if (startValue !== undefined && endValue !== undefined) { - onZoomEnd(Math.round(startValue), Math.round(endValue)) + const areas = params.areas + if (areas && areas.length > 0) { + const coordRange = areas[0].coordRange + + if (coordRange && coordRange.length === 2 && onZoomEndRef.current) { + const startValue = Math.round(coordRange[0]) + const endValue = Math.round(coordRange[1]) + + onZoomEndRef.current(startValue, endValue) + + // clears brush after selection + chartInstanceRef.current?.dispatchAction({ + type: 'brush', + command: 'clear', + areas: [], + }) } } }, - [onZoomEnd] + [] ) // chart ready handler - stable reference const handleChartReady = useCallback((chart: echarts.ECharts) => { chartInstanceRef.current = chart - // activate datazoom + // Activate brush selection mode chart.dispatchAction( { type: 'takeGlobalCursor', - key: 'dataZoomSelect', - dataZoomSelectActive: true, + key: 'brush', + brushOption: { + brushType: 'lineX', + brushMode: 'single', + }, }, { flush: true } ) @@ -171,7 +180,10 @@ function TeamMetricsChart({ // build complete echarts option once const option = useMemo(() => { // calculate y-axis max based on data only - const yAxisMax = calculateYAxisMax(chartData, config.yAxisScaleFactor) + const yAxisMax = calculateAxisMax( + chartData.map((d) => d.y), + config.yAxisScaleFactor + ) const seriesData = buildSeriesData(chartData) const isLive = hasLiveData(chartData) @@ -243,7 +255,20 @@ function TeamMetricsChart({ // build complete option object return { - ...STATIC_ECHARTS_CONFIG, + backgroundColor: 'transparent', + animation: false, + brush: { + brushType: 'lineX', + brushMode: 'single', + xAxisIndex: 0, + brushLink: 'all', + brushStyle: { + borderWidth: 1, + }, + outOfBrush: { + colorAlpha: 0.3, + }, + }, grid: { top: 10, right: 5, @@ -336,22 +361,6 @@ function TeamMetricsChart({ show: false, }, }, - toolbox: { - ...STATIC_ECHARTS_CONFIG.toolbox, - feature: { - dataZoom: { - yAxisIndex: 'none', - brushStyle: { - // background with 0.2 opacity - color: bgInverted, - opacity: 0.2, - borderType: 'solid', - borderWidth: 1, - borderColor: bgInverted, - }, - }, - }, - }, series, } }, [ @@ -366,7 +375,6 @@ function TeamMetricsChart({ areaTo, stroke, fgTertiary, - bgInverted, fontMono, errorHighlight, errorBg, @@ -385,7 +393,7 @@ function TeamMetricsChart({ onChartReady={handleChartReady} className={className} onEvents={{ - datazoom: handleZoom, + brushEnd: handleBrushEnd, globalout: handleGlobalOut, }} /> diff --git a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts index a526359af..96f4a259d 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts +++ b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts @@ -40,39 +40,6 @@ export function calculateAverage(data: TeamMetricDataPoint[]): number { return sum / len } -/** - * Calculate y-axis max with snapping to nice values - * Always based on data, independent of any limit lines - */ -export function calculateYAxisMax( - data: TeamMetricDataPoint[], - scaleFactor: number -): number { - if (data.length === 0) { - return 1 - } - - // find max in single pass - let max = 0 - for (let i = 0; i < data.length; i++) { - const y = data[i]!.y - if (y > max) max = y - } - - const snapToAxis = (value: number): number => { - if (value < 10) return Math.ceil(value) - if (value < 100) return Math.ceil(value / 10) * 10 - if (value < 1000) return Math.ceil(value / 50) * 50 - if (value < 10000) return Math.ceil(value / 100) * 100 - return Math.ceil(value / 1000) * 1000 - } - - const calculatedMax = snapToAxis(max * scaleFactor) - - // ensure minimum y-axis range when all data is zero - return Math.max(calculatedMax, 1) -} - /** * Check if data has recent points (for live indicator) */ diff --git a/src/features/dashboard/usage/chart-config.tsx b/src/features/dashboard/usage/chart-config.tsx deleted file mode 100644 index 68ce4cea6..000000000 --- a/src/features/dashboard/usage/chart-config.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { AreaProps } from 'recharts' -import { CategoricalChartProps } from 'recharts/types/chart/generateCategoricalChart' - -export const chartConfig = { - cost: { - label: 'Cost', - theme: { - light: 'var(--accent-main-highlight )', - dark: 'var(--accent-main-highlight )', - }, - }, - vcpu: { - label: 'vCPU Hours', - theme: { - light: 'var(--fg)', - dark: 'var(--fg)', - }, - }, - ram: { - label: 'RAM Hours', - theme: { - light: 'var(--fg)', - dark: 'var(--fg)', - }, - }, -} - -export const commonChartProps: Partial = { - margin: { top: 10, right: 25, bottom: 10, left: 10 }, -} - -export const commonAreaProps: Partial> = { - type: 'monotone', -} - -export const commonXAxisProps = { - axisLine: false, - tickLine: false, - tickMargin: 12, - fontSize: 12, - minTickGap: 30, - allowDataOverflow: true, -} as const - -export const commonYAxisProps = { - axisLine: false, - tickLine: false, - tickMargin: 12, - fontSize: 12, - width: 50, - allowDataOverflow: true, -} as const - -export const bigNumbersAxisTickFormatter = (value: number) => { - if (value >= 1000000) { - const millions = value / 1000000 - return millions % 1 === 0 - ? millions.toFixed(0) + 'M' - : millions.toFixed(1) + 'M' - } else if (value >= 1000) { - const thousands = value / 1000 - return thousands % 1 === 0 - ? thousands.toFixed(0) + 'K' - : thousands.toFixed(1) + 'K' - } - return value.toLocaleString() -} diff --git a/src/features/dashboard/usage/compute-usage-chart/index.tsx b/src/features/dashboard/usage/compute-usage-chart/index.tsx new file mode 100644 index 000000000..2eaa8bf97 --- /dev/null +++ b/src/features/dashboard/usage/compute-usage-chart/index.tsx @@ -0,0 +1,316 @@ +'use client' + +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { calculateAxisMax } from '@/lib/utils/chart' +import { EChartsOption, SeriesOption } from 'echarts' +import ReactEChartsCore from 'echarts-for-react/lib/core' +import { BarChart } from 'echarts/charts' +import { + BrushComponent, + GridComponent, + ToolboxComponent, + TooltipComponent, +} from 'echarts/components' +import * as echarts from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { useTheme } from 'next-themes' +import { memo, useCallback, useMemo, useRef } from 'react' +import { COMPUTE_CHART_CONFIGS } from '../constants' +import type { ComputeUsageChartProps } from './types' + +echarts.use([ + BarChart, + GridComponent, + TooltipComponent, + BrushComponent, + CanvasRenderer, + ToolboxComponent, +]) + +function ComputeUsageChart({ + type, + data, + className, + onHover, + onHoverEnd, + onBrushEnd, +}: ComputeUsageChartProps) { + const chartRef = useRef(null) + const chartInstanceRef = useRef(null) + const { resolvedTheme } = useTheme() + + const onHoverRef = useRef(onHover) + const onHoverEndRef = useRef(onHoverEnd) + const onBrushEndRef = useRef(onBrushEnd) + + onHoverRef.current = onHover + onHoverEndRef.current = onHoverEnd + onBrushEndRef.current = onBrushEnd + + const config = COMPUTE_CHART_CONFIGS[type] + + const cssVars = useCssVars([ + config.barColorVar, + '--stroke', + '--fg-tertiary', + '--bg-inverted', + '--font-mono', + ] as const) + + const barColor = cssVars[config.barColorVar] || '#000' + const stroke = cssVars['--stroke'] || '#000' + const fgTertiary = cssVars['--fg-tertiary'] || '#666' + const bgInverted = cssVars['--bg-inverted'] || '#fff' + const fontMono = cssVars['--font-mono'] || 'monospace' + + const handleAxisPointer = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params: any) => { + const index = params.seriesData[0].dataIndex + + if (index !== undefined && onHoverRef.current) { + onHoverRef.current(index) + } + + return '' + }, + [] + ) + + const handleGlobalOut = useCallback(() => { + if (onHoverEndRef.current) { + onHoverEndRef.current() + } + }, []) + + const handleBrushEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params: any) => { + const areas = params.areas + if (areas && areas.length > 0) { + const area = areas[0] + const coordRange = area.coordRange + + if (coordRange && coordRange.length === 2 && onBrushEndRef.current) { + const startIndex = coordRange[0] + const endIndex = coordRange[1] + + onBrushEndRef.current(startIndex, endIndex) + + chartInstanceRef.current?.dispatchAction({ + type: 'brush', + command: 'clear', + areas: [], + }) + } + } + }, + [] + ) + + const handleChartReady = useCallback((chart: echarts.ECharts) => { + chartInstanceRef.current = chart + + // activate brush selection mode + chart.dispatchAction( + { + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { + brushType: 'lineX', + brushMode: 'single', + }, + }, + { flush: true } + ) + + chart.group = 'usage' + echarts.connect('usage') + }, []) + + const option = useMemo(() => { + const yAxisMax = calculateAxisMax( + data.map((d) => d.y), + config.yAxisScaleFactor + ) + + const seriesItem: SeriesOption = { + id: config.id, + name: config.name, + type: 'bar', + itemStyle: { + color: 'transparent', + borderColor: barColor, + borderWidth: 0.3, + borderCap: 'square', + opacity: 1, + decal: { + symbol: 'line', + symbolSize: 1.5, + rotation: -Math.PI / 4, + dashArrayX: [1, 0], + dashArrayY: [2, 4], + color: barColor, + }, + }, + barCategoryGap: '15%', + emphasis: { + itemStyle: { + opacity: 1, + }, + }, + data: data.map((d) => d.y), + } + + const series: EChartsOption['series'] = [seriesItem] + + return { + backgroundColor: 'transparent', + animation: false, + toolbox: { + show: false, + }, + brush: { + brushType: 'lineX', + brushMode: 'single', + xAxisIndex: 0, + brushLink: 'all', + brushStyle: { + color: bgInverted, + opacity: 0.2, + borderType: 'solid', + borderWidth: 1, + borderColor: bgInverted, + }, + inBrush: { + opacity: 1, + }, + outOfBrush: { + color: 'transparent', + opacity: 0.6, + }, + }, + grid: { + top: 10, + bottom: 20, + left: 0, + right: 0, + }, + xAxis: [ + { + type: 'category', + data: data.map((d) => d.x), + axisPointer: { + show: true, + type: 'line', + lineStyle: { color: stroke, type: 'solid', width: 1 }, + snap: false, + label: { + backgroundColor: 'transparent', + // only to get currently axis value + formatter: handleAxisPointer, + }, + }, + axisLine: { + show: true, + lineStyle: { color: stroke }, + }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + showMinLabel: true, + showMaxLabel: true, + alignMinLabel: 'center', + alignMaxLabel: 'center', + formatter: (value: string, index: number) => { + const isStartOfPeriod = index === 0 + const isEndOfPeriod = index === data.length - 1 + + if (isStartOfPeriod || isEndOfPeriod) { + return value + } + return '' + }, + }, + }, + ], + yAxis: [ + { + type: 'value', + min: 0, + max: yAxisMax, + interval: yAxisMax / 2, + axisLine: { + show: false, + }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: stroke, type: 'dashed' }, + interval: 0, + }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + interval: 0, + formatter: config.yAxisFormatter, + }, + axisPointer: { + show: false, + }, + }, + ], + series, + } + }, [ + data, + config, + barColor, + bgInverted, + stroke, + fgTertiary, + fontMono, + handleAxisPointer, + ]) + + return ( + + ) +} + +const MemoizedComputeUsageChart = memo( + ComputeUsageChart, + (prevProps, nextProps) => { + return ( + prevProps.type === nextProps.type && + prevProps.data === nextProps.data && + prevProps.samplingMode === nextProps.samplingMode && + prevProps.className === nextProps.className + // exclude onHover and onHoverEnd - they're handled via refs + ) + } +) + +MemoizedComputeUsageChart.displayName = 'ComputeUsageChart' + +export default MemoizedComputeUsageChart diff --git a/src/features/dashboard/usage/compute-usage-chart/types.ts b/src/features/dashboard/usage/compute-usage-chart/types.ts new file mode 100644 index 000000000..5236d3ef4 --- /dev/null +++ b/src/features/dashboard/usage/compute-usage-chart/types.ts @@ -0,0 +1,30 @@ +import { ComputUsageAxisPoint, SamplingMode } from '../types' + +export type ComputeChartType = 'cost' | 'ram' | 'vcpu' | 'sandboxes' + +export interface ComputeUsageChartProps { + startTime?: number + endTime?: number + type: ComputeChartType + data: ComputUsageAxisPoint[] + samplingMode: SamplingMode + className?: string + onHover?: (index: number) => void + onHoverEnd?: () => void + onBrushEnd?: (startIndex: number, endIndex: number) => void +} + +export interface ComputeDataPoint { + x: number // timestamp + y: number // value + label: string // formatted label for display +} + +export interface ComputeChartConfig { + id: string + name: string + valueKey: 'total_cost' | 'ram_gb_hours' | 'vcpu_hours' | 'count' + barColorVar: string + yAxisScaleFactor: number + yAxisFormatter: (value: number) => string +} diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts new file mode 100644 index 000000000..612789f79 --- /dev/null +++ b/src/features/dashboard/usage/constants.ts @@ -0,0 +1,181 @@ +import { formatAxisNumber } from '@/lib/utils/formatting' +import { TimeRangePreset } from '@/ui/time-range-presets' +import { + ComputeChartConfig, + ComputeChartType, +} from './compute-usage-chart/types' + +/** + * Initial timeframe prefix in milliseconds (3 days) + * Used to set the default time range for displaying usage data + */ +export const INITIAL_TIMEFRAME_DATA_POINT_PREFIX_MS = 3 * 24 * 60 * 60 * 1000 + +/** + * Default fallback range in milliseconds (30 days) + * Used when no data is available to determine the appropriate range + */ +export const INITIAL_TIMEFRAME_FALLBACK_RANGE_MS = 30 * 24 * 60 * 60 * 1000 + +/** + * Threshold in days for switching between sampling modes + */ +export const HOURLY_SAMPLING_THRESHOLD_DAYS = 3 +export const WEEKLY_SAMPLING_THRESHOLD_DAYS = 60 + +export const TIME_RANGE_PRESETS: TimeRangePreset[] = [ + { + id: 'last-7-days', + label: 'Last 7 days', + shortcut: '7D', + getValue: () => { + const end = new Date() + end.setHours(23, 59, 59, 999) + const start = new Date(end) + start.setDate(start.getDate() - 6) + start.setHours(0, 0, 0, 0) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'last-14-days', + label: 'Last 14 days', + shortcut: '14D', + getValue: () => { + const end = new Date() + end.setHours(23, 59, 59, 999) + const start = new Date(end) + start.setDate(start.getDate() - 13) + start.setHours(0, 0, 0, 0) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'last-30-days', + label: 'Last 30 days', + shortcut: '30D', + getValue: () => { + const end = new Date() + end.setHours(23, 59, 59, 999) + const start = new Date(end) + start.setDate(start.getDate() - 29) + start.setHours(0, 0, 0, 0) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'last-90-days', + label: 'Last 90 days', + shortcut: '90D', + getValue: () => { + const end = new Date() + end.setHours(23, 59, 59, 999) + const start = new Date(end) + start.setDate(start.getDate() - 89) + start.setHours(0, 0, 0, 0) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'this-month', + label: 'This month', + getValue: () => { + const now = new Date() + const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0) + const end = new Date( + now.getFullYear(), + now.getMonth() + 1, + 0, + 23, + 59, + 59, + 999 + ) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'last-month', + label: 'Last month', + getValue: () => { + const now = new Date() + const start = new Date( + now.getFullYear(), + now.getMonth() - 1, + 1, + 0, + 0, + 0, + 0 + ) + const end = new Date( + now.getFullYear(), + now.getMonth(), + 0, + 23, + 59, + 59, + 999 + ) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'this-year', + label: 'This year', + getValue: () => { + const now = new Date() + const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999) + return { start: start.getTime(), end: end.getTime() } + }, + }, + { + id: 'last-year', + label: 'Last year', + getValue: () => { + const now = new Date() + const start = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 999) + return { start: start.getTime(), end: end.getTime() } + }, + }, +] + +export const COMPUTE_CHART_CONFIGS: Record< + ComputeChartType, + ComputeChartConfig +> = { + sandboxes: { + id: 'sandboxes-usage', + name: 'Sandboxes', + valueKey: 'count', + barColorVar: '--accent-main-highlight', + yAxisScaleFactor: 1.8, + yAxisFormatter: formatAxisNumber, + }, + cost: { + id: 'cost-usage', + name: 'Cost', + valueKey: 'total_cost', + barColorVar: '--accent-positive-highlight', + yAxisScaleFactor: 1.8, + yAxisFormatter: (value: number) => `$${formatAxisNumber(value)}`, + }, + ram: { + id: 'ram-usage', + name: 'RAM Hours', + valueKey: 'ram_gb_hours', + barColorVar: '--bg-inverted', + yAxisScaleFactor: 1.8, + yAxisFormatter: formatAxisNumber, + }, + vcpu: { + id: 'vcpu-usage', + name: 'vCPU Hours', + valueKey: 'vcpu_hours', + barColorVar: '--bg-inverted', + yAxisScaleFactor: 1.8, + yAxisFormatter: formatAxisNumber, + }, +} diff --git a/src/features/dashboard/usage/cost-card.tsx b/src/features/dashboard/usage/cost-card.tsx deleted file mode 100644 index faacf8965..000000000 --- a/src/features/dashboard/usage/cost-card.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { l } from '@/lib/clients/logger/logger' -import { getUsageThroughReactCache } from '@/server/usage/get-usage' -import { ChartPlaceholder } from '@/ui/chart-placeholder' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' -import { Suspense } from 'react' -import { CostChart } from './cost-chart' - -async function CostCardContentResolver({ teamId }: { teamId: string }) { - const result = await getUsageThroughReactCache({ teamId }) - - if (!result?.data || result.serverError || result.validationErrors) { - const errorMessage = - result?.serverError || - (Array.isArray(result?.validationErrors?.formErrors) && - result?.validationErrors?.formErrors[0]) || - 'Could not load cost usage data.' - - l.error({ - key: 'cost_card:server_error', - error: result?.serverError, - team_id: teamId, - context: { - errorMessage, - }, - }) - - throw new Error(errorMessage) - } - - const dataFromAction = result.data - - const latestCost = - dataFromAction.compute?.[dataFromAction.compute.length - 1]?.total_cost - - if (!latestCost) { - return ( - - ) - } - - return ( - <> -
-

- $ - {new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(latestCost || 0)} -

- this month -
- - - ) -} - -export function CostCard({ - teamId, - className, -}: { - teamId: string - className?: string -}) { - return ( - - - Usage Costs - - Total cost of all resources per month. - - - - - } - > - - - - - ) -} diff --git a/src/features/dashboard/usage/cost-chart.tsx b/src/features/dashboard/usage/cost-chart.tsx deleted file mode 100644 index 1d4a929b9..000000000 --- a/src/features/dashboard/usage/cost-chart.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client' - -import { UsageData } from '@/server/usage/types' -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/ui/primitives/chart' -import { useMemo } from 'react' -import { Area, AreaChart, XAxis, YAxis } from 'recharts' -import { - bigNumbersAxisTickFormatter, - chartConfig, - commonChartProps, - commonXAxisProps, - commonYAxisProps, -} from './chart-config' - -export function CostChart({ data }: { data: UsageData['compute'] }) { - const chartData = useMemo(() => { - return data.map((item) => ({ - x: `${item.month}/${item.year}`, - y: item.total_cost, - })) - }, [data]) - - return ( - - - - - - - - - - `$${bigNumbersAxisTickFormatter(value)}`} - /> - { - if (!active || !payload || !payload.length || !payload[0]?.payload) - return null - - return ( - label} - formatter={(value, name, item) => [ - - {Number(value).toFixed(2).toLocaleString()} - , - `$`, - ]} - payload={payload} - active={active} - /> - ) - }} - /> - - - - ) -} diff --git a/src/features/dashboard/usage/display-utils.ts b/src/features/dashboard/usage/display-utils.ts new file mode 100644 index 000000000..70d7d1e9c --- /dev/null +++ b/src/features/dashboard/usage/display-utils.ts @@ -0,0 +1,239 @@ +import { + formatDateRange, + formatDay, + formatHour, + formatNumber, +} from '@/lib/utils/formatting' +import { + determineSamplingMode, + normalizeToEndOfSamplingPeriod, + normalizeToStartOfSamplingPeriod, +} from './sampling-utils' +import { + DisplayValue, + SampledDataPoint, + SamplingMode, + Timeframe, +} from './types' + +/** + * Format a timestamp to a human-readable date using Intl.DateTimeFormat + * @param timestamp - Unix timestamp in milliseconds + * @returns Formatted date string (e.g., "Jan 15, 2024") + */ +export function formatAxisDate( + timestamp: number, + samplingMode: SamplingMode +): string { + switch (samplingMode) { + case 'hourly': + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + hour12: true, + }).format(new Date(timestamp)) + default: + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(new Date(timestamp)) + } +} + +/** + * Formats display values for a specific sampled data point (when hovering) + */ +export function formatHoveredValues( + sandboxCount: number, + cost: number, + vcpuHours: number, + ramGibHours: number, + timestamp: number, + timeframe: Timeframe +): { + sandboxes: DisplayValue + cost: DisplayValue + vcpu: DisplayValue + ram: DisplayValue +} { + let timestampLabel: string + let label: string + const samplingMode = determineSamplingMode(timeframe) + + // edge bucket keys match the hour containing the timeframe boundary + const normalizedStartTimestamp = normalizeToStartOfSamplingPeriod( + timeframe.start, + 'hourly' + ) + const normalizedEndTimestamp = normalizeToStartOfSamplingPeriod( + timeframe.end, + 'hourly' + ) + + const timestampIsAtStartEdge = timestamp === normalizedStartTimestamp + const timestampIsAtEndEdge = timestamp === normalizedEndTimestamp + + switch (samplingMode) { + case 'hourly': + timestampLabel = formatHour(timestamp) + label = 'at' + break + + case 'daily': + if (timestampIsAtStartEdge && timestampIsAtEndEdge) { + // both edges in same bucket - show precise range + timestampLabel = `${formatHour(normalizedStartTimestamp)} - ${formatHour(normalizedEndTimestamp)}` + label = 'during' + } else if (timestampIsAtStartEdge) { + // partial day at start - show from start hour to end of day + const endOfDay = new Date(timestamp) + endOfDay.setHours(23, 59, 59, 999) + timestampLabel = `${formatHour(timestamp)} - end of ${formatDay(timestamp)}` + label = 'during' + } else if (timestampIsAtEndEdge) { + // partial day at end - show from start of day to end hour + const startOfDay = new Date(timestamp) + startOfDay.setHours(0, 0, 0, 0) + timestampLabel = `${formatDay(timestamp)} - ${formatHour(timestamp)}` + label = 'during' + } else { + timestampLabel = formatDay(timestamp) + label = 'on' + } + break + + case 'weekly': + if (timestampIsAtStartEdge && timestampIsAtEndEdge) { + // both edges in same bucket - show precise range + timestampLabel = `${formatHour(normalizedStartTimestamp)} - ${formatHour(normalizedEndTimestamp)}` + label = 'during' + } else if (timestampIsAtStartEdge) { + // partial week at start - show from start hour to end of week + const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly') + timestampLabel = `${formatHour(timestamp)} - ${formatDay(weekEnd)}` + label = 'during' + } else if (timestampIsAtEndEdge) { + // partial week at end - show from start of week to end hour + const weekStart = normalizeToStartOfSamplingPeriod(timestamp, 'weekly') + timestampLabel = `${formatDay(weekStart)} - ${formatHour(timestamp)}` + label = 'during' + } else { + const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly') + timestampLabel = formatDateRange(timestamp, weekEnd) + label = 'during week' + } + break + } + + return { + sandboxes: { + displayValue: formatNumber(sandboxCount), + label, + timestamp: timestampLabel, + }, + cost: { + displayValue: `$${cost.toFixed(2)}`, + label, + timestamp: timestampLabel, + }, + vcpu: { + displayValue: formatNumber(vcpuHours), + label, + timestamp: timestampLabel, + }, + ram: { + displayValue: formatNumber(ramGibHours), + label, + timestamp: timestampLabel, + }, + } +} + +/** + * Formats display values for total aggregates (no hover) + */ +export function formatTotalValues(totals: { + sandboxes: number + cost: number + vcpu: number + ram: number +}): { + sandboxes: DisplayValue + cost: DisplayValue + vcpu: DisplayValue + ram: DisplayValue +} { + return { + sandboxes: { + displayValue: formatNumber(totals.sandboxes), + label: 'total over range', + timestamp: null, + }, + cost: { + displayValue: `$${totals.cost.toFixed(2)}`, + label: 'total over range', + timestamp: null, + }, + vcpu: { + displayValue: formatNumber(totals.vcpu), + label: 'total over range', + timestamp: null, + }, + ram: { + displayValue: formatNumber(totals.ram), + label: 'total over range', + timestamp: null, + }, + } +} + +/** + * Formats display values for empty state (no data in range) + */ +export function formatEmptyValues(): { + sandboxes: DisplayValue + cost: DisplayValue + vcpu: DisplayValue + ram: DisplayValue +} { + return { + sandboxes: { + displayValue: '0', + label: 'no data in range', + timestamp: null, + }, + cost: { + displayValue: '$0.00', + label: 'no data in range', + timestamp: null, + }, + vcpu: { + displayValue: '0', + label: 'no data in range', + timestamp: null, + }, + ram: { + displayValue: '0', + label: 'no data in range', + timestamp: null, + }, + } +} + +export function calculateTotals(sampledData: SampledDataPoint[]): { + sandboxes: number + cost: number + vcpu: number + ram: number +} { + return sampledData.reduce( + (acc, point) => ({ + sandboxes: acc.sandboxes + point.sandboxCount, + cost: acc.cost + point.cost, + vcpu: acc.vcpu + point.vcpuHours, + ram: acc.ram + point.ramGibHours, + }), + { sandboxes: 0, cost: 0, vcpu: 0, ram: 0 } + ) +} diff --git a/src/features/dashboard/usage/ram-card.tsx b/src/features/dashboard/usage/ram-card.tsx deleted file mode 100644 index 8d73e7e62..000000000 --- a/src/features/dashboard/usage/ram-card.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { l } from '@/lib/clients/logger/logger' -import { getUsageThroughReactCache } from '@/server/usage/get-usage' -import { ChartPlaceholder } from '@/ui/chart-placeholder' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' -import { Suspense } from 'react' -import { RAMChart } from './ram-chart' - -async function RAMCardContentResolver({ teamId }: { teamId: string }) { - const result = await getUsageThroughReactCache({ teamId }) - - if (!result?.data || result.serverError || result.validationErrors) { - const errorMessage = - result?.serverError || - (Array.isArray(result?.validationErrors?.formErrors) && - result?.validationErrors?.formErrors[0]) || - 'Could not load RAM usage data.' - - l.error({ - key: 'ram_card:server_error', - error: result?.serverError, - team_id: teamId, - context: { - errorMessage, - }, - }) - - throw new Error(errorMessage) - } - - const latestRAM = - result.data.compute?.[result.data.compute.length - 1]?.ram_gb_hours - - if (!latestRAM) { - return ( - - ) - } - - return ( - <> -
-

- {new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(latestRAM || 0)} -

- GB-hours this month -
- - - ) -} - -export function RAMCard({ - teamId, - className, -}: { - teamId: string - className?: string -}) { - return ( - - - RAM Hours - - Memory usage duration across all sandboxes per month. - - - - - } - > - - - - - ) -} diff --git a/src/features/dashboard/usage/ram-chart.tsx b/src/features/dashboard/usage/ram-chart.tsx deleted file mode 100644 index c44728bb8..000000000 --- a/src/features/dashboard/usage/ram-chart.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client' - -import { UsageData } from '@/server/usage/types' -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/ui/primitives/chart' -import { useMemo } from 'react' -import { Area, AreaChart, XAxis, YAxis } from 'recharts' -import { - bigNumbersAxisTickFormatter, - chartConfig, - commonChartProps, - commonXAxisProps, - commonYAxisProps, -} from './chart-config' - -interface RAMChartProps { - data: UsageData['compute'] -} - -export function RAMChart({ data }: RAMChartProps) { - const chartData = useMemo(() => { - return data.map((item) => ({ - x: `${item.month}/${item.year}`, - y: item.ram_gb_hours, - })) - }, [data]) - - return ( - - - - - - - - - - - { - if (!active || !payload || !payload.length || !payload[0]?.payload) - return null - - const dataPoint = payload[0]!.payload // Actual data for the bar - let dateRangeString = '' - const dateFormatOptions: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - } - - if (dataPoint.year !== undefined && dataPoint.month !== undefined) { - const startDate = new Date(dataPoint.year, dataPoint.month, 1) - const endDate = new Date(dataPoint.year, dataPoint.month + 1, 0) // 0 day of next month is last day of current month - dateRangeString = `(${startDate.toLocaleDateString(undefined, dateFormatOptions)} - ${endDate.toLocaleDateString(undefined, dateFormatOptions)})` - } - - return ( - label} - formatter={(value, name, item) => [ - - {Number(value).toLocaleString()} - , - `RAM Hours`, - ]} - payload={payload} - active={active} - /> - ) - }} - /> - - - - ) -} diff --git a/src/features/dashboard/usage/sampling-utils.ts b/src/features/dashboard/usage/sampling-utils.ts new file mode 100644 index 000000000..9eca7f46f --- /dev/null +++ b/src/features/dashboard/usage/sampling-utils.ts @@ -0,0 +1,170 @@ +import { UsageResponse } from '@/types/billing' +import { startOfISOWeek } from 'date-fns' +import { + HOURLY_SAMPLING_THRESHOLD_DAYS, + WEEKLY_SAMPLING_THRESHOLD_DAYS, +} from './constants' +import { SampledDataPoint, SamplingMode, Timeframe } from './types' + +export function determineSamplingMode(timeframe: Timeframe): SamplingMode { + const rangeDays = (timeframe.end - timeframe.start) / (24 * 60 * 60 * 1000) + + if (rangeDays <= HOURLY_SAMPLING_THRESHOLD_DAYS) { + return 'hourly' + } + + if (rangeDays >= WEEKLY_SAMPLING_THRESHOLD_DAYS) { + return 'weekly' + } + + return 'daily' +} + +export function getSamplingModeStepMs(samplingMode: SamplingMode): number { + switch (samplingMode) { + case 'hourly': + return 60 * 60 * 1000 + case 'daily': + return 24 * 60 * 60 * 1000 + case 'weekly': + return 7 * 24 * 60 * 60 * 1000 + } +} + +export function processUsageData( + hourlyData: UsageResponse['hour_usages'], + timeframe: Timeframe +): SampledDataPoint[] { + if (!hourlyData || hourlyData.length === 0) { + return [] + } + + return aggregateHours(hourlyData, timeframe) +} + +export function normalizeToStartOfSamplingPeriod( + timestamp: number, + mode: SamplingMode +): number { + const date = new Date(timestamp) + + switch (mode) { + case 'hourly': + return date.setMinutes(0, 0, 0) + + case 'daily': + date.setMinutes(0, 0, 0) + return date.setHours(0, 0, 0, 0) + + case 'weekly': + date.setMinutes(0, 0, 0) + date.setHours(0, 0, 0, 0) + + return startOfISOWeek(date).getTime() + } +} + +export function normalizeToEndOfSamplingPeriod( + timestamp: number, + mode: SamplingMode +): number { + const date = new Date(timestamp) + + switch (mode) { + case 'hourly': + date.setMinutes(59, 59, 999) + return date.getTime() + + case 'daily': + date.setHours(23, 59, 59, 999) + return date.getTime() + + case 'weekly': + const weekStart = startOfISOWeek(date) + weekStart.setDate(weekStart.getDate() + 6) + weekStart.setHours(23, 59, 59, 999) + return weekStart.getTime() + } +} + +/** + * Aggregates hourly usage data into sampling periods (hourly, daily, or weekly). + * For daily and weekly modes, partial buckets at the start and end of the timeframe + * are truncated to align with the timeframe boundaries, normalized to hourly timestamps. + */ +function aggregateHours( + hourlyData: UsageResponse['hour_usages'], + timeframe: Timeframe +): SampledDataPoint[] { + const samplingMode = determineSamplingMode(timeframe) + + // if sampling mode is hourly, return the hourly data + if (samplingMode === 'hourly') { + return hourlyData.map((d) => ({ + timestamp: d.timestamp, + sandboxCount: d.sandbox_count, + cost: d.price_for_ram + d.price_for_cpu, + vcpuHours: d.cpu_hours, + ramGibHours: d.ram_gib_hours, + })) + } + + // pre-calculate sampling period boundaries for edge bucket detection + const timeframeStartPeriod = normalizeToStartOfSamplingPeriod( + timeframe.start, + samplingMode + ) + const timeframeEndPeriod = normalizeToStartOfSamplingPeriod( + timeframe.end, + samplingMode + ) + + function createBucketKey(timestamp: number): number { + const timestampPeriodStart = normalizeToStartOfSamplingPeriod( + timestamp, + samplingMode + ) + + // // check if timestamp is in the same sampling period as timeframe.start + // if (timestampPeriodStart === timeframeStartPeriod) { + // return normalizeToStartOfSamplingPeriod(timeframe.start, 'hourly') + // } + + // // check if timestamp is in the same sampling period as timeframe.end + // if (timestampPeriodStart === timeframeEndPeriod) { + // return normalizeToStartOfSamplingPeriod(timeframe.end, 'hourly') + // } + + // middle bucket: use full sampling period + return timestampPeriodStart + } + + // group data by timestamp buckets + const bucketMap = new Map() + + hourlyData.forEach((h) => { + const bucketTimestampKey = createBucketKey(h.timestamp) + + // get or create bucket + const existing = bucketMap.get(bucketTimestampKey) + if (existing) { + existing.sandboxCount += h.sandbox_count + existing.cost += h.price_for_ram + h.price_for_cpu + existing.vcpuHours += h.cpu_hours + existing.ramGibHours += h.ram_gib_hours + } else { + bucketMap.set(bucketTimestampKey, { + timestamp: bucketTimestampKey, + sandboxCount: h.sandbox_count, + cost: h.price_for_ram + h.price_for_cpu, + vcpuHours: h.cpu_hours, + ramGibHours: h.ram_gib_hours, + }) + } + }) + + // convert to sorted array + return Array.from(bucketMap.values()).sort( + (a, b) => a.timestamp - b.timestamp + ) +} diff --git a/src/features/dashboard/usage/sandboxes-card.tsx b/src/features/dashboard/usage/sandboxes-card.tsx deleted file mode 100644 index 9d7b70b3d..000000000 --- a/src/features/dashboard/usage/sandboxes-card.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { getUsageThroughReactCache } from '@/server/usage/get-usage' -import { ChartPlaceholder } from '@/ui/chart-placeholder' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' -import { Suspense } from 'react' -import { SandboxesChart } from './sandboxes-chart' - -export function SandboxesCard({ - className, - teamId, -}: { - className?: string - teamId: string -}) { - return ( - - - Sandboxes Usage - - Total sandboxes started and resumed over time. - - - - - } - > - - - - - ) -} - -async function SandboxesStartedContent({ teamId }: { teamId: string }) { - const response = await getUsageThroughReactCache({ teamId }) - - if (response?.serverError || response?.validationErrors || !response?.data) { - throw new Error(response?.serverError || 'Failed to load usage') - } - - // This rerenders the chart placeholder, which makes it look weird when it transitions from loading to empty in some cases. - // TODO: Fix this. - if (response.data.sandboxes.length === 0) { - return ( - No started sandbox data found.

} - classNames={{ - container: 'h-60', - }} - /> - ) - } - - return ( - - ) -} diff --git a/src/features/dashboard/usage/sandboxes-chart.tsx b/src/features/dashboard/usage/sandboxes-chart.tsx deleted file mode 100644 index 038845c32..000000000 --- a/src/features/dashboard/usage/sandboxes-chart.tsx +++ /dev/null @@ -1,376 +0,0 @@ -'use client' - -import { cn } from '@/lib/utils' -import { UsageData } from '@/server/usage/types' -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/ui/primitives/chart' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/ui/primitives/select' -import { useMemo, useRef, useState } from 'react' -import { Bar, BarChart, BarProps, XAxis, YAxis } from 'recharts' -import { - bigNumbersAxisTickFormatter, - chartConfig, - commonChartProps, - commonXAxisProps, - commonYAxisProps, -} from './chart-config' - -const getWeek = (date: Date) => { - const d = new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - ) - const dayNum = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - dayNum) - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)) - return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7) -} - -// Helper function to get the start and end date of an ISO week -const getDateRangeOfWeek = (weekNumber: number, year: number) => { - const simple = new Date(year, 0, 1 + (weekNumber - 1) * 7) - const dow = simple.getDay() - const ISOweekStart = simple - if (dow <= 4) { - ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1) - } else { - ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay()) - } - const ISOweekEnd = new Date(ISOweekStart) - ISOweekEnd.setDate(ISOweekStart.getDate() + 6) - return { start: ISOweekStart, end: ISOweekEnd } -} - -export type GroupingOption = 'week' | 'month' - -// Helper to iterate through months in a range -function* iterateMonths(startDate: Date, endDate: Date) { - const currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1) - const finalMonthDate = new Date(endDate.getFullYear(), endDate.getMonth(), 1) - - while (currentDate <= finalMonthDate) { - yield { - year: currentDate.getFullYear(), - month: currentDate.getMonth(), // 0-indexed - date: new Date(currentDate), - } - currentDate.setMonth(currentDate.getMonth() + 1) - } -} - -const CustomBarShape = (props: BarProps) => { - const { width, height, fill } = props - const x = Number(props.x) - const y = Number(props.y) - - if ( - width === undefined || - width <= 0 || - height === undefined || - height <= 0 || - x === undefined || - y === undefined - ) { - return null - } - - const desiredRadius = 1 - const r = Math.min(desiredRadius, width / 2, height) - - const borderColor = 'var(--color-accent-info-highlight)' - const strokeWidth = 1 - - const fillPath = ` - M ${x},${y + r} - A ${r},${r} 0 0 1 ${x + r},${y} - L ${x + width - r},${y} - A ${r},${r} 0 0 1 ${x + width},${y + r} - L ${x + width},${y + height} - L ${x},${y + height} - Z - ` - - const borderPath = ` - M ${x},${y + height} - L ${x},${y + r} - A ${r},${r} 0 0 1 ${x + r},${y} - L ${x + width - r},${y} - A ${r},${r} 0 0 1 ${x + width},${y + r} - L ${x + width},${y + height} - ` - - return ( - - - - - ) -} - -// --- Utility Types --- -type MonthChartPoint = { - x: string - y: number - year: number - month: number - originalDate: Date -} -type WeekChartPoint = { - x: string - y: number - year: number - week: number - originalDate: Date -} - -// --- Date Helpers (UTC) --- -function getUTCMonthKey(date: Date) { - return `${date.getUTCFullYear()}-${date.getUTCMonth()}` -} -function getUTCWeekKey(date: Date) { - return `${date.getUTCFullYear()}-W${getWeek(date)}` -} - -// --- Aggregation Helpers --- -function aggregateSandboxesByMonth( - data: { date: Date | string; count: number }[] -): MonthChartPoint[] { - const map: Record = {} - data.forEach(({ date, count }) => { - const d = new Date(date) - const key = getUTCMonthKey(d) - if (!map[key]) { - map[key] = { - x: d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), - y: 0, - year: d.getUTCFullYear(), - month: d.getUTCMonth(), - originalDate: d, - } - } - map[key].y += count - }) - return Object.values(map).sort((a, b) => - a.year === b.year ? a.month - b.month : a.year - b.year - ) -} - -function aggregateSandboxesByWeek( - data: { date: Date | string; count: number }[] -): WeekChartPoint[] { - const map: Record = {} - data.forEach(({ date, count }) => { - const d = new Date(date) - const year = d.getUTCFullYear() - const week = getWeek(d) - const key = getUTCWeekKey(d) - if (!map[key]) { - map[key] = { - x: `W${week} ${year}`, - y: 0, - year, - week, - originalDate: d, - } - } - map[key].y += count - }) - return Object.values(map).sort((a, b) => - a.year === b.year ? a.week - b.week : a.year - b.year - ) -} - -interface SandboxesChartProps { - data: UsageData['sandboxes'] - classNames?: { - container?: string - } -} - -export function SandboxesChart({ data, classNames }: SandboxesChartProps) { - const [grouping, setGrouping] = useState('month') - const chartContainerRef = useRef(null) - - // Memoized chart data - const chartData = useMemo(() => { - if (!data?.length) return [] - return grouping === 'month' - ? aggregateSandboxesByMonth(data) - : aggregateSandboxesByWeek(data) - }, [data, grouping]) - - // Memoized totals - const totalSandboxes = useMemo( - () => data.reduce((sum, d) => sum + d.count, 0), - [data] - ) - const totalThisMonth = useMemo(() => { - const now = new Date() - const thisMonth = now.getUTCMonth() - const thisYear = now.getUTCFullYear() - return data.reduce((sum, d) => { - const date = new Date(d.date) - return date.getUTCMonth() === thisMonth && - date.getUTCFullYear() === thisYear - ? sum + d.count - : sum - }, 0) - }, [data]) - - return ( - <> -
-
-

- {totalSandboxes.toLocaleString()} -

-
- - total sandboxes, grouped by - - -
-
-
-

- {totalThisMonth.toLocaleString()} -

-

- sandboxes this month -

-
-
- - - - - - - - - - { - if ( - !active || - !payload || - !payload.length || - !payload[0]?.payload - ) - return null - - const dataPoint = payload[0].payload // Actual data for the bar - let dateRangeString = '' - const dateFormatOptions: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - } - - if ( - grouping === 'month' && - dataPoint.year !== undefined && - dataPoint.month !== undefined - ) { - const startDate = new Date( - Date.UTC(dataPoint.year, dataPoint.month, 1) - ) - const endDate = new Date( - Date.UTC(dataPoint.year, dataPoint.month + 1, 0) - ) - dateRangeString = `(${startDate.toLocaleDateString(undefined, dateFormatOptions)} - ${endDate.toLocaleDateString(undefined, dateFormatOptions)})` - } else if ( - grouping === 'week' && - dataPoint.year !== undefined && - dataPoint.week !== undefined - ) { - const { start, end } = getDateRangeOfWeek( - dataPoint.week, - dataPoint.year - ) - dateRangeString = `(${start.toLocaleDateString(undefined, dateFormatOptions)} - ${end.toLocaleDateString(undefined, dateFormatOptions)})` - } - - return ( - `${label} ${dateRangeString}`} - formatter={(value, name, item) => [ - - {Number(value).toLocaleString()} - , - `Sandboxes Started`, - ]} - payload={payload} - active={active} - /> - ) - }} - /> - } - /> - - - - ) -} diff --git a/src/features/dashboard/usage/types.ts b/src/features/dashboard/usage/types.ts new file mode 100644 index 000000000..d63cbeb44 --- /dev/null +++ b/src/features/dashboard/usage/types.ts @@ -0,0 +1,42 @@ +export type SamplingMode = 'hourly' | 'daily' | 'weekly' + +export interface ComputUsageAxisPoint { + x: string + y: number +} + +export interface Timeframe { + start: number + end: number +} + +export interface DisplayValue { + displayValue: string + label: string + timestamp: string | null +} + +export interface MetricTotals { + sandboxes: number + cost: number + vcpu: number + ram: number +} + +export interface ComputeUsageSeriesData { + sandboxes: ComputUsageAxisPoint[] + cost: ComputUsageAxisPoint[] + vcpu: ComputUsageAxisPoint[] + ram: ComputUsageAxisPoint[] +} + +/** + * Sampled data point - represents aggregated usage for a time period + */ +export interface SampledDataPoint { + timestamp: number // start of the period (day or week) + sandboxCount: number + cost: number + vcpuHours: number + ramGibHours: number +} diff --git a/src/features/dashboard/usage/usage-charts-context.tsx b/src/features/dashboard/usage/usage-charts-context.tsx new file mode 100644 index 000000000..d5964af42 --- /dev/null +++ b/src/features/dashboard/usage/usage-charts-context.tsx @@ -0,0 +1,271 @@ +'use client' + +import { fillTimeSeriesWithEmptyPoints } from '@/lib/utils/time-series' +import { UsageResponse } from '@/types/billing' +import { parseAsInteger, useQueryStates } from 'nuqs' +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react' +import { + INITIAL_TIMEFRAME_DATA_POINT_PREFIX_MS, + INITIAL_TIMEFRAME_FALLBACK_RANGE_MS, +} from './constants' +import { + calculateTotals, + formatAxisDate, + formatEmptyValues, + formatHoveredValues, + formatTotalValues, +} from './display-utils' +import { + determineSamplingMode, + normalizeToEndOfSamplingPeriod, + processUsageData, +} from './sampling-utils' +import { + ComputeUsageSeriesData, + DisplayValue, + MetricTotals, + SamplingMode, + Timeframe, +} from './types' + +interface UsageChartsContextValue { + displayedData: ComputeUsageSeriesData + timeframe: Timeframe + setTimeframe: (start: number, end: number) => void + setHoveredIndex: (index: number | null) => void + onBrushEnd: (startIndex: number, endIndex: number) => void + totals: MetricTotals + samplingMode: SamplingMode + displayValues: { + sandboxes: DisplayValue + cost: DisplayValue + vcpu: DisplayValue + ram: DisplayValue + } +} + +const UsageChartsContext = createContext( + undefined +) + +interface UsageChartsProviderProps { + data: UsageResponse + children: ReactNode +} + +const timeframeParams = { + start: parseAsInteger, + end: parseAsInteger, +} + +export function UsageChartsProvider({ + data, + children, +}: UsageChartsProviderProps) { + // MUTABLE STATE + + const [params, setParams] = useQueryStates(timeframeParams, { + history: 'push', + shallow: true, + }) + + const [hoveredIndex, setHoveredIndex] = useState(null) + + // DERIVED STATE + + const defaultRange = useMemo(() => { + const now = Date.now() + + if (data.hour_usages && data.hour_usages.length > 0) { + const firstTimestamp = data.hour_usages[0]!.timestamp + const start = firstTimestamp - INITIAL_TIMEFRAME_DATA_POINT_PREFIX_MS + return { start, end: now } + } + + return { start: now - INITIAL_TIMEFRAME_FALLBACK_RANGE_MS, end: now } + }, [data]) + + const timeframe = useMemo( + () => ({ + start: params.start ?? defaultRange.start, + end: params.end ?? defaultRange.end, + }), + [params.start, params.end, defaultRange] + ) + + const samplingMode = useMemo( + () => determineSamplingMode(timeframe), + [timeframe] + ) + + // NOTE - this assumes that there would be either: + // 1. a value in all metrics for the exact same hour + // 2. no value in any metric for the exact same hour + const zeroFilledTimeframeFilteredData = useMemo(() => { + return fillTimeSeriesWithEmptyPoints( + data.hour_usages, + { + start: timeframe.start, + end: timeframe.end, + step: 60 * 60 * 1000, // hourly + timestampAccessorKey: 'timestamp', + emptyPointGenerator: (timestamp: number) => ({ + timestamp, + sandbox_count: 0, + cpu_hours: 0, + ram_gib_hours: 0, + price_for_ram: 0, + price_for_cpu: 0, + }), + } + ) + }, [data.hour_usages, timeframe]) + + const sampledData = useMemo( + () => processUsageData(zeroFilledTimeframeFilteredData, timeframe), + [zeroFilledTimeframeFilteredData, timeframe] + ) + + const seriesData = useMemo(() => { + return { + sandboxes: sampledData.map((d) => ({ + x: d.timestamp, + y: d.sandboxCount, + })), + cost: sampledData.map((d) => ({ + x: d.timestamp, + y: d.cost, + })), + vcpu: sampledData.map((d) => ({ + x: d.timestamp, + y: d.vcpuHours, + })), + ram: sampledData.map((d) => ({ + x: d.timestamp, + y: d.ramGibHours, + })), + } + }, [sampledData]) + + const displayedData = useMemo(() => { + return { + sandboxes: seriesData.sandboxes.map((d) => ({ + x: formatAxisDate(d.x, samplingMode), + y: d.y, + })), + cost: seriesData.cost.map((d) => ({ + x: formatAxisDate(d.x, samplingMode), + y: d.y, + })), + vcpu: seriesData.vcpu.map((d) => ({ + x: formatAxisDate(d.x, samplingMode), + y: d.y, + })), + ram: seriesData.ram.map((d) => ({ + x: formatAxisDate(d.x, samplingMode), + y: d.y, + })), + } + }, [seriesData, samplingMode]) + + const totals = useMemo( + () => calculateTotals(sampledData), + [sampledData] + ) + + const displayValues = useMemo(() => { + if ( + hoveredIndex !== null && + seriesData.sandboxes[hoveredIndex] !== undefined + ) { + return formatHoveredValues( + seriesData.sandboxes[hoveredIndex].y, + seriesData.cost[hoveredIndex]!.y, + seriesData.vcpu[hoveredIndex]!.y, + seriesData.ram[hoveredIndex]!.y, + seriesData.sandboxes[hoveredIndex]!.x, + timeframe + ) + } + + if (sampledData.length === 0) { + return formatEmptyValues() + } + + return formatTotalValues(totals) + }, [ + hoveredIndex, + sampledData.length, + totals, + seriesData.sandboxes, + seriesData.cost, + seriesData.vcpu, + seriesData.ram, + timeframe, + ]) + + const setTimeframe = useCallback( + (start: number, end: number) => { + setParams({ start: start, end: end }) + }, + [setParams] + ) + + const onBrushEnd = useCallback( + (startIndex: number, endIndex: number) => { + setHoveredIndex(null) + setTimeframe( + seriesData.sandboxes[startIndex]!.x, + normalizeToEndOfSamplingPeriod( + seriesData.sandboxes[endIndex]!.x, + samplingMode + ) + ) + }, + [seriesData.sandboxes, setTimeframe, samplingMode] + ) + + const value = useMemo( + () => ({ + displayedData, + timeframe, + setTimeframe, + setHoveredIndex, + onBrushEnd, + totals, + samplingMode, + displayValues, + }), + [ + displayedData, + timeframe, + onBrushEnd, + setTimeframe, + totals, + samplingMode, + displayValues, + ] + ) + + return ( + + {children} + + ) +} + +export function useUsageCharts() { + const context = useContext(UsageChartsContext) + + if (context === undefined) { + throw new Error('useUsageCharts must be used within UsageChartsProvider') + } + return context +} diff --git a/src/features/dashboard/usage/usage-metric-chart.tsx b/src/features/dashboard/usage/usage-metric-chart.tsx new file mode 100644 index 000000000..53fa2203c --- /dev/null +++ b/src/features/dashboard/usage/usage-metric-chart.tsx @@ -0,0 +1,81 @@ +'use client' + +import { AnimatedMetricDisplay } from '@/features/dashboard/sandboxes/monitoring/charts/animated-metric-display' +import { cn } from '@/lib/utils' +import { Card, CardContent, CardHeader } from '@/ui/primitives/card' +import ComputeUsageChart from './compute-usage-chart' +import { useUsageCharts } from './usage-charts-context' +import { UsageTimeRangeControls } from './usage-time-range-controls' + +type UsageMetricType = 'sandboxes' | 'cost' | 'vcpu' | 'ram' + +interface MetricConfig { + title: string +} + +const METRIC_CONFIGS: Record = { + sandboxes: { title: 'Started & Resumed Sandboxes' }, + cost: { title: 'Usage Cost' }, + vcpu: { title: 'vCPU Hours' }, + ram: { title: 'RAM Hours' }, +} + +interface UsageMetricChartProps { + metric: UsageMetricType + className?: string + timeRangeControlsClassName?: string +} + +export function UsageMetricChart({ + metric, + className, + timeRangeControlsClassName, +}: UsageMetricChartProps) { + const { + displayedData, + setHoveredIndex, + timeframe, + setTimeframe, + displayValues, + samplingMode, + onBrushEnd, + } = useUsageCharts() + + const config = METRIC_CONFIGS[metric] + const { displayValue, label, timestamp } = displayValues[metric] + const data = displayedData[metric] + + return ( + + +
+ + {config.title} + + +
+ +
+ +
+ setHoveredIndex(null)} + onBrushEnd={onBrushEnd} + /> +
+
+
+ ) +} diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx new file mode 100644 index 000000000..f625e68d9 --- /dev/null +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -0,0 +1,192 @@ +'use client' + +import { cn } from '@/lib/utils' +import { findMatchingPreset } from '@/lib/utils/time-range' +import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' +import CopyButton from '@/ui/copy-button' +import { Button } from '@/ui/primitives/button' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/ui/primitives/popover' +import { Separator } from '@/ui/primitives/separator' +import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' +import { TimeRangePresets, type TimeRangePreset } from '@/ui/time-range-presets' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' +import { TIME_RANGE_PRESETS } from './constants' +import { + determineSamplingMode, + normalizeToEndOfSamplingPeriod, + normalizeToStartOfSamplingPeriod, +} from './sampling-utils' +import { useUsageCharts } from './usage-charts-context' + +interface UsageTimeRangeControlsProps { + timeframe: { + start: number + end: number + } + onTimeRangeChange: (start: number, end: number) => void + className?: string +} + +export function UsageTimeRangeControls({ + timeframe, + onTimeRangeChange, + className, +}: UsageTimeRangeControlsProps) { + const [isTimePickerOpen, setIsTimePickerOpen] = useState(false) + + const selectedPresetId = useMemo( + () => + findMatchingPreset( + TIME_RANGE_PRESETS, + timeframe.start, + timeframe.end, + 1000 * 60 * 60 * 24 // 1 day in tolerance + ), + [timeframe.start, timeframe.end] + ) + + const rangeLabel = useMemo(() => { + const formatter = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + return `${formatter.format(timeframe.start)} - ${formatter.format(timeframe.end)}` + }, [timeframe.start, timeframe.end]) + + const rangeCopyValue = useMemo( + () => formatTimeframeAsISO8601Interval(timeframe.start, timeframe.end), + [timeframe.start, timeframe.end] + ) + + const quarterOfRangeDuration = useMemo(() => { + return Math.floor((timeframe.end - timeframe.start) / 4) + }, [timeframe.start, timeframe.end]) + + const handlePreviousRange = useCallback(() => { + const samplingMode = determineSamplingMode(timeframe) + + onTimeRangeChange( + normalizeToStartOfSamplingPeriod( + timeframe.start - quarterOfRangeDuration, + samplingMode + ), + normalizeToEndOfSamplingPeriod( + timeframe.end - quarterOfRangeDuration, + samplingMode + ) + ) + }, [timeframe, quarterOfRangeDuration, onTimeRangeChange]) + + const handleNextRange = useCallback(() => { + const samplingMode = determineSamplingMode(timeframe) + + onTimeRangeChange( + normalizeToStartOfSamplingPeriod( + timeframe.start + quarterOfRangeDuration, + samplingMode + ), + normalizeToEndOfSamplingPeriod( + timeframe.end + quarterOfRangeDuration, + samplingMode + ) + ) + }, [timeframe, quarterOfRangeDuration, onTimeRangeChange]) + + const handleTimeRangeApply = useCallback( + (values: TimeRangeValues) => { + const startTime = values.startTime || '00:00:00' + const endTime = values.endTime || '23:59:59' + + const startTimestamp = new Date( + `${values.startDate} ${startTime}` + ).getTime() + const endTimestamp = new Date(`${values.endDate} ${endTime}`).getTime() + + onTimeRangeChange(startTimestamp, endTimestamp) + setIsTimePickerOpen(false) + }, + [onTimeRangeChange] + ) + + const handlePresetSelect = useCallback( + (preset: TimeRangePreset) => { + const { start, end } = preset.getValue() + onTimeRangeChange(start, end) + setIsTimePickerOpen(false) + }, + [onTimeRangeChange] + ) + + return ( +
+ + + + + + + +
+ + + + +
+
+
+ +
+ ) +} diff --git a/src/features/dashboard/usage/vcpu-card.tsx b/src/features/dashboard/usage/vcpu-card.tsx deleted file mode 100644 index 9105648d2..000000000 --- a/src/features/dashboard/usage/vcpu-card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { l } from '@/lib/clients/logger/logger' -import { getUsageThroughReactCache } from '@/server/usage/get-usage' -import { ChartPlaceholder } from '@/ui/chart-placeholder' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' -import { Suspense } from 'react' -import { VCPUChart } from './vcpu-chart' - -async function VCPUCardContentResolver({ teamId }: { teamId: string }) { - const result = await getUsageThroughReactCache({ teamId }) - - if (!result?.data || result.serverError || result.validationErrors) { - const errorMessage = - result?.serverError || - result?.validationErrors?.formErrors?.[0] || - 'Could not load usage data.' - - l.error({ - key: 'vcpu_card:server_error', - error: result?.serverError, - team_id: teamId, - context: { - errorMessage, - }, - }) - - throw new Error(errorMessage) - } - - const latestVCPU = - result.data.compute?.[result.data.compute.length - 1]?.vcpu_hours - - if (!latestVCPU) { - return ( - - ) - } - - return ( - <> -
-

- {new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(latestVCPU || 0)} -

- hours this month -
- - - ) -} - -export function VCPUCard({ - teamId, - className, -}: { - teamId: string - className?: string -}) { - return ( - - - vCPU Hours - - Virtual CPU time consumed by your sandboxes per month. - - - - - } - > - - - - - ) -} diff --git a/src/features/dashboard/usage/vcpu-chart.tsx b/src/features/dashboard/usage/vcpu-chart.tsx deleted file mode 100644 index 4da0ce86a..000000000 --- a/src/features/dashboard/usage/vcpu-chart.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client' - -import { UsageData } from '@/server/usage/types' -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/ui/primitives/chart' -import { useMemo } from 'react' -import { Area, AreaChart, XAxis, YAxis } from 'recharts' -import { - bigNumbersAxisTickFormatter, - chartConfig, - commonChartProps, - commonXAxisProps, - commonYAxisProps, -} from './chart-config' - -interface VCPUChartProps { - data: UsageData['compute'] -} - -export function VCPUChart({ data }: VCPUChartProps) { - const chartData = useMemo(() => { - return data.map((item) => ({ - x: `${item.month}/${item.year}`, - y: item.vcpu_hours, - })) - }, [data]) - - return ( - - - - - - - - - - - { - if (!active || !payload || !payload.length || !payload[0]?.payload) - return null - - return ( - label} - formatter={(value, name, item) => [ - - {Number(value).toLocaleString()} - , - `vCPU Hours`, - ]} - payload={payload} - active={active} - /> - ) - }} - /> - - - - ) -} diff --git a/src/lib/utils/chart.ts b/src/lib/utils/chart.ts new file mode 100644 index 000000000..b3dd0536a --- /dev/null +++ b/src/lib/utils/chart.ts @@ -0,0 +1,21 @@ +export function calculateAxisMax(data: number[], scaleFactor: number): number { + if (data.length === 0) return 1 + + let max = 0 + for (let i = 0; i < data.length; i++) { + const y = data[i]! + if (y > max) max = y + } + + const snapToAxis = (value: number): number => { + if (value < 10) return Math.ceil(value) + if (value < 100) return Math.ceil(value / 10) * 10 + if (value < 1000) return Math.ceil(value / 50) * 50 + if (value < 10000) return Math.ceil(value / 100) * 100 + return Math.ceil(value / 1000) * 1000 + } + + const calculatedMax = snapToAxis(max * scaleFactor) + + return Math.max(calculatedMax, 1) +} diff --git a/src/lib/utils/chart.tsx b/src/lib/utils/chart.tsx deleted file mode 100644 index 10e86b451..000000000 --- a/src/lib/utils/chart.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { formatAveragingPeriod } from '@/lib/utils/formatting' -import { SingleValueTooltip } from '@/ui/charts/tooltips' -import type { TooltipComponentFormatterCallbackParams } from 'echarts' -import { renderToString } from 'react-dom/server' - -/** - * Creates a tooltip formatter function for single-value charts. - * This is a generic utility that can be used by any chart component. - * - * The function extracts the relevant data points regardless of format, - * ensuring consistent tooltip rendering across different chart configurations. - */ -export function createSingleValueTooltipFormatter({ - step, - label, - valueClassName = 'text-accent-info-highlight', - descriptionClassName = 'text-fg-tertiary opacity-75', - timestampClassName = 'text-fg-tertiary', -}: { - step: number - label: string | ((value: number) => string) - valueClassName?: string - descriptionClassName?: string - timestampClassName?: string -}) { - return (params: TooltipComponentFormatterCallbackParams) => { - // handle both array of series data and single series data point - const paramsData = Array.isArray(params) ? params[0] : params - - if (!paramsData?.value) return '' - - const isTimeSeries = Array.isArray(paramsData.value) - - if (!isTimeSeries) { - throw new Error('This chart / data type is not supported.') - } - - const delta = paramsData.value - - const isCompatibleFormat = delta instanceof Array - - if (!isCompatibleFormat) { - throw new Error('This chart / data type is not supported.') - } - - const value = delta[1] as number - const timestamp = delta[0] as string - - // apply label function if provided and value is numeric - const displayLabel = - typeof label === 'function' && typeof value === 'number' - ? label(value) - : label - - return renderToString( - - ) - } -} diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index 2614c9af1..2257ed6b0 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -62,6 +62,85 @@ export function formatCompactDate(timestamp: number): string { return format(date, 'yyyy MMM d, h:mm:ss a zzz') } +export function formatDay(timestamp: number): string { + if (isThisYear(timestamp)) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(timestamp) + } + + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(timestamp) +} + +/** + * Format a timestamp to show date and hour (for hourly aggregations) + * @param timestamp - Unix timestamp in milliseconds + * @returns Formatted string (e.g., "Jan 5, 2pm" or "Jan 5, 2024, 2pm") + */ +export function formatHour(timestamp: number): string { + const date = new Date(timestamp) + const hour = date.getHours() + const ampm = hour >= 12 ? 'pm' : 'am' + const hour12 = hour % 12 || 12 + + if (isThisYear(timestamp)) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(timestamp) + `, ${hour12}${ampm}` + } + + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(timestamp) + `, ${hour12}${ampm}` +} + +/** + * Format a date range (e.g., for weekly aggregations) + * @param startTimestamp - Start of the range in milliseconds + * @param endTimestamp - End of the range in milliseconds + * @returns Formatted date range string (e.g., "Jan 1 - Jan 7" or "Dec 26, 2023 - Jan 1, 2024") + */ +export function formatDateRange( + startTimestamp: number, + endTimestamp: number +): string { + const startDate = new Date(startTimestamp) + const endDate = new Date(endTimestamp) + + const startYear = startDate.getFullYear() + const endYear = endDate.getFullYear() + const startMonth = startDate.getMonth() + const endMonth = endDate.getMonth() + const sameYear = startYear === endYear + const sameMonth = sameYear && startMonth === endMonth + + const startFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + }) + + const endFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: isThisYear(endDate) ? undefined : 'numeric', + }) + + if (sameMonth) { + return `${startFormat.format(startDate)} - ${endDate.getDate()}` + } + + return `${startFormat.format(startDate)} - ${endFormat.format(endDate)}` +} + /** * Parse and format a UTC date string into components * @param date - Date string or Date object @@ -214,6 +293,10 @@ export function formatAxisNumber( return formatter.format(value) } + if (value < 1 && value > 0) { + return value.toFixed(2) + } + return formatNumber(value, locale) } diff --git a/src/lib/utils/time-range.ts b/src/lib/utils/time-range.ts new file mode 100644 index 000000000..cf2f4663f --- /dev/null +++ b/src/lib/utils/time-range.ts @@ -0,0 +1,24 @@ +import type { TimeRangePreset } from '@/ui/time-range-presets' + +/** + * Finds which preset matches the given timeframe (with tolerance) + */ +export function findMatchingPreset( + presets: TimeRangePreset[], + start: number, + end: number, + toleranceMs = 10 * 1000 // 10 seconds +): string | undefined { + for (const preset of presets) { + const { start: presetStart, end: presetEnd } = preset.getValue() + + if ( + Math.abs(start - presetStart) <= toleranceMs && + Math.abs(end - presetEnd) <= toleranceMs + ) { + return preset.id + } + } + + return undefined +} diff --git a/src/lib/utils/time-series.ts b/src/lib/utils/time-series.ts new file mode 100644 index 000000000..419c9d379 --- /dev/null +++ b/src/lib/utils/time-series.ts @@ -0,0 +1,193 @@ +export interface TimeSeriesPoint { + x: number + y: number +} + +export interface FillTimeSeriesConfig { + start: number + end: number + step: number + anomalousGapTolerance?: number + timestampAccessorKey: keyof T + emptyPointGenerator: (timestamp: number) => T +} + +function isGapAnomalous( + gapDuration: number, + expectedStep: number, + anomalousGapTolerance: number +): boolean { + return gapDuration > expectedStep * (1 + anomalousGapTolerance) +} + +function generateEmptyTimeSeries( + start: number, + end: number, + step: number, + emptyPointGenerator: (timestamp: number) => T +): T[] { + const result: T[] = [] + + for (let timestamp = start; timestamp < end; timestamp += step) { + result.push(emptyPointGenerator(timestamp)) + } + + return result +} + +/** + * Fills time series data with empty values to create smooth, continuous series for visualization. + * + * This function handles: + * 1. Empty Data: Generates a complete empty-filled time series + * 2. Anomalous Gaps: Fills all missing steps between data points when gap exceeds tolerance + * 3. Viewport Boundaries: Always fills from first/last data points to config.start/end boundaries + * + * Anomalous gap detection is based on actual data points, not viewport boundaries. + * Config start/end are used only as viewport limits, not for gap detection. + * + * CRITICAL: this logic is highly tested. do not modify without extensive testing + */ +export function fillTimeSeriesWithEmptyPoints( + data: T[], + config: FillTimeSeriesConfig +): T[] { + const { + start, + end, + step, + anomalousGapTolerance = 0.5, + timestampAccessorKey, + emptyPointGenerator, + } = config + + if (!data.length) { + return generateEmptyTimeSeries(start, end, step, emptyPointGenerator) + } + + // sort data by timestamp + const sortedData = [...data].sort((a, b) => { + const aTime = a[timestampAccessorKey] as number + const bTime = b[timestampAccessorKey] as number + return aTime - bTime + }) + + const result: T[] = [] + + // fill backwards from first data point to config.start + const firstTimestamp = sortedData[0]![timestampAccessorKey] as number + for (let ts = firstTimestamp - step; ts >= start; ts -= step) { + result.push(emptyPointGenerator(ts)) + } + + // add data points and fill gaps + for (let i = 0; i < sortedData.length; i++) { + const currentPoint = sortedData[i]! + result.push(currentPoint) + + if (i === sortedData.length - 1) break + + const nextPoint = sortedData[i + 1]! + const currentTimestamp = currentPoint[timestampAccessorKey] as number + const nextTimestamp = nextPoint[timestampAccessorKey] as number + const actualGap = nextTimestamp - currentTimestamp + + if (isGapAnomalous(actualGap, step, anomalousGapTolerance)) { + // fill all missing timestamps at step intervals + for (let ts = currentTimestamp + step; ts < nextTimestamp; ts += step) { + result.push(emptyPointGenerator(ts)) + } + } + } + + // fill forwards from last data point to config.end + const lastTimestamp = sortedData[sortedData.length - 1]![ + timestampAccessorKey + ] as number + for (let ts = lastTimestamp + step; ts <= end; ts += step) { + result.push(emptyPointGenerator(ts)) + } + + // sort and strip potential overfetching + const sorted = result.sort((a, b) => { + const aTime = a[timestampAccessorKey] as number + const bTime = b[timestampAccessorKey] as number + return aTime - bTime + }) + + return sorted.filter((d) => { + const timestamp = d[timestampAccessorKey] as number + return timestamp >= start && timestamp <= end + }) +} + +/** + * Downsamples time series data by aggregating points into weekly buckets. + * Each week's data is summed and assigned to the start of that week (Monday). + * + * @param data - Array of time series points to downsample + * @returns Array of downsampled points with weekly aggregation + */ +export function downsampleToWeekly(data: TimeSeriesPoint[]): TimeSeriesPoint[] { + if (!data.length) return [] + + // Group data by week (Monday-based) + const weeklyMap = new Map() + + data.forEach((point) => { + const timestamp = + typeof point.x === 'number' ? point.x : new Date(point.x).getTime() + const date = new Date(timestamp) + + // Get Monday of the week + const dayOfWeek = date.getUTCDay() + const diff = (dayOfWeek === 0 ? -6 : 1) - dayOfWeek // Adjust Sunday to previous Monday + const monday = new Date(date) + monday.setUTCDate(date.getUTCDate() + diff) + monday.setUTCHours(0, 0, 0, 0) + + const weekStart = monday.getTime() + + // Aggregate values for this week + const currentValue = weeklyMap.get(weekStart) || 0 + weeklyMap.set(weekStart, currentValue + point.y) + }) + + // Convert map to sorted array + return Array.from(weeklyMap.entries()) + .map(([timestamp, value]) => ({ x: timestamp, y: value })) + .sort((a, b) => { + const timeA = typeof a.x === 'number' ? a.x : new Date(a.x).getTime() + const timeB = typeof b.x === 'number' ? b.x : new Date(b.x).getTime() + return timeA - timeB + }) +} + +/** + * Finds the weekly bucket that contains the given timestamp and returns its start timestamp. + * + * @param timestamp - The timestamp to find the week for + * @returns The start timestamp (Monday) of the week containing the input timestamp + */ +export function getWeekStartForTimestamp(timestamp: number): number { + const date = new Date(timestamp) + const dayOfWeek = date.getUTCDay() + const diff = (dayOfWeek === 0 ? -6 : 1) - dayOfWeek + const monday = new Date(date) + monday.setUTCDate(date.getUTCDate() + diff) + monday.setUTCHours(0, 0, 0, 0) + return monday.getTime() +} + +/** + * Gets the end timestamp (Sunday 23:59:59) for a given week start. + * + * @param weekStart - The start timestamp of the week (Monday) + * @returns The end timestamp of that week (Sunday) + */ +export function getWeekEndForWeekStart(weekStart: number): number { + const endDate = new Date(weekStart) + endDate.setUTCDate(endDate.getUTCDate() + 6) // Move to Sunday + endDate.setUTCHours(23, 59, 59, 999) + return endDate.getTime() +} diff --git a/src/server/usage/get-usage.ts b/src/server/usage/get-usage.ts index 5eb9df3d9..32c6a1073 100644 --- a/src/server/usage/get-usage.ts +++ b/src/server/usage/get-usage.ts @@ -3,101 +3,46 @@ import 'server-only' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' -import { - ComputeUsageMonthDelta, - SandboxesUsageDelta, - UsageData, -} from '@/server/usage/types' import { UsageResponse } from '@/types/billing' -import { cache } from 'react' import { z } from 'zod' const GetUsageAuthActionSchema = z.object({ teamId: z.uuid(), }) -async function _fetchTeamUsageDataLogic(teamId: string, accessToken: string) { - const response = await fetch( - `${process.env.BILLING_API_URL}/v2/teams/${teamId}/usage`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), - }, - } - ) - - if (!response.ok) { - const text = (await response.text()) ?? 'Failed to fetch usage data' - throw new Error(text) - } - - const data = (await response.json()) as UsageResponse - - return transformResponseToUsageData(data) -} - -const transformResponseToUsageData = (response: UsageResponse): UsageData => { - // group daily usages by month - const monthlyUsage = response.day_usages.reduce( - (acc, usage) => { - const date = new Date(usage.date) - const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - - if (!acc[monthKey]) { - acc[monthKey] = { - total_cost: 0, - ram_gb_hours: 0, - vcpu_hours: 0, - month: date.getMonth() + 1, - year: date.getFullYear(), - } - } - - acc[monthKey].ram_gb_hours += usage.ram_gib_hours - acc[monthKey].vcpu_hours += usage.cpu_hours - acc[monthKey].total_cost += usage.price_for_ram + usage.price_for_cpu - - return acc - }, - {} as Record - ) - - const computeUsage = Object.values(monthlyUsage).sort((a, b) => { - if (a.year !== b.year) return a.year - b.year - return a.month - b.month - }) - - const sandboxesUsage = response.day_usages.reduce((acc, usage) => { - acc.push({ - date: new Date(usage.date), - count: usage.sandbox_count, - }) - return acc - }, [] as SandboxesUsageDelta[]) - - return { - compute: computeUsage, - sandboxes: sandboxesUsage, - credits: response.credits, - } -} - -export const getAndCacheTeamUsageData = cache(_fetchTeamUsageDataLogic) - -export const getUsageThroughReactCache = authActionClient +export const getUsage = authActionClient .schema(GetUsageAuthActionSchema) .metadata({ serverFunctionName: 'getUsage' }) .action(async ({ parsedInput, ctx }) => { const { teamId } = parsedInput const accessToken = ctx.session.access_token - const result = await getAndCacheTeamUsageData(teamId, accessToken) + const response = await fetch( + `${process.env.BILLING_API_URL}/v2/teams/${teamId}/usage`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + } + ) + + if (!response.ok) { + const text = (await response.text()) ?? 'Failed to fetch usage data' + return returnServerError(text) + } + + const responseData: UsageResponse = await response.json() - if (!result) { - return returnServerError('Failed to fetch usage data') + // convert unix seconds to milliseconds because JavaScript + const data: UsageResponse = { + ...responseData, + hour_usages: responseData.hour_usages.map((hour) => ({ + ...hour, + timestamp: hour.timestamp * 1000, + })), } - return result + return data }) diff --git a/src/server/usage/types.ts b/src/server/usage/types.ts deleted file mode 100644 index 6d0d3e91f..000000000 --- a/src/server/usage/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -interface SandboxesUsageDelta { - date: Date - count: number -} - -interface ComputeUsageMonthDelta { - month: number - year: number - total_cost: number - ram_gb_hours: number - vcpu_hours: number -} - -type UsageData = { - sandboxes: SandboxesUsageDelta[] - compute: ComputeUsageMonthDelta[] - credits: number -} - -export type { ComputeUsageMonthDelta, SandboxesUsageDelta, UsageData } diff --git a/src/types/billing.d.ts b/src/types/billing.d.ts index 399d3bf7e..ffc3c49e4 100644 --- a/src/types/billing.d.ts +++ b/src/types/billing.d.ts @@ -24,6 +24,14 @@ interface UsageResponse { price_for_ram: number price_for_cpu: number }[] + hour_usages: { + timestamp: number + sandbox_count: number + cpu_hours: number + ram_gib_hours: number + price_for_ram: number + price_for_cpu: number + }[] } interface CreateTeamsResponse { diff --git a/src/ui/chart-placeholder.tsx b/src/ui/chart-placeholder.tsx index ebc301e6f..7581b8340 100644 --- a/src/ui/chart-placeholder.tsx +++ b/src/ui/chart-placeholder.tsx @@ -1,9 +1,5 @@ 'use client' -import { - chartConfig, - commonChartProps, -} from '@/features/dashboard/usage/chart-config' import { cn } from '@/lib/utils' import { ChartContainer, @@ -53,7 +49,7 @@ export function ChartPlaceholder({ } > - + {isLoading ? ( -
- {items?.map((item, index) => ( -
-
{item.label}
-
{item.value}
-
- ))} - {label && ( - {label} - )} -
-
- ) -} - -interface SingleValueTooltipProps { - value: number | string - label: string - unit?: string - timestamp?: string | number | Date - description?: string - classNames?: { - container?: string - value?: string - timestamp?: string - description?: string - } -} - -export function SingleValueTooltip({ - value, - label, - unit = '', - timestamp, - description, - classNames = {}, -}: SingleValueTooltipProps) { - const formattedValue = typeof value === 'number' ? formatNumber(value) : value - - return ( -
-
- {formattedValue} {unit} {label} -
- {description && ( -
- {description} -
- )} - {timestamp && ( -
- {formatCompactDate(new Date(timestamp).getTime())} -
- )} -
- ) -} - -interface LimitLineTooltipProps { - value: number - limit: number -} - -export function LimitLineTooltip({ value, limit }: LimitLineTooltipProps) { - const isLimit = value === limit - - if (isLimit) { - return ( -
-
- CONCURRENT SANDBOX LIMIT -
-
- Your plan currently allows for {formatNumber(limit)} concurrent - sandboxes. New sandbox creation will be blocked when this limit is - reached. -
-
- - // - ) - } - - return null -} diff --git a/src/ui/primitives/animated-number.tsx b/src/ui/primitives/animated-number.tsx index 5d6660406..50b52c307 100644 --- a/src/ui/primitives/animated-number.tsx +++ b/src/ui/primitives/animated-number.tsx @@ -74,7 +74,7 @@ function AnimatedNumberComponent({ key={index} className="relative inline-block overflow-hidden select-none" > - + - + {!hideTime && ( +
+ )}
) }) diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx new file mode 100644 index 000000000..36dec25cb --- /dev/null +++ b/src/ui/time-range-picker.tsx @@ -0,0 +1,304 @@ +/** + * General-purpose time range selection component + * A simplified abstraction for picking start and end date/time ranges + */ + +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useCallback, useEffect, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { cn } from '@/lib/utils' +import { + parseDateTimeComponents, + tryParseDatetime, +} from '@/lib/utils/formatting' + +import { Button } from './primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from './primitives/form' +import { TimeInput } from './time-input' + +export interface TimeRangeValues { + startDate: string + startTime: string | null + endDate: string + endTime: string | null +} + +interface TimeRangePickerProps { + /** Initial start datetime in any parseable format */ + startDateTime: string + /** Initial end datetime in any parseable format */ + endDateTime: string + /** Optional minimum selectable date */ + minDate?: Date + /** Optional maximum selectable date */ + maxDate?: Date + /** Called when Apply button is clicked */ + onApply?: (values: TimeRangeValues) => void + /** Called whenever values change (real-time) */ + onChange?: (values: TimeRangeValues) => void + /** Custom className for the container */ + className?: string + /** Hide time inputs and only show date pickers (default: false) */ + hideTime?: boolean +} + +export function TimeRangePicker({ + startDateTime, + endDateTime, + minDate, + maxDate, + onApply, + onChange, + className, + hideTime = false, +}: TimeRangePickerProps) { + 'use no memo' + + const startParts = useMemo( + () => parseDateTimeComponents(startDateTime), + [startDateTime] + ) + const endParts = useMemo( + () => parseDateTimeComponents(endDateTime), + [endDateTime] + ) + + // Create dynamic zod schema based on min/max dates + const schema = useMemo(() => { + // When hideTime is true, allow dates up to end of today + // Otherwise, allow up to now + 10 seconds (for time drift) + const defaultMaxDate = hideTime + ? (() => { + const endOfToday = new Date() + endOfToday.setDate(endOfToday.getDate() + 1) + endOfToday.setHours(0, 0, 0, 0) + return endOfToday + })() + : new Date(Date.now() + 10000) + + const maxDateValue = maxDate || defaultMaxDate + const minDateValue = minDate + + return z + .object({ + startDate: z.string().min(1, 'Start date is required'), + startTime: z.string().nullable(), + endDate: z.string().min(1, 'End date is required'), + endTime: z.string().nullable(), + }) + .superRefine((data, ctx) => { + const startTimeStr = data.startTime || '00:00:00' + const endTimeStr = data.endTime || '23:59:59' + const startTimestamp = tryParseDatetime( + `${data.startDate} ${startTimeStr}` + )?.getTime() + const endTimestamp = tryParseDatetime( + `${data.endDate} ${endTimeStr}` + )?.getTime() + + if (!startTimestamp) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid start date format', + path: ['startDate'], + }) + return + } + + if (!endTimestamp) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid end date format', + path: ['endDate'], + }) + return + } + + // validate against min date + if (minDateValue && startTimestamp < minDateValue.getTime()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Start date cannot be before ${minDateValue.toLocaleDateString()}`, + path: ['startDate'], + }) + } + + // validate end date is not before start date + if (endTimestamp < startTimestamp) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'End date cannot be before start date', + path: ['endDate'], + }) + } + }) + }, [minDate, maxDate, hideTime]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + startDate: startParts.date || '', + startTime: startParts.time || null, + endDate: endParts.date || '', + endTime: endParts.time || null, + }, + mode: 'onChange', + }) + + // sync with external props when they change + useEffect(() => { + const currentStartTime = form.getValues('startDate') + ? tryParseDatetime( + `${form.getValues('startDate')} ${form.getValues('startTime')}` + )?.getTime() + : undefined + const currentEndTime = form.getValues('endDate') + ? tryParseDatetime( + `${form.getValues('endDate')} ${form.getValues('endTime')}` + )?.getTime() + : undefined + + const propStartTime = startDateTime + ? tryParseDatetime(startDateTime)?.getTime() + : undefined + const propEndTime = endDateTime + ? tryParseDatetime(endDateTime)?.getTime() + : undefined + + // detect meaningful external changes (>1s difference) + const startChanged = + propStartTime && + currentStartTime && + Math.abs(propStartTime - currentStartTime) > 1000 + const endChanged = + propEndTime && + currentEndTime && + Math.abs(propEndTime - currentEndTime) > 1000 + + const isExternalChange = startChanged || endChanged + + if (isExternalChange && !form.formState.isDirty) { + const newStartParts = parseDateTimeComponents(startDateTime) + const newEndParts = parseDateTimeComponents(endDateTime) + + form.reset({ + startDate: newStartParts.date || '', + startTime: newStartParts.time || null, + endDate: newEndParts.date || '', + endTime: newEndParts.time || null, + }) + } + }, [startDateTime, endDateTime, form]) + + // Notify on changes + useEffect(() => { + const subscription = form.watch((values) => { + onChange?.(values as TimeRangeValues) + }) + return () => subscription.unsubscribe() + }, [form, onChange]) + + const handleSubmit = useCallback( + (values: TimeRangeValues) => { + onApply?.(values) + }, + [onApply] + ) + + return ( +
+ + ( + + + Start Time + + + { + dateField.onChange(value) + form.trigger(['startDate', 'endDate']) + }} + onTimeChange={(value) => { + form.setValue('startTime', value || null, { + shouldValidate: true, + shouldDirty: true, + }) + form.trigger(['startDate', 'endDate']) + }} + disabled={false} + hideTime={hideTime} + /> + + + + )} + /> + + ( + + + End Time + + + { + dateField.onChange(value) + form.trigger(['startDate', 'endDate']) + }} + onTimeChange={(value) => { + form.setValue('endTime', value || null, { + shouldValidate: true, + shouldDirty: true, + }) + form.trigger(['startDate', 'endDate']) + }} + disabled={false} + hideTime={hideTime} + /> + + + + )} + /> + + + + + ) +} diff --git a/src/ui/time-range-presets.tsx b/src/ui/time-range-presets.tsx new file mode 100644 index 000000000..803c60fa8 --- /dev/null +++ b/src/ui/time-range-presets.tsx @@ -0,0 +1,83 @@ +/** + * Time range preset selector component + * Provides radio selection for common time range presets + */ + +'use client' + +import { cn } from '@/lib/utils' +import { useCallback } from 'react' +import { Label } from './primitives/label' +import { RadioGroup, RadioGroupItem } from './primitives/radio-group' +import { ScrollArea } from './primitives/scroll-area' + +export interface TimeRangePreset { + id: string + label: string + shortcut?: string + description?: string + getValue: () => { start: number; end: number } +} + +interface TimeRangePresetsProps { + presets: TimeRangePreset[] + onSelect?: (preset: TimeRangePreset) => void + selectedId?: string + className?: string +} + +export function TimeRangePresets({ + presets, + onSelect, + selectedId, + className, +}: TimeRangePresetsProps) { + const handleValueChange = useCallback( + (value: string) => { + const preset = presets.find((p) => p.id === value) + if (preset && onSelect) { + onSelect(preset) + } + }, + [presets, onSelect] + ) + + return ( +
+ + + + {presets.map((preset) => ( + + ))} + + +
+ ) +}