From 84bfc8622556541c05f48fb11036c615df7909fd Mon Sep 17 00:00:00 2001 From: ericjypark Date: Thu, 10 Sep 2026 21:09:35 -0500 Subject: [PATCH] feat: add shareable usage cards --- README.md | 23 + README.zh-CN.md | 2 + Resources/en.lproj/Localizable.strings | 87 ++++ Resources/zh-Hans.lproj/Localizable.strings | 87 ++++ Sources/App.swift | 32 ++ Sources/Cost/CostStore.swift | 16 +- Sources/Sharing/WeeklyCardExporter.swift | 68 +++ Sources/Sharing/WeeklyCardHistoryStore.swift | 60 +++ Sources/Sharing/WeeklyCardLaunchGate.swift | 27 ++ Sources/Sharing/WeeklyCardShareButton.swift | 66 +++ Sources/Sharing/WeeklyCardStudio.swift | 447 ++++++++++++++++++ Sources/Sharing/WeeklyCardStyle.swift | 61 +++ Sources/Sharing/WeeklyCardTier.swift | 28 ++ .../Sharing/WeeklyCardWindowController.swift | 61 +++ Sources/Sharing/WeeklyUsageCard.swift | 326 +++++++++++++ Sources/Sharing/WeeklyUsageSnapshot.swift | 338 +++++++++++++ Sources/Views/PanelFooter.swift | 4 + Sources/Views/Settings/SegmentedControl.swift | 3 +- Sources/Views/SettingsView.swift | 5 + Tests/WeeklyCardRenderHarness.swift | 136 ++++++ Tests/WeeklyUsageSnapshotTests.swift | 342 ++++++++++++++ docs/USAGE-HISTORY.md | 15 +- scripts/preview-weekly-card.sh | 29 ++ scripts/run-tests.sh | 1 + scripts/test-weekly-card.sh | 13 + 25 files changed, 2263 insertions(+), 14 deletions(-) create mode 100644 Sources/Sharing/WeeklyCardExporter.swift create mode 100644 Sources/Sharing/WeeklyCardHistoryStore.swift create mode 100644 Sources/Sharing/WeeklyCardLaunchGate.swift create mode 100644 Sources/Sharing/WeeklyCardShareButton.swift create mode 100644 Sources/Sharing/WeeklyCardStudio.swift create mode 100644 Sources/Sharing/WeeklyCardStyle.swift create mode 100644 Sources/Sharing/WeeklyCardTier.swift create mode 100644 Sources/Sharing/WeeklyCardWindowController.swift create mode 100644 Sources/Sharing/WeeklyUsageCard.swift create mode 100644 Sources/Sharing/WeeklyUsageSnapshot.swift create mode 100644 Tests/WeeklyCardRenderHarness.swift create mode 100644 Tests/WeeklyUsageSnapshotTests.swift create mode 100644 scripts/preview-weekly-card.sh create mode 100644 scripts/test-weekly-card.sh diff --git a/README.md b/README.md index c718a501..e94a020e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,23 @@ providers' own usage endpoints. calendar using logs from every supported provider, regardless of which providers are selected for the usage pills. Click a provider in the calendar legend to filter its history; click it again to show all providers. +- **A usage card worth sharing.** Open **Overview → Share usage** or + **Settings → General → Usage card**. Put your estimated API value + in USD front and center, with a flowing cumulative chart and provider + amounts, or spotlight your token count. Card colors are earned: White below + $1K / 100M tokens, Black from $1K / 100M, and Blue from $10K / 1B, using the + selected metric. Pick **Last 7 days**, **This month**, **Last 3 months**, **This year**, + or **All time**. Last 7 days is the default and includes today plus the previous + six days. Last 3 months is a rolling calendar + window ending today. Month and year run through today; All time reads the oldest + available local usage records on demand. Choose a feed / square / story format and optional + signature, then share the image and caption + through the macOS share menu. Use **Actual size** for a closer look and the + **…** menu to save a 1080-pixel-wide PNG or copy the image and caption. + After an app update, the card opens once when your weekly usage is ready. + Figures follow your local calendar and include cache usage; + API value is an estimate, not your subscription bill. + Missing prices are marked as partial. Everything is rendered on your Mac. - **Usage history that stays yours.** CodexIsland saves captured token counts in its own local database, including older records still available when it scans. Provider log cleanup no longer erases captured history. Repeated @@ -310,6 +327,12 @@ Native app behavior: in `CLAUDE_CONFIG_DIR`), Codex session logs from `~/.codex/sessions/`, and OpenCode data from `~/.local/share/opencode/`. Aggregation happens entirely on-device — no log content is uploaded or shared anywhere. +- Default Claude history discovery also includes locally mirrored Cowork + session logs in `~/Library/Application Support/Claude/local-agent-mode-sessions/`. + Captured usage is retained in + `~/Library/Application Support/dev.codexisland.CodexIsland/usage-history.sqlite3`. + The database contains token counts, models, timestamps, and opaque record + identifiers; it does not contain prompts, responses, or credentials. The visitor badge at the top of this README is an external `hits.sh` image that counts badge requests. It is not bundled with or contacted by the native app. diff --git a/README.zh-CN.md b/README.zh-CN.md index e3f252c0..71cd8841 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,6 +19,8 @@ CodexIsland 是一个原生 macOS 悬浮层,把 MacBook 刘海变成类似 Dyn - **悬停预览。** 鼠标移到刘海附近时,胶囊会展开到足够显示每个可见服务的 5 小时百分比和重置提示。 - **点击展开。** 点击岛可打开完整 Usage / Cost / Overview 面板,包含服务列、图表控制和分页。 - **Usage 与 Cost 横向切换。** Cost 页面会从本地 Claude Code 和 Codex 日志估算今天与本月至今的美元成本、token 吞吐量和趋势。 +- **用量分享卡片。** 从 **Overview → 分享用量** 或 **设置 → 通用 → 用量卡片** 打开。以美元 API 换算价值为主角,展示流动的累计曲线与各提供商金额,也可切换为 token 数。配色由所选指标自动决定:低于 $1K / 1 亿 token 为白卡,达到 $1K / 1 亿为黑卡,达到 $10K / 10 亿为蓝卡。时间范围可选最近 7 天、本月、最近 3 个月、今年或全部时间,默认最近 7 天,包含今天及之前六天。最近 3 个月为截至今天的滚动日历范围。本月和今年统计至今天,全部时间按需读取最早保存的本地用量记录。选择动态 / 方形 / 故事比例和可选署名,通过 macOS 分享菜单发送图片与文案。点击“实际大小”查看细节,通过“…”菜单保存 1080 像素宽的 PNG 或复制图片与文案。应用更新后,有本周用量时会自动打开一次。按本地日历统计并包含缓存;金额为估算值,并非订阅账单,缺少价格时会标注。全部在 Mac 上生成。 +- **持久保存用量历史。** CodexIsland 将采集到的 token 数量保存在自己的本地数据库中,即使提供商清理日志也会保留。重复扫描不会重复计数。仅保存用量、模型、时间及匿名记录标识,不保存对话或凭据。从 **设置 → 通用 → 恢复 Claude 用量…** 打开恢复窗口,预览留存日志、备份和旧每日快照中的计数,确认后再导入。也可使用 [终端恢复脚本](docs/USAGE-HISTORY.md#recover-your-claude-usage)。详见[用量历史存储说明](docs/USAGE-HISTORY.md)。 - **可配置 token 统计口径。** 可以选择统计所有 token(包含缓存,接近 ccusage 口径),或只统计输入 + 输出(接近 Anthropic claude.ai 统计面板)。 - **不遮挡岛外点击。** 窗口会忽略可见轮廓外的鼠标事件,菜单栏和后面的 app 仍能正常操作。 - **多种图表样式。** 支持 Ring、Bar、Stepped、Numeric、Sparkline;可在设置中选择默认样式,也可在展开面板里 Cmd 点击切换。 diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 1f6b48c4..ed594a07 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -227,6 +227,93 @@ "Sign in with Claude" = "Sign in with Claude"; "Open Claude Code and run /login." = "Open Claude Code and run /login."; +"A fresh page for your week." = "A fresh page for your week."; +"Caption copied. Add it to your post." = "Caption copied. Add it to your post."; +"Copy caption" = "Copy caption"; +"Copy image" = "Copy image"; +"Could not copy the caption. Try again." = "Could not copy the caption. Try again."; +"Could not copy the image. Try saving a PNG instead." = "Could not copy the image. Try saving a PNG instead."; +"Could not create the image. Try again." = "Could not create the image. Try again."; +"Could not export card" = "Could not export card"; +"Create card…" = "Create card…"; +"Create your weekly usage card" = "Create your weekly usage card"; +"Created on your Mac." = "Created on your Mac."; +"Feed · 4:5" = "Feed · 4:5"; +"Format" = "Format"; +"Gathering your week…" = "Gathering your week…"; +"Image copied. Paste it into your post." = "Image copied. Paste it into your post."; +"Include" = "Include"; +"Includes cache tokens. Only totals and your signature appear on the card." = "Includes cache tokens. Only totals and your signature appear on the card."; +"Includes today and the previous six days." = "Includes today and the previous six days."; +"Last 3 months" = "Last 3 months"; +"Last 7 days" = "Last 7 days"; +"Make it yours. Share it anywhere." = "Make it yours. Share it anywhere."; +"Name or @handle (optional)" = "Name or @handle (optional)"; +"Name or handle on card" = "Name or handle on card"; +"No recorded tokens in this period. Try another week or include more providers." = "No recorded tokens in this period. Try another week or include more providers."; +"OK" = "OK"; +"PNG saved. Your week is ready to share." = "PNG saved. Your week is ready to share."; +"Reading usage records on this Mac." = "Reading usage records on this Mac."; +"Ready for your feed" = "Ready for your feed"; +"Refresh records" = "Refresh records"; +"Save PNG…" = "Save PNG…"; +"Save weekly card" = "Save weekly card"; +"Share week" = "Share week"; +"Signature" = "Signature"; +"Some local records could not be read. The card is marked as partial." = "Some local records could not be read. The card is marked as partial."; +"Square · 1:1" = "Square · 1:1"; +"Story · 9:16" = "Story · 9:16"; +"Week" = "Week"; +"Weekly usage card" = "Weekly usage card"; +"Your week has a shape." = "Your week has a shape."; +"Your week with AI, ready to share." = "Your week with AI, ready to share."; + +"Your week. Your flex." = "Your week. Your flex."; +"Share…" = "Share…"; +"Earned color" = "Earned color"; +"White" = "White"; +"Black" = "Black"; +"Blue" = "Blue"; +"Current tier" = "Current tier"; +"Color is earned from your weekly API value." = "Color is earned from your weekly API value."; +"Color is earned from your weekly tokens, including cache." = "Color is earned from your weekly tokens, including cache."; +"Put your AI tab on display." = "Put your AI tab on display."; +"Spotlight" = "Spotlight"; +"API value" = "API value"; +"Your usage at API rates in USD. An estimate, not your subscription bill." = "Your usage at API rates in USD. An estimate, not your subscription bill."; +"Every token, including cache reads and writes." = "Every token, including cache reads and writes."; +"Your tokens need a price." = "Your tokens need a price."; +"Refresh records to load API prices, or switch the spotlight to tokens." = "Refresh records to load API prices, or switch the spotlight to tokens."; +"Some tokens have no known price. The + marks a partial value." = "Some tokens have no known price. The + marks a partial value."; + +"Weekly card" = "Weekly card"; +"About this card" = "About this card"; +"API value estimates your usage at API rates in USD, not your subscription bill. Tokens include cache reads and writes." = "API value estimates your usage at API rates in USD, not your subscription bill. Tokens include cache reads and writes."; +"The card includes daily usage, provider totals, active days, and your signature. It does not include prompts or conversations." = "The card includes daily usage, provider totals, active days, and your signature. It does not include prompts or conversations."; +"Fit" = "Fit"; +"Actual size" = "Actual size"; +"View color tiers" = "View color tiers"; +"More export options" = "More export options"; +"Some local records could not be read. Totals may be incomplete." = "Some local records could not be read. Totals may be incomplete."; +"Usage card" = "Usage card"; +"Period" = "Period"; +"This month" = "This month"; +"This year" = "This year"; +"All time" = "All time"; +"Last 7 days includes today and the previous six days. Month and year also run through today. All time includes all saved usage records on this Mac." = "Last 7 days includes today and the previous six days. Month and year also run through today. All time includes all saved usage records on this Mac."; +"Gathering your usage…" = "Gathering your usage…"; +"A fresh page for your usage." = "A fresh page for your usage."; +"No recorded tokens in this period. Try another period or include more providers." = "No recorded tokens in this period. Try another period or include more providers."; +"PNG saved. Your card is ready to share." = "PNG saved. Your card is ready to share."; +"Share usage" = "Share usage"; +"Create your usage card" = "Create your usage card"; +"Save usage card" = "Save usage card"; +"Your time with AI, ready to share." = "Your time with AI, ready to share."; + +"Captured token counts are saved on this Mac even if provider logs are removed. Records deleted before capture may still be missing." = "Captured token counts are saved on this Mac even if provider logs are removed. Records deleted before capture may still be missing."; + +"Includes recovered daily totals. Their original dates are preserved; historical records may be incomplete." = "Includes recovered daily totals. Their original dates are preserved; historical records may be incomplete."; + "Usage history could not be saved. Check available disk space and try refreshing." = "Usage history could not be saved. Check available disk space and try refreshing."; "Recover Claude usage" = "Recover Claude usage"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 5a3f94dd..7dd4e336 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -227,6 +227,93 @@ "Sign in with Claude" = "登录 Claude"; "Open Claude Code and run /login." = "打开 Claude Code 并运行 /login。"; +"A fresh page for your week." = "新一周,从这里开始。"; +"Caption copied. Add it to your post." = "文案已复制,可添加到帖子中。"; +"Copy caption" = "复制文案"; +"Copy image" = "复制图片"; +"Could not copy the caption. Try again." = "无法复制文案,请重试。"; +"Could not copy the image. Try saving a PNG instead." = "无法复制图片,请尝试保存为 PNG。"; +"Could not create the image. Try again." = "无法生成图片,请重试。"; +"Could not export card" = "无法导出卡片"; +"Create card…" = "制作卡片…"; +"Create your weekly usage card" = "制作你的每周用量卡片"; +"Created on your Mac." = "在你的 Mac 上生成。"; +"Feed · 4:5" = "动态 · 4:5"; +"Format" = "格式"; +"Gathering your week…" = "正在整理本周记录…"; +"Image copied. Paste it into your post." = "图片已复制,可粘贴到帖子中。"; +"Include" = "包含"; +"Includes cache tokens. Only totals and your signature appear on the card." = "包含缓存 token。卡片仅展示汇总数据和你的署名。"; +"Includes today and the previous six days." = "包含今天及之前六天。"; +"Last 3 months" = "最近 3 个月"; +"Last 7 days" = "最近 7 天"; +"Make it yours. Share it anywhere." = "定制你的风格,分享你的每周记录。"; +"Name or @handle (optional)" = "姓名或 @用户名(可选)"; +"Name or handle on card" = "卡片上的姓名或用户名"; +"No recorded tokens in this period. Try another week or include more providers." = "此时段暂无 token 记录。试试其他时段,或选择更多提供商。"; +"OK" = "好"; +"PNG saved. Your week is ready to share." = "PNG 已保存,可以分享你的每周记录了。"; +"Reading usage records on this Mac." = "正在读取这台 Mac 上的用量记录。"; +"Ready for your feed" = "随时分享"; +"Refresh records" = "刷新记录"; +"Save PNG…" = "保存 PNG…"; +"Save weekly card" = "保存每周卡片"; +"Share week" = "分享本周"; +"Signature" = "署名"; +"Some local records could not be read. The card is marked as partial." = "部分本地记录无法读取,卡片会标注数据不完整。"; +"Square · 1:1" = "方形 · 1:1"; +"Story · 9:16" = "故事 · 9:16"; +"Week" = "时段"; +"Weekly usage card" = "每周用量卡片"; +"Your week has a shape." = "看见你这一周的形状。"; +"Your week with AI, ready to share." = "与 AI 度过的一周,值得分享。"; + +"Your week. Your flex." = "这一周,值得炫耀。"; +"Share…" = "分享…"; +"Earned color" = "配色等级"; +"White" = "白色"; +"Black" = "黑色"; +"Blue" = "蓝色"; +"Current tier" = "当前等级"; +"Color is earned from your weekly API value." = "配色由本周 API 换算价值决定。"; +"Color is earned from your weekly tokens, including cache." = "配色由本周 token 用量决定,包含缓存。"; +"Put your AI tab on display." = "晒出你的 AI 用量账单。"; +"Spotlight" = "主角"; +"API value" = "API 换算价值"; +"Your usage at API rates in USD. An estimate, not your subscription bill." = "按 API 费率换算为美元的使用价值,仅为估算,并非订阅账单。"; +"Every token, including cache reads and writes." = "统计所有 token,包括缓存读取和写入。"; +"Your tokens need a price." = "这些 token 还没有价格。"; +"Refresh records to load API prices, or switch the spotlight to tokens." = "刷新记录以载入 API 价格,或选择展示 token。"; +"Some tokens have no known price. The + marks a partial value." = "部分 token 暂无已知价格,+ 表示价值估算不完整。"; + +"Weekly card" = "每周卡片"; +"About this card" = "关于此卡片"; +"API value estimates your usage at API rates in USD, not your subscription bill. Tokens include cache reads and writes." = "API 价值按 API 费率以美元估算使用量,并非订阅账单。Token 包含缓存读取与写入。"; +"The card includes daily usage, provider totals, active days, and your signature. It does not include prompts or conversations." = "卡片包含每日使用量、各服务商总量、活跃天数和签名,不包含提示词或对话内容。"; +"Fit" = "适应窗口"; +"Actual size" = "实际大小"; +"View color tiers" = "查看颜色等级"; +"More export options" = "更多导出选项"; +"Some local records could not be read. Totals may be incomplete." = "部分本地记录无法读取,总量可能不完整。"; +"Usage card" = "用量卡片"; +"Period" = "时间范围"; +"This month" = "本月"; +"This year" = "今年"; +"All time" = "全部时间"; +"Last 7 days includes today and the previous six days. Month and year also run through today. All time includes all saved usage records on this Mac." = "最近 7 天包含今天及之前六天。本月和今年也统计至今天。全部时间包含此 Mac 上保存的所有用量记录。"; +"Gathering your usage…" = "正在汇总用量…"; +"A fresh page for your usage." = "开始记录你的使用历程。"; +"No recorded tokens in this period. Try another period or include more providers." = "此时间范围内没有 token 记录。请尝试其他时间范围或选择更多服务商。"; +"PNG saved. Your card is ready to share." = "PNG 已保存,卡片可以分享了。"; +"Share usage" = "分享用量"; +"Create your usage card" = "创建用量卡片"; +"Save usage card" = "保存用量卡片"; +"Your time with AI, ready to share." = "把你的 AI 使用历程分享出去。"; + +"Captured token counts are saved on this Mac even if provider logs are removed. Records deleted before capture may still be missing." = "已采集的令牌数量会保存在这台 Mac 上,即使提供商删除日志也会保留。采集之前已删除的记录仍可能缺失。"; + +"Includes recovered daily totals. Their original dates are preserved; historical records may be incomplete." = "包含恢复的每日总量,并保留原始日期;历史记录可能仍不完整。"; + "Usage history could not be saved. Check available disk space and try refreshing." = "无法保存用量历史。请检查可用磁盘空间,然后尝试刷新。"; "Recover Claude usage" = "恢复 Claude 用量"; diff --git a/Sources/App.swift b/Sources/App.swift index f26d4492..a75a4d15 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import Combine @main enum CodexIslandEntryPoint { @@ -27,8 +28,17 @@ struct CodexIslandApp: App { final class AppDelegate: NSObject, NSApplicationDelegate { var island: IslandWindowController? private var settingsShortcutMonitor: Any? + private var weeklyCardLaunchObservation: AnyCancellable? func applicationDidFinishLaunching(_ notification: Notification) { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let appPreferences = Bundle.main.bundleIdentifier + .flatMap { UserDefaults.standard.persistentDomain(forName: $0) } ?? [:] + let existingInstallation = appPreferences.keys.contains { $0.hasPrefix("MacIsland.") } + let launchGate = WeeklyCardLaunchGate(defaults: .standard) + let offerWeeklyCard = !AppEnvironment.isDemo + && launchGate.prepare(version: version, existingInstallation: existingInstallation) + // Before any window or store exists: the first cost scan must price // against the cached catalog, not fall back to the seed and then // silently change its numbers a moment later. @@ -59,6 +69,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { PricingCatalog.startAutoRefresh() CurrencyStore.shared.startAutoRefresh() + if offerWeeklyCard { + let costs = CostStore.shared + weeklyCardLaunchObservation = Publishers.CombineLatest3( + costs.$claudeLoading, costs.$codexLoading, costs.$connectedLoading + ) + .filter { !$0 && !$1 && $2.isEmpty } + .first() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + Task { @MainActor in + self?.weeklyCardLaunchObservation = nil + guard launchGate.isPending(version: version) else { return } + launchGate.complete(version: version) + let snapshot = WeeklyUsageSnapshot.make(buckets: Dictionary( + uniqueKeysWithValues: IslandProvider.allCases.map { ($0, costs.cost(for: $0).dailyTokens) } + )) + guard snapshot.totalTokens > 0 else { return } + WeeklyCardWindowController.shared.show(refresh: false) + } + } + } + // Wire the alert engine after the usage store so its initial // recompute sees whatever values the first refresh has produced. AlertEngine.shared.start() diff --git a/Sources/Cost/CostStore.swift b/Sources/Cost/CostStore.swift index 8e6d23d9..27d9aa69 100644 --- a/Sources/Cost/CostStore.swift +++ b/Sources/Cost/CostStore.swift @@ -216,7 +216,7 @@ final class CostStore: ObservableObject { 24, 31, 128, 44, 82, 76, 310, 122, 218, 236, 98, 64, 47, 286, 140, 205, 59, 276, 119, 48, 0, 86, 136, 154, 168, 148, 132, 94, 402, 211, - ], millionScale: 1_000_000) + ], millionScale: 1_000_000, apiDollarsPerMillion: 0.696) ) // Codex: evening-person pattern — flat all morning, light midday, // explodes 6pm-11pm. Single big surge contrasts Claude's two-peak day. @@ -237,7 +237,7 @@ final class CostStore: ObservableObject { 12, 18, 24, 29, 37, 42, 51, 59, 66, 74, 83, 90, 99, 108, 117, 124, 136, 145, 157, 166, 175, 188, 201, 214, 228, 239, 254, 268, 282, 164, - ], millionScale: 1_000_000) + ], millionScale: 1_000_000, apiDollarsPerMillion: 0.832) ) for (provider, scale) in [(IslandProvider.grok, 0.32), (.antigravity, 0.24)] { func scaled(_ window: CostWindow) -> CostWindow { @@ -252,7 +252,8 @@ final class CostStore: ObservableObject { dailyTokens: codex.dailyTokens.map { DailyTokenBucket(dayStart: $0.dayStart, tokens: Int(Double($0.tokens) * scale), - billableTokens: Int(Double($0.billableTokens) * scale)) + billableTokens: Int(Double($0.billableTokens) * scale), + dollars: $0.dollars.map { $0 * scale }, unpricedTokens: 0) }) connectedUpdated[provider] = Date() localNotices[provider] = "Demo data — illustrative API-equivalent cost, not actual spending." @@ -262,18 +263,21 @@ final class CostStore: ObservableObject { private static func demoDailyBuckets( _ values: [Int], - millionScale: Int + millionScale: Int, + apiDollarsPerMillion: Double ) -> [DailyTokenBucket] { var cal = Calendar(identifier: .gregorian) cal.timeZone = .current - let days = CostSummary.yearHistoryDays() + let days = CostSummary.localHistoryDays() let today = cal.startOfDay(for: Date()) let start = cal.date(byAdding: .day, value: -(days - 1), to: today) ?? today return (0.. Data { + let renderer = ImageRenderer(content: WeeklyUsageCard(snapshot: snapshot, + format: format, signature: signature, metric: metric)) + renderer.proposedSize = ProposedViewSize(format.size) + renderer.scale = 2 + renderer.isOpaque = true + guard let image = renderer.cgImage, + let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) + else { throw WeeklyCardExportError.renderingFailed } + return data + } + + static func copyImage(_ data: Data, to pasteboard: NSPasteboard = .general) throws { + let item = NSPasteboardItem() + guard item.setData(data, forType: .png) else { throw WeeklyCardExportError.clipboardFailed } + if let image = NSImage(data: data), let tiff = image.tiffRepresentation { + item.setData(tiff, forType: .tiff) + } + pasteboard.clearContents() + guard pasteboard.writeObjects([item]) else { throw WeeklyCardExportError.clipboardFailed } + } + + static func save(_ data: Data, filename: String, window: NSWindow?, + completion: @escaping (Result) -> Void) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.nameFieldStringValue = filename + panel.canCreateDirectories = true + panel.title = L10n.tr("Save usage card") + let finish: (NSApplication.ModalResponse) -> Void = { response in + guard response == .OK, let url = panel.url else { + completion(.success(nil)) + return + } + do { + try data.write(to: url, options: .atomic) + completion(.success(url)) + } catch { + completion(.failure(error)) + } + } + if let window { + panel.beginSheetModal(for: window, completionHandler: finish) + } else { + panel.begin(completionHandler: finish) + } + } +} diff --git a/Sources/Sharing/WeeklyCardHistoryStore.swift b/Sources/Sharing/WeeklyCardHistoryStore.swift new file mode 100644 index 00000000..0a8d3ecc --- /dev/null +++ b/Sources/Sharing/WeeklyCardHistoryStore.swift @@ -0,0 +1,60 @@ +import Foundation +import Combine + +@MainActor +final class WeeklyCardHistoryStore: ObservableObject { + @Published private(set) var buckets: [IslandProvider: [DailyTokenBucket]]? + @Published private(set) var isLoading = false + @Published private(set) var partialProviders = Set() + @Published private(set) var saveErrors: [IslandProvider: String] = [:] + + func loadIfNeeded() { + if buckets == nil { refresh() } + } + + func refresh() { + guard !isLoading else { return } + isLoading = true + Task { + let result = await Task.detached(priority: .utility) { + let now = Date() + let openCode = UsageLedger.shared.retain(OpenCodeLogReader.scan(lookbackDays: nil), + source: .openCode, now: now, observedAt: now) + var buckets: [IslandProvider: [DailyTokenBucket]] = [:] + var partial = Set() + var saveErrors: [IslandProvider: String] = [:] + let claude = UsageLedger.shared.retain(ClaudeLogReader.scan(lookbackDays: nil), + source: .claude, now: now, observedAt: now) + let codex = UsageLedger.shared.retain(CodexLogReader.scan(lookbackDays: nil), + source: .codex, now: now, observedAt: now) + buckets[.claude] = CostSummary.summarize( + events: claude.events + openCode.events.filter { $0.provider == .claude }, + now: now, includeAllHistory: true, historicalDays: claude.historicalDays + ).dailyTokens + buckets[.codex] = CostSummary.summarize( + events: codex.events + openCode.events.filter { $0.provider == .codex }, + now: now, includeAllHistory: true, historicalDays: codex.historicalDays + ).dailyTokens + saveErrors[.claude] = claude.saveError ?? openCode.saveError + saveErrors[.codex] = codex.saveError ?? openCode.saveError + for provider in [IslandProvider.antigravity, .grok] { + let scan = provider == .antigravity + ? AntigravityLogReader.scan(lookbackDays: nil, now: now) + : GrokLogReader.scan(lookbackDays: nil, now: now) + let saved = UsageLedger.shared.retain(scan.events, + source: provider == .antigravity ? .antigravity : .grok, + now: now, observedAt: now) + buckets[provider] = CostSummary.summarize(events: saved.events, now: now, includeAllHistory: true, + historicalDays: saved.historicalDays).dailyTokens + saveErrors[provider] = saved.saveError + if scan.notice?.hasPrefix("Some") == true { partial.insert(provider) } + } + return (buckets, partial, saveErrors) + }.value + buckets = result.0 + partialProviders = result.1 + saveErrors = result.2 + isLoading = false + } + } +} diff --git a/Sources/Sharing/WeeklyCardLaunchGate.swift b/Sources/Sharing/WeeklyCardLaunchGate.swift new file mode 100644 index 00000000..c6122d7a --- /dev/null +++ b/Sources/Sharing/WeeklyCardLaunchGate.swift @@ -0,0 +1,27 @@ +import Foundation + +struct WeeklyCardLaunchGate { + let defaults: UserDefaults + private let versionKey = "WeeklyCard.lastLaunchVersion" + private let pendingKey = "WeeklyCard.pendingLaunchVersion" + + func prepare(version: String, existingInstallation: Bool) -> Bool { + guard !version.isEmpty else { return false } + let previous = defaults.string(forKey: versionKey) + let upgraded = previous.map { version.compare($0, options: .numeric) == .orderedDescending } + ?? existingInstallation + if previous == nil || upgraded { + defaults.set(version, forKey: versionKey) + if upgraded { defaults.set(version, forKey: pendingKey) } + } + return isPending(version: version) + } + + func isPending(version: String) -> Bool { + defaults.string(forKey: pendingKey) == version + } + + func complete(version: String) { + if isPending(version: version) { defaults.removeObject(forKey: pendingKey) } + } +} diff --git a/Sources/Sharing/WeeklyCardShareButton.swift b/Sources/Sharing/WeeklyCardShareButton.swift new file mode 100644 index 00000000..705f993a --- /dev/null +++ b/Sources/Sharing/WeeklyCardShareButton.swift @@ -0,0 +1,66 @@ +import AppKit +import SwiftUI + +struct WeeklyCardShareContent { + let image: NSImage + let caption: String + + init(png: Data, caption: String) throws { + guard let image = NSImage(data: png) else { throw WeeklyCardExportError.renderingFailed } + self.image = image + self.caption = caption + } +} + +struct WeeklyCardShareButton: NSViewRepresentable { + let enabled: Bool + let content: () throws -> WeeklyCardShareContent + let onError: (Error) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(title: L10n.tr("Share…"), target: context.coordinator, + action: #selector(Coordinator.share(_:))) + button.image = NSImage(systemSymbolName: "square.and.arrow.up", accessibilityDescription: nil) + button.imagePosition = .imageLeading + button.imageHugsTitle = true + button.bezelStyle = .rounded + button.font = .systemFont(ofSize: 12, weight: .semibold) + button.controlSize = .regular + button.setContentHuggingPriority(.defaultLow, for: .horizontal) + button.sendAction(on: .leftMouseDown) + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + context.coordinator.parent = self + button.isEnabled = enabled + } + + @MainActor + final class Coordinator: NSObject, NSSharingServicePickerDelegate { + var parent: WeeklyCardShareButton + private var picker: NSSharingServicePicker? + + init(_ parent: WeeklyCardShareButton) { self.parent = parent } + + @objc func share(_ sender: NSButton) { + guard parent.enabled else { return } + do { + let content = try parent.content() + let picker = NSSharingServicePicker(items: [content.image, content.caption]) + self.picker = picker + picker.delegate = self + picker.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + } catch { + parent.onError(error) + } + } + + nonisolated func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, + didChoose service: NSSharingService?) { + Task { @MainActor [weak self] in self?.picker = nil } + } + } +} diff --git a/Sources/Sharing/WeeklyCardStudio.swift b/Sources/Sharing/WeeklyCardStudio.swift new file mode 100644 index 00000000..f5a251fc --- /dev/null +++ b/Sources/Sharing/WeeklyCardStudio.swift @@ -0,0 +1,447 @@ +import AppKit +import SwiftUI + +struct WeeklyCardStudio: View { + @ObservedObject private var store = CostStore.shared + @StateObject private var history = WeeklyCardHistoryStore() + @AppStorage("WeeklyCard.format") private var formatRaw = WeeklyCardFormat.feed.rawValue + @AppStorage("WeeklyCard.metric") private var metricRaw = WeeklyCardMetric.apiValue.rawValue + @State private var period = WeeklyCardPeriod.lastSevenDays + @State private var included = Set(IslandProvider.allCases) + @State private var signature = "" + @State private var now = Date() + @State private var exporting = false + @State private var status: String? + @State private var exportError: String? + @State private var showingCardDetails = false + @State private var actualSize = false + + private let surface = Color(red: 0.020, green: 0.020, blue: 0.027) + private let canvas = Color(red: 0.065, green: 0.067, blue: 0.080) + private var tier: WeeklyCardTier { snapshot.tier(for: metric) } + private var format: WeeklyCardFormat { WeeklyCardFormat(rawValue: formatRaw) ?? .feed } + private var metric: WeeklyCardMetric { WeeklyCardMetric(rawValue: metricRaw) ?? .apiValue } + private var usesFullHistory: Bool { + !AppEnvironment.isDemo && period.needsExtendedHistory(now: now, calendar: .current) + } + private var isLoading: Bool { usesFullHistory ? history.isLoading || history.buckets == nil : store.loading } + private var historySaveError: String? { + let errors = usesFullHistory ? history.saveErrors : store.historySaveErrors + return IslandProvider.allCases.filter { included.contains($0) }.compactMap { errors[$0] }.first + } + private var snapshot: WeeklyUsageSnapshot { + WeeklyUsageSnapshot.make( + buckets: usesFullHistory ? history.buckets ?? [:] : Dictionary(uniqueKeysWithValues: IslandProvider.allCases.map { + ($0, store.cost(for: $0).dailyTokens) + }), included: included, period: period, now: now, + isDemo: AppEnvironment.isDemo, + hasPartialRecords: usesFullHistory ? !included.isDisjoint(with: history.partialProviders) + : included.contains { store.localNotices[$0]?.hasPrefix("Some") == true } + ) + } + private var canExport: Bool { + !isLoading && !exporting && snapshot.totalTokens > 0 + && (metric == .tokens || snapshot.hasPricedUsage) + } + + var body: some View { + VStack(spacing: 0) { + header + rule + HStack(spacing: 0) { + preview + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(canvas) + Rectangle().fill(.white.opacity(0.06)).frame(width: 1) + VStack(alignment: .leading, spacing: 0) { + essentialControls.padding(.bottom, 20) + rule + ScrollView { + controls.padding(.vertical, 20) + } + rule + exportActions.padding(.top, 16) + } + .padding(20) + .frame(width: 280) + } + } + .frame(minWidth: 780, minHeight: 640) + .background(surface) + .preferredColorScheme(.dark) + .ignoresSafeArea(.container, edges: .top) + .onChange(of: signature) { value in + signature = String(value.filter { !$0.isNewline && !$0.isASCIIControl }.prefix(32)) + status = nil + } + .onChange(of: formatRaw) { _ in status = nil } + .onChange(of: metricRaw) { _ in status = nil } + .onChange(of: period) { _ in + status = nil + if usesFullHistory { history.loadIfNeeded() } + } + .onChange(of: included) { _ in status = nil } + .onChange(of: store.lastUpdated) { _ in + now = Date() + status = nil + } + .onReceive(NotificationCenter.default.publisher(for: .codexIslandUsageHistoryRecovered)) { _ in + status = nil + if usesFullHistory { history.refresh() } + } + .alert(L10n.tr("Could not export card"), isPresented: Binding( + get: { exportError != nil }, set: { if !$0 { exportError = nil } } + )) { + Button(L10n.tr("OK"), role: .cancel) { exportError = nil } + } message: { + Text(exportError ?? "") + } + } + + private var rule: some View { + Rectangle().fill(.white.opacity(0.06)).frame(height: 1) + } + + private var header: some View { + HStack(spacing: 12) { + Text(L10n.tr("Usage card")) + .font(Typography.rowTitle) + .foregroundStyle(.white.opacity(0.90)) + Spacer() + Button { refresh() } label: { + Image(systemName: "arrow.clockwise").frame(width: 28, height: 28) + } + .disabled(isLoading || exporting) + .help(L10n.tr("Refresh records")) + .accessibilityLabel(L10n.tr("Refresh records")) + Button { showingCardDetails.toggle() } label: { + Image(systemName: "info.circle").frame(width: 28, height: 28) + } + .help(L10n.tr("About this card")) + .accessibilityLabel(L10n.tr("About this card")) + .popover(isPresented: $showingCardDetails, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.tr("About this card")).font(Typography.providerTitle) + Text(L10n.tr("API value estimates your usage at API rates in USD, not your subscription bill. Tokens include cache reads and writes.")) + Text(L10n.tr("The card includes daily usage, provider totals, active days, and your signature. It does not include prompts or conversations.")) + Text(L10n.tr("Last 7 days includes today and the previous six days. Month and year also run through today. All time includes all saved usage records on this Mac.")) + Text(L10n.tr("Captured token counts are saved on this Mac even if provider logs are removed. Records deleted before capture may still be missing.")) + } + .font(Typography.tabLabel) + .fixedSize(horizontal: false, vertical: true) + .padding(20) + .frame(width: 300) + } + } + .font(Typography.tabLabel) + .foregroundStyle(.white.opacity(0.65)) + .buttonStyle(.borderless) + .padding(.leading, 96) + .padding(.trailing, 16) + .frame(height: 52) + } + + private var preview: some View { + VStack(spacing: 0) { + GeometryReader { geometry in + if isLoading { + placeholder(icon: "", title: "Gathering your usage…", + detail: "Reading usage records on this Mac.", loading: true) + } else if snapshot.totalTokens == 0 { + placeholder(icon: "chart.bar.xaxis", title: "A fresh page for your usage.", + detail: "No recorded tokens in this period. Try another period or include more providers.") + } else if metric == .apiValue && !snapshot.hasPricedUsage { + placeholder(icon: "dollarsign.circle", title: "Your tokens need a price.", + detail: "Refresh records to load API prices, or switch the spotlight to tokens.") + } else if actualSize { + ScrollView([.horizontal, .vertical]) { + card.padding(24) + .frame(minWidth: geometry.size.width, minHeight: geometry.size.height) + } + } else { + let available = CGSize(width: max(1, geometry.size.width - 48), + height: max(1, geometry.size.height - 48)) + let scale = min(available.width / format.size.width, + available.height / format.size.height) + card + .scaleEffect(scale) + .frame(width: format.size.width * scale, height: format.size.height * scale) + .shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 8) + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + HStack(spacing: 8) { + Text(format.pixelLabel).font(Typography.caption) + Spacer() + Button { actualSize.toggle() } label: { + Label(L10n.tr(actualSize ? "Fit" : "Actual size"), + systemImage: actualSize ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + } + .buttonStyle(.borderless) + .font(Typography.label) + .disabled(snapshot.totalTokens == 0 || isLoading) + } + .foregroundStyle(.white.opacity(0.60)) + .padding(.horizontal, 24) + .frame(height: 40) + } + } + + private var card: some View { + WeeklyUsageCard(snapshot: snapshot, format: format, signature: signature, metric: metric) + } + + private func placeholder(icon: String, title: String, detail: String, loading: Bool = false) -> some View { + VStack(spacing: 14) { + if loading { + ProgressView().controlSize(.small) + } else { + Image(systemName: icon).font(.system(size: 32, weight: .light)) + .foregroundStyle(.secondary) + } + Text(L10n.tr(title)).font(.system(size: 20, weight: .medium)) + Text(L10n.tr(detail)) + .font(Typography.tabLabel) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 290) + if !loading { + Button(L10n.tr("Refresh records")) { refresh() } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var essentialControls: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + controlLabel("Spotlight") + SegmentedControl( + items: WeeklyCardMetric.allCases.map(\.rawValue), + selected: $metricRaw, + label: { WeeklyCardMetric(rawValue: $0)?.title ?? $0 }, + accessibilityPrefix: "Spotlight", + labelFont: Typography.tabLabel + ) + } + HStack { + controlLabel("Period") + Spacer() + Picker(L10n.tr("Period"), selection: $period) { + ForEach(WeeklyCardPeriod.allCases) { option in + Text(L10n.tr(option.title)).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + } + } + .controlSize(.regular) + .disabled(exporting) + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 20) { + HStack { + controlLabel("Format") + Spacer() + Picker(L10n.tr("Format"), selection: $formatRaw) { + ForEach(WeeklyCardFormat.allCases) { option in + Text(L10n.tr(option.title)).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + } + VStack(alignment: .leading, spacing: 8) { + controlLabel("Signature") + TextField(L10n.tr("Name or @handle (optional)"), text: $signature) + .textFieldStyle(.roundedBorder) + .font(Typography.tabLabel) + .accessibilityLabel(L10n.tr("Name or handle on card")) + } + VStack(alignment: .leading, spacing: 12) { + controlLabel("Providers") + LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading), + GridItem(.flexible(), alignment: .leading)], spacing: 12) { + ForEach(IslandProvider.allCases) { provider in + Toggle(isOn: Binding( + get: { included.contains(provider) }, + set: { if $0 { included.insert(provider) } else { included.remove(provider) } } + )) { + HStack(spacing: 5) { + Circle().fill(provider.color).frame(width: 5, height: 5) + Text(provider.name).font(Typography.tabLabel) + } + } + .toggleStyle(WeeklyCardCheckboxStyle()) + .accessibilityLabel(provider.name) + } + } + } + if let historySaveError { + Label(L10n.tr(historySaveError), systemImage: "exclamationmark.triangle") + .font(Typography.label) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + if snapshot.hasRecoveredHistory { + Text(L10n.tr("Includes recovered daily totals. Their original dates are preserved; historical records may be incomplete.")) + .font(Typography.label) + .foregroundStyle(.white.opacity(0.5)) + .fixedSize(horizontal: false, vertical: true) + } + if snapshot.hasPartialRecords { + Label(L10n.tr("Some local records could not be read. Totals may be incomplete."), + systemImage: "exclamationmark.triangle") + .font(Typography.label) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + if metric == .apiValue && snapshot.hasPartialPricing { + Text(L10n.tr("Some tokens have no known price. The + marks a partial value.")) + .font(Typography.label) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .disabled(exporting) + } + + private func controlLabel(_ title: String) -> some View { + Text(L10n.tr(title)).font(Typography.label).foregroundStyle(.white.opacity(0.60)) + } + + private var exportActions: some View { + VStack(spacing: 10) { + if let status { + Text(status) + .font(Typography.label) + .foregroundStyle(.green) + .multilineTextAlignment(.center) + .accessibilityLabel(status) + } + HStack(spacing: 8) { + WeeklyCardShareButton(enabled: canExport, content: { + status = nil + let card = snapshot + let png = try WeeklyCardExporter.png(snapshot: card, format: format, + signature: signature, metric: metric) + return try WeeklyCardShareContent(png: png, caption: card.shareText(metric: metric)) + }, onError: { error in + exportError = error.localizedDescription + }) + .frame(height: 32) + Menu { + Button { export(save: false) } label: { + Label(L10n.tr("Copy image"), systemImage: "doc.on.doc") + } + .keyboardShortcut("c", modifiers: [.command, .shift]) + Button { export(save: true) } label: { + Label(L10n.tr("Save PNG…"), systemImage: "arrow.down.to.line") + } + .keyboardShortcut("s", modifiers: .command) + Divider() + Button { copyCaption() } label: { + Label(L10n.tr("Copy caption"), systemImage: "text.alignleft") + } + } label: { + Image(systemName: "ellipsis") + .font(Typography.providerTitle) + .frame(width: 32, height: 32) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .background(RoundedRectangle(cornerRadius: 7).fill(.white.opacity(0.08))) + .help(L10n.tr("More export options")) + .accessibilityLabel(L10n.tr("More export options")) + .disabled(!canExport) + } + } + } + + private func refresh() { + now = Date() + status = nil + if usesFullHistory { history.refresh() } else { store.refresh() } + } + + private func copyCaption() { + NSPasteboard.general.clearContents() + if NSPasteboard.general.setString(snapshot.shareText(metric: metric), forType: .string) { + status = L10n.tr("Caption copied. Add it to your post.") + } else { + exportError = L10n.tr("Could not copy the caption. Try again.") + } + } + + private func export(save: Bool) { + guard canExport else { return } + exporting = true + status = nil + do { + let data = try WeeklyCardExporter.png(snapshot: snapshot, + format: format, signature: signature, metric: metric) + if save { + let filename = "CodexIsland-\(period.rawValue)-\(snapshot.filenameDate)-\(metric.rawValue)-\(tier.rawValue)-\(format.rawValue).png" + WeeklyCardExporter.save(data, filename: filename, window: WeeklyCardWindowController.shared.window) { result in + exporting = false + switch result { + case .success(let url): + if url != nil { status = L10n.tr("PNG saved. Your card is ready to share.") } + case .failure(let error): exportError = error.localizedDescription + } + } + } else { + try WeeklyCardExporter.copyImage(data) + exporting = false + status = L10n.tr("Image copied. Paste it into your post.") + } + } catch { + exporting = false + exportError = error.localizedDescription + } + } +} + +private struct WeeklyCardCheckboxStyle: ToggleStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + Button { configuration.isOn.toggle() } label: { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(.white.opacity(configuration.isOn ? 0.14 : 0.03)) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(.white.opacity(0.25), lineWidth: 1) + } + .overlay { + if configuration.isOn { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white.opacity(0.90)) + } + } + .frame(width: 15, height: 15) + configuration.label + .foregroundStyle(.white.opacity(configuration.isOn ? 0.85 : 0.55)) + } + .contentShape(Rectangle()) + } + .buttonStyle(PressableButtonStyle(scale: 0.97)) + .opacity(isEnabled ? 1 : 0.45) + .accessibilityRepresentation { + Toggle(isOn: configuration.$isOn) { configuration.label } + .toggleStyle(.checkbox) + } + } +} + +private extension Character { + var isASCIIControl: Bool { unicodeScalars.contains { $0.value < 32 || $0.value == 127 } } +} diff --git a/Sources/Sharing/WeeklyCardStyle.swift b/Sources/Sharing/WeeklyCardStyle.swift new file mode 100644 index 00000000..9cde639d --- /dev/null +++ b/Sources/Sharing/WeeklyCardStyle.swift @@ -0,0 +1,61 @@ +import SwiftUI + +enum WeeklyCardFormat: String, CaseIterable, Identifiable { + case feed, square, story + var id: String { rawValue } + var title: String { + switch self { + case .feed: return "Feed · 4:5" + case .square: return "Square · 1:1" + case .story: return "Story · 9:16" + } + } + var size: CGSize { + switch self { + case .feed: return CGSize(width: 540, height: 675) + case .square: return CGSize(width: 540, height: 540) + case .story: return CGSize(width: 540, height: 960) + } + } + var pixelLabel: String { "1080 × \(Int(size.height * 2)) PNG" } +} + +extension WeeklyCardTier { + var theme: WeeklyCardTheme { + switch self { + case .white: return .paper + case .black: return .midnight + case .blue: return .cobalt + } + } +} + +enum WeeklyCardTheme { + case midnight, paper, cobalt + + var background: Color { + switch self { + case .midnight: return Color(red: 0.035, green: 0.044, blue: 0.052) + case .paper: return Color(red: 0.956, green: 0.946, blue: 0.918) + case .cobalt: return Color(red: 0.055, green: 0.17, blue: 0.79) + } + } + var foreground: Color { + self == .paper ? Color(red: 0.10, green: 0.15, blue: 0.19) : Color(red: 0.97, green: 0.96, blue: 0.91) + } + var secondary: Color { foreground.opacity(self == .paper ? 0.72 : 0.70) } + var rule: Color { foreground.opacity(0.18) } + + func color(for provider: IslandProvider) -> Color { + switch (self, provider) { + case (.paper, .claude): return Color(red: 0.75, green: 0.30, blue: 0.20) + case (.paper, .codex): return Color(red: 0.12, green: 0.38, blue: 0.79) + case (.paper, .antigravity): return Color(red: 0.47, green: 0.32, blue: 0.71) + case (.paper, .grok): return Color(red: 0.19, green: 0.30, blue: 0.30) + case (_, .claude): return Color(red: 0.96, green: 0.57, blue: 0.42) + case (_, .codex): return Color(red: 0.52, green: 0.80, blue: 0.98) + case (_, .antigravity): return Color(red: 0.76, green: 0.66, blue: 0.98) + case (_, .grok): return Color(red: 0.87, green: 0.96, blue: 0.73) + } + } +} diff --git a/Sources/Sharing/WeeklyCardTier.swift b/Sources/Sharing/WeeklyCardTier.swift new file mode 100644 index 00000000..badb19bf --- /dev/null +++ b/Sources/Sharing/WeeklyCardTier.swift @@ -0,0 +1,28 @@ +import Foundation + +enum WeeklyCardTier: String, CaseIterable, Identifiable { + case white, black, blue + + var id: String { rawValue } + var title: String { rawValue.capitalized } + + func minimum(for metric: WeeklyCardMetric) -> Double { + switch self { + case .white: return 0 + case .black: return metric == .apiValue ? 1_000 : 100_000_000 + case .blue: return metric == .apiValue ? 10_000 : 1_000_000_000 + } + } + + func thresholdLabel(for metric: WeeklyCardMetric) -> String { + let value = minimum(for: metric) + if metric == .apiValue { return WeeklyUsageSnapshot.money(value, cents: false) + "+" } + let count = WeeklyUsageSnapshot.compactTokens(Int(value)) + return "\(count.value)\(count.unit)+" + } + + static func earned(value: Double, metric: WeeklyCardMetric) -> Self { + guard value.isFinite else { return .white } + return allCases.reversed().first { value >= $0.minimum(for: metric) } ?? .white + } +} diff --git a/Sources/Sharing/WeeklyCardWindowController.swift b/Sources/Sharing/WeeklyCardWindowController.swift new file mode 100644 index 00000000..ef0f3c3f --- /dev/null +++ b/Sources/Sharing/WeeklyCardWindowController.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI + +@MainActor +final class WeeklyCardWindowController: NSWindowController { + static let shared = WeeklyCardWindowController() + private let hosting = NSHostingController(rootView: WeeklyCardStudio()) + + private init() { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 740), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.title = L10n.tr("Usage card") + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.contentViewController = hosting + window.contentMinSize = NSSize(width: 780, height: 640) + window.setContentSize(NSSize(width: 900, height: 740)) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + window.center() + super.init(window: window) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func show(refresh: Bool = true) { + if !AppEnvironment.isDemo { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + WeeklyCardLaunchGate(defaults: .standard).complete(version: version) + } + if window?.isMiniaturized == true { + window?.deminiaturize(nil) + } else if window?.isVisible != true { + hosting.rootView = WeeklyCardStudio() + if refresh { CostStore.shared.refresh() } + window?.center() + } + NSApp.activate(ignoringOtherApps: true) + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + window?.makeFirstResponder(nil) + } +} + +struct WeeklyCardButton: View { + var body: some View { + Button { WeeklyCardWindowController.shared.show() } label: { + Label(L10n.tr("Share usage"), systemImage: "square.and.arrow.up") + .font(Typography.button) + .foregroundStyle(.white.opacity(0.72)) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(L10n.tr("Create your usage card")) + } +} diff --git a/Sources/Sharing/WeeklyUsageCard.swift b/Sources/Sharing/WeeklyUsageCard.swift new file mode 100644 index 00000000..9aee351a --- /dev/null +++ b/Sources/Sharing/WeeklyUsageCard.swift @@ -0,0 +1,326 @@ +import SwiftUI +import AppKit + +struct WeeklyUsageCard: View { + let snapshot: WeeklyUsageSnapshot + let format: WeeklyCardFormat + var signature = "" + var metric: WeeklyCardMetric = .apiValue + + private var tier: WeeklyCardTier { snapshot.tier(for: metric) } + private var theme: WeeklyCardTheme { tier.theme } + private var compact: Bool { format == .square } + private var tokens: (value: String, unit: String) { + WeeklyUsageSnapshot.compactTokens(snapshot.totalTokens) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + Text(snapshot.dateLabel) + .lineLimit(1) + .layoutPriority(1) + Spacer(minLength: 12) + Text(signature.trimmingCharacters(in: .whitespacesAndNewlines)) + .lineLimit(1) + .truncationMode(.tail) + } + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(theme.secondary) + + heading + .padding(.top, compact ? 16 : 26) + + WeeklyValueFlow(snapshot: snapshot, theme: theme, metric: metric) + .frame(maxHeight: .infinity) + .padding(.top, compact ? 18 : 28) + .padding(.bottom, compact ? 18 : 24) + + providerLegend + + footer + .padding(.top, compact ? 18 : 24) + } + .padding(.horizontal, 36) + .padding(.vertical, format == .story ? 88 : 32) + .frame(width: format.size.width, height: format.size.height) + .background(theme.background) + .foregroundStyle(theme.foreground) + .environment(\.colorScheme, theme == .paper ? .light : .dark) + .accessibilityElement(children: .ignore) + .accessibilityLabel(snapshot.shareText(metric: metric)) + } + + private var heading: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 14) { + Text(metric == .apiValue ? snapshot.valueHeadline : snapshot.period.tokenHeadline) + .font(.system(size: compact ? 32 : 36, weight: .medium)) + .tracking(-1.1) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, alignment: .leading) + if metric == .apiValue, let milestone = snapshot.valueMilestone { + WeeklyMilestoneSeal(milestone: milestone, theme: theme) + } + } + + if metric == .apiValue { + moneyHeadline + .padding(.top, compact ? 2 : 8) + HStack(alignment: .firstTextBaseline) { + Text(snapshot.hasPartialPricing ? "Known API-equivalent value · USD" : "API-equivalent value · USD") + .foregroundStyle(theme.secondary) + Spacer(minLength: 8) + Text(snapshot.period.valueQualifier) + } + .font(.system(size: 12, weight: .medium)) + .padding(.top, 2) + .padding(.bottom, compact ? 12 : 18) + } else { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(tokens.value).tracking(-3) + Text(tokens.unit).tracking(-2).foregroundStyle(theme.secondary) + } + .font(.system(size: compact ? 94 : 112, weight: .regular, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.vertical, compact ? 0 : 2) + } + + HStack(spacing: 0) { + Text(metric == .apiValue ? "\(tokens.value)\(tokens.unit) tokens" : "tokens") + .foregroundStyle(theme.foreground) + Text(" / \(snapshot.activeDays) of \(snapshot.durationLabel) active") + .foregroundStyle(theme.secondary) + Spacer() + } + .font(.system(size: 13, weight: .medium)) + } + } + + private var moneyHeadline: some View { + let formatted = WeeklyUsageSnapshot.money(snapshot.totalDollars) + let subcent = formatted.hasPrefix("<") + let amount = formatted.dropFirst(subcent ? 2 : 1).split(separator: ".") + return HStack(alignment: .firstTextBaseline, spacing: 0) { + Text(subcent ? "<$" : "$") + .font(.system(size: compact ? 42 : 50, weight: .regular, design: .rounded)) + .foregroundStyle(theme.secondary) + .padding(.trailing, 3) + Text(amount.first.map(String.init) ?? "0") + .font(.system(size: compact ? 90 : 108, weight: .regular, design: .rounded)) + .tracking(-3) + Text(".\(amount.count > 1 ? String(amount[1]) : "00")\(snapshot.valueSuffix)") + .font(.system(size: compact ? 34 : 40, weight: .regular, design: .rounded)) + .foregroundStyle(theme.secondary) + .padding(.leading, 2) + } + .lineLimit(1) + .minimumScaleFactor(0.42) + } + + private var providerLegend: some View { + LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading), + GridItem(.flexible(), alignment: .leading)], + alignment: .leading, spacing: 8) { + ForEach(snapshot.rankedProviders(for: metric)) { item in + HStack(spacing: 7) { + Circle().fill(theme.color(for: item.provider)) + .frame(width: 7, height: 7) + Text(item.provider.name) + .foregroundStyle(theme.foreground) + Text(providerValue(item)) + .foregroundStyle(theme.secondary) + .monospacedDigit() + } + .font(.system(size: 12, weight: .medium)) + } + } + } + + private func providerValue(_ item: WeeklyUsageSnapshot.ProviderTotal) -> String { + guard metric == .apiValue else { return snapshot.percentLabel(for: item.tokens) } + if item.unpricedTokens == item.tokens { return "Unpriced" } + return WeeklyUsageSnapshot.money(item.dollars) + (item.unpricedTokens > 0 ? "+" : "") + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 12) { + Rectangle().fill(theme.rule).frame(height: 0.5) + HStack(alignment: .center, spacing: 8) { + HStack(spacing: 6) { + WeeklyCardBrandMark() + .frame(width: 17, height: 17) + Text("CodexIsland") + .font(.system(size: 14, weight: .semibold)) + .tracking(-0.3) + } + Spacer(minLength: 4) + VStack(alignment: .trailing, spacing: 4) { + HStack(spacing: 5) { + Text(metric == .apiValue ? snapshot.valueChallenge : snapshot.period.tokenCallToAction) + Image(systemName: "arrow.up.right") + } + .font(.system(size: 11, weight: .medium)) + Text("codexisland.com") + .font(.system(size: 11, weight: .semibold)) + } + } + } + } + +} + +private struct WeeklyMilestoneSeal: View { + let milestone: WeeklyValueMilestone + let theme: WeeklyCardTheme + + var body: some View { + VStack(spacing: 1) { + Text(milestone.label) + .font(.system(size: 21, weight: .semibold, design: .rounded)) + .tracking(-0.5) + .lineLimit(1) + .minimumScaleFactor(0.75) + Text("CLUB") + .font(.system(size: 8, weight: .semibold)) + .tracking(2.5) + } + .padding(9) + .frame(width: 76, height: 66) + .background { + WeeklySealOutline().stroke(theme.foreground.opacity(0.65), lineWidth: 0.75) + WeeklySealOutline().stroke(theme.rule, lineWidth: 0.5).padding(4) + } + .accessibilityLabel("\(milestone.label) API value milestone") + } +} + +private struct WeeklySealOutline: Shape { + func path(in rect: CGRect) -> Path { + let cut: CGFloat = 10 + return Path { path in + path.move(to: CGPoint(x: rect.minX + cut, y: rect.minY)) + let corners = [ + CGPoint(x: rect.maxX - cut, y: rect.minY), + CGPoint(x: rect.maxX, y: rect.minY + cut), + CGPoint(x: rect.maxX, y: rect.maxY - cut), + CGPoint(x: rect.maxX - cut, y: rect.maxY), + CGPoint(x: rect.minX + cut, y: rect.maxY), + CGPoint(x: rect.minX, y: rect.maxY - cut), + CGPoint(x: rect.minX, y: rect.minY + cut) + ] + for point in corners { path.addLine(to: point) } + path.closeSubpath() + } + } +} + +private struct WeeklyCardBrandMark: View { + private static let image = Bundle.main.url(forResource: "codexisland_logo", withExtension: "png") + .flatMap { NSImage(contentsOf: $0) } + + var body: some View { + if let image = Self.image { + Image(nsImage: image).resizable().renderingMode(.template).scaledToFit() + } else { + Image(systemName: "curlybraces").resizable().scaledToFit() + } + } +} + +private struct WeeklyValueFlow: View { + let snapshot: WeeklyUsageSnapshot + let theme: WeeklyCardTheme + let metric: WeeklyCardMetric + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(metric == .apiValue ? "Cumulative API value" : "Cumulative tokens") + Spacer() + Text(snapshot.durationLabel) + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(theme.secondary) + Canvas { context, size in draw(in: &context, size: size) } + } + .accessibilityHidden(true) + } + + private func draw(in context: inout GraphicsContext, size: CGSize) { + let baseline = max(16, size.height - 24) + let height = max(1, baseline - 12) + let inset: CGFloat = 12 + let width = max(1, size.width - inset * 2) + let total = metric == .apiValue ? snapshot.totalDollars : Double(snapshot.totalTokens) + let maximum = max(total, 0.000001) + let dayCount = max(1, snapshot.days.count) + let indices = snapshot.chartPointIndices + func points(_ values: [Double]) -> [CGPoint] { + indices.map { index in + CGPoint(x: inset + width * CGFloat(index) / CGFloat(dayCount), + y: baseline - height * CGFloat(values[index] / maximum)) + } + } + var boundary = Array(repeating: 0.0, count: dayCount + 1) + for item in snapshot.rankedProviders(for: metric) { + let values = snapshot.cumulativeValues(for: item.provider, metric: metric) + let upper = zip(boundary, values).map(+) + let lowerPoints = points(boundary) + let upperPoints = points(upper) + var band = curve(upperPoints) + if let last = lowerPoints.last { band.addLine(to: last) } + appendCurve(Array(lowerPoints.reversed()), to: &band) + band.closeSubpath() + let color = theme.color(for: item.provider) + context.fill(band, with: .linearGradient( + Gradient(colors: [color, color.opacity(0.70), color.opacity(0.12)]), + startPoint: .zero, endPoint: CGPoint(x: 0, y: baseline))) + context.stroke(curve(upperPoints), with: .color(color), lineWidth: 1.2) + boundary = upper + } + + let ridge = points(boundary) + if total > 0 { + context.stroke(curve(ridge), with: .color(theme.foreground.opacity(0.85)), lineWidth: 1.5) + let markerIndices = Set(snapshot.chartLabelDayIndices.map { $0 + 1 }) + for (index, point) in zip(indices, ridge) where markerIndices.contains(index) { + context.fill(Path(ellipseIn: CGRect(x: point.x - 2, y: point.y - 2, width: 4, height: 4)), + with: .color(theme.foreground)) + } + if let end = ridge.last { + context.fill(Path(ellipseIn: CGRect(x: end.x - 4, y: end.y - 4, width: 8, height: 8)), + with: .color(theme.foreground)) + } + } + var axis = Path() + axis.move(to: CGPoint(x: inset, y: baseline)) + axis.addLine(to: CGPoint(x: size.width - inset, y: baseline)) + context.stroke(axis, with: .color(theme.rule), lineWidth: 0.5) + for index in snapshot.chartLabelDayIndices { + let label = Text(snapshot.chartLabel(for: snapshot.days[index])) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(theme.secondary) + context.draw(label, at: CGPoint(x: inset + width * CGFloat(index + 1) / CGFloat(dayCount), y: baseline + 16), + anchor: index == dayCount - 1 ? .trailing : .center) + } + } + + private func curve(_ points: [CGPoint]) -> Path { + var path = Path() + if let start = points.first { path.move(to: start) } + appendCurve(points, to: &path) + return path + } + + private func appendCurve(_ points: [CGPoint], to path: inout Path) { + for (start, end) in zip(points, points.dropFirst()) { + // Shared interpolation keeps cumulative bands ordered, with no overshoot. + let middle = (start.x + end.x) / 2 + path.addCurve(to: end, control1: CGPoint(x: middle, y: start.y), + control2: CGPoint(x: middle, y: end.y)) + } + } +} diff --git a/Sources/Sharing/WeeklyUsageSnapshot.swift b/Sources/Sharing/WeeklyUsageSnapshot.swift new file mode 100644 index 00000000..052a25c7 --- /dev/null +++ b/Sources/Sharing/WeeklyUsageSnapshot.swift @@ -0,0 +1,338 @@ +import Foundation + +enum WeeklyCardMetric: String, CaseIterable, Identifiable { + case apiValue, tokens + var id: String { rawValue } + var title: String { self == .apiValue ? "API value" : "Tokens" } +} + +struct WeeklyValueMilestone { + let minimumDollars: Double + let label: String + let headline: String + + private static let tiers: [Self] = [ + .init(minimumDollars: 1_000_000_000, label: "$1B", headline: "Billions. One week."), + .init(minimumDollars: 100_000_000, label: "$100M", headline: "Nine-figure week."), + .init(minimumDollars: 10_000_000, label: "$10M", headline: "Eight-figure week."), + .init(minimumDollars: 1_000_000, label: "$1M", headline: "Seven-figure week."), + .init(minimumDollars: 100_000, label: "$100K", headline: "Six-figure week."), + .init(minimumDollars: 10_000, label: "$10K", headline: "Five-figure week."), + .init(minimumDollars: 1_000, label: "$1K", headline: "Four-figure week."), + .init(minimumDollars: 100, label: "$100", headline: "Three-figure week.") + ] + + static func earned(dollars: Double) -> Self? { + guard dollars.isFinite else { return nil } + return tiers.first { dollars >= $0.minimumDollars } + } + + func headline(for period: WeeklyCardPeriod) -> String { + switch period { + case .lastSevenDays: return headline + case .thisMonth: return headline.replacingOccurrences(of: "week", with: "month") + case .lastThreeMonths: + return minimumDollars >= 1_000_000_000 ? "Billions. 3 months." + : headline.replacingOccurrences(of: "-figure week.", with: " figures. 3 months.") + case .thisYear: return headline.replacingOccurrences(of: "week", with: "year") + case .allTime: + return minimumDollars >= 1_000_000_000 ? "Billions. All time." + : headline.replacingOccurrences(of: "week", with: "total") + } + } +} + +enum WeeklyCardPeriod: String, CaseIterable, Identifiable { + case lastSevenDays, thisMonth, lastThreeMonths, thisYear, allTime + + var id: String { rawValue } + var title: String { + switch self { + case .lastSevenDays: return "Last 7 days" + case .thisMonth: return "This month" + case .lastThreeMonths: return "Last 3 months" + case .thisYear: return "This year" + case .allTime: return "All time" + } + } + + var tokenHeadline: String { + switch self { + case .lastSevenDays: return "My week with AI." + case .thisMonth: return "My month with AI." + case .lastThreeMonths: return "Three months with AI." + case .thisYear: return "My year with AI." + case .allTime: return "My AI journey." + } + } + + var valueQualifier: String { + switch self { + case .lastSevenDays: return "In just 7 days." + case .thisMonth: return "This month so far." + case .lastThreeMonths: return "In just 3 months." + case .thisYear: return "This year so far." + case .allTime: return "All time." + } + } + + var tokenCallToAction: String { + switch self { + case .lastSevenDays: return "Your week. Your card." + case .thisMonth: return "Your month. Your card." + case .lastThreeMonths: return "Your AI. Your card." + case .thisYear: return "Your year. Your card." + case .allTime: return "Your AI. Your card." + } + } + + func interval(now: Date, calendar: Calendar, earliestRecord: Date? = nil) -> DateInterval { + let today = calendar.startOfDay(for: now) + let end = calendar.date(byAdding: .day, value: 1, to: today) ?? today + let start: Date + switch self { + case .lastSevenDays: + start = calendar.date(byAdding: .day, value: -6, to: today) ?? today + case .thisMonth: + start = calendar.dateInterval(of: .month, for: today)?.start ?? today + case .lastThreeMonths: + start = calendar.date(byAdding: .month, value: -3, to: end) ?? today + case .thisYear: + start = calendar.dateInterval(of: .year, for: today)?.start ?? today + case .allTime: + start = min(today, calendar.startOfDay(for: earliestRecord ?? today)) + } + return DateInterval(start: start, end: end) + } + + func needsExtendedHistory(now: Date, calendar: Calendar) -> Bool { + if self == .allTime { return true } + guard self == .lastThreeMonths else { return false } + let yearStart = calendar.dateInterval(of: .year, for: now)?.start ?? now + return interval(now: now, calendar: calendar).start < yearStart + } +} + +struct WeeklyUsageSnapshot { + struct Day: Identifiable { + let date: Date + let tokens: [IslandProvider: Int] + let dollars: [IslandProvider: Double] + var id: Date { date } + var total: Int { tokens.values.reduce(0, +) } + var totalDollars: Double { dollars.values.reduce(0, +) } + + func value(for provider: IslandProvider, metric: WeeklyCardMetric) -> Double { + metric == .apiValue ? dollars[provider] ?? 0 : Double(tokens[provider] ?? 0) + } + } + + struct ProviderTotal: Identifiable { + let provider: IslandProvider + let tokens: Int + let dollars: Double + let unpricedTokens: Int + var id: IslandProvider { provider } + } + + let period: WeeklyCardPeriod + let interval: DateInterval + let days: [Day] + let providers: [ProviderTotal] + let calendar: Calendar + let isDemo: Bool + let hasPartialRecords: Bool + let recoveredTokens: Int + + var totalTokens: Int { providers.reduce(0) { $0 + $1.tokens } } + var totalDollars: Double { providers.reduce(0) { $0 + $1.dollars } } + var valueMilestone: WeeklyValueMilestone? { .earned(dollars: totalDollars) } + var valueHeadline: String { valueMilestone?.headline(for: period) ?? "My AI tab." } + var valueChallenge: String { valueMilestone == nil ? "What's your AI tab?" : "Can you top this?" } + var hasPartialPricing: Bool { providers.contains { $0.unpricedTokens > 0 } } + var hasRecoveredHistory: Bool { recoveredTokens > 0 } + var hasPricedUsage: Bool { providers.contains { $0.tokens > $0.unpricedTokens } } + var valueSuffix: String { hasPartialPricing || hasPartialRecords ? "+" : "" } + var activeDays: Int { days.filter { $0.total > 0 }.count } + var durationLabel: String { "\(days.count) \(days.count == 1 ? "day" : "days")" } + var peakDay: Day? { days.filter { $0.total > 0 }.max { $0.total < $1.total } } + + static func make( + buckets: [IslandProvider: [DailyTokenBucket]], + included: Set = Set(IslandProvider.allCases), + period: WeeklyCardPeriod = .lastSevenDays, + now: Date = Date(), + calendar: Calendar = .current, + isDemo: Bool = false, + hasPartialRecords: Bool = false + ) -> WeeklyUsageSnapshot { + let earliestRecord = included.flatMap { buckets[$0] ?? [] } + .filter { $0.tokens > 0 && $0.dayStart <= now }.map(\.dayStart).min() + let interval = period.interval(now: now, calendar: calendar, earliestRecord: earliestRecord) + var totals: [Date: [IslandProvider: Int]] = [:] + var dollars: [Date: [IslandProvider: Double]] = [:] + var unpriced: [IslandProvider: Int] = [:] + var recoveredTokens = 0 + for provider in IslandProvider.allCases where included.contains(provider) { + for bucket in buckets[provider] ?? [] { + let day = calendar.startOfDay(for: bucket.dayStart) + guard day >= interval.start, day < interval.end, day <= now else { continue } + let tokens = max(0, bucket.tokens) + recoveredTokens += min(tokens, max(0, bucket.recoveredTokens ?? 0)) + totals[day, default: [:]][provider, default: 0] += tokens + if let amount = bucket.dollars, amount.isFinite, amount >= 0 { + dollars[day, default: [:]][provider, default: 0] += amount + unpriced[provider, default: 0] += min(tokens, max(0, bucket.unpricedTokens ?? 0)) + } else { + unpriced[provider, default: 0] += tokens + } + } + } + let dayCount = max(1, calendar.dateComponents([.day], from: interval.start, to: interval.end).day ?? 1) + let days = (0.. ProviderTotal? in + let tokens = days.reduce(0) { $0 + ($1.tokens[provider] ?? 0) } + return tokens > 0 ? ProviderTotal(provider: provider, tokens: tokens, + dollars: days.reduce(0) { $0 + ($1.dollars[provider] ?? 0) }, + unpricedTokens: unpriced[provider] ?? 0) : nil + }.sorted { + if $0.tokens == $1.tokens { return $0.provider.rawValue < $1.provider.rawValue } + return $0.tokens > $1.tokens + } + return WeeklyUsageSnapshot(period: period, interval: interval, days: days, providers: providers, + calendar: calendar, isDemo: isDemo, + hasPartialRecords: hasPartialRecords, recoveredTokens: recoveredTokens) + } + + var dateLabel: String { + let end = days.last?.date ?? interval.start + let sameYear = calendar.component(.year, from: interval.start) == calendar.component(.year, from: end) + let startText = dateText(interval.start, format: sameYear ? "MMM d" : "MMM d, yyyy") + return "\(startText) – \(dateText(end, format: "MMM d, yyyy"))" + } + + var filenameDate: String { dateText(interval.start, format: "yyyy-MM-dd") } + + func dateText(_ date: Date, format: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = format + return formatter.string(from: date) + } + + func rankedProviders(for metric: WeeklyCardMetric) -> [ProviderTotal] { + guard metric == .apiValue else { return providers } + return providers.sorted { + if $0.dollars == $1.dollars { return $0.provider.rawValue < $1.provider.rawValue } + return $0.dollars > $1.dollars + } + } + + func tier(for metric: WeeklyCardMetric) -> WeeklyCardTier { + .earned(value: metric == .apiValue ? totalDollars : Double(totalTokens), metric: metric) + } + + func cumulativeValues(for provider: IslandProvider, metric: WeeklyCardMetric) -> [Double] { + var total = 0.0 + return [0] + days.map { day in + total += day.value(for: provider, metric: metric) + return total + } + } + + var chartLabelDayIndices: [Int] { + if days.count <= 7 { return Array(days.indices) } + return (1...5).map { max(0, Int((Double(days.count) * Double($0) / 5).rounded()) - 1) } + } + + var chartPointIndices: [Int] { + guard days.count > 31 else { return Array(0...days.count) } + let step = max(1, Int(ceil(Double(days.count) / 60))) + return Set([0, days.count] + Array(stride(from: step, to: days.count, by: step)) + + chartLabelDayIndices.map { $0 + 1 }).sorted() + } + + func chartLabel(for day: Day) -> String { + let format = days.count <= 7 ? "EEE" : days.count <= 366 ? "MMM d" : "MMM yy" + return dateText(day.date, format: format).uppercased() + } + + func shareText(metric: WeeklyCardMetric = .tokens) -> String { + let count = Self.compactTokens(totalTokens) + let stack = providers.map(\.provider.name).joined(separator: " + ") + let demoLabel = period == .lastSevenDays ? "Demo week" : "Demo \(period.title.lowercased())" + let qualifier = isDemo ? demoLabel : String(period.tokenHeadline.dropLast()) + let caveat = (hasPartialRecords ? " Partial local records." : "") + + (hasRecoveredHistory ? " Includes recovered daily totals on their original dates." : "") + if metric == .apiValue { + let pricing = hasPartialPricing ? " Some tokens have no known price." : "" + let timeframe = period == .lastThreeMonths ? "over the last 3 months" : "in \(durationLabel)" + return """ + \(isDemo ? "Demo: " : "")\(valueHeadline) \(Self.money(totalDollars))\(valueSuffix) \(timeframe). + \(tier(for: metric).title) card · \(count.value)\(count.unit) tokens incl. cache · \(stack) + \(dateLabel) + API-rate estimate (USD), not a bill.\(caveat)\(pricing) + + \(valueChallenge) + https://codexisland.com + """ + } + return """ + \(qualifier): \(count.value)\(count.unit) tokens. \(activeDays)/\(days.count) active days. + \(tier(for: metric).title) card · \(stack) + \(dateLabel) · Includes cache tokens.\(caveat) + + \(period == .lastSevenDays ? "What does your week look like?" : "What does your AI usage look like?") + Make your card with CodexIsland → https://codexisland.com + """ + } + + static func money(_ amount: Double, cents: Bool = true) -> String { + if cents && amount > 0 && amount < 0.01 { return "<$0.01" } + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.currencySymbol = "$" + formatter.minimumFractionDigits = cents ? 2 : 0 + formatter.maximumFractionDigits = cents ? 2 : 0 + let precision = cents ? 100.0 : 1.0 + let rounded = (amount * precision).rounded() / precision + // Keep normal cent rounding except when it would imply an unearned milestone. + if rounded.isFinite, + WeeklyValueMilestone.earned(dollars: rounded)?.label != WeeklyValueMilestone.earned(dollars: amount)?.label { + formatter.roundingMode = .down + } + return formatter.string(from: NSNumber(value: amount.isFinite ? max(0, amount) : 0)) ?? "$0.00" + } + + static func compactTokens(_ tokens: Int) -> (value: String, unit: String) { + let count = max(0, tokens) + let units: [(Double, String)] = [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")] + for (divisor, unit) in units where Double(count) >= divisor * 0.99995 { + let raw = Double(count) / divisor + var value = (raw * 10).rounded() / 10 + if WeeklyCardTier.earned(value: value * divisor, metric: .tokens) + != WeeklyCardTier.earned(value: Double(count), metric: .tokens) { + if raw < 1 { continue } + value = (raw * 10).rounded(.down) / 10 + } + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + return (formatter.string(from: NSNumber(value: value)) ?? "0", unit) + } + return (String(count), "") + } + + func percentLabel(for tokens: Int) -> String { + let percent = totalTokens > 0 ? Double(tokens) / Double(totalTokens) * 100 : 0 + return percent > 0 && percent < 1 ? "<1%" : "\(Int(percent.rounded()))%" + } +} diff --git a/Sources/Views/PanelFooter.swift b/Sources/Views/PanelFooter.swift index 4c51454c..b6380e73 100644 --- a/Sources/Views/PanelFooter.swift +++ b/Sources/Views/PanelFooter.swift @@ -40,6 +40,10 @@ struct PanelFooter: View { SettingsButton() chip + if screenPref.screen == .overview { + WeeklyCardButton() + } + if !activeStyleCycled { HStack(spacing: 5) { Image(systemName: "command") diff --git a/Sources/Views/Settings/SegmentedControl.swift b/Sources/Views/Settings/SegmentedControl.swift index f2f4e2bd..4e653dc9 100644 --- a/Sources/Views/Settings/SegmentedControl.swift +++ b/Sources/Views/Settings/SegmentedControl.swift @@ -9,6 +9,7 @@ struct SegmentedControl: View { @Binding var selected: Value let label: (Value) -> String var accessibilityPrefix: String = "" + var labelFont: Font = Typography.bodyNumber var body: some View { HStack(spacing: 0) { @@ -19,7 +20,7 @@ struct SegmentedControl: View { selected = item } label: { Text(itemLabel) - .font(Typography.bodyNumber) + .font(labelFont) .foregroundStyle(isOn ? Color.white.opacity(0.95) : .white.opacity(0.55)) diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index 4fd5db2a..39e366e9 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -137,6 +137,11 @@ struct SettingsView: View { private var generalTab: some View { VStack(alignment: .leading, spacing: 0) { generalSection + SettingsRow(title: "Usage card", subtitle: "Your time with AI, ready to share.") { + Button(L10n.tr("Create card…")) { WeeklyCardWindowController.shared.show() } + .controlSize(.small) + } + .padding(.horizontal, 14) SettingsRow(title: "Usage history", subtitle: "Find older Claude counts.") { HStack(spacing: 8) { RecoveryHelp( diff --git a/Tests/WeeklyCardRenderHarness.swift b/Tests/WeeklyCardRenderHarness.swift new file mode 100644 index 00000000..373db9b6 --- /dev/null +++ b/Tests/WeeklyCardRenderHarness.swift @@ -0,0 +1,136 @@ +import AppKit +import SwiftUI + +@main +struct WeeklyCardRenderHarness { + @MainActor + static func main() throws { + let app = NSApplication.shared + app.setActivationPolicy(.regular) + if CommandLine.arguments.contains("--studio") { + WeeklyCardWindowController.shared.show() + app.run() + return + } + let destination = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "notes/weekly-card/renders") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + guard let now = ISO8601DateFormatter().date(from: "2026-09-10T12:00:00Z") else { return } + let interval = WeeklyCardPeriod.lastSevenDays.interval(now: now, calendar: calendar) + let values: [IslandProvider: [Int]] = [ + .claude: [3_200_000, 1_800_000, 5_100_000, 8_400_000, 2_200_000, 4_800_000, 6_200_000], + .codex: [9_300_000, 7_600_000, 2_400_000, 6_200_000, 10_800_000, 15_400_000, 12_300_000], + .antigravity: [1_200_000, 0, 1_900_000, 2_800_000, 1_000_000, 3_600_000, 2_100_000], + .grok: [0, 0, 0, 0, 400_000, 100_000, 500_000] + ] + let costs: [IslandProvider: [Double]] = [ + .claude: [71.2, 38.4, 91.25, 182.35, 48.70, 121.80, 148.16], + .codex: [45.10, 29.40, 17.60, 67.10, 56.70, 109.70, 97.80], + .antigravity: [11.70, 0, 14.50, 39.70, 12.55, 44.80, 25.40], + .grok: [0, 0, 0, 0, 8.55, 2.30, 10.15] + ] + let buckets = Dictionary(uniqueKeysWithValues: values.map { provider, values in + (provider, + values.enumerated().map { index, value in + DailyTokenBucket(dayStart: calendar.date(byAdding: .day, value: index, to: interval.start) ?? now, + tokens: value, billableTokens: value / 10, + dollars: costs[provider]?[index], unpricedTokens: 0) + }) + }) + let snapshot = WeeklyUsageSnapshot.make(buckets: buckets, now: now, calendar: calendar, isDemo: true) + for (expectedTier, multiplier) in [(WeeklyCardTier.white, 0.1), (.black, 1.0), (.blue, 10.0)] { + let scaled = buckets.mapValues { values in + values.map { bucket in + DailyTokenBucket(dayStart: bucket.dayStart, tokens: Int(Double(bucket.tokens) * multiplier), + billableTokens: Int(Double(bucket.billableTokens) * multiplier), + dollars: bucket.dollars.map { $0 * multiplier }, unpricedTokens: 0) + } + } + let card = WeeklyUsageSnapshot.make(buckets: scaled, now: now, calendar: calendar, isDemo: true) + for metric in WeeklyCardMetric.allCases { + guard card.tier(for: metric) == expectedTier else { throw WeeklyCardExportError.renderingFailed } + for format in WeeklyCardFormat.allCases { + let data = try WeeklyCardExporter.png(snapshot: card, format: format, signature: "@yourname", metric: metric) + try verify(data, format: format, tier: expectedTier) + try data.write(to: destination.appendingPathComponent("\(expectedTier.rawValue)-\(metric.rawValue)-\(format.rawValue).png")) + } + } + } + for (label, amount) in [("empty", 0), ("tiny", 1), ("huge", 99_999_999_999)] { + let snapshot = WeeklyUsageSnapshot.make(buckets: [.grok: [DailyTokenBucket(dayStart: interval.start, tokens: amount, billableTokens: amount, + dollars: Double(amount) / 100_000, unpricedTokens: 0)]], + now: now, calendar: calendar, isDemo: true, hasPartialRecords: true) + let data = try WeeklyCardExporter.png(snapshot: snapshot, format: .square, + signature: "@a_very_long_signature_12345678901") + try verify(data, format: .square, tier: snapshot.tier(for: .apiValue)) + try data.write(to: destination.appendingPathComponent("edge-\(label).png")) + } + let differentTiers = WeeklyUsageSnapshot.make(buckets: [.grok: [DailyTokenBucket( + dayStart: interval.start, tokens: 1_000_000_000, billableTokens: 1_000_000, dollars: 9.99, unpricedTokens: 0 + )]], now: now, calendar: calendar, isDemo: true) + for metric in WeeklyCardMetric.allCases { + let data = try WeeklyCardExporter.png(snapshot: differentTiers, format: .square, signature: "@yourname", metric: metric) + try verify(data, format: .square, tier: metric == .apiValue ? .white : .blue) + try data.write(to: destination.appendingPathComponent("metric-\(metric.rawValue).png")) + } + let historyStart = calendar.date(byAdding: .day, value: -800, to: calendar.startOfDay(for: now)) ?? now + let history = Dictionary(uniqueKeysWithValues: IslandProvider.allCases.enumerated().map { providerIndex, provider in + let records = (0...800).map { index in + let active = index % (providerIndex + 3) != 0 + let tokens = active ? (index % 13 + 1) * 100_000 : 0 + return DailyTokenBucket(dayStart: calendar.date(byAdding: .day, value: index, to: historyStart) ?? now, + tokens: tokens, billableTokens: tokens / 10, + dollars: Double(tokens) / 100_000, unpricedTokens: 0) + } + return (provider, records) + }) + for period in [WeeklyCardPeriod.thisMonth, .lastThreeMonths, .thisYear, .allTime] { + let card = WeeklyUsageSnapshot.make(buckets: history, period: period, now: now, calendar: calendar, isDemo: true) + for metric in WeeklyCardMetric.allCases { + for format in WeeklyCardFormat.allCases { + let data = try WeeklyCardExporter.png(snapshot: card, format: format, signature: "@yourname", metric: metric) + try verify(data, format: format, tier: card.tier(for: metric)) + try data.write(to: destination.appendingPathComponent("period-\(period.rawValue)-\(metric.rawValue)-\(format.rawValue).png")) + } + } + } + let singleDay = WeeklyUsageSnapshot.make(buckets: [.codex: [DailyTokenBucket( + dayStart: now, tokens: 1000, billableTokens: 1000, dollars: 1, unpricedTokens: 0 + )]], period: .allTime, now: now, calendar: calendar, isDemo: true) + let singleDayPNG = try WeeklyCardExporter.png(snapshot: singleDay, format: .feed, signature: "@yourname") + try verify(singleDayPNG, format: .feed, tier: .white) + try singleDayPNG.write(to: destination.appendingPathComponent("period-single-day.png")) + let data = try Data(contentsOf: destination.appendingPathComponent("black-apiValue-feed.png")) + let share = try WeeklyCardShareContent(png: data, caption: snapshot.shareText(metric: .apiValue)) + guard share.image.representations.contains(where: { $0.pixelsWide == 1080 && $0.pixelsHigh == 1350 }), + share.caption.contains("not a bill"), share.caption.contains("https://codexisland.com") else { + throw WeeklyCardExportError.renderingFailed + } + let clipboard = NSPasteboard.withUniqueName() + defer { clipboard.releaseGlobally() } + try WeeklyCardExporter.copyImage(data, to: clipboard) + guard clipboard.data(forType: .png) == data, clipboard.data(forType: .tiff) != nil else { + throw WeeklyCardExportError.clipboardFailed + } + print("PASS: 48 PNG renders, earned color pixels, exact output dimensions, sharing image/caption, PNG/TIFF clipboard round trip") + print(destination.path) + } + + static func verify(_ data: Data, format: WeeklyCardFormat, tier: WeeklyCardTier) throws { + guard let image = NSBitmapImageRep(data: data), image.pixelsWide == 1080, + image.pixelsHigh == Int(format.size.height * 2) else { + throw WeeklyCardExportError.renderingFailed + } + guard let color = image.colorAt(x: 0, y: 0)?.usingColorSpace(.sRGB) else { + throw WeeklyCardExportError.renderingFailed + } + let correctColor: Bool + switch tier { + case .white: correctColor = min(color.redComponent, color.greenComponent, color.blueComponent) > 0.8 + case .black: correctColor = max(color.redComponent, color.greenComponent, color.blueComponent) < 0.12 + case .blue: correctColor = color.blueComponent > 0.6 && color.redComponent < 0.2 && color.greenComponent < 0.35 + } + guard correctColor else { throw WeeklyCardExportError.renderingFailed } + } +} diff --git a/Tests/WeeklyUsageSnapshotTests.swift b/Tests/WeeklyUsageSnapshotTests.swift new file mode 100644 index 00000000..ae54ded5 --- /dev/null +++ b/Tests/WeeklyUsageSnapshotTests.swift @@ -0,0 +1,342 @@ +import Foundation + +enum L10n { + static let locale = Locale(identifier: "en_US") + static func tr(_ text: String, _ arguments: CVarArg...) -> String { String(format: text, arguments: arguments) } +} + +@main +struct WeeklyUsageSnapshotTests { + static var assertions = 0 + + static func expect(_ result: Bool, _ message: String) { + guard result else { print("FAIL: \(message)"); exit(1) } + assertions += 1 + } + + static func main() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Chicago") ?? .current + calendar.firstWeekday = 1 + func date(_ value: String) -> Date { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + guard let date = formatter.date(from: value) else { fatalError("Invalid fixture date") } + return date + } + func bucket(_ value: String, _ tokens: Int) -> DailyTokenBucket { + DailyTokenBucket(dayStart: date(value), tokens: tokens, billableTokens: tokens / 10) + } + + let now = date("2026-09-10 13:00") + let recent = WeeklyCardPeriod.lastSevenDays.interval(now: now, calendar: calendar) + expect(recent.start == date("2026-09-04 00:00") && recent.end == date("2026-09-11 00:00"), + "last seven days includes today and the six preceding local dates") + let sunday = WeeklyCardPeriod.lastSevenDays.interval(now: date("2026-09-13 23:59"), calendar: calendar) + expect(sunday.start == date("2026-09-07 00:00") && sunday.end == date("2026-09-14 00:00"), + "Sunday includes the current day instead of selecting a completed week") + let midnight = WeeklyCardPeriod.lastSevenDays.interval(now: date("2026-09-11 00:00"), calendar: calendar) + expect(midnight.start == date("2026-09-05 00:00") && midnight.end == date("2026-09-12 00:00"), + "the seven-day window advances at local midnight") + + let spring = WeeklyCardPeriod.lastSevenDays.interval(now: date("2026-03-09 12:00"), calendar: calendar) + expect(spring.duration == 167 * 3600, "spring-forward week uses calendar arithmetic") + let fall = WeeklyCardPeriod.lastSevenDays.interval(now: date("2026-11-02 12:00"), calendar: calendar) + expect(fall.duration == 169 * 3600, "fall-back week uses calendar arithmetic") + let springCard = WeeklyUsageSnapshot.make(buckets: [:], period: .lastSevenDays, + now: date("2026-03-09 12:00"), calendar: calendar) + expect(springCard.days.count == 7 && Set(springCard.days.map(\.date)).count == 7, + "DST week has exactly seven distinct daily buckets") + expect(springCard.days.allSatisfy { calendar.component(.hour, from: $0.date) == 0 }, + "day labels stay at midnight across DST") + + let month = WeeklyCardPeriod.thisMonth.interval(now: now, calendar: calendar) + let year = WeeklyCardPeriod.thisYear.interval(now: now, calendar: calendar) + expect(month.start == date("2026-09-01 00:00") && month.end == date("2026-09-11 00:00"), + "this month begins on the first and ends after today") + expect(year.start == date("2026-01-01 00:00") && year.end == date("2026-09-11 00:00"), + "this year begins January first and ends after today") + expect(WeeklyCardPeriod.allCases.map(\.title) == ["Last 7 days", "This month", "Last 3 months", "This year", "All time"], + "period selection contains exactly the five agreed ranges") + expect(WeeklyUsageSnapshot.make(buckets: [:], now: now, calendar: calendar).interval == recent, + "the default snapshot covers the rolling seven-day window") + let threeMonths = WeeklyCardPeriod.lastThreeMonths.interval(now: now, calendar: calendar) + expect(threeMonths.start == date("2026-06-11 00:00") && threeMonths.end == date("2026-09-11 00:00"), + "last three months is a rolling calendar window ending after today") + let january = WeeklyCardPeriod.lastThreeMonths.interval(now: date("2026-01-10 12:00"), calendar: calendar) + expect(january.start == date("2025-10-11 00:00") && january.end == date("2026-01-11 00:00"), + "three months crosses New Year without dropping prior-year dates") + let leapQuarter = WeeklyCardPeriod.lastThreeMonths.interval(now: date("2024-05-30 12:00"), calendar: calendar) + expect(leapQuarter.start == date("2024-02-29 00:00"), "calendar month subtraction clamps correctly at leap February") + expect(WeeklyCardPeriod.lastThreeMonths.needsExtendedHistory(now: date("2026-03-30 12:00"), calendar: calendar) + && !WeeklyCardPeriod.lastThreeMonths.needsExtendedHistory(now: date("2026-03-31 12:00"), calendar: calendar), + "older history loads only when the three-month range crosses the retained year") + let quarter = WeeklyUsageSnapshot.make(buckets: [.codex: [ + bucket("2025-10-10 00:00", 999), bucket("2025-10-11 00:00", 10), + bucket("2025-12-31 00:00", 20), bucket("2026-01-10 00:00", 30), bucket("2026-01-11 00:00", 999) + ]], period: .lastThreeMonths, now: date("2026-01-10 12:00"), calendar: calendar) + expect(quarter.totalTokens == 60 && quarter.days.count == 92 && quarter.activeDays == 3, + "three-month totals include both boundaries and previous-year usage without future data") + expect(quarter.shareText().contains("Three months with AI") + && quarter.shareText(metric: .apiValue).contains("over the last 3 months"), + "both captions identify the three-month period") + expect(WeeklyValueMilestone.earned(dollars: 1000)?.headline(for: .lastThreeMonths) == "Four figures. 3 months.", + "the earned money headline follows the three-month period") + let leap = WeeklyUsageSnapshot.make(buckets: [:], period: .thisYear, + now: date("2024-03-01 12:00"), calendar: calendar) + expect(leap.days.count == 61 && leap.days.contains { $0.date == date("2024-02-29 00:00") }, + "year-to-date retains leap day") + let dstMonth = WeeklyUsageSnapshot.make(buckets: [:], period: .thisMonth, + now: date("2026-03-09 12:00"), calendar: calendar) + expect(dstMonth.days.count == 9 && dstMonth.days.allSatisfy { calendar.component(.hour, from: $0.date) == 0 }, + "month-to-date stays on local midnights across daylight saving") + let longHistory: [IslandProvider: [DailyTokenBucket]] = [ + .claude: [bucket("2022-01-01 00:00", 0), bucket("2024-02-29 12:00", 100), + bucket("2025-12-31 12:00", 200), bucket("2026-01-01 00:00", 300), + bucket("2026-08-31 00:00", 400), bucket("2026-09-01 00:00", 500), + bucket("2026-09-10 00:00", 600), bucket("2026-09-11 00:00", 999_999)], + .codex: [bucket("2026-09-05 00:00", 700)] + ] + let allTime = WeeklyUsageSnapshot.make(buckets: longHistory, period: .allTime, now: now, calendar: calendar) + let monthCard = WeeklyUsageSnapshot.make(buckets: longHistory, period: .thisMonth, now: now, calendar: calendar) + let yearCard = WeeklyUsageSnapshot.make(buckets: longHistory, period: .thisYear, now: now, calendar: calendar) + expect(allTime.interval.start == date("2024-02-29 00:00") && allTime.totalTokens == 2800, + "all time starts at the oldest positive record and rejects future data") + expect(allTime.days.last?.date == date("2026-09-10 00:00") && allTime.activeDays == 7, + "all-time activity counts only days with usage") + expect(monthCard.days.count == 10 && monthCard.totalTokens == 1800 && monthCard.activeDays == 3, + "month totals exclude previous months and use the actual day count") + expect(yearCard.days.count == 253 && yearCard.totalTokens == 2500, + "year totals exclude prior years without losing January usage") + let filteredHistory = WeeklyUsageSnapshot.make(buckets: longHistory, included: [.codex], period: .allTime, + now: now, calendar: calendar) + expect(filteredHistory.interval.start == date("2026-09-05 00:00") && filteredHistory.totalTokens == 700, + "all-time range and totals follow selected providers") + let emptyHistory = WeeklyUsageSnapshot.make(buckets: longHistory, included: [], period: .allTime, + now: now, calendar: calendar) + expect(emptyHistory.days.count == 1 && emptyHistory.totalTokens == 0, + "empty all-time selections have a finite one-day range") + expect(allTime.dateLabel.contains("2024") && allTime.dateLabel.contains("2026"), + "all-time date range prints both years") + for card in [monthCard, yearCard, allTime] { + expect(card.chartPointIndices.first == 0 && card.chartPointIndices.last == card.days.count + && card.chartPointIndices.count <= 67, + "long-range chart keeps both endpoints with bounded drawing work") + expect(card.chartLabelDayIndices.count <= 5 && card.chartLabelDayIndices.allSatisfy { card.days.indices.contains($0) }, + "long-range date labels stay sparse and inside the selected range") + expect(card.cumulativeValues(for: .claude, metric: .tokens).last == Double(card.providers.first { $0.provider == .claude }?.tokens ?? 0), + "cumulative chart ends at the complete provider total") + expect(!card.shareText().contains("My week") && !card.shareText(metric: .apiValue).contains("in 7 days"), + "longer-period captions never describe a seven-day result") + } + expect(monthCard.shareText().contains("3/10 active days") && yearCard.shareText().contains("My year with AI"), + "captions identify the selected range and active-day denominator") + expect(WeeklyValueMilestone.earned(dollars: 1000)?.headline(for: .thisMonth) == "Four-figure month." + && WeeklyValueMilestone.earned(dollars: 1000)?.headline(for: .allTime) == "Four-figure total.", + "earned money headlines identify the selected period") + let historicalEvent = TokenEvent(provider: .codex, timestamp: date("2024-02-29 12:00"), model: "gpt-5", + inputTokens: 10, outputTokens: 2, cacheCreationTokens: 3, cacheReadTokens: 5) + let completeCost = CostSummary.summarize(events: [historicalEvent], now: now, includeAllHistory: true) + let boundedCost = CostSummary.summarize(events: [historicalEvent], now: now) + expect(completeCost.dailyTokens.reduce(0) { $0 + $1.tokens } == 20 && boundedCost.dailyTokens.allSatisfy { $0.tokens == 0 }, + "on-demand history retains older years without expanding normal refresh history") + expect(completeCost.today.tokens == 0 && completeCost.month.tokens == 0, + "older history does not inflate current cost windows") + + let buckets: [IslandProvider: [DailyTokenBucket]] = [ + .claude: [bucket("2026-09-03 00:00", 99_000), bucket("2026-09-04 00:00", 100), + bucket("2026-09-04 15:00", 200), bucket("2026-09-10 00:00", 900), + bucket("2026-09-11 00:00", 99_000)], + .codex: [bucket("2026-09-05 00:00", 500)], + .grok: [bucket("2026-09-05 00:00", 1)], + .antigravity: [bucket("2026-09-06 00:00", -500)] + ] + let summary = WeeklyUsageSnapshot.make(buckets: buckets, now: now, calendar: calendar) + expect(summary.totalTokens == 1701, "all providers and cache tokens counted, boundaries excluded") + expect(summary.days.first?.total == 300, "duplicate local-day buckets are combined") + expect(summary.activeDays == 3, "active days counted across providers without duplication") + expect(summary.peakDay?.date == date("2026-09-10 00:00"), "today's usage can be the peak day") + expect(summary.days.count == 7 && summary.days.last?.total == 900 + && summary.dateLabel == "Sep 4 – Sep 10, 2026", + "the card contains exactly seven days with today's total and the matching date range") + expect(summary.providers.map(\.provider) == [.claude, .codex, .grok], "ranked provider mix excludes empty totals") + expect(summary.percentLabel(for: 1) == "<1%", "small nonzero shares never read as zero") + expect(summary.providers.reduce(0) { $0 + $1.tokens } == summary.days.reduce(0) { $0 + $1.total }, + "chart, legend and headline share one total") + let filtered = WeeklyUsageSnapshot.make(buckets: buckets, included: [.codex], now: now, calendar: calendar) + expect(filtered.totalTokens == 500 && filtered.activeDays == 1 && filtered.providers.count == 1, + "provider selection filters every displayed statistic") + let empty = WeeklyUsageSnapshot.make(buckets: buckets, included: [], now: now, calendar: calendar) + expect(empty.totalTokens == 0 && empty.activeDays == 0 && empty.peakDay == nil && empty.days.count == 7, + "empty selection preserves date structure without invented activity") + + let newYear = WeeklyUsageSnapshot.make(buckets: [.codex: [bucket("2025-12-31 00:00", 42)]], + now: date("2026-01-05 10:00"), calendar: calendar) + expect(newYear.totalTokens == 42, "January share includes previous-year history") + expect(newYear.dateLabel.contains("2025") && newYear.dateLabel.contains("2026"), + "cross-year cards print both years") + expect(CostSummary.localHistoryDays(now: date("2026-01-02 10:00")) == 14, + "log scans retain a complete prior week near New Year") + let event = TokenEvent(provider: .codex, timestamp: date("2025-12-31 12:00"), model: "gpt-5", + inputTokens: 10, outputTokens: 2, cacheCreationTokens: 3, cacheReadTokens: 5) + let future = TokenEvent(provider: .codex, timestamp: date("2026-01-03 12:00"), model: "gpt-5", + inputTokens: 1000, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0) + let cost = CostSummary.summarize(events: [event, future], now: date("2026-01-02 10:00")) + expect(cost.dailyTokens.reduce(0) { $0 + $1.tokens } == 20, "cost pipeline retains December and rejects future events") + expect(cost.dailyTokens.reduce(0) { $0 + $1.billableTokens } == 12, "cache-inclusive and billable totals stay distinct") + + expect(WeeklyUsageSnapshot.compactTokens(0).value == "0", "zero formatting") + expect(WeeklyUsageSnapshot.compactTokens(999_999).unit == "M", "rounding promotes suffix instead of 1000K") + expect(WeeklyUsageSnapshot.compactTokens(1_250_000_000).value == "1.3", "billions retain meaningful precision") + let demo = WeeklyUsageSnapshot.make(buckets: buckets, now: now, calendar: calendar, isDemo: true, hasPartialRecords: true) + expect(demo.shareText().contains("Demo week") && demo.shareText().contains("Partial local records"), + "demo and incomplete sources remain labeled in shared copy") + expect(summary.shareText().contains("Includes cache tokens") && summary.shareText().contains("https://codexisland.com"), + "share caption defines the metric and provides a make-your-own path") + + let oldCache = Data("{\"dayStart\":0,\"tokens\":123,\"billableTokens\":12}".utf8) + let restored = try JSONDecoder().decode(DailyTokenBucket.self, from: oldCache) + expect(restored.tokens == 123 && restored.dollars == nil, + "pre-price caches decode as unknown value, never a fabricated zero") + expect(!summary.hasPricedUsage && summary.hasPartialPricing, + "legacy token-only history does not become a zero-dollar card") + let pricedBuckets: [IslandProvider: [DailyTokenBucket]] = [ + .claude: [DailyTokenBucket(dayStart: date("2026-09-04 00:00"), tokens: 100, billableTokens: 50, + dollars: 1000.25, unpricedTokens: 0), + DailyTokenBucket(dayStart: date("2026-09-10 00:00"), tokens: 100, billableTokens: 50, + dollars: 234.31, unpricedTokens: 0), + DailyTokenBucket(dayStart: date("2026-09-11 00:00"), tokens: 100, billableTokens: 50, + dollars: 999, unpricedTokens: 0)], + .codex: [DailyTokenBucket(dayStart: date("2026-09-05 00:00"), tokens: 900, billableTokens: 500, + dollars: 45.50, unpricedTokens: 0)] + ] + let priced = WeeklyUsageSnapshot.make(buckets: pricedBuckets, now: now, calendar: calendar) + expect(abs(priced.totalDollars - 1280.06) < 0.000001, "weekly API value sums same calendar days as tokens") + expect(priced.hasPricedUsage && !priced.hasPartialPricing, "complete prices enable money spotlight") + expect(priced.rankedProviders(for: .apiValue).first?.provider == .claude + && priced.rankedProviders(for: .tokens).first?.provider == .codex, + "provider ranking follows the chosen metric") + let running = priced.cumulativeValues(for: .claude, metric: .apiValue) + expect(running.count == 8 && running.first == 0 && abs((running.last ?? 0) - 1234.56) < 0.000001, + "cumulative curve starts at zero and ends at the priced provider total") + expect(zip(running, running.dropFirst()).allSatisfy { $0 <= $1 }, "empty days plateau in cumulative spending") + let pricedSelection = WeeklyUsageSnapshot.make(buckets: pricedBuckets, included: [.codex], now: now, calendar: calendar) + expect(pricedSelection.totalDollars == 45.50 && pricedSelection.totalTokens == 900, + "provider filter changes dollars and tokens together") + expect(priced.valueMilestone?.label == "$1K" && pricedSelection.valueMilestone == nil, + "milestone reflects only the providers included in the card") + expect(priced.tier(for: .apiValue) == .black && priced.tier(for: .tokens) == .white, + "the spotlight selects independent money and token color thresholds") + expect(pricedSelection.tier(for: .apiValue) == .white, "provider filtering recalculates the earned color") + expect(priced.shareText(metric: .apiValue).contains("Black card") + && priced.shareText(metric: .tokens).contains("White card"), + "shared captions identify the same earned color as the card") + for metric in WeeklyCardMetric.allCases { + for tier in [WeeklyCardTier.black, .blue] { + let minimum = tier.minimum(for: metric) + let step = metric == .apiValue ? 0.001 : 1.0 + expect(WeeklyCardTier.earned(value: minimum, metric: metric) == tier + && WeeklyCardTier.earned(value: minimum - step, metric: metric) != tier, + "color tiers require the actual money or token threshold") + } + expect([Double.nan, .infinity, -1, 0].allSatisfy { WeeklyCardTier.earned(value: $0, metric: metric) == .white }, + "missing or invalid usage cannot unlock a special color") + } + expect(WeeklyCardTier.black.minimum(for: .apiValue) == 1000 && WeeklyCardTier.blue.minimum(for: .apiValue) == 10_000, + "money colors unlock at one thousand and ten thousand USD") + expect(WeeklyCardTier.black.minimum(for: .tokens) == 100_000_000 && WeeklyCardTier.blue.minimum(for: .tokens) == 1_000_000_000, + "token colors unlock at one hundred million and one billion") + let beforeBlack = WeeklyUsageSnapshot.compactTokens(99_999_999) + let beforeBlue = WeeklyUsageSnapshot.compactTokens(999_999_999) + expect(beforeBlack.value + beforeBlack.unit == "99.9M" && beforeBlue.value + beforeBlue.unit == "999.9M", + "compact token labels never imply a higher color tier through rounding") + for (threshold, label) in [(100.0, "$100"), (1000, "$1K"), (10_000, "$10K"), (100_000, "$100K"), + (1_000_000, "$1M"), (10_000_000, "$10M"), (100_000_000, "$100M"), (1_000_000_000, "$1B")] { + expect(WeeklyValueMilestone.earned(dollars: threshold)?.label == label + && WeeklyValueMilestone.earned(dollars: threshold - 0.001)?.label != label, + "milestones require the actual threshold, not a rounded display value") + } + expect([Double.nan, .infinity, -1, 0, 99.99].allSatisfy { WeeklyValueMilestone.earned(dollars: $0) == nil }, + "invalid prices and amounts below the first threshold never earn a badge") + expect(priced.shareText(metric: .apiValue).hasPrefix("Four-figure week. $1,280.06 in 7 days."), + "caption leads with the same earned title and amount as the image") + var partialBuckets = pricedBuckets + partialBuckets[.grok] = [DailyTokenBucket(dayStart: date("2026-09-06 00:00"), tokens: 500, billableTokens: 500, + dollars: 0, unpricedTokens: 500)] + let partial = WeeklyUsageSnapshot.make(buckets: partialBuckets, now: now, calendar: calendar) + expect(partial.hasPartialPricing && partial.valueSuffix == "+" && partial.hasPricedUsage, + "unpriced models produce an explicit lower-bound estimate") + expect(partial.valueMilestone?.label == "$1K", "unpriced tokens cannot inflate the earned milestone") + expect(partial.tier(for: .apiValue) == .black && summary.tier(for: .apiValue) == .white, + "unpriced tokens and legacy caches cannot inflate the money color tier") + expect(partial.shareText(metric: .apiValue).contains("$1,280.06+") + && partial.shareText(metric: .apiValue).contains("not a bill"), + "money caption preserves both partial-pricing and estimate qualifiers") + for invalid in [Double.nan, Double.infinity, -1] { + let bad = WeeklyUsageSnapshot.make(buckets: [.grok: [DailyTokenBucket(dayStart: date("2026-09-06 00:00"), tokens: 2, billableTokens: 2, + dollars: invalid)]], now: now, calendar: calendar) + expect(!bad.hasPricedUsage && bad.hasPartialPricing, "invalid prices cannot create a money card") + } + let free = WeeklyUsageSnapshot.make(buckets: [.grok: [DailyTokenBucket(dayStart: date("2026-09-06 00:00"), tokens: 2, billableTokens: 2, + dollars: 0, unpricedTokens: 0)]], now: now, calendar: calendar) + expect(free.hasPricedUsage && !free.hasPartialPricing, "known free usage stays distinct from missing pricing") + expect(WeeklyUsageSnapshot.money(1234.56) == "$1,234.56", "grouped USD amount retains cents") + expect(WeeklyUsageSnapshot.money(999.9999) == "$999.99", "display does not round up into an unearned milestone") + expect(WeeklyUsageSnapshot.money(1294.9099999999) == "$1,294.91", "ordinary sums retain cent rounding despite floating-point noise") + expect(WeeklyUsageSnapshot.money(0.0001) == "<$0.01", "subcent activity never looks free") + let roundTrip = try JSONDecoder().decode(DailyTokenBucket.self, + from: JSONEncoder().encode(pricedBuckets[.claude]?[0])) + expect(roundTrip.dollars == 1000.25 && roundTrip.unpricedTokens == 0, "daily API pricing survives cache round trip") + expect(abs(cost.dailyTokens.compactMap(\.dollars).reduce(0, +) - Pricing.cost(for: event)) < 0.000001, + "weekly history uses the same token-category pricing as the cost screen") + let unknown = TokenEvent(provider: .codex, timestamp: now, model: "unknown-test-model-no-rates", + inputTokens: 10, outputTokens: 2, cacheCreationTokens: 3, cacheReadTokens: 5) + let unknownCost = CostSummary.summarize(events: [unknown], now: now) + expect(unknownCost.dailyTokens.compactMap(\.unpricedTokens).reduce(0, +) == 20, + "unpriced daily history retains cache tokens in coverage accounting") + let recoveredBucket = DailyTokenBucket(dayStart: date("2026-01-09 00:00"), tokens: 1000, billableTokens: 100, + dollars: 0, unpricedTokens: 1000, recoveredTokens: 1000) + let recoveredCard = WeeklyUsageSnapshot.make(buckets: [.claude: [recoveredBucket]], period: .thisYear, + now: now, calendar: calendar) + expect(recoveredCard.hasRecoveredHistory && recoveredCard.recoveredTokens == 1000, + "the selected period exposes recovered daily history") + expect(recoveredCard.hasPartialPricing && !recoveredCard.hasPricedUsage, + "recovered totals without a model never create a fabricated API price") + expect(recoveredCard.shareText().contains("Includes recovered daily totals on their original dates."), + "shared captions explain recovered dates") + let excludedRecovery = WeeklyUsageSnapshot.make(buckets: [.claude: [recoveredBucket]], included: [.codex], + period: .thisYear, now: now, calendar: calendar) + expect(!excludedRecovery.hasRecoveredHistory, "excluded providers do not show a recovery note") + let recoveryRoundTrip = try JSONDecoder().decode(DailyTokenBucket.self, from: JSONEncoder().encode(recoveredBucket)) + expect(recoveryRoundTrip.recoveredTokens == 1000, "recovery attribution survives the display cache") + testLaunchGate() + print("PASS: \(assertions) weekly-card assertions") + } + + static func testLaunchGate() { + let suite = "WeeklyCardLaunchTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { fatalError("Could not create test preferences") } + defer { defaults.removePersistentDomain(forName: suite) } + let gate = WeeklyCardLaunchGate(defaults: defaults) + expect(!gate.prepare(version: "", existingInstallation: true), "missing build version never triggers a prompt") + expect(!gate.prepare(version: "0.2.4", existingInstallation: false), "fresh installs establish a baseline without an update prompt") + expect(!gate.prepare(version: "0.2.4", existingInstallation: true), "normal restart does not reopen the card") + expect(gate.prepare(version: "0.2.5", existingInstallation: true), "upgrades queue a card presentation") + let relaunched = WeeklyCardLaunchGate(defaults: defaults) + expect(relaunched.prepare(version: "0.2.5", existingInstallation: true), "pending presentation survives exit before usage loads") + relaunched.complete(version: "0.2.5") + expect(!gate.prepare(version: "0.2.5", existingInstallation: true), "completed presentation remains dismissed after relaunch") + expect(!gate.prepare(version: "0.2.3", existingInstallation: true), "downgrade does not count as an update") + expect(!gate.prepare(version: "0.2.5", existingInstallation: true), "returning from a downgrade does not repeat a seen version") + expect(gate.prepare(version: "0.2.10", existingInstallation: true), "component-wise version comparison handles multi-digit updates") + gate.complete(version: "0.2.4") + expect(gate.isPending(version: "0.2.10"), "completing another version cannot consume the current prompt") + defaults.removePersistentDomain(forName: suite) + expect(gate.prepare(version: "0.2.5", existingInstallation: true), "existing users without version tracking receive the feature introduction") + gate.complete(version: "0.2.5") + expect(!gate.prepare(version: "0.2.5", existingInstallation: true), "legacy migration introduction is also shown only once") + } +} diff --git a/docs/USAGE-HISTORY.md b/docs/USAGE-HISTORY.md index 854296c3..47b2daa7 100644 --- a/docs/USAGE-HISTORY.md +++ b/docs/USAGE-HISTORY.md @@ -43,8 +43,9 @@ The remainder keeps the original calendar date; moving it between hourly or current-timezone daily buckets would invent a distribution the source did not record. Overlapping aggregate intervals are rejected transactionally. -Recovered counts with no model breakdown remain unpriced. Cached zero days do -not establish that no activity occurred. +Recovered counts with no model breakdown remain unpriced. Usage cards identify +recovered daily totals, and their API-value view marks the missing pricing as +partial. Cached zero days do not establish that no activity occurred. ## Recover your Claude usage @@ -59,10 +60,10 @@ an old CodexIsland preferences file. Changing any selection clears the old preview, so you must scan again before importing. Click **Import recovery** to save the reviewed records. The app backs up the -archive, imports the exact records from the preview, and refreshes usage -history. Records that appear in source files after the preview are not silently +archive, imports the exact records from the preview, and refreshes the usage +card. Records that appear in source files after the preview are not silently included. If the app has already captured the same usage, it is not counted -again. +again. An open **All time** card also refreshes after recovery. The optional terminal script uses the same recovery code. No Xcode, Python, login, or downloads are required. Start with a preview: @@ -79,8 +80,8 @@ counts unchanged. To save the verified records, run it again with `--apply`: bash /Applications/CodexIsland.app/Contents/Resources/recover-claude-usage.sh --apply ``` -Refresh CodexIsland to include the recovered counts. You can repeat recovery -safely. Copied Claude messages +Refresh CodexIsland, then open **Overview → Share usage** and select **This +year** or **All time**. You can repeat recovery safely. Copied Claude messages are deduplicated by message and request IDs, and overlapping daily snapshots contribute only the part not covered by detailed records. Recovery leaves already captured messages intact and never reduces saved daily totals. diff --git a/scripts/preview-weekly-card.sh b/scripts/preview-weekly-card.sh new file mode 100644 index 00000000..aef4f7c3 --- /dev/null +++ b/scripts/preview-weekly-card.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +CARD_PREVIEW_DIR="$PWD/notes/weekly-card" +CARD_PREVIEW_APP="$CARD_PREVIEW_DIR/WeeklyCardPreview.app" +mkdir -p "$CARD_PREVIEW_APP/Contents/MacOS" "$CARD_PREVIEW_APP/Contents/Resources" "$CARD_PREVIEW_APP/Contents/Frameworks" +./scripts/setup-sparkle.sh +cp Resources/codexisland_logo.png "$CARD_PREVIEW_APP/Contents/Resources/" +cp -R Resources/*.lproj "$CARD_PREVIEW_APP/Contents/Resources/" +cp -a Vendor/Sparkle/Sparkle.framework "$CARD_PREVIEW_APP/Contents/Frameworks/" +cat > "$CARD_PREVIEW_APP/Contents/Info.plist" <<'PLIST' + + + +CFBundleExecutableWeeklyCardPreview +CFBundleIdentifierdev.codexisland.WeeklyCardPreview +CFBundleNameWeeklyCardPreview +CFBundlePackageTypeAPPL +NSHighResolutionCapable + +PLIST +CARD_SOURCES=() +while IFS= read -r file; do CARD_SOURCES+=("$file"); done < <(find Sources -name '*.swift' ! -name 'App.swift' | sort) +swiftc -O -target "$(uname -m)-apple-macos13.0" -parse-as-library -F Vendor/Sparkle \ + -framework SwiftUI -framework AppKit -framework ServiceManagement -framework Sparkle \ + -Xlinker -rpath -Xlinker '@executable_path/../Frameworks' \ + -o "$CARD_PREVIEW_APP/Contents/MacOS/WeeklyCardPreview" \ + "${CARD_SOURCES[@]}" Tests/WeeklyCardRenderHarness.swift +CODEXISLAND_DEMO=1 "$CARD_PREVIEW_APP/Contents/MacOS/WeeklyCardPreview" "${1:-$CARD_PREVIEW_DIR/renders}" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index bc759221..359b7859 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -189,5 +189,6 @@ swiftc \ "$OUT_DIR/provider-session-recovery-tests" +bash scripts/test-weekly-card.sh bash scripts/test-usage-ledger.sh bash scripts/test-claude-recovery.sh diff --git a/scripts/test-weekly-card.sh b/scripts/test-weekly-card.sh new file mode 100644 index 00000000..99b5def6 --- /dev/null +++ b/scripts/test-weekly-card.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +CARD_TEST_DIR=$(mktemp -d) +trap 'rm -rf "$CARD_TEST_DIR"' EXIT +swiftc -parse-as-library -o "$CARD_TEST_DIR/weekly-card-tests" \ + Sources/Model/IslandProvider.swift \ + Sources/Cost/TokenEvent.swift Sources/Cost/CostUsage.swift \ + Sources/Cost/CostBucketing.swift Sources/Cost/HistoricalUsageDay.swift Sources/Cost/CostSummary.swift \ + Sources/Cost/PricingCatalog.swift Sources/Cost/Pricing.swift \ + Sources/Sharing/WeeklyUsageSnapshot.swift Sources/Sharing/WeeklyCardTier.swift Sources/Sharing/WeeklyCardLaunchGate.swift \ + Tests/WeeklyUsageSnapshotTests.swift +"$CARD_TEST_DIR/weekly-card-tests"