Skip to content

Commit 159b799

Browse files
author
Cody Hoover
committed
Support exporting to CSV files
1 parent aac9da0 commit 159b799

File tree

5 files changed

+120
-76
lines changed

5 files changed

+120
-76
lines changed

.vscode/settings.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"editor.tabSize": 2
2+
"editor.tabSize": 2,
3+
"typescript.tsdk": "node_modules\\typescript\\lib"
34
}

src/components/Settings.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function Settings(props: SettingsProps) {
6262
<label id="download-label">4. Download</label>
6363
<button className="download-button" aria-labelledby="download-label download-ical-button" id="download-ical-button" onClick={() => onDownload("ical")}>iCal</button>
6464
<button className="download-button" aria-labelledby="download-label download-json-button" id="download-json-button" onClick={() => onDownload("json")}>json</button>
65-
{/* <button className="download-button" aria-labelledby="download-label download-csv-button" id="download-csv-button" onClick={() => onDownload("csv")}>csv</button> */}
65+
<button className="download-button" aria-labelledby="download-label download-csv-button" id="download-csv-button" onClick={() => onDownload("csv")}>csv</button>
6666
</div>
6767
</div>
6868
</Card>

src/lib/csvProcessor.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
parse as parseCsv,
3+
unparse as unparseCsv,
4+
UnparseConfig
5+
} from "papaparse";
6+
7+
import { Plan, Workout, Units } from "./workout";
8+
9+
type PlanInfo = Omit<Plan, "workouts">;
10+
type PlanHeadings = keyof PlanInfo;
11+
type WorkoutHeadings = keyof Workout;
12+
13+
const PLAN_HEADINGS: PlanHeadings[] = [
14+
"title",
15+
"raceType",
16+
"raceDistance",
17+
"units"
18+
];
19+
const NEWLINE = "\r\n";
20+
const UNPARSE_OPTIONS: UnparseConfig = {
21+
header: true,
22+
newline: NEWLINE,
23+
skipEmptyLines: true
24+
};
25+
26+
export const planToCsv = (plan: Plan): string => {
27+
const { workouts, ...planInfo } = plan;
28+
29+
const planInfoData = [
30+
PLAN_HEADINGS,
31+
[planInfo.title, planInfo.raceType, planInfo.raceDistance, planInfo.units]
32+
];
33+
34+
const csv = [
35+
unparseCsv(planInfoData, UNPARSE_OPTIONS),
36+
unparseCsv(workouts, UNPARSE_OPTIONS)
37+
].join(NEWLINE);
38+
39+
return csv;
40+
};
41+
42+
export const csvToPlan = (file: string): Plan | null => {
43+
try {
44+
const result = parseCsv(file, { skipEmptyLines: true });
45+
const [
46+
planHeadings,
47+
planValues,
48+
workoutHeadings,
49+
...workouts
50+
] = result.data as [
51+
PlanHeadings[],
52+
string[],
53+
WorkoutHeadings[],
54+
...string[][]
55+
];
56+
57+
// Get column order from the file
58+
const titleIndex = planHeadings.indexOf("title");
59+
const raceTypeIndex = planHeadings.indexOf("raceType");
60+
const raceDistanceIndex = planHeadings.indexOf("raceDistance");
61+
const unitsIndex = planHeadings.indexOf("units");
62+
const descriptionIndex = workoutHeadings.indexOf("description");
63+
const workoutDistanceIndex = workoutHeadings.indexOf("totalDistance");
64+
65+
// TODO: Validate values
66+
const plan: Plan = {
67+
title: planValues[titleIndex],
68+
raceType: planValues[raceTypeIndex],
69+
raceDistance: parseFloat(planValues[raceDistanceIndex]),
70+
units: planValues[unitsIndex] as Units,
71+
workouts: workouts.reduce(
72+
(output, workout) => {
73+
if (workout.length === 2) {
74+
output.push({
75+
description: workout[descriptionIndex],
76+
totalDistance: parseFloat(workout[workoutDistanceIndex])
77+
});
78+
}
79+
80+
return output;
81+
},
82+
[] as Workout[]
83+
)
84+
};
85+
86+
return plan;
87+
} catch (e) {
88+
return null;
89+
}
90+
};

src/lib/exporter.ts

+15-5
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import { ics, saveAs } from "../lib/ics";
33
import { ScheduledPlan, Plan } from "./workout";
44
import { formatWorkoutFromTemplate } from "./formatter";
55
import { chunkArray } from "../lib/utils";
6+
import { planToCsv } from "./csvProcessor";
67

7-
export type Filetype = 'ical' | 'json' | 'csv';
8+
export type Filetype = "ical" | "json" | "csv";
89

