Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 48 additions & 40 deletions src/app/dashboard/[teamSlug]/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server'
import { getAuthContext } from '@/core/server/auth'
import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug'
import { getUsage } from '@/core/server/functions/usage/get-usage'
import { BillingTimezoneBanner } from '@/features/dashboard/usage/billing-timezone-banner'
import { EMPTY_USAGE } from '@/features/dashboard/usage/redesign/usage-fallback'
import { UsageMetricDetail } from '@/features/dashboard/usage/redesign/usage-metric-detail'
import { UsageChartsProvider } from '@/features/dashboard/usage/usage-charts-context'
import { UsageMetricChart } from '@/features/dashboard/usage/usage-metric-chart'
import { UsageTimezoneProvider } from '@/features/dashboard/usage/usage-timezone'
import { UsageTopTimeRangeControls } from '@/features/dashboard/usage/usage-top-time-range-controls'
import ErrorBoundary from '@/ui/error'
import Frame from '@/ui/frame'
Expand Down Expand Up @@ -44,9 +46,11 @@ export default async function UsagePage({
// when usage can't be loaded instead of erroring the whole screen.
if (newUsagePage) {
return (
<UsageChartsProvider data={result?.data ?? EMPTY_USAGE}>
<UsageMetricDetail metric="cost" />
</UsageChartsProvider>
<UsageTimezoneProvider>
<UsageChartsProvider data={result?.data ?? EMPTY_USAGE}>
<UsageMetricDetail metric="cost" />
</UsageChartsProvider>
</UsageTimezoneProvider>
)
}

Expand All @@ -65,43 +69,47 @@ export default async function UsagePage({
}

return (
<UsageChartsProvider data={result.data}>
<div className="h-full max-h-full min-h-0 overflow-y-auto">
<div className="container mx-auto p-0 md:p-8 2xl:px-24 2xl:py-8 max-w-[1800px] lg:flex lg:flex-col lg:h-full">
<Frame
classNames={{
wrapper: 'w-full lg:flex-1 lg:min-h-[700px] lg:max-h-full',
frame: 'lg:h-full max-lg:border-0 lg:flex lg:flex-col',
}}
>
<div className="hidden lg:flex lg:justify-end lg:px-3 lg:pt-3">
<UsageTopTimeRangeControls />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 lg:grid-rows-2 lg:min-h-0 lg:flex-1">
<UsageMetricChart
metric="sandboxes"
className="min-h-[48svh] lg:min-h-0 lg:h-full"
timeRangeControlsClassName="flex lg:hidden"
/>
<UsageMetricChart
metric="cost"
className="min-h-[48svh] lg:min-h-0 lg:h-full"
timeRangeControlsClassName="hidden"
/>
<UsageMetricChart
metric="vcpu"
className="min-h-[48svh] lg:min-h-0 lg:h-full lg:border-t lg:border-stroke"
timeRangeControlsClassName="flex lg:hidden"
/>
<UsageMetricChart
metric="ram"
className="min-h-[48svh] lg:min-h-0 lg:h-full lg:border-t lg:border-stroke"
timeRangeControlsClassName="hidden"
/>
</div>
</Frame>
<UsageTimezoneProvider>
<UsageChartsProvider data={result.data}>
<div className="h-full max-h-full min-h-0 overflow-y-auto">
<div className="container mx-auto p-0 md:p-8 2xl:px-24 2xl:py-8 max-w-[1800px] lg:flex lg:flex-col lg:h-full">
<Frame
classNames={{
wrapper: 'w-full lg:flex-1 lg:min-h-[700px] lg:max-h-full',
frame: 'lg:h-full max-lg:border-0 lg:flex lg:flex-col',
}}
>
<BillingTimezoneBanner className="m-3 mb-0 lg:hidden" />
<div className="hidden lg:flex lg:items-start lg:gap-3 lg:px-3 lg:pt-3">
<BillingTimezoneBanner className="mr-auto" />
<UsageTopTimeRangeControls className="ml-auto" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 lg:grid-rows-2 lg:min-h-0 lg:flex-1">
<UsageMetricChart
metric="sandboxes"
className="min-h-[48svh] lg:min-h-0 lg:h-full"
timeRangeControlsClassName="flex lg:hidden"
/>
<UsageMetricChart
metric="cost"
className="min-h-[48svh] lg:min-h-0 lg:h-full"
timeRangeControlsClassName="hidden"
/>
<UsageMetricChart
metric="vcpu"
className="min-h-[48svh] lg:min-h-0 lg:h-full lg:border-t lg:border-stroke"
timeRangeControlsClassName="flex lg:hidden"
/>
<UsageMetricChart
metric="ram"
className="min-h-[48svh] lg:min-h-0 lg:h-full lg:border-t lg:border-stroke"
timeRangeControlsClassName="hidden"
/>
</div>
</Frame>
</div>
</div>
</div>
</UsageChartsProvider>
</UsageChartsProvider>
</UsageTimezoneProvider>
)
}
31 changes: 29 additions & 2 deletions src/features/dashboard/timezone/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import {
useMemo,
useState,
} from 'react'
import { type Timezone, TimezoneSchema } from './schema'
import { type Timezone, UTC_TIMEZONE } from './schema'
import { getBrowserTimezone, parseTimezone } from './utils'

const DEFAULT_TIMEZONE = TimezoneSchema.parse('UTC')
const DEFAULT_TIMEZONE = UTC_TIMEZONE

interface TimezoneContextValue {
timezone: Timezone
Expand Down Expand Up @@ -99,6 +99,33 @@ export const TimezoneProvider = ({
)
}

interface TimezoneOverrideProps {
children: ReactNode
timezone: Timezone
}

const setTimezoneNoop = async () => false

/**
* Pins `useTimezone()` to a fixed timezone for a subtree without touching the
* persisted preference. `setTimezone` is disabled under the override.
*/
export const TimezoneOverride = ({
children,
timezone,
}: TimezoneOverrideProps) => {
const value = useMemo(
() => ({ timezone, setTimezone: setTimezoneNoop }),
[timezone]
)

return (
<TimezoneContext.Provider value={value}>
{children}
</TimezoneContext.Provider>
)
}

export const useTimezone = () => {
const context = useContext(TimezoneContext)
if (!context) {
Expand Down
4 changes: 2 additions & 2 deletions src/features/dashboard/timezone/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { TimezoneProvider, useTimezone } from './context'
export { TimezoneOverride, TimezoneProvider, useTimezone } from './context'
export type { Timezone } from './schema'
export { TimezoneSchema } from './schema'
export { TimezoneSchema, UTC_TIMEZONE } from './schema'
export {
formatTimezoneLabel,
getBrowserTimezone,
Expand Down
4 changes: 3 additions & 1 deletion src/features/dashboard/timezone/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ const TimezoneSchema = z

type Timezone = z.infer<typeof TimezoneSchema>

export { TimezoneSchema, type Timezone }
const UTC_TIMEZONE = TimezoneSchema.parse('UTC')

export { TimezoneSchema, UTC_TIMEZONE, type Timezone }
45 changes: 45 additions & 0 deletions src/features/dashboard/usage/billing-timezone-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'

import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { formatTimezoneAbbreviation } from '@/lib/utils/formatting'
import { Button } from '@/ui/primitives/button'
import { InfoIcon } from '@/ui/primitives/icons'
import { useUsageTimezone } from './usage-timezone'
import { isBillingTimezoneBannerVisible } from './usage-timezone-utils'

export function BillingTimezoneBanner({ className }: { className?: string }) {
const { userTimezone, isPinnedToUtc, setPinnedToUtc } = useUsageTimezone()

const userTimezoneAbbreviation = useMemo(
() => formatTimezoneAbbreviation(new Date(), userTimezone),
[userTimezone]
)

if (!isBillingTimezoneBannerVisible(userTimezone)) {
return null
}

return (
<div
className={cn(
'border-accent-info-highlight bg-bg-1 flex flex-wrap items-center gap-x-2 gap-y-2 border-l-[3px] py-1.5 pr-1.5 pl-3',
className
)}
>
<InfoIcon className="text-accent-info-highlight size-4 shrink-0" />
<span className="prose-label text-fg-secondary">
{`Billing uses UTC, which differs from your default timezone (${userTimezoneAbbreviation}).`}
</span>
<Button
variant="secondary"
size="small"
onClick={() => setPinnedToUtc(!isPinnedToUtc)}
>
{isPinnedToUtc
? `Switch back to ${userTimezoneAbbreviation}`
: 'View in UTC'}
</Button>
</div>
)
}
8 changes: 8 additions & 0 deletions src/features/dashboard/usage/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export const INITIAL_TIMEFRAME_FALLBACK_RANGE_MS = 30 * 24 * 60 * 60 * 1000
export const HOURLY_SAMPLING_THRESHOLD_DAYS = 3
export const WEEKLY_SAMPLING_THRESHOLD_DAYS = 60

/**
* How far a timeframe may deviate from a preset's boundaries and still be
* treated as that preset. Shared between the time-range controls (preset
* highlight) and the UTC pin (timeframe re-anchoring) — the two must not
* drift, or a highlighted preset would fail to re-anchor.
*/
export const PRESET_MATCH_TOLERANCE_MS = 24 * 60 * 60 * 1000

type CalendarDateParts = ReturnType<typeof getDateParts>

const shiftToMonthStart = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useId, useState } from 'react'
import { cn } from '@/lib/utils'
import { ChevronDownIcon, CpuIcon } from '@/ui/primitives/icons'
import { Tabs, TabsList, TabsTrigger } from '@/ui/primitives/tabs'
import { BillingTimezoneBanner } from '../billing-timezone-banner'
import { useUsageCharts } from '../usage-charts-context'
import { UsageTopTimeRangeControls } from '../usage-top-time-range-controls'
import { USAGE_METRICS, type UsageMetricKey } from './metrics'
Expand Down Expand Up @@ -38,8 +39,9 @@ export function UsageMetricDetail({ metric }: { metric: UsageMetricKey }) {
return (
<div className="h-full max-h-full min-h-0 overflow-y-auto">
<div className="flex w-full flex-col gap-4 p-6">
<div className="flex flex-wrap items-center gap-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<UsageTopTimeRangeControls />
<BillingTimezoneBanner className="ml-auto" />
</div>

<SingleMetricChart
Expand Down
7 changes: 5 additions & 2 deletions src/features/dashboard/usage/usage-time-range-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
import { Separator } from '@/ui/primitives/separator'
import { TimeRangePicker } from '@/ui/time-range-picker'
import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets'
import { getUsageTimeRangePresets } from './constants'
import {
getUsageTimeRangePresets,
PRESET_MATCH_TOLERANCE_MS,
} from './constants'
import {
determineSamplingMode,
normalizeToEndOfSamplingPeriod,
Expand Down Expand Up @@ -56,7 +59,7 @@ export function UsageTimeRangeControls({
timeRangePresets,
timeframe.start,
timeframe.end,
1000 * 60 * 60 * 24 // 1 day in tolerance
PRESET_MATCH_TOLERANCE_MS
),
[timeRangePresets, timeframe.start, timeframe.end]
)
Expand Down
61 changes: 61 additions & 0 deletions src/features/dashboard/usage/usage-timezone-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type Timezone, UTC_TIMEZONE } from '@/features/dashboard/timezone'
import { findMatchingPreset } from '@/lib/utils/time-range'
import type { TimeRangePreset } from '@/ui/time-range-presets'
import {
getUsageTimeRangePresets,
PRESET_MATCH_TOLERANCE_MS,
} from './constants'

const CALENDAR_ANCHORED_PRESET_IDS = new Set([
'this-month',
'last-month',
'this-year',
'last-year',
])

// Identical ranges (e.g. "this-month" vs "last-30-days" on the 30th of a
// 30-day month) must resolve to the calendar preset, or pinning to UTC can
// re-anchor to a rolling window that misses the billing month.
const byCalendarAnchoredFirst = (a: TimeRangePreset, b: TimeRangePreset) =>
Number(CALENDAR_ANCHORED_PRESET_IDS.has(b.id)) -
Number(CALENDAR_ANCHORED_PRESET_IDS.has(a.id))

export function resolveUsageTimezone(
userTimezone: Timezone,
isPinnedToUtc: boolean
): Timezone {
return isPinnedToUtc ? UTC_TIMEZONE : userTimezone
}

/**
* A preset-aligned timeframe describes calendar boundaries ("This month"), so
* switching timezone re-anchors it to the new timezone's boundaries — that is
* what makes totals match billing when pinning to UTC. Ranges within the
* highlight tolerance of a preset are treated as that preset; only genuinely
* custom ranges return null and are kept as picked.
*/
export function reanchorTimeframeToTimezone(
timeframe: { start: number; end: number },
fromTimezone: Timezone,
toTimezone: Timezone
): { start: number; end: number } | null {
const matchedPresetId = findMatchingPreset(
getUsageTimeRangePresets(fromTimezone).toSorted(byCalendarAnchoredFirst),
timeframe.start,
timeframe.end,
PRESET_MATCH_TOLERANCE_MS
Comment thread
drankou marked this conversation as resolved.
)
if (!matchedPresetId) return null

const preset = getUsageTimeRangePresets(toTimezone).find(
(candidate) => candidate.id === matchedPresetId
)

return preset?.getValue() ?? null
}

export function isBillingTimezoneBannerVisible(
userTimezone: Timezone
): boolean {
return userTimezone !== UTC_TIMEZONE
}
Loading
Loading