import ExcelJS from "exceljs"; import { flavourMeta } from "../data/flavours"; import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types"; import { caffeineFor, caffeinePerCan, currency, makeImportKey, oneDecimal, spendFor, sugarFor, sum, topByCans, wholeNumber, } from "./metrics"; const WORKBOOK_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; const ENTRIES_SHEET = "Intake Entries"; const SUMMARY_SHEET = "Summary"; const MIKU_BLUE = "FF39D5FF"; const PASTEL_PINK = "FFFFB7D9"; const MIDNIGHT = "FF0B1022"; const CHROME = "FFE8ECF4"; const RED_BULL_RED = "FFFF3448"; const RED_BULL_YELLOW = "FFFFD84D"; const ENTRY_COLUMNS = [ { header: "Date", key: "date", width: 14 }, { header: "Time", key: "time", width: 12 }, { header: "Flavour", key: "flavour", width: 22 }, { header: "Size", key: "size", width: 12 }, { header: "Cans", key: "cans", width: 10 }, { header: "Price per can", key: "pricePerCan", width: 16 }, { header: "Total cost", key: "totalCost", width: 15 }, { header: "Caffeine", key: "caffeine", width: 15 }, { header: "Sugar estimate", key: "sugar", width: 17 }, { header: "Store/location", key: "store", width: 24 }, { header: "Notes", key: "notes", width: 36 }, ] as const; export async function createExcelExport(entries: RedBullEntry[]) { const workbook = new ExcelJS.Workbook(); workbook.creator = "Red Bull Intake Tracker"; workbook.created = new Date(); workbook.modified = new Date(); workbook.properties.date1904 = false; addEntriesSheet(workbook, entries); addSummarySheet(workbook, entries); const buffer = await workbook.xlsx.writeBuffer(); return new Blob([buffer], { type: WORKBOOK_MIME }); } export async function parseExcelImport(file: File, existingEntries: RedBullEntry[]): Promise { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await file.arrayBuffer()); const worksheet = workbook.getWorksheet(ENTRIES_SHEET) ?? workbook.worksheets[0]; if (!worksheet) { throw new Error("No worksheet found in that Excel file."); } const headers = headerMap(worksheet.getRow(1)); const rows: ImportPreviewRow[] = []; const seen = new Set(existingEntries.map((entry) => entry.importKey || makeImportKey(entry))); worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; if (rowIsBlank(row)) return; const label = stringCell(row.getCell(headers.date ?? 1).value).trim().toLowerCase(); if (label === "totals" || label === "total") return; const result = parseEntryRow(row, headers, rowNumber); if (!result.entry || result.errors.length) { rows.push(result); return; } const key = makeImportKey({ ...result.entry, dateTime: new Date(result.entry.dateTime).toISOString(), notes: result.entry.notes ?? "", store: result.entry.store ?? "", }); const duplicate = seen.has(key); rows.push({ ...result, duplicate, duplicateReason: duplicate ? "Matches an existing or earlier imported row." : undefined, }); if (!duplicate) seen.add(key); }); return { fileName: file.name, rows }; } export function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); } function addEntriesSheet(workbook: ExcelJS.Workbook, entries: RedBullEntry[]) { const worksheet = workbook.addWorksheet(ENTRIES_SHEET, { views: [{ state: "frozen", ySplit: 1 }], properties: { defaultRowHeight: 22 }, }); worksheet.columns = [...ENTRY_COLUMNS]; const header = worksheet.getRow(1); header.height = 28; header.eachCell((cell, index) => { styleHeaderCell(cell, index % 2 === 0 ? PASTEL_PINK : MIKU_BLUE); }); entries .slice() .sort((left, right) => new Date(left.dateTime).getTime() - new Date(right.dateTime).getTime()) .forEach((entry) => { const date = new Date(entry.dateTime); worksheet.addRow({ date: toDateLabel(date), time: toTimeLabel(date), flavour: entry.flavour, size: `${entry.sizeMl}ml`, cans: entry.cans, pricePerCan: entry.pricePerCan, totalCost: spendFor(entry), caffeine: caffeineFor(entry), sugar: sugarFor(entry), store: entry.store ?? "", notes: entry.notes ?? "", }); }); const totals = worksheet.addRow({ date: "Totals", cans: sum(entries, (entry) => entry.cans), totalCost: sum(entries, spendFor), caffeine: sum(entries, caffeineFor), sugar: sum(entries, sugarFor), }); totals.font = { bold: true, color: { argb: MIDNIGHT } }; totals.fill = { type: "pattern", pattern: "solid", fgColor: { argb: CHROME } }; worksheet.getColumn("pricePerCan").numFmt = '"£"#,##0.00'; worksheet.getColumn("totalCost").numFmt = '"£"#,##0.00'; worksheet.getColumn("caffeine").numFmt = '0"mg"'; worksheet.getColumn("sugar").numFmt = '0.0"g"'; worksheet.getColumn("cans").numFmt = "0.00"; worksheet.autoFilter = { from: "A1", to: `K${Math.max(1, worksheet.rowCount)}`, }; worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; row.eachCell((cell) => { cell.border = lightBorder(); cell.alignment = { vertical: "middle", wrapText: true }; }); }); autoWidth(worksheet); } function addSummarySheet(workbook: ExcelJS.Workbook, entries: RedBullEntry[]) { const worksheet = workbook.addWorksheet(SUMMARY_SHEET, { views: [{ state: "frozen", ySplit: 1 }], properties: { defaultRowHeight: 24 }, }); worksheet.columns = [ { header: "Metric", key: "metric", width: 28 }, { header: "Value", key: "value", width: 26 }, ]; worksheet.getRow(1).eachCell((cell, index) => { styleHeaderCell(cell, index === 1 ? MIKU_BLUE : PASTEL_PINK); }); const summaryRows = [ ["Exported at", new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short" }).format(new Date())], ["Entries", entries.length], ["Total cans", oneDecimal.format(sum(entries, (entry) => entry.cans))], ["Total cost", currency.format(sum(entries, spendFor))], ["Estimated caffeine", `${wholeNumber.format(sum(entries, caffeineFor))}mg`], ["Estimated sugar", `${oneDecimal.format(sum(entries, sugarFor))}g`], ["Favourite flavour", topByCans(entries)], ]; summaryRows.forEach(([metric, value]) => worksheet.addRow({ metric, value })); worksheet.addRow({}); const byFlavourHeader = worksheet.addRow({ metric: "Flavour", value: "Cans" }); byFlavourHeader.eachCell((cell, index) => styleHeaderCell(cell, index === 1 ? RED_BULL_RED : RED_BULL_YELLOW)); const flavourTotals = new Map(); entries.forEach((entry) => { flavourTotals.set(entry.flavour, (flavourTotals.get(entry.flavour) ?? 0) + entry.cans); }); [...flavourTotals.entries()] .sort((left, right) => right[1] - left[1]) .forEach(([metric, value]) => worksheet.addRow({ metric, value: oneDecimal.format(value) })); worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; row.eachCell((cell) => { cell.border = lightBorder(); cell.alignment = { vertical: "middle", wrapText: true }; }); }); autoWidth(worksheet); } function parseEntryRow(row: ExcelJS.Row, headers: Record, rowNumber: number): ImportPreviewRow { const errors: string[] = []; const dateValue = cellAt(row, headers.date); const timeValue = cellAt(row, headers.time); const flavour = stringCell(cellAt(row, headers.flavour)).trim(); const sizeMl = parseSize(stringCell(cellAt(row, headers.size))); const cans = parseNumber(cellAt(row, headers.cans)); const pricePerCan = parseNumber(cellAt(row, headers.pricePerCan)); const caffeineTotal = parseNumber(cellAt(row, headers.caffeine)); const store = stringCell(cellAt(row, headers.store)).trim(); const notes = stringCell(cellAt(row, headers.notes)).trim(); const dateTime = parseDateTime(dateValue, timeValue); if (!dateTime) errors.push("Date/time is invalid or missing."); if (!flavour) errors.push("Flavour is required."); if (!Number.isFinite(sizeMl) || sizeMl <= 0) errors.push("Size must be a positive ml value."); if (!Number.isFinite(cans) || cans <= 0) errors.push("Cans must be greater than zero."); if (!Number.isFinite(pricePerCan) || pricePerCan < 0) errors.push("Price per can must be zero or more."); if (errors.length || !dateTime) { return { rowNumber, errors, duplicate: false }; } const meta = flavourMeta(flavour); const caffeineOverride = Number.isFinite(caffeineTotal) && caffeineTotal > 0 ? caffeineTotal / cans : undefined; const entry: EntryDraft = { cans, flavour, flavourAccent: meta.accent, sizeMl, pricePerCan, dateTime, notes, store, sugarFree: Boolean(meta.sugarFree), caffeineMgPerCan: caffeineOverride && Math.abs(caffeineOverride - caffeinePerCan(sizeMl)) > 0.5 ? caffeineOverride : undefined, source: "excel", }; return { rowNumber, entry, errors, duplicate: false }; } function headerMap(row: ExcelJS.Row) { const map: Record = {}; row.eachCell((cell, columnNumber) => { const key = normaliseHeader(stringCell(cell.value)); if (key) map[key] = columnNumber; }); return map; } function normaliseHeader(value: string) { const clean = value.toLowerCase().replace(/[^a-z]/g, ""); const aliases: Record = { date: "date", time: "time", flavour: "flavour", flavor: "flavour", size: "size", cans: "cans", pricepercan: "pricePerCan", totalcost: "totalCost", caffeine: "caffeine", sugarestimate: "sugar", storelocation: "store", store: "store", location: "store", notes: "notes", }; return aliases[clean]; } function rowIsBlank(row: ExcelJS.Row) { let hasValue = false; row.eachCell((cell) => { if (stringCell(cell.value).trim()) hasValue = true; }); return !hasValue; } function parseDateTime(dateValue: ExcelJS.CellValue, timeValue: ExcelJS.CellValue) { const dateString = stringCell(dateValue).trim(); const timeString = stringCell(timeValue).trim(); if (!dateString) return null; if (dateValue instanceof Date) { const date = new Date(dateValue); const time = parseTimeParts(timeValue); if (time) date.setHours(time.hours, time.minutes, 0, 0); return date.toISOString(); } const isoDate = dateString.includes("T") ? dateString : `${dateString}T${timeString || "00:00"}`; const parsed = new Date(isoDate); if (!Number.isNaN(parsed.getTime())) return parsed.toISOString(); const gbParts = dateString.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (gbParts) { const [, day, month, year] = gbParts; const parsedGb = new Date(`${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${timeString || "00:00"}`); if (!Number.isNaN(parsedGb.getTime())) return parsedGb.toISOString(); } return null; } function parseTimeParts(value: ExcelJS.CellValue) { if (value instanceof Date) return { hours: value.getHours(), minutes: value.getMinutes() }; const text = stringCell(value).trim(); const match = text.match(/^(\d{1,2}):(\d{2})/); if (!match) return null; return { hours: Number(match[1]), minutes: Number(match[2]) }; } function parseSize(value: string) { return parseNumber(value.replace(/ml/i, "")); } function parseNumber(value: ExcelJS.CellValue | string) { if (typeof value === "number") return value; const text = typeof value === "string" ? value : stringCell(value); const clean = text.replace(/[£,$mg]/gi, "").replace(/g$/i, "").trim(); const parsed = Number(clean); return Number.isFinite(parsed) ? parsed : Number.NaN; } function cellAt(row: ExcelJS.Row, index: number | undefined) { return index ? row.getCell(index).value : null; } function stringCell(value: ExcelJS.CellValue): string { if (value == null) return ""; if (value instanceof Date) return toDateLabel(value); if (typeof value === "object") { if ("result" in value) return stringCell(value.result as ExcelJS.CellValue); if ("text" in value) return value.text; if ("richText" in value) return value.richText.map((part) => part.text).join(""); if ("hyperlink" in value && "text" in value) return String(value.text); } return String(value); } function toDateLabel(date: Date) { return date.toISOString().slice(0, 10); } function toTimeLabel(date: Date) { return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; } function styleHeaderCell(cell: ExcelJS.Cell, fill: string) { cell.font = { bold: true, color: { argb: MIDNIGHT } }; cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: fill } }; cell.border = lightBorder(); cell.alignment = { horizontal: "center", vertical: "middle", wrapText: true }; } function lightBorder() { return { top: { style: "thin", color: { argb: "FFD6E4F0" } }, left: { style: "thin", color: { argb: "FFD6E4F0" } }, bottom: { style: "thin", color: { argb: "FFD6E4F0" } }, right: { style: "thin", color: { argb: "FFD6E4F0" } }, } satisfies Partial; } function autoWidth(worksheet: ExcelJS.Worksheet) { worksheet.columns.forEach((column) => { let maxLength = 10; column.eachCell?.({ includeEmpty: true }, (cell) => { maxLength = Math.max(maxLength, stringCell(cell.value).length); }); column.width = Math.min(Math.max(maxLength + 2, column.width ?? 12), 44); }); }