diff --git a/bun.lock b/bun.lock index a305473..2dc0bc0 100644 --- a/bun.lock +++ b/bun.lock @@ -125,7 +125,7 @@ }, "packages/adapter-elysia": { "name": "@tablecraft/adapter-elysia", - "version": "0.1.5", + "version": "0.2.0", "devDependencies": { "@tablecraft/engine": "workspace:*", "elysia": "^1.4.28", @@ -139,7 +139,7 @@ }, "packages/adapter-express": { "name": "@tablecraft/adapter-express", - "version": "0.1.5", + "version": "0.2.0", "devDependencies": { "@tablecraft/engine": "workspace:*", "@types/express": "^5.0.0", @@ -154,7 +154,7 @@ }, "packages/adapter-hono": { "name": "@tablecraft/adapter-hono", - "version": "0.1.4", + "version": "0.2.0", "devDependencies": { "@tablecraft/engine": "workspace:*", "hono": "^4.12.18", @@ -182,7 +182,7 @@ }, "packages/adapter-sveltekit": { "name": "@tablecraft/adapter-sveltekit", - "version": "0.1.3", + "version": "0.2.0", "devDependencies": { "@tablecraft/engine": "workspace:*", "@types/node": "^25.2.2", @@ -196,7 +196,7 @@ }, "packages/client": { "name": "@tablecraft/client", - "version": "0.1.12", + "version": "0.2.0", "devDependencies": { "@types/react": "^19.2.13", "axios": "^1.16.0", @@ -214,7 +214,7 @@ }, "packages/codegen": { "name": "@tablecraft/codegen", - "version": "0.0.11", + "version": "0.1.0", "bin": { "tablecraft-codegen": "./dist/cli.js", }, @@ -230,7 +230,7 @@ }, "packages/engine": { "name": "@tablecraft/engine", - "version": "0.1.8", + "version": "0.2.0", "dependencies": { "drizzle-orm": "^0.45.2", "zod": "^3.24.1", @@ -244,7 +244,7 @@ }, "packages/plugin-cache": { "name": "@tablecraft/plugin-cache", - "version": "0.1.0-beta.6", + "version": "0.2.0", "devDependencies": { "@tablecraft/engine": "workspace:*", "typescript": "^5.9.3", @@ -2232,6 +2232,8 @@ "vite-node/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + "vite-web-example/@tablecraft/client": ["@tablecraft/client@0.1.12", "", { "peerDependencies": { "axios": "^1.15.2", "react": ">=18.0.0" }, "optionalPeers": ["axios", "react"] }, "sha512-ar8FjNFfKeAVxMzk20xY7VMzYBAZtQvn4sTo13+/DpQcNnu0x0kWS+nMuG1nVeEL+t2HEslCDApGE2J69suZkQ=="], + "vite-web-example/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], diff --git a/docs/export-config.md b/docs/export-config.md index cdce531..a2f9c10 100644 --- a/docs/export-config.md +++ b/docs/export-config.md @@ -86,6 +86,29 @@ const exportConfig = defineExportConfig()({ --- +## Timezone-Aware Timestamps (`timeZone`, `locale`) + +Format timestamps in a specific IANA timezone instead of exporting raw UTC strings: + +```tsx +const exportConfig = defineExportConfig()({ + entityName: 'orders', + timeZone: 'Asia/Kolkata', + locale: 'en-IN', // optional, defaults to the browser locale +}); +// "2026-01-01T00:00:00Z" → "1 Jan 2026, 5:30:00 am GMT+5:30" +``` + +- Only ISO timestamps with an explicit offset (`Z`, `+05:30`, `+0530`) and `Date` values are converted. +- Date-only values such as `2026-01-01` are left unchanged so the calendar day never shifts. +- `transformFunction` runs first and receives the original values; timestamps it returns are then formatted. +- A malformed `timeZone` or `locale` makes the export fail with an error toast. A well-formed but unsupported locale falls back to the runtime default. +- Impossible dates (e.g. `2026-02-30T00:00:00Z`) are left unchanged. + +The same options are available on the `exportData()` helper, and `createTimezoneExportTransform()` is exported for custom export flows. + +--- + ## CSV / Excel Toggle ```tsx diff --git a/packages/table/src/export.tsx b/packages/table/src/export.tsx index 00b8b86..6496dbc 100644 --- a/packages/table/src/export.tsx +++ b/packages/table/src/export.tsx @@ -3,7 +3,11 @@ import type { Table } from "@tanstack/react-table"; import { DownloadIcon, Loader2, FileJson, FileSpreadsheet } from "lucide-react"; import { toast } from "sonner"; import type { ExportableData, DataTransformFunction, ExportConfig, TableConfig } from "./types"; -import { exportToCSV, exportToExcel } from "./utils/export-utils"; +import { + createTimezoneExportTransform, + exportToCSV, + exportToExcel, +} from "./utils/export-utils"; import { cn } from "./utils/cn"; import { Popover, @@ -114,6 +118,11 @@ export function DataTableExport({ getExportMeta(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = `${entityName}-export-${timestamp}`; + const transformFunction = createTimezoneExportTransform( + exportConfig?.timeZone, + exportConfig?.locale, + exportConfig?.transformFunction as DataTransformFunction | undefined, + ); let success = false; if (type === "csv") { @@ -122,7 +131,7 @@ export function DataTableExport({ filename, exportHeaders, exportColumnMapping, - exportConfig?.transformFunction as DataTransformFunction | undefined + transformFunction, ); } else { success = await exportToExcel( @@ -131,7 +140,7 @@ export function DataTableExport({ exportColumnMapping, exportColumnWidths, exportHeaders, - exportConfig?.transformFunction as DataTransformFunction | undefined + transformFunction, ); } @@ -224,4 +233,4 @@ export function DataTableExport({ ); -} \ No newline at end of file +} diff --git a/packages/table/src/index.ts b/packages/table/src/index.ts index 61599b9..772ca37 100644 --- a/packages/table/src/index.ts +++ b/packages/table/src/index.ts @@ -63,7 +63,12 @@ export { export { cn } from "./utils/cn"; export { preprocessSearch } from "./utils/search"; export { isDeepEqual, debounce, resetUrlState } from "./utils/deep-utils"; -export { exportToCSV, exportToExcel, exportData } from "./utils/export-utils"; +export { + createTimezoneExportTransform, + exportToCSV, + exportToExcel, + exportData, +} from "./utils/export-utils"; export { formatDate, validateDateString, parseDateFromUrl } from "./utils/date-format"; export { extractDefaultColumnSizes, diff --git a/packages/table/src/types.ts b/packages/table/src/types.ts index 455155e..de149be 100644 --- a/packages/table/src/types.ts +++ b/packages/table/src/types.ts @@ -559,6 +559,14 @@ type KnownStringKeys = Extract< export interface ExportConfig> { /** Display name used in filenames and toast messages (e.g. "orders") */ entityName: string; + /** + * IANA timezone used to format UTC timestamps in CSV and XLSX exports. + * Date-only values are preserved because they represent calendar dates. + * @example "Asia/Kolkata" + */ + timeZone?: string; + /** Optional locale used with `timeZone` when formatting export timestamps. */ + locale?: string; /** * Map column keys to human-readable header names for the export file. * Independent from removeHeaders — you can rename any column. diff --git a/packages/table/src/utils/export-utils.ts b/packages/table/src/utils/export-utils.ts index 287de4f..c85d00f 100644 --- a/packages/table/src/utils/export-utils.ts +++ b/packages/table/src/utils/export-utils.ts @@ -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( + timeZone?: string, + locale?: string, + transformFunction?: DataTransformFunction, +): DataTransformFunction | undefined { + if (!timeZone) return transformFunction; + + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat(locale, { + timeZone, + day: "numeric", + month: "short", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + timeZoneName: "shortOffset", + }); + } catch { + throw new RangeError( + `Invalid export timeZone or locale: timeZone=${timeZone}, locale=${locale ?? "default"}`, + ); + } + + 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; + }; +} + /** * 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( columnWidths?: Array<{ wch: number }>; entityName?: string; transformFunction?: DataTransformFunction; + /** IANA timezone used to format timestamps (see `createTimezoneExportTransform`). */ + timeZone?: string; + /** Locale used with `timeZone` when formatting timestamps. */ + locale?: string; } ): Promise { try { @@ -236,6 +317,11 @@ export async function exportData( 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( filename, options?.headers, options?.columnMapping, - options?.transformFunction + transformFunction ); } else { success = await exportToExcel( @@ -253,7 +339,7 @@ export async function exportData( options?.columnMapping, options?.columnWidths, options?.headers, - options?.transformFunction + transformFunction ); } diff --git a/packages/table/test/export-utils.test.ts b/packages/table/test/export-utils.test.ts index b407ece..d5bf8b0 100644 --- a/packages/table/test/export-utils.test.ts +++ b/packages/table/test/export-utils.test.ts @@ -9,7 +9,11 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { exportData, exportToCSV } from "../src/utils/export-utils"; +import { + createTimezoneExportTransform, + exportData, + exportToCSV, +} from "../src/utils/export-utils"; // ─── helpers ───────────────────────────────────────────────────────────────── @@ -318,6 +322,108 @@ describe("exportToCSV — basic", () => { }); }); +describe("createTimezoneExportTransform", () => { + it("formats UTC timestamps in the supplied timezone", () => { + const transform = createTimezoneExportTransform("Asia/Kolkata", "en-US"); + const row = transform?.({ createdAt: "2026-01-01T00:00:00Z" }); + + expect(row?.createdAt).toContain("5:30:00 AM"); + expect(row?.createdAt).toContain("GMT+5:30"); + }); + + it("includes offsets that distinguish repeated daylight-saving local times", () => { + const transform = createTimezoneExportTransform("America/Los_Angeles", "en-US"); + const row = transform?.({ + beforeFallback: "2026-11-01T08:30:00Z", + afterFallback: "2026-11-01T09:30:00Z", + }); + + expect(row?.beforeFallback).toContain("GMT-7"); + expect(row?.afterFallback).toContain("GMT-8"); + }); + + it("preserves date-only and ordinary string values", () => { + const transform = createTimezoneExportTransform("Asia/Kolkata", "en-US"); + const row = transform?.({ date: "2026-01-01", name: "Alice" }); + + expect(row).toEqual({ date: "2026-01-01", name: "Alice" }); + }); + + it("throws before export when the timezone or locale is invalid", () => { + expect(() => createTimezoneExportTransform("Not/A-Timezone")).toThrow( + "Invalid export timeZone or locale", + ); + expect(() => createTimezoneExportTransform("Asia/Kolkata", "not_a_locale")).toThrow( + "Invalid export timeZone or locale", + ); + }); + + it("runs a custom transform with original timestamps before formatting", () => { + const rawTimestamp = "2026-01-01T00:00:00Z"; + const transform = createTimezoneExportTransform( + "Asia/Kolkata", + "en-US", + (row) => ({ + ...row, + customTransformSaw: row.createdAt === rawTimestamp ? "raw" : "formatted", + }), + ); + const row = transform?.({ createdAt: rawTimestamp }); + + expect(row?.customTransformSaw).toBe("raw"); + expect(row?.createdAt).toContain("5:30:00 AM"); + }); + + it("formats Date instances returned by a custom transform", () => { + const transform = createTimezoneExportTransform( + "Asia/Kolkata", + "en-US", + (row) => ({ ...row, createdAt: new Date(String(row.createdAt)) }), + ); + const row = transform?.({ createdAt: "2026-01-01T00:00:00Z" }); + + expect(row?.createdAt).toContain("5:30:00 AM"); + expect(row?.createdAt).toContain("GMT+5:30"); + }); + + it("parses offsets written without a colon", () => { + const transform = createTimezoneExportTransform("UTC", "en-US"); + const row = transform?.({ createdAt: "2026-01-01T05:30:00+0530" }); + + expect(row?.createdAt).toContain("12:00:00 AM"); + }); + + it("keeps seconds so events within the same minute stay distinct", () => { + const transform = createTimezoneExportTransform("UTC", "en-US"); + const row = transform?.({ + first: "2026-01-01T00:00:01Z", + last: "2026-01-01T00:00:59Z", + }); + + expect(row?.first).toContain("12:00:01 AM"); + expect(row?.last).toContain("12:00:59 AM"); + }); + + it("preserves impossible calendar dates instead of rolling them over", () => { + const transform = createTimezoneExportTransform("UTC", "en-US"); + const row = transform?.({ createdAt: "2026-02-30T00:00:00Z" }); + + expect(row?.createdAt).toBe("2026-02-30T00:00:00Z"); + }); + + it("reports the invalid locale in the error message", () => { + expect(() => createTimezoneExportTransform("Asia/Kolkata", "not_a_locale")).toThrow( + "locale=not_a_locale", + ); + }); + + it("returns the custom transform unchanged when no timezone is set", () => { + const custom = (row: Record) => row; + expect(createTimezoneExportTransform(undefined, "en-US", custom)).toBe(custom); + expect(createTimezoneExportTransform()).toBeUndefined(); + }); +}); + describe("exportData — csv wrapper", () => { let restore: () => void; @@ -372,6 +478,21 @@ describe("exportData — csv wrapper", () => { cap8.restore(); }); + it("applies timeZone and locale options to timestamps", async () => { + const cap10 = captureDownload(); + await exportData( + "csv", + async () => [{ id: 1, createdAt: "2026-01-01T00:00:00Z" }], + undefined, + undefined, + { timeZone: "Asia/Kolkata", locale: "en-US" }, + ); + const text = await cap10.blobs[0].text(); + expect(text).toContain("5:30:00 AM"); + expect(text).toContain("GMT+5:30"); + cap10.restore(); + }); + it('uses "items" as default entityName when not provided', async () => { const cap9 = captureDownload(); await exportData("csv", async () => [{ id: 1 }]);