Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions apps/nowcasting-app/components/helpers/csvDownload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { CombinedData } from "../types";
import { DateTime } from "luxon";
import { CSVColumn } from "../layout/header/csv-download-modal";
import { getSettlementPeriodForDate } from "./chartUtils";

interface CSVRow {
startDateTime: string;
endDateTime: string;
settlementPeriod: number | null;
solarGenerationPvliveInitial: number | null;
solarGenerationPvliveUpdated: number | null;
solarForecast: number | null;
solarForecastP10: number | null;
solarForecastP90: number | null;
nForecast: number | null;
}

const COLUMN_CONFIG: Record<CSVColumn, { key: keyof CSVRow; header: string }> = {
startDateTime: { key: "startDateTime", header: "Start DateTime" },
endDateTime: { key: "endDateTime", header: "End DateTime" },
settlementPeriod: { key: "settlementPeriod", header: "Settlement Period" },
solarGenerationPvliveInitial: {
key: "solarGenerationPvliveInitial",
header: "Solar Generation PVLive Initial (MW)"
},
solarGenerationPvliveUpdated: {
key: "solarGenerationPvliveUpdated",
header: "Solar Generation PVLive Updated (MW)"
},
solarForecast: { key: "solarForecast", header: "Solar Forecast (MW)" },
solarForecastP10: { key: "solarForecastP10", header: "Solar Forecast P10 (MW)" },
solarForecastP90: { key: "solarForecastP90", header: "Solar Forecast P90 (MW)" },
nForecast: { key: "nForecast", header: "N Forecast (MW)" }
};

const createEmptyRow = (timestamp: string): CSVRow => {
const start = DateTime.fromISO(timestamp);
const end = start.plus({ minutes: 30 });
const settlementPeriod = getSettlementPeriodForDate(start);

return {
startDateTime: start.toISO() || "",
endDateTime: end.toISO() || "",
settlementPeriod,
solarGenerationPvliveInitial: null,
solarGenerationPvliveUpdated: null,
solarForecast: null,
solarForecastP10: null,
solarForecastP90: null,
nForecast: null
};
};

const getOrCreateRow = (map: Map<string, CSVRow>, ts: string): CSVRow => {
if (!map.has(ts)) {
map.set(ts, createEmptyRow(ts));
}
return map.get(ts)!;
};

export const downloadNationalCsv = (
combinedData: CombinedData | null,
selectedColumns: CSVColumn[]
) => {
if (!combinedData) return;

const dataByTimestamp = new Map<string, CSVRow>();

// PV initial
combinedData.pvRealDayInData?.forEach((entry) => {
const row = getOrCreateRow(dataByTimestamp, entry.datetimeUtc);
row.solarGenerationPvliveInitial = entry.solarGenerationKw
? entry.solarGenerationKw / 1000
: null;
});

// PV updated
combinedData.pvRealDayAfterData?.forEach((entry) => {
const row = getOrCreateRow(dataByTimestamp, entry.datetimeUtc);
row.solarGenerationPvliveUpdated = entry.solarGenerationKw
? entry.solarGenerationKw / 1000
: null;
});

// Forecast
combinedData.nationalForecastData?.forEach((entry) => {
const row = getOrCreateRow(dataByTimestamp, entry.targetTime);
row.solarForecast = entry.expectedPowerGenerationMegawatts;
row.solarForecastP10 = entry.plevels?.plevel_10 ?? null;
row.solarForecastP90 = entry.plevels?.plevel_90 ?? null;
});

// N forecast
combinedData.nationalNHourData?.forEach((entry) => {
const row = getOrCreateRow(dataByTimestamp, entry.targetTime);
row.nForecast = entry.expectedPowerGenerationMegawatts;
});

// sort + build rows
const csvRows = Array.from(dataByTimestamp.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, row]) => row);

const csv = generateCsv(csvRows, selectedColumns);

// download
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);

const a = document.createElement("a");
a.href = url;

const now = DateTime.now().toUTC().toFormat("yyyy-MM-dd_HH-mm");
a.download = `Quartz-National-${now}.csv`;

