intial commit
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { Account, Channel, Client, ID, OAuthProvider, Permission, Query, Role, TablesDB } from "appwrite";
|
||||
|
||||
const env = import.meta.env;
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
export const appwriteConfig = {
|
||||
endpoint: env.VITE_APPWRITE_ENDPOINT || "https://fra.cloud.appwrite.io/v1",
|
||||
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
|
||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||
};
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(appwriteConfig.endpoint)
|
||||
.setProject(appwriteConfig.projectId);
|
||||
|
||||
const account = new Account(client);
|
||||
const tablesDB = new TablesDB(client);
|
||||
|
||||
export async function pingAppwrite() {
|
||||
return client.ping();
|
||||
}
|
||||
|
||||
export { account, Channel, client, ID, OAuthProvider, Permission, Query, Role, tablesDB };
|
||||
|
||||
function resolveOAuthUrl(value?: string) {
|
||||
if (!value) return currentOrigin;
|
||||
|
||||
const configured = new URL(value, currentOrigin);
|
||||
const current = new URL(currentOrigin);
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
if (env.DEV && localHosts.has(configured.hostname) && localHosts.has(current.hostname)) {
|
||||
return currentOrigin;
|
||||
}
|
||||
|
||||
return configured.toString().replace(/\/$/, "");
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Models } from "appwrite";
|
||||
import { flavourMeta } from "../data/flavours";
|
||||
import type { EntryDraft, RedBullEntry } from "../types";
|
||||
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
|
||||
import { makeId, makeImportKey } from "./metrics";
|
||||
|
||||
type EntryRow = Models.Row & {
|
||||
userId: string;
|
||||
cans: number;
|
||||
flavour: string;
|
||||
flavourAccent: string;
|
||||
sizeMl: number;
|
||||
pricePerCan: number;
|
||||
dateTime: string;
|
||||
notes?: string;
|
||||
store?: string;
|
||||
sugarFree: boolean;
|
||||
caffeineMgPerCan?: number;
|
||||
importKey: string;
|
||||
source: RedBullEntry["source"];
|
||||
};
|
||||
|
||||
export async function listEntries(userId: string) {
|
||||
const rows: EntryRow[] = [];
|
||||
const limit = 100;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await tablesDB.listRows<EntryRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.collectionId,
|
||||
queries: [
|
||||
Query.equal("userId", userId),
|
||||
Query.orderDesc("dateTime"),
|
||||
Query.limit(limit),
|
||||
Query.offset(offset),
|
||||
],
|
||||
});
|
||||
|
||||
rows.push(...response.rows);
|
||||
if (response.rows.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return rows.map(fromRow);
|
||||
}
|
||||
|
||||
export async function createEntry(userId: string, draft: EntryDraft) {
|
||||
const entry = buildEntry(userId, draft);
|
||||
|
||||
const row = await tablesDB.createRow<EntryRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.collectionId,
|
||||
rowId: ID.custom(entry.id),
|
||||
data: toRowData(entry),
|
||||
permissions: userRowPermissions(userId),
|
||||
});
|
||||
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
export async function createEntries(userId: string, drafts: EntryDraft[]) {
|
||||
const saved: RedBullEntry[] = [];
|
||||
for (const draft of drafts) {
|
||||
saved.push(await createEntry(userId, draft));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
export async function updateEntry(userId: string, id: string, draft: EntryDraft) {
|
||||
const entry = buildEntry(userId, draft, id);
|
||||
const row = await tablesDB.updateRow<EntryRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.collectionId,
|
||||
rowId: id,
|
||||
data: toRowData(entry),
|
||||
permissions: userRowPermissions(userId),
|
||||
});
|
||||
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
export async function deleteEntry(id: string) {
|
||||
await tablesDB.deleteRow({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.collectionId,
|
||||
rowId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEntry(userId: string, draft: EntryDraft, id: string = makeId()): RedBullEntry {
|
||||
const meta = flavourMeta(draft.flavour);
|
||||
const entry: RedBullEntry = {
|
||||
id,
|
||||
userId,
|
||||
cans: draft.cans,
|
||||
flavour: draft.flavour,
|
||||
flavourAccent: draft.flavourAccent || meta.accent,
|
||||
sizeMl: draft.sizeMl,
|
||||
pricePerCan: draft.pricePerCan,
|
||||
dateTime: new Date(draft.dateTime).toISOString(),
|
||||
notes: draft.notes ?? "",
|
||||
store: draft.store ?? "",
|
||||
sugarFree: draft.sugarFree || Boolean(meta.sugarFree),
|
||||
caffeineMgPerCan: draft.caffeineMgPerCan,
|
||||
importKey: "",
|
||||
source: draft.source ?? "manual",
|
||||
};
|
||||
|
||||
entry.importKey = makeImportKey(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function isDuplicateDraft(existing: RedBullEntry[], draft: EntryDraft) {
|
||||
const key = makeImportKey({
|
||||
...draft,
|
||||
dateTime: new Date(draft.dateTime).toISOString(),
|
||||
notes: draft.notes ?? "",
|
||||
store: draft.store ?? "",
|
||||
});
|
||||
return existing.some((entry) => entry.importKey === key || makeImportKey(entry) === key);
|
||||
}
|
||||
|
||||
export function appwriteErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (/permissions?.*create|action 'create'|create.*permissions?/i.test(error.message)) {
|
||||
return "Appwrite table permissions need Users -> Create, with Row Security enabled on intake_entries.";
|
||||
}
|
||||
if (/not authorized|401|unauthorized/i.test(error.message)) {
|
||||
return "Appwrite denied the table request. Enable Row Security on intake_entries and grant table-level Users -> Create; rows are then read by per-user row permissions.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return "Appwrite request failed.";
|
||||
}
|
||||
|
||||
function fromRow(row: EntryRow): RedBullEntry {
|
||||
return {
|
||||
id: row.$id,
|
||||
userId: row.userId,
|
||||
cans: row.cans,
|
||||
flavour: row.flavour,
|
||||
flavourAccent: row.flavourAccent,
|
||||
sizeMl: row.sizeMl,
|
||||
pricePerCan: row.pricePerCan,
|
||||
dateTime: row.dateTime,
|
||||
notes: row.notes ?? "",
|
||||
store: row.store ?? "",
|
||||
sugarFree: row.sugarFree,
|
||||
caffeineMgPerCan: row.caffeineMgPerCan,
|
||||
importKey: row.importKey || makeImportKey(row),
|
||||
source: row.source ?? "manual",
|
||||
createdAt: row.$createdAt,
|
||||
updatedAt: row.$updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toRowData(entry: RedBullEntry) {
|
||||
return {
|
||||
userId: entry.userId,
|
||||
cans: entry.cans,
|
||||
flavour: entry.flavour,
|
||||
flavourAccent: entry.flavourAccent,
|
||||
sizeMl: entry.sizeMl,
|
||||
pricePerCan: entry.pricePerCan,
|
||||
dateTime: entry.dateTime,
|
||||
notes: entry.notes ?? "",
|
||||
store: entry.store ?? "",
|
||||
sugarFree: entry.sugarFree,
|
||||
caffeineMgPerCan: entry.caffeineMgPerCan,
|
||||
importKey: entry.importKey,
|
||||
source: entry.source,
|
||||
};
|
||||
}
|
||||
|
||||
function userRowPermissions(userId: string) {
|
||||
const role = Role.user(userId);
|
||||
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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<ImportPreview> {
|
||||
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<string, number>();
|
||||
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<string, number>, 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<string, number> = {};
|
||||
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<string, string> = {
|
||||
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<ExcelJS.Borders>;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { RedBullEntry } from "../types";
|
||||
|
||||
export const CAFFEINE_PER_250ML = 80;
|
||||
export const SUGAR_PER_250ML = 27;
|
||||
export const STANDARD_CAN_VALUES = {
|
||||
250: { pricePerCan: 1.75, caffeineMg: 80 },
|
||||
355: { pricePerCan: 2.2, caffeineMg: 114 },
|
||||
473: { pricePerCan: 2.85, caffeineMg: 151 },
|
||||
} as const;
|
||||
|
||||
export function spendFor(entry: RedBullEntry) {
|
||||
return entry.cans * entry.pricePerCan;
|
||||
}
|
||||
|
||||
export function defaultPriceForSize(sizeMl: number) {
|
||||
if (sizeMl === 250 || sizeMl === 355 || sizeMl === 473) {
|
||||
return STANDARD_CAN_VALUES[sizeMl].pricePerCan;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function caffeinePerCan(sizeMl: number, override?: number) {
|
||||
if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
|
||||
return override;
|
||||
}
|
||||
if (sizeMl === 250 || sizeMl === 355 || sizeMl === 473) {
|
||||
return STANDARD_CAN_VALUES[sizeMl].caffeineMg;
|
||||
}
|
||||
return (sizeMl / 250) * CAFFEINE_PER_250ML;
|
||||
}
|
||||
|
||||
export function caffeineFor(entry: RedBullEntry) {
|
||||
return entry.cans * caffeinePerCan(entry.sizeMl, entry.caffeineMgPerCan);
|
||||
}
|
||||
|
||||
export function sugarFor(entry: RedBullEntry) {
|
||||
if (entry.sugarFree) return 0;
|
||||
return entry.cans * (entry.sizeMl / 250) * SUGAR_PER_250ML;
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date) {
|
||||
const next = new Date(date);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function startOfWeek(date: Date) {
|
||||
const next = startOfDay(date);
|
||||
const day = next.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
next.setDate(next.getDate() + diff);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function startOfMonth(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
}
|
||||
|
||||
export function isSameDay(left: Date, right: Date) {
|
||||
return startOfDay(left).getTime() === startOfDay(right).getTime();
|
||||
}
|
||||
|
||||
export function isWithin(date: Date, start: Date, end: Date) {
|
||||
return date.getTime() >= start.getTime() && date.getTime() <= end.getTime();
|
||||
}
|
||||
|
||||
export function formatDateKey(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatLocalInput(date: Date) {
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function humanDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export const currency = new Intl.NumberFormat("en-GB", {
|
||||
style: "currency",
|
||||
currency: "GBP",
|
||||
});
|
||||
|
||||
export const wholeNumber = new Intl.NumberFormat("en-GB", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export const oneDecimal = new Intl.NumberFormat("en-GB", {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function sum(entries: RedBullEntry[], selector: (entry: RedBullEntry) => number) {
|
||||
return entries.reduce((total, entry) => total + selector(entry), 0);
|
||||
}
|
||||
|
||||
export function entriesInRange(entries: RedBullEntry[], start: Date, end: Date) {
|
||||
return entries.filter((entry) => isWithin(new Date(entry.dateTime), start, end));
|
||||
}
|
||||
|
||||
export function daysBetween(left: Date, right: Date) {
|
||||
return Math.floor(
|
||||
(startOfDay(right).getTime() - startOfDay(left).getTime()) / 86_400_000,
|
||||
);
|
||||
}
|
||||
|
||||
export function trackedWeeks(entries: RedBullEntry[]) {
|
||||
if (!entries.length) return 1;
|
||||
const first = entries
|
||||
.map((entry) => new Date(entry.dateTime))
|
||||
.sort((a, b) => a.getTime() - b.getTime())[0];
|
||||
return Math.max(1, Math.ceil((Date.now() - first.getTime()) / (7 * 86_400_000)));
|
||||
}
|
||||
|
||||
export function groupByDay(entries: RedBullEntry[]) {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ label: string; spend: number; cans: number; caffeine: number; sugar: number }
|
||||
>();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const date = new Date(entry.dateTime);
|
||||
const key = formatDateKey(date);
|
||||
const existing =
|
||||
grouped.get(key) ??
|
||||
({
|
||||
label: new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(date),
|
||||
spend: 0,
|
||||
cans: 0,
|
||||
caffeine: 0,
|
||||
sugar: 0,
|
||||
} satisfies { label: string; spend: number; cans: number; caffeine: number; sugar: number });
|
||||
|
||||
existing.spend += spendFor(entry);
|
||||
existing.cans += entry.cans;
|
||||
existing.caffeine += caffeineFor(entry);
|
||||
existing.sugar += sugarFor(entry);
|
||||
grouped.set(key, existing);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.slice(-30)
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
export function groupByWeek(entries: RedBullEntry[]) {
|
||||
const grouped = new Map<string, { label: string; spend: number; cans: number }>();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const date = new Date(entry.dateTime);
|
||||
const week = startOfWeek(date);
|
||||
const key = formatDateKey(week);
|
||||
const label = `W/C ${new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(week)}`;
|
||||
const existing = grouped.get(key) ?? { label, spend: 0, cans: 0 };
|
||||
existing.spend += spendFor(entry);
|
||||
existing.cans += entry.cans;
|
||||
grouped.set(key, existing);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.slice(-10)
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
export function groupByFlavour(entries: RedBullEntry[]) {
|
||||
const grouped = new Map<string, { name: string; value: number; spend: number; accent: string }>();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const existing =
|
||||
grouped.get(entry.flavour) ??
|
||||
({
|
||||
name: entry.flavour,
|
||||
value: 0,
|
||||
spend: 0,
|
||||
accent: entry.flavourAccent,
|
||||
} satisfies { name: string; value: number; spend: number; accent: string });
|
||||
existing.value += entry.cans;
|
||||
existing.spend += spendFor(entry);
|
||||
grouped.set(entry.flavour, existing);
|
||||
});
|
||||
|
||||
return [...grouped.values()].sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
export function topByCans(entries: RedBullEntry[]) {
|
||||
return groupByFlavour(entries)[0]?.name ?? "None yet";
|
||||
}
|
||||
|
||||
export function highestAveragePrice(entries: RedBullEntry[], key: "flavour" | "store") {
|
||||
const grouped = new Map<string, { total: number; cans: number }>();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const label = key === "flavour" ? entry.flavour : entry.store?.trim();
|
||||
if (!label) return;
|
||||
const existing = grouped.get(label) ?? { total: 0, cans: 0 };
|
||||
existing.total += spendFor(entry);
|
||||
existing.cans += entry.cans;
|
||||
grouped.set(label, existing);
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
average: value.cans > 0 ? value.total / value.cans : 0,
|
||||
}))
|
||||
.sort((a, b) => b.average - a.average)[0];
|
||||
}
|
||||
|
||||
export function currentStreak(entries: RedBullEntry[]) {
|
||||
if (!entries.length) return 0;
|
||||
const days = new Set(entries.map((entry) => formatDateKey(startOfDay(new Date(entry.dateTime)))));
|
||||
let cursor = startOfDay(new Date());
|
||||
let streak = 0;
|
||||
|
||||
while (days.has(formatDateKey(cursor))) {
|
||||
streak += 1;
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
export function daysSinceLast(entries: RedBullEntry[]) {
|
||||
if (!entries.length) return 0;
|
||||
const latest = entries
|
||||
.map((entry) => new Date(entry.dateTime))
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0];
|
||||
return Math.max(0, daysBetween(latest, new Date()));
|
||||
}
|
||||
|
||||
export function makeId() {
|
||||
return crypto.randomUUID?.() ?? `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function makeImportKey(entry: Pick<RedBullEntry, "dateTime" | "flavour" | "sizeMl" | "cans" | "pricePerCan" | "store" | "notes">) {
|
||||
return [
|
||||
new Date(entry.dateTime).toISOString(),
|
||||
entry.flavour.trim().toLowerCase(),
|
||||
entry.sizeMl,
|
||||
Number(entry.cans).toFixed(3),
|
||||
Number(entry.pricePerCan).toFixed(2),
|
||||
(entry.store ?? "").trim().toLowerCase(),
|
||||
(entry.notes ?? "").trim().toLowerCase(),
|
||||
].join("|");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { flavourMeta } from "../data/flavours";
|
||||
import type { EntryDraft, RedBullEntry } from "../types";
|
||||
|
||||
export function exportPayload(entries: RedBullEntry[]) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
app: "Red Bull Intake Tracker",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
entries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseImport(raw: string): EntryDraft[] {
|
||||
const parsed = JSON.parse(raw);
|
||||
const entries = Array.isArray(parsed) ? parsed : parsed?.entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new Error("Import file does not contain an entries array.");
|
||||
}
|
||||
|
||||
const valid = entries.map(coerceEntryDraft).filter(Boolean) as EntryDraft[];
|
||||
if (!valid.length && entries.length) {
|
||||
throw new Error("No valid Red Bull entries were found in that file.");
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
function coerceEntryDraft(value: unknown): EntryDraft | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const entry = value as Partial<RedBullEntry>;
|
||||
if (
|
||||
typeof entry.cans !== "number" ||
|
||||
typeof entry.flavour !== "string" ||
|
||||
typeof entry.sizeMl !== "number" ||
|
||||
typeof entry.pricePerCan !== "number" ||
|
||||
typeof entry.dateTime !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = flavourMeta(entry.flavour);
|
||||
const draft: EntryDraft = {
|
||||
cans: entry.cans,
|
||||
flavour: entry.flavour,
|
||||
flavourAccent: entry.flavourAccent ?? meta.accent,
|
||||
sizeMl: entry.sizeMl,
|
||||
pricePerCan: entry.pricePerCan,
|
||||
dateTime: entry.dateTime,
|
||||
notes: entry.notes ?? "",
|
||||
store: entry.store ?? "",
|
||||
sugarFree: entry.sugarFree ?? Boolean(meta.sugarFree),
|
||||
caffeineMgPerCan: entry.caffeineMgPerCan,
|
||||
source: "json",
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
Reference in New Issue
Block a user