Skip to content

Commit bc379ab

Browse files
committed
feat: add ZIP file support for CSV uploads and implement sample data loading
1 parent a47abd2 commit bc379ab

9 files changed

Lines changed: 257 additions & 18 deletions

File tree

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@tanstack/react-table": "^8.21.3",
2222
"@tanstack/react-virtual": "^3.13.23",
2323
"clsx": "^2.1.1",
24+
"fflate": "^0.8.2",
2425
"highcharts": "^12.5.0",
2526
"highcharts-react-official": "^3.2.3",
2627
"lz-string": "^1.5.0",

src/components/CsvManager.module.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,37 @@
5252
background: var(--borderColor-accent-emphasis, #0969da);
5353
opacity: 0.5;
5454
}
55+
56+
.zipInfo {
57+
display: flex;
58+
gap: var(--base-size-12, 12px);
59+
padding: var(--base-size-16, 16px);
60+
background: var(--bgColor-muted, #f6f8fa);
61+
border: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, #d1d9e0b3);
62+
border-radius: var(--borderRadius-medium, 6px);
63+
}
64+
65+
.zipInfoIcon {
66+
flex-shrink: 0;
67+
color: var(--fgColor-muted, #59636e);
68+
padding-top: 2px;
69+
}
70+
71+
.zipInfoTitle {
72+
font-weight: var(--base-text-weight-semibold, 600);
73+
font-size: var(--text-body-size-medium, 14px);
74+
margin-bottom: var(--base-size-4, 4px);
75+
}
76+
77+
.zipInfoDesc {
78+
font-size: var(--text-body-size-small, 12px);
79+
color: var(--fgColor-muted, #59636e);
80+
line-height: var(--text-body-lineHeight-medium, 20px);
81+
}
82+
83+
.zipInfoDesc code {
84+
padding: 2px 6px;
85+
background: var(--bgColor-neutral-muted, #818b981f);
86+
border-radius: var(--borderRadius-small, 4px);
87+
font-size: var(--text-body-size-small, 12px);
88+
}

src/components/CsvManager.tsx

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import {
1111
DownloadIcon,
1212
FileIcon,
13+
FileZipIcon,
1314
TrashIcon,
1415
UploadIcon,
1516
} from '@primer/octicons-react';
@@ -29,6 +30,7 @@ import { getReportSchema } from '../lib/report-schema';
2930
import { formatDateRange } from '../lib/formatters';
3031
import { FileDropzone } from './FileDropzone';
3132
import { parseCSV } from '../lib/csv-parser';
33+
import { createZipArchive, extractCsvsFromZip, isZipFile, ACCEPTED_FILE_TYPES } from '../lib/zip';
3234
import styles from './CsvManager.module.css';
3335
import tableStyles from './ReportTable.module.css';
3436

@@ -46,6 +48,24 @@ interface FileRow {
4648

4749
const columnHelper = createColumnHelper<FileRow>();
4850

51+
function ZipInfo() {
52+
return (
53+
<div className={styles.zipInfo}>
54+
<div className={styles.zipInfoIcon}>
55+
<FileZipIcon size={24} />
56+
</div>
57+
<div>
58+
<Text as="p" className={styles.zipInfoTitle}>ZIP backup &amp; restore</Text>
59+
<Text as="p" className={styles.zipInfoDesc}>
60+
<strong>Download all</strong> saves your reports as a single <code>github-reports-YYYY-MM-DD.zip</code> archive.
61+
To restore, just drop the ZIP file here or click <strong>Add file</strong> and select it.
62+
All CSVs inside the archive will be imported automatically.
63+
</Text>
64+
</div>
65+
</div>
66+
);
67+
}
68+
4969
function SortIcon({ direction }: { direction: 'asc' | 'desc' | false }) {
5070
if (direction === 'asc') {
5171
return (
@@ -85,9 +105,15 @@ export function CsvManager() {
85105
const handleAddFile = useCallback(
86106
async (files: FileList) => {
87107
for (const file of Array.from(files)) {
88-
const text = await file.text();
89-
const parsed = parseCSV(text, file.name);
90-
addReport(parsed, text);
108+
if (isZipFile(file)) {
109+
const csvFiles = await extractCsvsFromZip(file);
110+
for (const { name, content } of csvFiles) {
111+
addReport(parseCSV(content, name), content);
112+
}
113+
} else {
114+
const text = await file.text();
115+
addReport(parseCSV(text, file.name), text);
116+
}
91117
}
92118
},
93119
[addReport],
@@ -110,10 +136,20 @@ export function CsvManager() {
110136
);
111137

112138
const handleDownloadAll = useCallback(() => {
113-
reports.forEach((_, i) => {
114-
setTimeout(() => handleDownloadFile(i), i * 200);
139+
const files: Record<string, string> = {};
140+
reports.forEach((report, i) => {
141+
const csv = rawCsvs[i];
142+
if (csv) files[report.fileName] = csv;
115143
});
116-
}, [reports, handleDownloadFile]);
144+
const blob = createZipArchive(files);
145+
const url = URL.createObjectURL(blob);
146+
const a = document.createElement('a');
147+
a.href = url;
148+
const date = new Date().toISOString().slice(0, 10);
149+
a.download = `github-reports-${date}.zip`;
150+
a.click();
151+
URL.revokeObjectURL(url);
152+
}, [reports, rawCsvs]);
117153

118154
const handleDeleteFile = useCallback(
119155
(index: number) => {
@@ -255,6 +291,7 @@ export function CsvManager() {
255291
<div className={styles.emptyState}>
256292
<FileDropzone forceShow reportType="usage_report" />
257293
</div>
294+
<ZipInfo />
258295
</div>
259296
);
260297
}
@@ -297,7 +334,7 @@ export function CsvManager() {
297334
<input
298335
ref={fileInputRef}
299336
type="file"
300-
accept=".csv"
337+
accept={ACCEPTED_FILE_TYPES}
301338
multiple
302339
style={{ display: 'none' }}
303340
onChange={(e) => {
@@ -367,6 +404,8 @@ export function CsvManager() {
367404
</table>
368405
</div>
369406

407+
<ZipInfo />
408+
370409
{confirmDeleteAll && (
371410
<Dialog
372411
title="Delete all files?"

src/components/FileDropzone.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Button, Dialog, Flash, FormControl, Text, TextInput } from '@primer/rea
33
import { Blankslate } from '@primer/react/experimental';
44
import { UploadIcon, LinkExternalIcon } from '@primer/octicons-react';
55
import { parseCSV } from '../lib/csv-parser';
6+
import { extractCsvsFromZip, isZipFile, ACCEPTED_FILE_TYPES } from '../lib/zip';
67
import { useReport } from '../context/useReport';
78
import { REPORT_TYPES, type ReportType } from '../lib/types';
89
import styles from './FileDropzone.module.css';
@@ -120,19 +121,34 @@ export function FileDropzone({ forceShow, reportType = REPORT_TYPES.PREMIUM_REQU
120121
async (files: FileList | File[]) => {
121122
setError(null);
122123
for (const file of Array.from(files)) {
123-
if (!file.name.endsWith('.csv')) {
124-
setError(`"${file.name}" is not a CSV file.`);
125-
continue;
126-
}
127124
try {
125+
if (isZipFile(file)) {
126+
const csvFiles = await extractCsvsFromZip(file);
127+
if (csvFiles.length === 0) {
128+
setError(`"${file.name}" contains no CSV files.`);
129+
continue;
130+
}
131+
for (const { name, content } of csvFiles) {
132+
const report = parseCSV(content, name);
133+
const dupeIndex = addReport(report, content);
134+
if (dupeIndex >= 0) {
135+
setError(`"${name}" is already loaded.`);
136+
}
137+
}
138+
continue;
139+
}
140+
if (!file.name.endsWith('.csv')) {
141+
setError(`"${file.name}" is not a CSV or ZIP file.`);
142+
continue;
143+
}
128144
const text = await file.text();
129145
const report = parseCSV(text, file.name);
130146
const dupeIndex = addReport(report, text);
131147
if (dupeIndex >= 0) {
132-
setError(`"${file.name}" is already loaded — switched to that report.`);
148+
setError(`"${file.name}" is already loaded.`);
133149
}
134150
} catch (err) {
135-
setError(err instanceof Error ? err.message : 'Failed to parse CSV');
151+
setError(err instanceof Error ? err.message : 'Failed to parse file');
136152
}
137153
}
138154
},
@@ -190,15 +206,15 @@ export function FileDropzone({ forceShow, reportType = REPORT_TYPES.PREMIUM_REQU
190206
<Blankslate.Visual>
191207
<UploadIcon size={48} />
192208
</Blankslate.Visual>
193-
<Blankslate.Heading>Drop a CSV here</Blankslate.Heading>
209+
<Blankslate.Heading>Drop a CSV or ZIP here</Blankslate.Heading>
194210
<Blankslate.Description>
195211
or click to browse
196212
</Blankslate.Description>
197213
</Blankslate>
198214
<input
199215
ref={inputRef}
200216
type="file"
201-
accept=".csv"
217+
accept={ACCEPTED_FILE_TYPES}
202218
multiple
203219
className={styles.fileInput}
204220
onChange={(e) => {

src/components/InsightsSidebar.tsx

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import React from 'react';
1+
import React, { useState, useCallback } from 'react';
22
import {
33
ActionMenu,
44
ActionList,
55
Heading,
66
IconButton,
77
NavList,
8+
Dialog,
9+
Text,
10+
Button,
11+
Spinner,
12+
Stack,
813
} from '@primer/react';
914
import {
1015
CopilotIcon,
@@ -28,6 +33,8 @@ import {
2833
type PageType,
2934
} from '../lib/report-schema';
3035
import type { ParsedReport, UsageReportRow } from '../lib/types';
36+
import { parseCSV } from '../lib/csv-parser';
37+
import { useReport } from '../context/useReport';
3138
import { formatDisplayValue } from '../lib/formatters';
3239
import { ACTIONS_STORAGE_SKUS, type ActionsSubView } from '../hooks/useProductNavigation';
3340
import styles from '../App.module.css';
@@ -63,6 +70,32 @@ export function InsightsSidebar({
6370
}: InsightsSidebarProps) {
6471
const { colorMode, setColorMode } = useColorMode();
6572
const onboarding = useOnboardingContext();
73+
const { reports, addReport } = useReport();
74+
const [showSamplePrompt, setShowSamplePrompt] = useState(false);
75+
const [loadingSamples, setLoadingSamples] = useState(false);
76+
77+
const handleTourRestart = useCallback(() => {
78+
if (reports.length === 0) {
79+
setShowSamplePrompt(true);
80+
} else {
81+
onboarding.restart();
82+
}
83+
}, [reports.length, onboarding]);
84+
85+
const handleLoadSamples = useCallback(async () => {
86+
setLoadingSamples(true);
87+
try {
88+
const { loadSampleData } = await import('../lib/sample-data');
89+
const samples = await loadSampleData();
90+
for (const { name, content } of samples) {
91+
addReport(parseCSV(content, name), content);
92+
}
93+
onboarding.restart();
94+
} finally {
95+
setLoadingSamples(false);
96+
setShowSamplePrompt(false);
97+
}
98+
}, [addReport, onboarding]);
6699

67100
return (
68101
<div className={styles.sidebarContent}>
@@ -232,10 +265,39 @@ export function InsightsSidebar({
232265
icon={QuestionIcon}
233266
variant="invisible"
234267
size="small"
235-
onClick={onboarding.restart}
268+
onClick={handleTourRestart}
236269
/>
237270
</span>
238271
</div>
272+
273+
{showSamplePrompt && (
274+
<Dialog
275+
title="Load sample data?"
276+
onClose={() => setShowSamplePrompt(false)}
277+
>
278+
<Text as="p">
279+
No reports are loaded yet. Would you like to load sample CSV data so you can explore the feature tour with real charts and tables?
280+
</Text>
281+
<Stack direction="horizontal" justify="end" gap="condensed" style={{ marginTop: 16 }}>
282+
<Button
283+
onClick={() => {
284+
setShowSamplePrompt(false);
285+
onboarding.restart();
286+
}}
287+
>
288+
No, just start tour
289+
</Button>
290+
<Button
291+
variant="primary"
292+
onClick={handleLoadSamples}
293+
disabled={loadingSamples}
294+
leadingVisual={loadingSamples ? Spinner : undefined}
295+
>
296+
{loadingSamples ? 'Loading...' : 'Yes, load sample data'}
297+
</Button>
298+
</Stack>
299+
</Dialog>
300+
)}
239301
</div>
240302
);
241303
}

src/components/ReportPageLayout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { REPORT_TYPES } from '../lib/types';
5959
import { formatDateRange, formatDateRangeCompact, preloadBotAvatars } from '../lib/formatters';
6060
import { computeSummary } from '../lib/aggregation';
6161
import { parseCSV } from '../lib/csv-parser';
62+
import { extractCsvsFromZip, isZipFile, ACCEPTED_FILE_TYPES } from '../lib/zip';
6263
import { getStoredValue, setStoredValue, STORAGE_KEYS } from '../lib/local-storage';
6364
import { readURLFilterState, writeURLFilterState } from '../lib/url-state';
6465
import { OnboardingBubble, ONBOARDING_STEPS } from './onboarding';
@@ -254,6 +255,13 @@ export function ReportPageLayout({ schema, allowedReportTypes, metricOptions }:
254255
const handleAddFile = useCallback(
255256
async (files: FileList) => {
256257
for (const file of Array.from(files)) {
258+
if (isZipFile(file)) {
259+
const csvFiles = await extractCsvsFromZip(file);
260+
for (const { name, content } of csvFiles) {
261+
addReport(parseCSV(content, name), content);
262+
}
263+
continue;
264+
}
257265
if (!file.name.endsWith('.csv')) continue;
258266
const text = await file.text();
259267
addReport(parseCSV(text, file.name), text);
@@ -502,7 +510,7 @@ export function ReportPageLayout({ schema, allowedReportTypes, metricOptions }:
502510
<input
503511
ref={fileInputRef}
504512
type="file"
505-
accept=".csv"
513+
accept={ACCEPTED_FILE_TYPES}
506514
multiple
507515
className={styles.hiddenInput}
508516
onChange={(e) => {

0 commit comments

Comments
 (0)