document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

function generateCsv(rows: CSVRow[], selectedColumns: CSVColumn[]): string {
const headers = selectedColumns.map((col) => COLUMN_CONFIG[col].header);

const lines = rows.map((row) =>
selectedColumns
.map((col) => {
const value = row[COLUMN_CONFIG[col].key];
return value ?? "";
})
.join(",")
);

return [headers.join(","), ...lines].join("\n");
}
18 changes: 18 additions & 0 deletions apps/nowcasting-app/components/icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,22 @@ export const ZoomOutIcon = (props: React.SVGProps<SVGSVGElement> & { title: stri
</svg>
</span>
);

export const DownloadIcon: React.FC<IconProps> = ({ className }) => (
<svg
className={className || ""}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="none"
>
<path
fill="currentColor"
fillRule="evenodd"
d="m16.75 8.96-4.01 4.01-.707.708-.708-.707-4.01-4.01 1.414-1.415 2.304 2.303V2h2v7.85l2.303-2.304zM1 20.34v-9h6v2H3v5h18v-5h-4v-2h6v9H1"
clipRule="evenodd"
/>
</svg>
);

export default ZoomOutIcon;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal preference is for camelCase file names, especially on React Component files 👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useState } from "react";

export type CSVColumn =
| "startDateTime"
| "endDateTime"
| "settlementPeriod"
| "solarGenerationPvliveInitial"
| "solarGenerationPvliveUpdated"
| "solarForecast"
| "solarForecastP10"
| "solarForecastP90"
| "nForecast";

const FIXED_COLUMNS: CSVColumn[] = ["startDateTime", "endDateTime"];

const SELECTABLE_COLUMNS: { id: CSVColumn; label: string }[] = [
{ id: "settlementPeriod", label: "Settlement Period" },
{ id: "solarGenerationPvliveInitial", label: "PVLive Initial (MW)" },
{ id: "solarGenerationPvliveUpdated", label: "PVLive Updated (MW)" },
{ id: "solarForecast", label: "Solar Forecast (MW)" },
{ id: "solarForecastP10", label: "Forecast P10 (MW)" },
{ id: "solarForecastP90", label: "Forecast P90 (MW)" },
{ id: "nForecast", label: "N Forecast (MW)" }
];

interface Props {
isOpen: boolean;
onClose: () => void;
onDownload: (cols: CSVColumn[]) => void;
}

export const CSVDownloadModal: React.FC<Props> = ({ isOpen, onClose, onDownload }) => {
const allSelectableIds = SELECTABLE_COLUMNS.map((c) => c.id);

const [selected, setSelected] = useState<CSVColumn[]>(allSelectableIds);

const toggle = (id: CSVColumn) =>
setSelected((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));

const toggleAll = () =>
setSelected((prev) => (prev.length === allSelectableIds.length ? [] : allSelectableIds));

const download = () => {
onDownload([...FIXED_COLUMNS, ...selected]);
onClose();
};

if (!isOpen) return null;

const allSelected = selected.length === allSelectableIds.length;

return (
<>
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />

<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full max-h-[80vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b p-4">
<h2 className="text-lg font-semibold">Select Columns to Download</h2>
</div>

<div className="p-4 space-y-3">
<label className="flex items-center gap-3 font-semibold border-b pb-2 cursor-pointer">
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
Select All
</label>

{SELECTABLE_COLUMNS.map((col) => (
<label key={col.id} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={selected.includes(col.id)}
onChange={() => toggle(col.id)}
/>
{col.label}
</label>
))}
</div>

<div className="sticky bottom-0 border-t p-4 flex gap-2">
<button onClick={onClose} className="flex-1 px-4 py-2 bg-gray-100">
Cancel
</button>

<button
onClick={download}
disabled={!selected.length}
className={`flex-1 px-4 py-2 ${
selected.length ? "bg-ocf-yellow" : "bg-gray-100 cursor-not-allowed"
}`}
>
Download
</button>
</div>
</div>
</div>
</>
);
};
Loading
Loading