Compare commits

...

1 Commits

Author SHA1 Message Date
Jack 6177759ca8 fix(app): align usage chart with local timezone 2026-04-25 17:31:41 +08:00
@@ -27,12 +27,16 @@ Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, L
async function getCosts(workspaceID: string, year: number, month: number) { async function getCosts(workspaceID: string, year: number, month: number) {
"use server" "use server"
return withActor(async () => { return withActor(async () => {
const startDate = new Date(year, month, 1) // Fetch a UTC buffer so client-side local dates at month boundaries are not dropped.
const endDate = new Date(year, month + 1, 1) const startDate = new Date(Date.UTC(year, month, 1))
startDate.setUTCDate(startDate.getUTCDate() - 1)
const endDate = new Date(Date.UTC(year, month + 1, 1))
endDate.setUTCDate(endDate.getUTCDate() + 1)
const hourBucket = sql<number>`FLOOR(UNIX_TIMESTAMP(${UsageTable.timeCreated}) / 3600)`
const usageData = await Database.use((tx) => const usageData = await Database.use((tx) =>
tx tx
.select({ .select({
date: sql<string>`DATE(${UsageTable.timeCreated})`, hourBucket: hourBucket.as("hour_bucket"),
model: UsageTable.model, model: UsageTable.model,
totalCost: sum(UsageTable.cost), totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID, keyId: UsageTable.keyID,
@@ -47,7 +51,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
), ),
) )
.groupBy( .groupBy(
sql`DATE(${UsageTable.timeCreated})`, hourBucket,
UsageTable.model, UsageTable.model,
UsageTable.keyID, UsageTable.keyID,
sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
@@ -55,6 +59,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
.then((x) => .then((x) =>
x.map((r) => ({ x.map((r) => ({
...r, ...r,
hourBucket: Number(r.hourBucket),
totalCost: r.totalCost ? parseInt(r.totalCost) : 0, totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
plan: r.plan as "sub" | "lite" | "byok" | null, plan: r.plan as "sub" | "lite" | "byok" | null,
})), })),
@@ -125,15 +130,13 @@ function getModelColor(model: string): string {
} }
function formatDateLabel(dateStr: string): string { function formatDateLabel(dateStr: string): string {
const date = new Date() const [year, month, day] = dateStr.split("-").map(Number)
const [y, m, d] = dateStr.split("-").map(Number) const date = new Date(year, month - 1, day)
date.setFullYear(y) return `${date.toLocaleDateString(undefined, { month: "short" })} ${day.toString().padStart(2, "0")}`
date.setMonth(m - 1) }
date.setDate(d)
date.setHours(0, 0, 0, 0) function formatDateKey(date: Date) {
const month = date.toLocaleDateString(undefined, { month: "short" }) return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
} }
function addOpacityToColor(color: string, opacity: number): string { function addOpacityToColor(color: string, opacity: number): string {
@@ -179,17 +182,24 @@ export function GraphSection() {
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false }) const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getModels = createMemo(() => {
if (!store.data?.usage) return []
return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => { const getDates = createMemo(() => {
const daysInMonth = new Date(store.year, store.month + 1, 0).getDate() const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
return Array.from({ length: daysInMonth }, (_, i) => { const dates = Array.from({ length: daysInMonth }, (_, i) => formatDateKey(new Date(store.year, store.month, i + 1)))
const date = new Date(store.year, store.month, i + 1) const dateSet = new Set(dates)
return date.toISOString().split("T")[0] return {
}) dates,
dateSet,
}
})
const getUsageForMonth = createMemo(() => {
if (!store.data?.usage) return []
const dateSet = getDates().dateSet
return store.data.usage.filter((row) => dateSet.has(formatDateKey(new Date(row.hourBucket * 3600 * 1000))))
})
const getModels = createMemo(() => {
return Array.from(new Set(getUsageForMonth().map((row) => row.model))).sort()
}) })
const getKeyName = (keyID: string | null): string => { const getKeyName = (keyID: string | null): string => {
@@ -205,9 +215,9 @@ export function GraphSection() {
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth() const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => { const chartConfig = createMemo((): ChartConfiguration | null => {
const data = store.data const dates = getDates().dates
const dates = getDates() const usage = getUsageForMonth()
if (!data?.usage?.length) return null if (usage.length === 0) return null
store.colorScheme store.colorScheme
const styles = getComputedStyle(document.documentElement) const styles = getComputedStyle(document.documentElement)
@@ -229,11 +239,12 @@ export function GraphSection() {
dailyDataLite.set(dateKey, new Map()) dailyDataLite.set(dateKey, new Map())
} }
data.usage usage
.filter((row) => (store.key ? row.keyId === store.key : true)) .filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => { .forEach((row) => {
const dateKey = formatDateKey(new Date(row.hourBucket * 3600 * 1000))
const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
const dayMap = targetMap.get(row.date) const dayMap = targetMap.get(dateKey)
if (!dayMap) return if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost) dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
}) })
@@ -286,6 +297,8 @@ export function GraphSection() {
}), }),
] ]
if (datasets.length === 0) return null
return { return {
type: "bar", type: "bar",
data: { data: {