diff --git a/src/components/usage/UsageDateRangePicker.tsx b/src/components/usage/UsageDateRangePicker.tsx new file mode 100644 index 000000000..1369205e1 --- /dev/null +++ b/src/components/usage/UsageDateRangePicker.tsx @@ -0,0 +1,415 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { resolveUsageRange } from "@/lib/usageRange"; +import { getLocaleFromLanguage } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; + +type DraftField = "start" | "end"; + +interface UsageDateRangePickerProps { + selection: UsageRangeSelection; + onApply: (selection: UsageRangeSelection) => void; + triggerLabel: string; +} + +/* ── helpers ── */ + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function toTs(d: Date): number { + return Math.floor(d.getTime() / 1000); +} + +function fromTs(ts: number): Date { + return new Date(ts * 1000); +} + +function fmtDate(ts: number): string { + const d = fromTs(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function fmtTime(ts: number): string { + const d = fromTs(ts); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +function parseDateInput(ts: number, value: string): number { + const [y, m, d] = value.split("-").map(Number); + if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) + return ts; + const base = fromTs(ts); + return toTs(new Date(y, m - 1, d, base.getHours(), base.getMinutes())); +} + +function parseTimeInput(ts: number, value: string): number { + const [h, min] = value.split(":").map(Number); + if (!Number.isFinite(h) || !Number.isFinite(min)) return ts; + const base = fromTs(ts); + return toTs( + new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, min), + ); +} + +function setDateKeepTime(ts: number, day: Date): number { + const base = fromTs(ts); + return toTs( + new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + base.getHours(), + base.getMinutes(), + ), + ); +} + +function getCalendarDays(month: Date): Date[] { + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const gridStart = new Date(first); + gridStart.setDate(first.getDate() - first.getDay()); + return Array.from({ length: 42 }, (_, i) => { + const d = new Date(gridStart); + d.setDate(gridStart.getDate() + i); + return d; + }); +} + +/* ── component ── */ + +export function UsageDateRangePicker({ + selection, + onApply, + triggerLabel, +}: UsageDateRangePickerProps) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [activeField, setActiveField] = useState("start"); + const resolvedRange = useMemo( + () => resolveUsageRange(selection), + [selection], + ); + const [draftStart, setDraftStart] = useState(resolvedRange.startDate); + const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate); + const [displayMonth, setDisplayMonth] = useState( + () => + new Date( + fromTs(resolvedRange.startDate).getFullYear(), + fromTs(resolvedRange.startDate).getMonth(), + 1, + ), + ); + const [error, setError] = useState(null); + + const language = i18n.resolvedLanguage || i18n.language || "en"; + const locale = getLocaleFromLanguage(language); + + // Reset draft when popover opens + useEffect(() => { + if (!open) return; + const r = resolveUsageRange(selection); + setDraftStart(r.startDate); + setDraftEnd(r.endDate); + setDisplayMonth( + new Date( + fromTs(r.startDate).getFullYear(), + fromTs(r.startDate).getMonth(), + 1, + ), + ); + setActiveField("start"); + setError(null); + }, [open, selection]); + + const calendarDays = useMemo( + () => getCalendarDays(displayMonth), + [displayMonth], + ); + + const weekdayLabels = useMemo( + () => + Array.from({ length: 7 }, (_, i) => + new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format( + new Date(2024, 0, 7 + i), + ), + ), + [locale], + ); + + const startDay = fromTs(draftStart); + const endDay = fromTs(draftEnd); + const today = new Date(); + + /* Pick a date from the calendar */ + const handleDatePick = (day: Date) => { + setError(null); + const nextTs = setDateKeepTime( + activeField === "start" ? draftStart : draftEnd, + day, + ); + + if (activeField === "start") { + setDraftStart(nextTs); + // Auto-swap if start > end + if (nextTs > draftEnd) { + setDraftEnd(nextTs); + } + // Auto-advance to end field + setActiveField("end"); + } else { + // If picked end < start, treat as new start and auto-advance + if (nextTs < draftStart) { + setDraftStart(nextTs); + setActiveField("end"); + } else { + setDraftEnd(nextTs); + } + } + + // Navigate calendar if the day is outside the displayed month + if ( + day.getMonth() !== displayMonth.getMonth() || + day.getFullYear() !== displayMonth.getFullYear() + ) { + setDisplayMonth(new Date(day.getFullYear(), day.getMonth(), 1)); + } + }; + + const handleApply = () => { + setError(null); + if (draftStart > draftEnd) { + setError(t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间")); + return; + } + onApply({ + preset: "custom", + customStartDate: draftStart, + customEndDate: draftEnd, + }); + setOpen(false); + }; + + const goToToday = () => { + setDisplayMonth(new Date(today.getFullYear(), today.getMonth(), 1)); + }; + + /* ── Field card (start / end) ── */ + const renderField = (field: DraftField) => { + const isActive = activeField === field; + const ts = field === "start" ? draftStart : draftEnd; + const setTs = field === "start" ? setDraftStart : setDraftEnd; + const label = + field === "start" + ? t("usage.startTime", "开始时间") + : t("usage.endTime", "结束时间"); + + return ( +
setActiveField(field)} + > +
+ {label} +
+
+ { + const next = parseDateInput(ts, e.target.value); + setTs(next); + const d = fromTs(next); + setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + setError(null); + }} + onFocus={() => setActiveField(field)} + /> + { + setTs(parseTimeInput(ts, e.target.value)); + setError(null); + }} + onFocus={() => setActiveField(field)} + /> +
+
+ ); + }; + + return ( + + + + + +
+ {/* Left: date fields */} +
+

+ {t("usage.customRangeHint", "支持日期与时间,最长 30 天")} +

+ {renderField("start")} + {renderField("end")} + + {error &&

{error}

} + +
+ + +
+
+ + {/* Right: calendar */} +
+ {/* Month navigation */} +
+ + + +
+ + {/* Weekday headers */} +
+ {weekdayLabels.map((label, i) => ( +
+ {label} +
+ ))} +
+ + {/* Day grid */} +
+ {calendarDays.map((day) => { + const isCurrentMonth = + day.getMonth() === displayMonth.getMonth(); + const isToday = isSameDay(day, today); + const isStart = isSameDay(day, startDay); + const isEnd = isSameDay(day, endDay); + const dayStart = startOfDay(day); + const inRange = + dayStart >= startOfDay(startDay) && + dayStart <= startOfDay(endDay); + const isEndpoint = isStart || isEnd; + + return ( + + ); + })} +
+
+
+
+
+ ); +} diff --git a/src/lib/usageRange.ts b/src/lib/usageRange.ts new file mode 100644 index 000000000..1dcd613c3 --- /dev/null +++ b/src/lib/usageRange.ts @@ -0,0 +1,84 @@ +import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage"; + +const DAY_SECONDS = 24 * 60 * 60; +const DAY_MS = DAY_SECONDS * 1000; + +export const MAX_CUSTOM_USAGE_RANGE_SECONDS = 30 * DAY_SECONDS; + +export interface ResolvedUsageRange { + startDate: number; + endDate: number; +} + +function getStartOfLocalDayDate(nowMs: number): Date { + const date = new Date(nowMs); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function getPresetLookbackStart( + preset: Exclude, + nowMs: number, +): number { + const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30; + return Math.floor( + getStartOfLocalDayDate(nowMs - (dayCount - 1) * DAY_MS).getTime() / 1000, + ); +} + +export function resolveUsageRange( + selection: UsageRangeSelection, + nowMs: number = Date.now(), +): ResolvedUsageRange { + const endDate = Math.floor(nowMs / 1000); + + switch (selection.preset) { + case "today": + return { + startDate: Math.floor(getStartOfLocalDayDate(nowMs).getTime() / 1000), + endDate, + }; + case "1d": + return { + startDate: endDate - DAY_SECONDS, + endDate, + }; + case "7d": + case "14d": + case "30d": + return { + startDate: getPresetLookbackStart(selection.preset, nowMs), + endDate, + }; + case "custom": { + const startDate = selection.customStartDate ?? endDate - DAY_SECONDS; + const customEndDate = selection.customEndDate ?? endDate; + return { + startDate, + endDate: customEndDate, + }; + } + } +} + +export function timestampToLocalDatetime(timestamp: number): string { + const date = new Date(timestamp * 1000); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +export function localDatetimeToTimestamp(datetime: string): number | undefined { + if (!datetime || datetime.length < 16) { + return undefined; + } + + const timestamp = new Date(datetime).getTime(); + if (Number.isNaN(timestamp)) { + return undefined; + } + + return Math.floor(timestamp / 1000); +}