added csv exporting logic

This commit is contained in:
Djeeberjr 2025-10-17 13:10:23 +02:00
parent cd63dd3ee4
commit 7346b47816
3 changed files with 190 additions and 0 deletions

31
web/src/lib/Day.ts Normal file
View File

@ -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<AttendanceDay> {
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<Day[]> {
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;
}

106
web/src/lib/csv.ts Normal file
View File

@ -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<string, any>;
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<string>();
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);
}

53
web/src/lib/exporting.ts Normal file
View File

@ -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<AttendanceDay[]> {
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<string> {
const days = await getDays(from, to);
const rows = prepareRows(mapping, days);
const csvString = generateCSVString(rows);
return csvString;
}