Skip to content

Commit 30c755b

Browse files
committed
feat: add export/import for cross-device data viewing
Export saves all session data as JSON via native NSSavePanel. Import loads a previously exported file via NSOpenPanel, merges sessions (deduplicating by sessionId+date), and re-renders the dashboard with combined data.
1 parent 01fea92 commit 30c755b

5 files changed

Lines changed: 510 additions & 2 deletions

File tree

App.swift

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri
130130
config.preferences = prefs
131131
config.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
132132

133-
// Register message handler for reload button
133+
// Register message handlers
134134
let contentController = WKUserContentController()
135135
contentController.add(self, name: "reload")
136+
contentController.add(self, name: "exportData")
137+
contentController.add(self, name: "importData")
136138
config.userContentController = contentController
137139

138140
webView = WKWebView(frame: window.contentView!.bounds, configuration: config)
@@ -153,8 +155,69 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri
153155
// MARK: - WKScriptMessageHandler
154156

155157
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
156-
if message.name == "reload" {
158+
switch message.name {
159+
case "reload":
157160
reloadDashboard()
161+
case "exportData":
162+
handleExport(message.body as? String ?? "")
163+
case "importData":
164+
handleImport()
165+
default:
166+
break
167+
}
168+
}
169+
170+
// MARK: - Export (NSSavePanel)
171+
172+
func handleExport(_ jsonString: String) {
173+
let panel = NSSavePanel()
174+
let dateStr = ISO8601DateFormatter().string(from: Date()).prefix(10)
175+
panel.nameFieldStringValue = "claude-usage-\(dateStr).json"
176+
panel.allowedContentTypes = [.json]
177+
panel.canCreateDirectories = true
178+
panel.title = "Export Usage Data"
179+
180+
panel.beginSheetModal(for: window) { [weak self] response in
181+
guard response == .OK, let url = panel.url else { return }
182+
do {
183+
try jsonString.write(to: url, atomically: true, encoding: .utf8)
184+
let count = (try? JSONSerialization.jsonObject(with: Data(jsonString.utf8)) as? [String: Any])?["sessions"]
185+
let sessionCount = (count as? [[String: Any]])?.count ?? 0
186+
self?.webView.evaluateJavaScript("window._showExportToast('Exported \(sessionCount) sessions to file')")
187+
} catch {
188+
self?.webView.evaluateJavaScript("window._showExportToast('Export failed: \(error.localizedDescription)', true)")
189+
}
190+
}
191+
}
192+
193+
// MARK: - Import (NSOpenPanel)
194+
195+
func handleImport() {
196+
let panel = NSOpenPanel()
197+
panel.allowedContentTypes = [.json]
198+
panel.allowsMultipleSelection = false
199+
panel.canChooseDirectories = false
200+
panel.title = "Import Usage Data"
201+
panel.message = "Select a claude-usage JSON file exported from another device"
202+
203+
panel.beginSheetModal(for: window) { [weak self] response in
204+
guard response == .OK, let url = panel.url else {
205+
self?.webView.evaluateJavaScript("if(window._importDataResolver) window._importDataResolver(null)")
206+
return
207+
}
208+
do {
209+
let jsonString = try String(contentsOf: url, encoding: .utf8)
210+
// Escape for JS string literal
211+
let escaped = jsonString
212+
.replacingOccurrences(of: "\\", with: "\\\\")
213+
.replacingOccurrences(of: "'", with: "\\'")
214+
.replacingOccurrences(of: "\n", with: "\\n")
215+
.replacingOccurrences(of: "\r", with: "\\r")
216+
self?.webView.evaluateJavaScript("if(window._importDataResolver) window._importDataResolver('\(escaped)')")
217+
} catch {
218+
self?.webView.evaluateJavaScript("window._showExportToast('Failed to read file', true)")
219+
self?.webView.evaluateJavaScript("if(window._importDataResolver) window._importDataResolver(null)")
220+
}
158221
}
159222
}
160223

