diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index c1012551c0..f55ae71cd2 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -60,6 +60,8 @@ struct SpendValues { pub struct UsageSpendSummary { pub rows: Vec, pub contract: SpendContract, + pub reporting_day: String, + pub dashboard_timezone: String, } #[derive(Debug, Clone)] @@ -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 { diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts new file mode 100644 index 0000000000..a9b0a127e2 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -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"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts new file mode 100644 index 0000000000..a418dd07d3 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -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; + } +} + +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)}`; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 1380c7b21b..3db82f6f53 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -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"; @@ -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; @@ -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"); } @@ -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")); @@ -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"] }], diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 865625efeb..8c3c1dc478 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -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"; diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index c705ff6dc0..d363a6b392 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -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::*; diff --git a/rust/src/core/timezone.rs b/rust/src/core/timezone.rs index 971b8dadd0..2052585a03 100644 --- a/rust/src/core/timezone.rs +++ b/rust/src/core/timezone.rs @@ -31,7 +31,7 @@ static GLOBALIZATION_PINNED: LazyLock = 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