-
-
Notifications
You must be signed in to change notification settings - Fork 359
fix(dashboard): unify timezone handling #1231
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
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,8 +1,19 @@ | ||||||
| "use client"; | ||||||
|
|
||||||
| import { addDays, endOfMonth, endOfWeek, format, startOfMonth, startOfWeek } from "date-fns"; | ||||||
| import { | ||||||
| addDays, | ||||||
| addMonths, | ||||||
| differenceInCalendarDays, | ||||||
| endOfMonth, | ||||||
| endOfWeek, | ||||||
| format, | ||||||
| isSameDay, | ||||||
| startOfMonth, | ||||||
| startOfWeek, | ||||||
| } from "date-fns"; | ||||||
| import { formatInTimeZone, toZonedTime } from "date-fns-tz"; | ||||||
| import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; | ||||||
| import { useTranslations } from "next-intl"; | ||||||
| import { useTimeZone, useTranslations } from "next-intl"; | ||||||
| import { useCallback, useMemo, useState } from "react"; | ||||||
| import type { DateRange } from "react-day-picker"; | ||||||
| import { Button } from "@/components/ui/button"; | ||||||
|
|
@@ -28,6 +39,10 @@ function formatDate(date: Date): string { | |||||
| return format(date, "yyyy-MM-dd"); | ||||||
| } | ||||||
|
|
||||||
| function formatDateInSystemTimeZone(date: Date, timeZone: string): string { | ||||||
| return formatInTimeZone(date, timeZone, "yyyy-MM-dd"); | ||||||
| } | ||||||
|
|
||||||
| function parseDate(dateStr: string): Date { | ||||||
| // Parse as local date to avoid timezone issues | ||||||
| // new Date("YYYY-MM-DD") parses as UTC, which causes off-by-one errors in different timezones | ||||||
|
|
@@ -37,8 +52,10 @@ function parseDate(dateStr: string): Date { | |||||
|
|
||||||
| function getDateRangeForPeriod( | ||||||
| period: QuickPeriod, | ||||||
| baseDate: Date = new Date() | ||||||
| timeZone: string, | ||||||
| now: Date = new Date() | ||||||
| ): { startDate: string; endDate: string } { | ||||||
| const baseDate = toZonedTime(now, timeZone); | ||||||
| switch (period) { | ||||||
| case "daily": | ||||||
| return { startDate: formatDate(baseDate), endDate: formatDate(baseDate) }; | ||||||
|
|
@@ -53,7 +70,7 @@ function getDateRangeForPeriod( | |||||
| return { startDate: formatDate(start), endDate: formatDate(end) }; | ||||||
| } | ||||||
| default: | ||||||
| return { startDate: "2020-01-01", endDate: formatDate(new Date()) }; | ||||||
| return { startDate: "2020-01-01", endDate: formatDateInSystemTimeZone(now, timeZone) }; | ||||||
|
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. Since
Suggested change
|
||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -63,7 +80,16 @@ function shiftDateRange( | |||||
| ): { startDate: string; endDate: string } { | ||||||
| const start = parseDate(range.startDate); | ||||||
| const end = parseDate(range.endDate); | ||||||
| const days = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; | ||||||
|
|
||||||
| if (isSameDay(start, startOfMonth(start)) && isSameDay(end, endOfMonth(start))) { | ||||||
| const month = addMonths(start, direction === "prev" ? -1 : 1); | ||||||
| return { | ||||||
| startDate: formatDate(startOfMonth(month)), | ||||||
| endDate: formatDate(endOfMonth(month)), | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| const days = differenceInCalendarDays(end, start) + 1; | ||||||
| const shift = direction === "prev" ? -days : days; | ||||||
|
|
||||||
| return { | ||||||
|
|
@@ -74,17 +100,19 @@ function shiftDateRange( | |||||
|
|
||||||
| export function DateRangePicker({ period, dateRange, onPeriodChange }: DateRangePickerProps) { | ||||||
| const t = useTranslations("dashboard.leaderboard"); | ||||||
| const timeZone = useTimeZone() ?? "UTC"; | ||||||
| const [calendarOpen, setCalendarOpen] = useState(false); | ||||||
| const today = useMemo(() => formatDateInSystemTimeZone(new Date(), timeZone), [timeZone]); | ||||||
|
|
||||||
| const currentRange = useMemo(() => { | ||||||
| if (period === "custom" && dateRange) { | ||||||
| return dateRange; | ||||||
| } | ||||||
| if (period !== "custom" && QUICK_PERIODS.includes(period as QuickPeriod)) { | ||||||
| return getDateRangeForPeriod(period as QuickPeriod); | ||||||
| return getDateRangeForPeriod(period as QuickPeriod, timeZone); | ||||||
| } | ||||||
| return getDateRangeForPeriod("daily"); | ||||||
| }, [period, dateRange]); | ||||||
| return getDateRangeForPeriod("daily", timeZone); | ||||||
| }, [period, dateRange, timeZone]); | ||||||
|
|
||||||
| const selectedRange: DateRange = useMemo(() => { | ||||||
| return { | ||||||
|
|
@@ -198,7 +226,7 @@ export function DateRangePicker({ period, dateRange, onPeriodChange }: DateRange | |||||
| selected={selectedRange} | ||||||
| onSelect={handleDateRangeSelect} | ||||||
| numberOfMonths={2} | ||||||
| disabled={{ after: new Date() }} | ||||||
| disabled={{ after: parseDate(today) }} | ||||||
| /> | ||||||
| </PopoverContent> | ||||||
| </Popover> | ||||||
|
|
@@ -207,7 +235,7 @@ export function DateRangePicker({ period, dateRange, onPeriodChange }: DateRange | |||||
| variant="outline" | ||||||
| size="icon-sm" | ||||||
| onClick={() => handleNavigate("next")} | ||||||
| disabled={period === "allTime" || currentRange.endDate >= formatDate(new Date())} | ||||||
| disabled={period === "allTime" || currentRange.endDate >= today} | ||||||
| title={t("dateRange.nextPeriod")} | ||||||
| > | ||||||
| <ChevronRight className="h-4 w-4" /> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| import { format, subDays } from "date-fns"; | ||
| import { toZonedTime } from "date-fns-tz"; | ||
|
|
||
| export type TimeRangePreset = "today" | "7days" | "30days" | "thisMonth"; | ||
|
|
||
| export interface UserInsightsFilters { | ||
|
|
@@ -17,33 +20,38 @@ export const DEFAULT_FILTERS: UserInsightsFilters = { | |
| export function resolveTimePresetDates(preset: TimeRangePreset): { | ||
| startDate?: string; | ||
| endDate?: string; | ||
| }; | ||
| export function resolveTimePresetDates( | ||
| preset: TimeRangePreset, | ||
| timeZone: string | undefined, | ||
| now?: Date | ||
| ): { | ||
| startDate?: string; | ||
| endDate?: string; | ||
| }; | ||
| export function resolveTimePresetDates( | ||
| preset: TimeRangePreset, | ||
| timeZone?: string, | ||
| now: Date = new Date() | ||
| ): { | ||
| startDate?: string; | ||
| endDate?: string; | ||
| } { | ||
| const now = new Date(); | ||
| const yyyy = now.getFullYear(); | ||
| const mm = String(now.getMonth() + 1).padStart(2, "0"); | ||
| const dd = String(now.getDate()).padStart(2, "0"); | ||
| const today = `${yyyy}-${mm}-${dd}`; | ||
| const baseDate = timeZone ? toZonedTime(now, timeZone) : now; | ||
| const today = format(baseDate, "yyyy-MM-dd"); | ||
|
|
||
| switch (preset) { | ||
| case "today": | ||
| return { startDate: today, endDate: today }; | ||
| case "7days": { | ||
| const start = new Date(now); | ||
| start.setDate(start.getDate() - 6); | ||
| const sy = start.getFullYear(); | ||
| const sm = String(start.getMonth() + 1).padStart(2, "0"); | ||
| const sd = String(start.getDate()).padStart(2, "0"); | ||
| return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; | ||
| const start = subDays(baseDate, 6); | ||
| return { startDate: format(start, "yyyy-MM-dd"), endDate: today }; | ||
| } | ||
| case "30days": { | ||
| const start = new Date(now); | ||
| start.setDate(start.getDate() - 29); | ||
| const sy = start.getFullYear(); | ||
| const sm = String(start.getMonth() + 1).padStart(2, "0"); | ||
| const sd = String(start.getDate()).padStart(2, "0"); | ||
| return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; | ||
| const start = subDays(baseDate, 29); | ||
| return { startDate: format(start, "yyyy-MM-dd"), endDate: today }; | ||
| } | ||
| case "thisMonth": | ||
| return { startDate: `${yyyy}-${mm}-01`, endDate: today }; | ||
| return { startDate: format(baseDate, "yyyy-MM-01"), endDate: today }; | ||
|
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.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts
Line: 55
Comment:
**`thisMonth` start-date format string uses unescaped literals**
`format(baseDate, "yyyy-MM-01")` relies on `0` and `1` not being recognized date-fns format tokens so they pass through as literals — producing "01" as the day. This coincidentally gives the correct first-of-month date today, but any future date-fns version that interprets single-digit characters differently would silently produce wrong output. The idiomatic alternative is `format(startOfMonth(baseDate), "yyyy-MM-dd")`.
How can I resolve this? If you propose a fix, please make it concise. |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
serializeChartBucketDatechanges day-resolution chart keys from date strings to ISO timestampsPreviously, day-resolution keys were
YYYY-MM-DDstrings; they are now full ISO-8601 timestamps (e.g."2025-01-14T16:00:00.000Z"for midnight Asia/Shanghai). Any client-side chart code that compares or formatsitem.dateagainst aYYYY-MM-DDpattern (e.g.item.date.slice(0, 10)or label formatters that branch on string length) will need updating. The serialized value is correct for the fix, but it's worth verifying the chart rendering components handle ISO timestamps uniformly for both hourly and daily resolutions.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!