Repository navigation
feat(table): support timezone-aware exports #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d2a54b8
7412ed3
0ca0aec
4f92d7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,82 @@ | ||
| import type { ExportableData, DataTransformFunction } from "../types"; | ||
|
|
||
| const ISO_TIMESTAMP_PATTERN = | ||
| /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})$/; | ||
|
|
||
| /** | ||
| * Parse an ISO-8601 timestamp that carries an explicit offset (`Z` or `±hh:mm`). | ||
| * Offsets without a colon (`+0530`) are normalized first because some engines | ||
| * (Safari) reject them. | ||
| */ | ||
| function parseIsoTimestamp(value: string): Date | null { | ||
| if (!ISO_TIMESTAMP_PATTERN.test(value)) return null; | ||
|
|
||
| // Reject impossible calendar days (e.g. 2026-02-30) that engines would | ||
| // otherwise silently roll over into the next month. | ||
| const year = Number(value.slice(0, 4)); | ||
| const month = Number(value.slice(5, 7)); | ||
| const day = Number(value.slice(8, 10)); | ||
| const calendarDay = new Date(Date.UTC(year, month - 1, day)); | ||
| if (calendarDay.getUTCMonth() !== month - 1 || calendarDay.getUTCDate() !== day) { | ||
| return null; | ||
| } | ||
|
|
||
| const normalized = value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); | ||
| const date = new Date(normalized); | ||
| return Number.isNaN(date.getTime()) ? null : date; | ||
| } | ||
|
|
||
| /** | ||
| * Creates an export transform that formats timestamps in an IANA timezone. | ||
| * ISO timestamps with an explicit offset and `Date` instances are formatted; | ||
| * calendar-only dates are deliberately preserved to avoid shifting their day. | ||
| * A supplied custom transform runs first and receives the original row. | ||
| * | ||
| * @throws {RangeError} When `timeZone` or `locale` is not supported. | ||
| */ | ||
| export function createTimezoneExportTransform<T extends ExportableData>( | ||
| timeZone?: string, | ||
| locale?: string, | ||
| transformFunction?: DataTransformFunction<T>, | ||
| ): DataTransformFunction<T> | undefined { | ||
| if (!timeZone) return transformFunction; | ||
|
|
||
| let formatter: Intl.DateTimeFormat; | ||
| try { | ||
| formatter = new Intl.DateTimeFormat(locale, { | ||
| timeZone, | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| day: "numeric", | ||
| month: "short", | ||
| year: "numeric", | ||
| hour: "numeric", | ||
| minute: "2-digit", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve seconds in exported timestamps. The formatter stops at 🤖 Prompt for AI Agents |
||
| second: "2-digit", | ||
| hour12: true, | ||
| timeZoneName: "shortOffset", | ||
| }); | ||
| } catch { | ||
| throw new RangeError( | ||
| `Invalid export timeZone or locale: timeZone=${timeZone}, locale=${locale ?? "default"}`, | ||
| ); | ||
|
Comment on lines
+58
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Detect unsupported locales before accepting the configuration. For a syntactically valid but unsupported locale, 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return (row) => { | ||
| const transformedRow = transformFunction ? transformFunction(row) : row; | ||
|
|
||
| return Object.fromEntries( | ||
| Object.entries(transformedRow).map(([key, value]) => { | ||
| if (value instanceof Date) { | ||
| return [key, Number.isNaN(value.getTime()) ? value : formatter.format(value)]; | ||
| } | ||
| if (typeof value !== "string") return [key, value]; | ||
|
|
||
| const date = parseIsoTimestamp(value); | ||
| return [key, date ? formatter.format(date) : value]; | ||
| }), | ||
| ) as Record<string, unknown>; | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Neutralize CSV-formula-injection vectors. Spreadsheet apps (Excel, Sheets, | ||
| * LibreOffice) evaluate any cell whose first character is `=`, `+`, `-`, `@`, | ||
|
|
@@ -222,6 +299,10 @@ export async function exportData<T extends ExportableData>( | |
| columnWidths?: Array<{ wch: number }>; | ||
| entityName?: string; | ||
| transformFunction?: DataTransformFunction<T>; | ||
| /** IANA timezone used to format timestamps (see `createTimezoneExportTransform`). */ | ||
| timeZone?: string; | ||
| /** Locale used with `timeZone` when formatting timestamps. */ | ||
| locale?: string; | ||
| } | ||
| ): Promise<boolean> { | ||
| try { | ||
|
|
@@ -236,6 +317,11 @@ export async function exportData<T extends ExportableData>( | |
| const entityName = options?.entityName || "items"; | ||
| const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); | ||
| const filename = `${entityName}-export-${timestamp}`; | ||
| const transformFunction = createTimezoneExportTransform( | ||
| options?.timeZone, | ||
| options?.locale, | ||
| options?.transformFunction, | ||
| ); | ||
|
|
||
| let success = false; | ||
| if (type === "csv") { | ||
|
|
@@ -244,7 +330,7 @@ export async function exportData<T extends ExportableData>( | |
| filename, | ||
| options?.headers, | ||
| options?.columnMapping, | ||
| options?.transformFunction | ||
| transformFunction | ||
| ); | ||
| } else { | ||
| success = await exportToExcel( | ||
|
|
@@ -253,7 +339,7 @@ export async function exportData<T extends ExportableData>( | |
| options?.columnMapping, | ||
| options?.columnWidths, | ||
| options?.headers, | ||
| options?.transformFunction | ||
| transformFunction | ||
| ); | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.