From 7346b4781609457185069e5c2084f9e4b1cb2694 Mon Sep 17 00:00:00 2001 From: Djeeberjr Date: Fri, 17 Oct 2025 13:10:23 +0200 Subject: [PATCH] added csv exporting logic --- web/src/lib/Day.ts | 31 ++++++++++++ web/src/lib/csv.ts | 106 +++++++++++++++++++++++++++++++++++++++ web/src/lib/exporting.ts | 53 ++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 web/src/lib/Day.ts create mode 100644 web/src/lib/csv.ts create mode 100644 web/src/lib/exporting.ts diff --git a/web/src/lib/Day.ts b/web/src/lib/Day.ts new file mode 100644 index 0000000..b5bd570 --- /dev/null +++ b/web/src/lib/Day.ts @@ -0,0 +1,31 @@ +export type Day = number; + +export interface AttendanceDay { + date: Day, + ids: string[], +} + +export function dayToDate(day: Day): Date { + const SEC_PER_DAY = 86_400; + + return new Date(day * SEC_PER_DAY * 1000); +} + +export async function fetchDay(day: Day): Promise { + let res = await fetch("/api/day?" + (new URLSearchParams({ day: day.toString() }).toString())); + + let json = await res.json(); + + return json; +} + +export async function fetchDays(from: Date, to: Date): Promise { + let q = new URLSearchParams({ from: (from.getTime() / 1000).toString(), to: (to.getTime() / 1000).toString() }); + + let res = await fetch("/api/days?" + q); + + let json = await res.json(); + + return json; +} + diff --git a/web/src/lib/csv.ts b/web/src/lib/csv.ts new file mode 100644 index 0000000..3c9e5b5 --- /dev/null +++ b/web/src/lib/csv.ts @@ -0,0 +1,106 @@ +interface CSVOptions { + delimiter?: string; // default: "," + includeHeader?: boolean; // default: true for object input, false for array-of-arrays unless headers provided + headerOrder?: string[]; // explicit ordering for columns that should come first + eol?: string; // default: "\r\n" + includeBOM?: boolean; // default: false (useful for Excel) + nullString?: string; // default: "" (how to render null/undefined) +} + +type RowObject = Record; +type InputRows = RowObject[]; + +export function generateCSVString(input: InputRows, opts: CSVOptions = {}): string { + const { + delimiter = ",", + includeHeader, + headerOrder, + eol = "\r\n", + includeBOM = false, + nullString = "", + } = opts; + + // Check if the value need quoting + // Usually not needed in our use case but still in case + const needsQuoting = (s: string) => + s.includes(delimiter) || s.includes('"') || s.includes("\n") || s.includes("\r") || /^\s|\s$/.test(s); + + // Transform the value of a cell in a SAFE string + const escapeCell = (raw: any): string => { + if (raw === null || raw === undefined) return nullString; + + let s = defaultStringify(raw); + + // Replace quotes + if (s.includes('"')) s = s.replace(/"/g, '""'); + + return needsQuoting(s) ? `"${s}"` : s; + }; + + // Transform the value of a cell into a string + function defaultStringify(v: any): string { + if (v === null || v === undefined) return nullString; + if (v instanceof Date) return v.toLocaleDateString(); + if (typeof v === "boolean") return v ? "X" : ""; + + if (typeof v === "object") { + // CHeck if array and join with "|" + if (Array.isArray(v)) return v.map(item => (item === null || item === undefined ? nullString : String(item))).join("|"); + + // If all fails parse it via json + // Should also not happen in our use case + try { return JSON.stringify(v); } catch { return String(v); } + } + return String(v); + } + + const objs = (input as RowObject[]) || []; + + // Derive headers in the order keys are first encountered (fixed: no reduce) + const seen = new Set(); + const derivedOrder: string[] = []; + for (const obj of objs) { + if (!obj || typeof obj !== "object") continue; + for (const k of Object.keys(obj)) { + if (!seen.has(k)) { + seen.add(k); + derivedOrder.push(k); + } + } + } + + // Apply headerOrder if provided: put listed columns first (in the order provided), + // then append the remaining derived headers in their derived order. + let finalHeaders = derivedOrder.slice(); + if (Array.isArray(headerOrder) && headerOrder.length > 0) { + const headSet = new Set(headerOrder); + const first = headerOrder.filter(h => seen.has(h)); // keep only headers that actually exist + const rest = derivedOrder.filter(h => !headSet.has(h)); + finalHeaders = first.concat(rest); + } + + const shouldIncludeHeader = typeof includeHeader === "boolean" ? includeHeader : finalHeaders.length > 0; + const rowsOut: string[] = []; + + if (shouldIncludeHeader && finalHeaders.length) { + rowsOut.push(finalHeaders.map(h => escapeCell(h)).join(delimiter)); + } + + // Parse every row + for (const obj of objs) { + + // Check if obj is null or somthing else we can't convert + if (!obj || typeof obj !== "object") { + + // produce empty row with same number of columns + rowsOut.push(finalHeaders.map(() => escapeCell(null)).join(delimiter)); + continue; + } + + // For every header check if a value on our row exist and print it + const row = finalHeaders.map(col => escapeCell(col in obj ? (obj as any)[col] : null)).join(delimiter); + rowsOut.push(row); + } + + return (includeBOM ? "\uFEFF" : "") + rowsOut.join(eol); +} diff --git a/web/src/lib/exporting.ts b/web/src/lib/exporting.ts new file mode 100644 index 0000000..9894f8f --- /dev/null +++ b/web/src/lib/exporting.ts @@ -0,0 +1,53 @@ +import { generateCSVString } from "./csv"; +import { dayToDate, fetchDay, fetchDays, type AttendanceDay, type Day } from "./Day"; +import type { IDMap } from "./IDMapping"; + +interface CSVRow { + ID: string + Vorname: string + Nachname: string + [key: string]: string | boolean +} + +function prepareRows(mapping: IDMap, days: AttendanceDay[]): CSVRow[] { + let csvData: CSVRow[] = []; + + const allIDs = Object.keys(mapping); + + for (const id of allIDs) { + const name = mapping[id]; + const row: CSVRow = { + ID: id, + Vorname: name.first, + Nachname: name.last, + }; + + for (const day of days) { + const dayKey = dayToDate(day.date).toLocaleDateString(); + row[dayKey] = day.ids.includes(id); + } + + csvData.push(row); + } + + return csvData; +} + +async function getDays(from: Date, to: Date): Promise { + const recordedDays: Day[] = await fetchDays(from, to); + let days: AttendanceDay[] = []; + + for (const day of recordedDays) { + days.push(await fetchDay(day)) + } + + return days; +} + +export async function generateCSVFile(from: Date, to: Date, mapping: IDMap): Promise { + const days = await getDays(from, to); + const rows = prepareRows(mapping, days); + const csvString = generateCSVString(rows); + + return csvString; +}