910
export function downloadPlanTemplate(plan: Plan, filetype: Filetype) {
10-
switch(filetype) {
11-
case 'json':
11+
switch (filetype) {
12+
case "json":
1213
downloadJson(plan);
1314
return;
14-
case 'csv':
15+
case "csv":
16+
downloadCsv(plan);
1517
return;
1618
}
1719
}
@@ -49,5 +51,13 @@ export function downloadPlanCalendar(plan: ScheduledPlan) {
4951
}
5052

5153
function downloadJson(plan: Plan) {
52-
saveAs!(new Blob([JSON.stringify(plan, null, ' ')]), `${plan.title}.json`);
54+
downloadCore(JSON.stringify(plan, null, " "), `${plan.title}.json`);
55+
}
56+
57+
function downloadCsv(plan: Plan) {
58+
downloadCore(planToCsv(plan), `${plan.title}.csv`);
59+
}
60+
61+
function downloadCore(file: string, filename: string) {
62+
saveAs!(new Blob([file]), filename);
5363
}

src/lib/importer.ts

+12-69
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
import { Plan, Workout, Units } from "./workout";
2-
import { parse as parseCsv } from "papaparse";
1+
import { Plan } from "./workout";
32
import { getFileExtension } from "./utils";
3+
import { csvToPlan } from "./csvProcessor";
44

55
export interface UploadResult {
66
plan?: Plan;
77
error?: string;
88
}
99

10-
type FileProcessor = (file: string) => Promise<Plan>;
10+
type FileProcessor = (file: string) => Plan | null;
1111

1212
export const importFile = (file: File): Promise<Plan> => {
1313
const extension = getFileExtension(file.name);
1414
switch (extension) {
1515
case ".json":
1616
return importCore(file, processJsonFile);
1717
case ".csv":
18-
return importCore(file, processCsvFile);
18+
return importCore(file, csvToPlan);
1919
default:
2020
return Promise.reject(
2121
`Could not import file. Filetype is not supported: ${extension}`
@@ -31,9 +31,11 @@ const importCore = (file: File, processFile: FileProcessor): Promise<Plan> => {
3131
const result =
3232
typedEvent && typedEvent.target && typedEvent.target.result;
3333

34-
try {
35-
resolve(processFile(result));
36-
} catch (e) {
34+
const plan = processFile(result);
35+
36+
if (plan) {
37+
resolve(plan);
38+
} else {
3739
reject(`Unable to read ${file.name}. Check the file and try again.`);
3840
}
3941
};
@@ -42,72 +44,13 @@ const importCore = (file: File, processFile: FileProcessor): Promise<Plan> => {
4244
});
4345
};
4446

45-
const processJsonFile = (file: string): Promise<Plan> => {
47+
const processJsonFile = (file: string): Plan | null => {
4648
try {
4749
const planObject = JSON.parse(file) as Plan;
4850
// TODO: validate object
49-
return Promise.resolve(planObject);
50-
} catch (e) {
51-
return Promise.reject(
52-
`Unable to read the json file. Check the file and try again.`
53-
);
54-
}
55-
};
56-
57-
type PlanHeadings = keyof Plan;
58-
type WorkoutHeadings = keyof Workout;
59-
type WorkoutValue = [string, string];
60-
61-
const processCsvFile = (file: string): Promise<Plan> => {
62-
try {
63-
const result = parseCsv(file);
64-
const [
65-
planHeadings,
66-
planValues,
67-
workoutHeadings,
68-
...workouts
69-
] = result.data as [
70-
PlanHeadings[],
71-
string[],
72-
WorkoutHeadings[],
73-
...string[][]
74-
];
75-
76-
const titleIndex = planHeadings.indexOf("title");
77-
const raceTypeIndex = planHeadings.indexOf("raceType");
78-
const raceDistanceIndex = planHeadings.indexOf("raceDistance");
79-
const unitsIndex = planHeadings.indexOf("units");
80-
81-
const descriptionIndex = workoutHeadings.indexOf("description");
82-
const workoutDistanceIndex = workoutHeadings.indexOf("totalDistance");
83-
84-
// TODO: Validate values
85-
const plan: Plan = {
86-
// Add some resiliency for the header in case these are in a different order
87-
title: planValues[titleIndex],
88-
raceType: planValues[raceTypeIndex],
89-
raceDistance: parseFloat(planValues[raceDistanceIndex]),
90-
units: planValues[unitsIndex] as Units,
91-
workouts: workouts.reduce(
92-
(output, workout) => {
93-
if (workout.length === 2) {
94-
output.push({
95-
description: workout[descriptionIndex],
96-
totalDistance: parseFloat(workout[workoutDistanceIndex])
97-
});
98-
}
99-
100-
return output;
101-
},
102-
[] as Workout[]
103-
)
104-
};
105-
106-
return Promise.resolve(plan);
51+
return planObject;
10752
} catch (e) {
108-
return Promise.reject(
109-
`Unable to read the csv file. Check the file and try again.`
110-
);
53+
return null;
11154
}
11255
};
11356

0 commit comments

Comments
 (0)