css/components/data-transfer.css

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/* ── Data Transfer (Export / Import) ─────────── */
2+
3+
.dt-actions {
4+
display: flex;
5+
gap: 8px;
6+
}
7+
8+
.dt-btn {
9+
display: inline-flex;
10+
align-items: center;
11+
gap: 5px;
12+
padding: 6px 12px;
13+
background: var(--bg-elevated);
14+
border: 1px solid var(--border-light);
15+
border-radius: var(--radius-sm);
16+
color: var(--text-secondary);
17+
font-family: 'JetBrains Mono', monospace;
18+
font-size: 0.68rem;
19+
letter-spacing: 0.02em;
20+
cursor: pointer;
21+
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
22+
white-space: nowrap;
23+
-webkit-app-region: no-drag;
24+
}
25+
26+
.dt-btn svg {
27+
width: 14px;
28+
height: 14px;
29+
stroke: currentColor;
30+
fill: none;
31+
stroke-width: 2;
32+
stroke-linecap: round;
33+
stroke-linejoin: round;
34+
flex-shrink: 0;
35+
}
36+
37+
.dt-btn:hover {
38+
border-color: var(--accent-cyan);
39+
color: var(--text-primary);
40+
box-shadow: 0 0 12px rgba(34, 211, 238, 0.08);
41+
}
42+
43+
.dt-btn:active {
44+
transform: scale(0.97);
45+
transition-duration: 0.05s;
46+
}
47+
48+
.dt-btn-export:hover {
49+
border-color: var(--accent-emerald);
50+
color: var(--accent-emerald);
51+
box-shadow: 0 0 12px rgba(52, 211, 153, 0.1);
52+
}
53+
54+
.dt-btn-import:hover {
55+
border-color: var(--accent-violet);
56+
color: var(--accent-violet);
57+
box-shadow: 0 0 12px rgba(167, 139, 250, 0.1);
58+
}
59+
60+
/* ── Toast ──────────────────────────────────── */
61+
62+
.dt-toast {
63+
position: fixed;
64+
bottom: 90px;
65+
right: 28px;
66+
z-index: 1100;
67+
padding: 10px 18px;
68+
background: var(--bg-card);
69+
border: 1px solid var(--accent-emerald);
70+
border-radius: var(--radius-sm);
71+
color: var(--accent-emerald);
72+
font-family: 'JetBrains Mono', monospace;
73+
font-size: 0.72rem;
74+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 16px rgba(52, 211, 153, 0.08);
75+
opacity: 0;
76+
transform: translateY(8px);
77+
pointer-events: none;
78+
transition: opacity 0.25s ease, transform 0.25s ease;
79+
}
80+
81+
.dt-toast-visible {
82+
opacity: 1;
83+
transform: translateY(0);
84+
}
85+
86+
.dt-toast-error {
87+
border-color: var(--accent-rose);
88+
color: var(--accent-rose);
89+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 16px rgba(251, 113, 133, 0.08);
90+
}
91+
92+
/* ── Import banner ──────────────────────────── */
93+
94+
.dt-import-banner {
95+
display: flex;
96+
align-items: center;
97+
justify-content: space-between;
98+
padding: 10px 20px;
99+
margin-bottom: 20px;
100+
background: var(--accent-violet-dim);
101+
border: 1px solid rgba(167, 139, 250, 0.25);
102+
border-radius: var(--radius-sm);
103+
animation: fadeSlideUp 0.3s ease forwards;
104+
}
105+
106+
.dt-import-banner-text {
107+
font-family: 'JetBrains Mono', monospace;
108+
font-size: 0.72rem;
109+
color: var(--accent-violet);
110+
}
111+
112+
.dt-import-banner-text strong {
113+
color: var(--text-primary);
114+
font-weight: 600;
115+
}
116+
117+
.dt-import-dismiss {
118+
padding: 4px 10px;
119+
background: transparent;
120+
border: 1px solid rgba(167, 139, 250, 0.3);
121+
border-radius: var(--radius-sm);
122+
color: var(--accent-violet);
123+
font-family: 'JetBrains Mono', monospace;
124+
font-size: 0.62rem;
125+
cursor: pointer;
126+
transition: background 0.2s ease;
127+
}
128+
129+
.dt-import-dismiss:hover {
130+
background: rgba(167, 139, 250, 0.12);
131+
}
132+
133+
@keyframes fadeSlideUp {
134+
from { opacity: 0; transform: translateY(8px); }
135+
to { opacity: 1; transform: translateY(0); }
136+
}

dashboard.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<link rel="stylesheet" href="css/components/expensive-callout.css">
2828
<link rel="stylesheet" href="css/components/footer.css">
2929
<link rel="stylesheet" href="css/components/reload-fab.css">
30+
<link rel="stylesheet" href="css/components/data-transfer.css">
3031

3132
<!-- Utilities -->
3233
<link rel="stylesheet" href="css/utilities.css">
@@ -43,6 +44,16 @@ <h1><span>Claude</span> Usage Tracker</h1>
4344
</div>
4445
<div class="header-meta">
4546
<span class="updated"><span class="status-dot"></span>Last sync: <span id="last-updated"></span></span>
47+
<div class="dt-actions">
48+
<button class="dt-btn dt-btn-export" id="dt-export-btn" type="button" title="Export data as JSON">
49+
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
50+
Export
51+
</button>
52+
<button class="dt-btn dt-btn-import" id="dt-import-btn" type="button" title="Import data from another device">
53+
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
54+
Import
55+
</button>
56+
</div>
4657
</div>
4758
</header>
4859

0 commit comments

Comments
 (0)