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
22 changes: 21 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ struct SpendValues {
pub struct UsageSpendSummary {
pub rows: Vec<UsageSpendRow>,
pub contract: SpendContract,
pub reporting_day: String,
pub dashboard_timezone: String,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -611,7 +613,25 @@ fn build_usage_spend_summary(
settings.hide_personal_info,
selected_summary,
);
UsageSpendSummary { rows, contract }
let reporting_day = last_included_reporting_day(&contract);
let dashboard_timezone = codexbar::core::local_timezone_name();
UsageSpendSummary {
rows,
contract,
reporting_day,
dashboard_timezone,
}
}

fn last_included_reporting_day(contract: &SpendContract) -> String {
contract
.daily
.iter()
.filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok())
.max()
.unwrap_or_else(|| chrono::Local::now().date_naive())
.format("%Y-%m-%d")
.to_string()
}

fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option<u64> {
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop-tauri/src/lib/usageSpendSharing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";

import {
formatUsageSpendReportingDay,
usageSpendShareFooter,
usageSpendSubscriptionCaption,
} from "./usageSpendSharing";

describe("usage spend sharing", () => {
it.each([
[0, "0 subscriptions"],
[1, "1 subscription"],
[2, "2 subscriptions"],
[12, "12 subscriptions"],
])("uses the correct subscription caption for %i", (count, expected) => {
expect(usageSpendSubscriptionCaption(count)).toBe(expected);
});

it("preserves the reporting day across dashboard timezones", () => {
expect(formatUsageSpendReportingDay("2026-09-19", "Pacific/Kiritimati")).toBe("Sep 19, 2026");
expect(formatUsageSpendReportingDay("2026-09-19", "America/Los_Angeles")).toBe("Sep 19, 2026");
expect(formatUsageSpendReportingDay("2026-10-01", "Pacific/Norfolk")).toBe("Oct 1, 2026");
});

it("falls back for an invalid dashboard timezone or date", () => {
expect(formatUsageSpendReportingDay("2026-10-01", "Not/AZone")).toBe("2026-10-01");
expect(formatUsageSpendReportingDay("2026-02-31", "UTC")).toBe("2026-02-31");
});

it("builds the report footer from the included day and row count", () => {
expect(
usageSpendShareFooter({
rows: [{}],
reportingDay: "2026-09-19",
dashboardTimezone: "UTC",
}),
).toBe("Data through Sep 19, 2026 · 1 subscription");
});
});
92 changes: 92 additions & 0 deletions apps/desktop-tauri/src/lib/usageSpendSharing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
interface CivilDate {
year: number;
month: number;
day: number;
}

export interface UsageSpendShareSummary {
rows: readonly unknown[];
reportingDay: string;
dashboardTimezone: string;
}

function parseCivilDate(value: string): CivilDate | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const [, yearText, monthText, dayText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) return null;

const instant = new Date(0);
instant.setUTCFullYear(year, month - 1, day);
instant.setUTCHours(12, 0, 0, 0);
if (
instant.getUTCFullYear() !== year ||
instant.getUTCMonth() !== month - 1 ||
instant.getUTCDate() !== day
) {
return null;
}
return { year, month, day };
}

function civilDateInstant(date: CivilDate): Date {
const instant = new Date(0);
instant.setUTCFullYear(date.year, date.month - 1, date.day);
instant.setUTCHours(12, 0, 0, 0);
return instant;
}

function partsInTimeZone(instant: Date, timeZone: string): CivilDate {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(instant);
const valueFor = (type: string) => parts.find((part) => part.type === type)?.value;
const year = Number(valueFor("year"));
const month = Number(valueFor("month"));
const day = Number(valueFor("day"));
if (![year, month, day].every(Number.isInteger)) {
throw new RangeError("Timezone did not provide a complete date");
}
return { year, month, day };
}

