Skip to content

Commit 1e5e6c1

Browse files
authored
fix(data-grid): date timezone issues and move utils to data-grid.ts (#1085)
* fix: date timezone issues and move utils to data-grid.ts - Add parseLocalDate to parse YYYY-MM-DD as local date (avoids UTC shift) - Add formatDateToString to format Date using local components - Move getUrlHref, formatFileSize, getFileIcon to lib/data-grid.ts - Fix DateCell to correctly display selected dates without off-by-one errors * chore: rebuild registry * fix: add validation guards to parseLocalDate and formatFileSize
1 parent b89a1c4 commit 1e5e6c1

3 files changed

Lines changed: 110 additions & 74 deletions

File tree

public/r/data-grid.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/components/data-grid/data-grid-cell-variants.tsx

Lines changed: 15 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,6 @@
11
"use client";
22

3-
import {
4-
Check,
5-
File,
6-
FileArchive,
7-
FileAudio,
8-
FileImage,
9-
FileSpreadsheet,
10-
FileText,
11-
FileVideo,
12-
Presentation,
13-
Upload,
14-
X,
15-
} from "lucide-react";
3+
import { Check, Upload, X } from "lucide-react";
164
import * as React from "react";
175
import { toast } from "sonner";
186
import { DataGridCellWrapper } from "@/components/data-grid/data-grid-cell-wrapper";
@@ -45,7 +33,16 @@ import { Skeleton } from "@/components/ui/skeleton";
4533
import { Textarea } from "@/components/ui/textarea";
4634
import { useBadgeOverflow } from "@/hooks/use-badge-overflow";
4735
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
48-
import { getCellKey, getLineCount } from "@/lib/data-grid";
36+
import {
37+
formatDateForDisplay,
38+
formatDateToString,
39+
formatFileSize,
40+
getCellKey,
41+
getFileIcon,
42+
getLineCount,
43+
getUrlHref,
44+
parseLocalDate,
45+
} from "@/lib/data-grid";
4946
import { cn } from "@/lib/utils";
5047
import type { DataGridCellProps, FileCellData } from "@/types/data-grid";
5148

@@ -494,23 +491,6 @@ export function NumberCell<TData>({
494491
);
495492
}
496493

497-
function getUrlHref(urlString: string): string {
498-
if (!urlString || urlString.trim() === "") return "";
499-
500-
const trimmed = urlString.trim();
501-
502-
// Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them)
503-
if (/^(javascript|data|vbscript|file):/i.test(trimmed)) {
504-
return "";
505-
}
506-
507-
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
508-
return trimmed;
509-
}
510-
511-
return `http://${trimmed}`;
512-
}
513-
514494
export function UrlCell<TData>({
515495
cell,
516496
tableMeta,
@@ -1253,12 +1233,6 @@ export function MultiSelectCell<TData>({
12531233
);
12541234
}
12551235

1256-
function formatDateForDisplay(dateStr: string) {
1257-
if (!dateStr) return "";
1258-
const date = new Date(dateStr);
1259-
return date.toLocaleDateString();
1260-
}
1261-
12621236
export function DateCell<TData>({
12631237
cell,
12641238
tableMeta,
@@ -1282,13 +1256,15 @@ export function DateCell<TData>({
12821256
setValue(initialValue ?? "");
12831257
}
12841258

1285-
const selectedDate = value ? new Date(value) : undefined;
1259+
// Parse date as local time to avoid timezone shifts
1260+
const selectedDate = value ? (parseLocalDate(value) ?? undefined) : undefined;
12861261

12871262
const onDateSelect = React.useCallback(
12881263
(date: Date | undefined) => {
12891264
if (!date || readOnly) return;
12901265

1291-
const formattedDate = date.toISOString().split("T")[0] ?? "";
1266+
// Format using local date components to avoid timezone issues
1267+
const formattedDate = formatDateToString(date);
12921268
setValue(formattedDate);
12931269
tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });
12941270
tableMeta?.onCellEditingStop?.();
@@ -1367,39 +1343,6 @@ export function DateCell<TData>({
13671343
);
13681344
}
13691345

1370-
function formatFileSize(bytes: number): string {
1371-
if (bytes === 0) return "0 B";
1372-
const k = 1024;
1373-
const sizes = ["B", "KB", "MB", "GB"];
1374-
const i = Math.floor(Math.log(bytes) / Math.log(k));
1375-
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
1376-
}
1377-
1378-
function getFileIcon(
1379-
type: string,
1380-
): React.ComponentType<React.SVGProps<SVGSVGElement>> {
1381-
if (type.startsWith("image/")) return FileImage;
1382-
if (type.startsWith("video/")) return FileVideo;
1383-
if (type.startsWith("audio/")) return FileAudio;
1384-
if (type.includes("pdf")) return FileText;
1385-
if (type.includes("zip") || type.includes("rar")) return FileArchive;
1386-
if (
1387-
type.includes("word") ||
1388-
type.includes("document") ||
1389-
type.includes("doc")
1390-
)
1391-
return FileText;
1392-
if (type.includes("sheet") || type.includes("excel") || type.includes("xls"))
1393-
return FileSpreadsheet;
1394-
if (
1395-
type.includes("presentation") ||
1396-
type.includes("powerpoint") ||
1397-
type.includes("ppt")
1398-
)
1399-
return Presentation;
1400-
return File;
1401-
}
1402-
14031346
export function FileCell<TData>({
14041347
cell,
14051348
tableMeta,

src/lib/data-grid.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@ import {
33
BaselineIcon,
44
CalendarIcon,
55
CheckSquareIcon,
6+
File,
7+
FileArchive,
8+
FileAudio,
69
FileIcon,
10+
FileImage,
11+
FileSpreadsheet,
12+
FileText,
13+
FileVideo,
714
HashIcon,
815
LinkIcon,
916
ListChecksIcon,
1017
ListIcon,
18+
Presentation,
1119
TextInitialIcon,
1220
} from "lucide-react";
1321
import type * as React from "react";
@@ -252,3 +260,88 @@ export function getColumnVariant(variant?: CellOpts["variant"]): {
252260
return null;
253261
}
254262
}
263+
264+
export function getUrlHref(urlString: string): string {
265+
if (!urlString || urlString.trim() === "") return "";
266+
267+
const trimmed = urlString.trim();
268+
269+
// Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them)
270+
if (/^(javascript|data|vbscript|file):/i.test(trimmed)) {
271+
return "";
272+
}
273+
274+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
275+
return trimmed;
276+
}
277+
278+
return `http://${trimmed}`;
279+
}
280+
281+
export function parseLocalDate(dateStr: unknown): Date | null {
282+
if (!dateStr) return null;
283+
if (dateStr instanceof Date) return dateStr;
284+
if (typeof dateStr !== "string") return null;
285+
const [year, month, day] = dateStr.split("-").map(Number);
286+
if (!year || !month || !day) return null;
287+
const date = new Date(year, month - 1, day);
288+
// Verify date wasn't auto-corrected (e.g. Feb 30 -> Mar 1)
289+
if (
290+
date.getFullYear() !== year ||
291+
date.getMonth() !== month - 1 ||
292+
date.getDate() !== day
293+
) {
294+
return null;
295+
}
296+
return date;
297+
}
298+
299+
export function formatDateToString(date: Date): string {
300+
const year = date.getFullYear();
301+
const month = String(date.getMonth() + 1).padStart(2, "0");
302+
const day = String(date.getDate()).padStart(2, "0");
303+
return `${year}-${month}-${day}`;
304+
}
305+
306+
export function formatDateForDisplay(dateStr: unknown): string {
307+
if (!dateStr) return "";
308+
const date = parseLocalDate(dateStr);
309+
if (!date) return typeof dateStr === "string" ? dateStr : "";
310+
return date.toLocaleDateString();
311+
}
312+
313+
export function formatFileSize(bytes: number): string {
314+
if (bytes <= 0 || !Number.isFinite(bytes)) return "0 B";
315+
const k = 1024;
316+
const sizes = ["B", "KB", "MB", "GB"];
317+
const i = Math.min(
318+
sizes.length - 1,
319+
Math.floor(Math.log(bytes) / Math.log(k)),
320+
);
321+
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
322+
}
323+
324+
export function getFileIcon(
325+
type: string,
326+
): React.ComponentType<React.SVGProps<SVGSVGElement>> {
327+
if (type.startsWith("image/")) return FileImage;
328+
if (type.startsWith("video/")) return FileVideo;
329+
if (type.startsWith("audio/")) return FileAudio;
330+
if (type.includes("pdf")) return FileText;
331+
if (type.includes("zip") || type.includes("rar")) return FileArchive;
332+
if (
333+
type.includes("word") ||
334+
type.includes("document") ||
335+
type.includes("doc")
336+
)
337+
return FileText;
338+
if (type.includes("sheet") || type.includes("excel") || type.includes("xls"))
339+
return FileSpreadsheet;
340+
if (
341+
type.includes("presentation") ||
342+
type.includes("powerpoint") ||
343+
type.includes("ppt")
344+
)
345+
return Presentation;
346+
return File;
347+
}

0 commit comments

Comments
 (0)