/** Formats a civil reporting day without allowing the timezone offset to roll it into another day. */
export function formatUsageSpendReportingDay(
reportingDay: string,
dashboardTimezone: string,
locale = "en-US",
): string {
const civilDate = parseCivilDate(reportingDay);
if (!civilDate) return reportingDay;

try {
const target = civilDateInstant(civilDate);
// Validate the configured zone, but keep the reporting day as the civil
// date supplied by the dashboard. Applying the zone offset to the instant
// can move October 1 (and other boundary dates) into a different calendar
// day in zones with a non-hour offset or a DST transition.
partsInTimeZone(target, dashboardTimezone);
return new Intl.DateTimeFormat(locale, {
timeZone: "UTC",
year: "numeric",
month: "short",
day: "numeric",
}).format(target);
} catch {
return reportingDay;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function usageSpendSubscriptionCaption(count: number): string {
return count === 1 ? "1 subscription" : `${count} subscriptions`;
}

export function usageSpendShareFooter(summary: UsageSpendShareSummary): string {
return `Data through ${formatUsageSpendReportingDay(summary.reportingDay, summary.dashboardTimezone)} · ${usageSpendSubscriptionCaption(summary.rows.length)}`;
}
11 changes: 8 additions & 3 deletions apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
updateSettings,
writeUsageSpendExport,
} from "../../../lib/tauri";
import { usageSpendShareFooter } from "../../../lib/usageSpendSharing";
import type { CostSummaryDisplayStyle, SettingsSnapshot, SpendContract, UsageSpendSummary } from "../../../types/bridge";
import type { LocaleKey } from "../../../i18n/keys";
import type { TabProps } from "../settingsTabs";
Expand Down Expand Up @@ -55,7 +56,7 @@ function renderSharePng(summary: UsageSpendSummary, title: string): string {
const headerH = 48;
const colW = [160, 100, 100, 80, 160];
const width = pad * 2 + colW.reduce((a, b) => a + b, 0);
const height = pad * 2 + headerH + Math.max(1, rows.length) * rowH + 36;
const height = pad * 2 + headerH + Math.max(1, rows.length) * rowH + 52;
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
Expand Down Expand Up @@ -126,6 +127,10 @@ function renderSharePng(summary: UsageSpendSummary, title: string): string {
});
}

ctx.fillStyle = "#8b9bb4";
ctx.font = "12px system-ui,Segoe UI,sans-serif";
ctx.fillText(usageSpendShareFooter(summary), pad, height - pad);

return canvas.toDataURL("image/png");
}

Expand Down Expand Up @@ -218,7 +223,7 @@ export default function UsageSpendTab(_props: TabProps) {
setShareError(t("UsageSpendShareFailed"));
return;
}
const stamp = new Date().toISOString().slice(0, 10);
const stamp = summary.reportingDay;
downloadDataUrl(dataUrl, `codexbar-usage-spend-${stamp}.png`);
} catch {
setShareError(t("UsageSpendShareFailed"));
Expand All @@ -245,7 +250,7 @@ export default function UsageSpendTab(_props: TabProps) {
return;
}
try {
const stamp = new Date().toISOString().slice(0, 10);
const stamp = summary.reportingDay;
const path = await save({
defaultPath: `codexbar-usage-spend-${stamp}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ export interface UsageSpendRow {
export interface UsageSpendSummary {
rows: UsageSpendRow[];
contract: SpendContract;
reportingDay: string;
dashboardTimezone: string;
}

export type CostProvenance = "listPriceEstimate" | "vendorMetered" | "mixed" | "unknown";
Expand Down
2 changes: 1 addition & 1 deletion rust/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub use redactor::*;
pub use session_equivalent_forecast::*;
pub use session_quota::*;
pub use sqlite::*;
pub(crate) use timezone::local_timezone_name;
pub use timezone::local_timezone_name;
pub use token_accounts::*;
pub use usage_pace::*;
pub use usage_snapshot::*;
Expand Down
2 changes: 1 addition & 1 deletion rust/src/core/timezone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ static GLOBALIZATION_PINNED: LazyLock<bool> = LazyLock::new(pin_globalization_dl

/// Returns the IANA name of the system timezone, or `"UTC"` if it cannot be
/// determined safely.
pub(crate) fn local_timezone_name() -> String {
pub fn local_timezone_name() -> String {
#[cfg(windows)]
{
// Deref runs the load+pin to completion on one thread while all
Expand Down