Skip to content

Commit c0bfc95

Browse files
committed
Adds a new shadcn component for picking the date and time
1 parent 076436c commit c0bfc95

5 files changed

Lines changed: 526 additions & 170 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
5+
import { DayPicker } from "react-day-picker";
6+
import { cn } from "~/utils/cn";
7+
8+
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
9+
10+
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
11+
return (
12+
<DayPicker
13+
showOutsideDays={showOutsideDays}
14+
className={cn("p-3", className)}
15+
classNames={{
16+
months: "flex flex-col sm:flex-row gap-2",
17+
month: "flex flex-col gap-4",
18+
month_caption: "flex justify-center pt-1 relative items-center w-full",
19+
caption_label: "text-sm font-medium text-text-bright",
20+
nav: "flex items-center gap-1",
21+
button_previous:
22+
"absolute left-1 top-0 size-7 bg-transparent p-0 text-text-dimmed hover:text-text-bright transition-colors flex items-center justify-center",
23+
button_next:
24+
"absolute right-1 top-0 size-7 bg-transparent p-0 text-text-dimmed hover:text-text-bright transition-colors flex items-center justify-center",
25+
month_grid: "w-full border-collapse",
26+
weekdays: "flex",
27+
weekday: "text-text-dimmed rounded-md w-8 font-normal text-[0.8rem]",
28+
week: "flex w-full mt-2",
29+
day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-charcoal-700 [&:has([aria-selected].day-outside)]:bg-charcoal-700/50 [&:has([aria-selected].day-range-end)]:rounded-r-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md",
30+
day_button: cn(
31+
"size-8 p-0 font-normal text-text-bright",
32+
"hover:bg-charcoal-700 hover:text-text-bright",
33+
"focus:bg-charcoal-700 focus:text-text-bright focus:outline-none",
34+
"aria-selected:opacity-100"
35+
),
36+
range_start: "day-range-start rounded-l-md",
37+
range_end: "day-range-end rounded-r-md",
38+
selected:
39+
"bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md",
40+
today: "bg-charcoal-700 text-text-bright rounded-md",
41+
outside:
42+
"day-outside text-text-dimmed opacity-50 aria-selected:bg-charcoal-700/50 aria-selected:text-text-dimmed aria-selected:opacity-30",
43+
disabled: "text-text-dimmed opacity-50",
44+
range_middle: "aria-selected:bg-charcoal-700 aria-selected:text-text-bright",
45+
hidden: "invisible",
46+
dropdowns: "flex gap-2 items-center justify-center",
47+
dropdown:
48+
"bg-charcoal-750 border border-charcoal-600 rounded px-2 py-1 text-sm text-text-bright focus:outline-none focus:border-charcoal-500",
49+
...classNames,
50+
}}
51+
components={{
52+
Chevron: ({ orientation }) => {
53+
if (orientation === "left") {
54+
return <ChevronLeftIcon className="size-4" />;
55+
}
56+
return <ChevronRightIcon className="size-4" />;
57+
},
58+
}}
59+
{...props}
60+
/>
61+
);
62+
}
63+
Calendar.displayName = "Calendar";
64+
65+
export { Calendar };
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { ChevronDownIcon } from "@heroicons/react/20/solid";
5+
import { format } from "date-fns";
6+
import { Calendar } from "./Calendar";
7+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
8+
import { Button } from "./Buttons";
9+
import { cn } from "~/utils/cn";
10+
11+
type DateTimePickerProps = {
12+
label: string;
13+
value?: Date;
14+
onChange?: (date: Date | undefined) => void;
15+
showSeconds?: boolean;
16+
showNowButton?: boolean;
17+
showClearButton?: boolean;
18+
className?: string;
19+
};
20+
21+
export function DateTimePicker({
22+
label,
23+
value,
24+
onChange,
25+
showSeconds = true,
26+
showNowButton = false,
27+
showClearButton = false,
28+
className,
29+
}: DateTimePickerProps) {
30+
const [open, setOpen] = React.useState(false);
31+
32+
// Extract time parts from value
33+
const hours = value ? value.getHours().toString().padStart(2, "0") : "";
34+
const minutes = value ? value.getMinutes().toString().padStart(2, "0") : "";
35+
const seconds = value ? value.getSeconds().toString().padStart(2, "0") : "";
36+
const timeValue = showSeconds ? `${hours}:${minutes}:${seconds}` : `${hours}:${minutes}`;
37+
38+
const handleDateSelect = (date: Date | undefined) => {
39+
if (date) {
40+
// Preserve the time from the current value if it exists
41+
if (value) {
42+
date.setHours(value.getHours());
43+
date.setMinutes(value.getMinutes());
44+
date.setSeconds(value.getSeconds());
45+
}
46+
onChange?.(date);
47+
} else {
48+
onChange?.(undefined);
49+
}
50+
setOpen(false);
51+
};
52+
53+
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
54+
const timeString = e.target.value;
55+
if (!timeString) return;
56+
57+
const [h, m, s] = timeString.split(":").map(Number);
58+
const newDate = value ? new Date(value) : new Date();
59+
newDate.setHours(h || 0);
60+
newDate.setMinutes(m || 0);
61+
newDate.setSeconds(s || 0);
62+
onChange?.(newDate);
63+
};
64+
65+
const handleNowClick = () => {
66+
onChange?.(new Date());
67+
};
68+
69+
const handleClearClick = () => {
70+
onChange?.(undefined);
71+
};
72+
73+
return (
74+
<div className={cn("flex items-center gap-2", className)}>
75+
<Popover open={open} onOpenChange={setOpen}>
76+
<PopoverTrigger asChild>
77+
<button
78+
type="button"
79+
className={cn(
80+
"flex h-6 items-center justify-between gap-2 rounded border border-charcoal-600 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-500",
81+
value ? "text-text-bright" : "text-text-dimmed"
82+
)}
83+
>
84+
{value ? format(value, "yyyy/MM/dd") : "Select date"}
85+
<ChevronDownIcon className="size-4 text-text-dimmed" />
86+
</button>
87+
</PopoverTrigger>
88+
<PopoverContent className="w-auto p-0" align="start">
89+
<Calendar
90+
mode="single"
91+
selected={value}
92+
onSelect={handleDateSelect}
93+
captionLayout="dropdown"
94+
/>
95+
</PopoverContent>
96+
</Popover>
97+
<input
98+
type="time"
99+
step={showSeconds ? "1" : "60"}
100+
value={value ? timeValue : ""}
101+
onChange={handleTimeChange}
102+
className={cn(
103+
"h-6 rounded border border-charcoal-600 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-500",
104+
"text-text-bright placeholder:text-text-dimmed",
105+
"focus:border-charcoal-500 focus:outline-none",
106+
"[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
107+
)}
108+
aria-label={`${label} time`}
109+
/>
110+
{showNowButton && (
111+
<Button type="button" variant="tertiary/small" onClick={handleNowClick}>
112+
Now
113+
</Button>
114+
)}
115+
{showClearButton && (
116+
<Button type="button" variant="tertiary/small" onClick={handleClearClick}>
117+
Clear
118+
</Button>
119+
)}
120+
</div>
121+
);
122+
}

apps/webapp/app/components/runs/v3/SharedFilters.tsx

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import * as Ariakit from "@ariakit/react";
22
import type { RuntimeEnvironment } from "@trigger.dev/database";
3+
import {
4+
startOfDay,
5+
endOfDay,
6+
startOfWeek,
7+
endOfWeek,
8+
startOfMonth,
9+
endOfMonth,
10+
startOfYear,
11+
subDays,
12+
subWeeks,
13+
subMonths,
14+
previousSaturday,
15+
isSaturday,
16+
isSunday,
17+
} from "date-fns";
318
import parse from "parse-duration";
419
import type { ReactNode } from "react";
520
import { startTransition, useCallback, useEffect, useState } from "react";
621
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
7-
import { DateField } from "~/components/primitives/DateField";
22+
import { DateTimePicker } from "~/components/primitives/DateTimePicker";
823
import { DateTime } from "~/components/primitives/DateTime";
924
import { Label } from "~/components/primitives/Label";
1025
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
@@ -528,36 +543,126 @@ export function TimeDropdown({
528543
<Label variant="small">
529544
From <span className="text-text-dimmed">(local time)</span>
530545
</Label>
531-
<DateField
546+
<DateTimePicker
532547
label="From time"
533-
defaultValue={fromValue}
534-
onValueChange={(value) => {
548+
value={fromValue}
549+
onChange={(value) => {
535550
setFromValue(value);
536551
setActiveSection("dateRange");
537552
setValidationError(null);
538553
}}
539-
granularity="second"
554+
showSeconds
540555
showNowButton
541556
showClearButton
542-
variant="small"
543557
/>
544558
</div>
545559
<div className="flex flex-col gap-1" onClick={(e) => e.stopPropagation()}>
546560
<Label variant="small">
547561
To <span className="text-text-dimmed">(local time)</span>
548562
</Label>
549-
<DateField
563+
<DateTimePicker
550564
label="To time"
551-
defaultValue={toValue}
552-
onValueChange={(value) => {
565+
value={toValue}
566+
onChange={(value) => {
553567
setToValue(value);
554568
setActiveSection("dateRange");
555569
setValidationError(null);
556570
}}
557-
granularity="second"
571+
showSeconds
558572
showNowButton
559573
showClearButton
560-
variant="small"
574+
/>
575+
</div>
576+
{/* Quick select date ranges */}
577+
<div className="mt-3 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
578+
<QuickDateButton
579+
label="Yesterday"
580+
onClick={() => {
581+
const yesterday = subDays(new Date(), 1);
582+
setFromValue(startOfDay(yesterday));
583+
setToValue(endOfDay(yesterday));
584+
setActiveSection("dateRange");
585+
setValidationError(null);
586+
}}
587+
/>
588+
<QuickDateButton
589+
label="Today"
590+
onClick={() => {
591+
const today = new Date();
592+
setFromValue(startOfDay(today));
593+
setToValue(today);
594+
setActiveSection("dateRange");
595+
setValidationError(null);
596+
}}
597+
/>
598+
<QuickDateButton
599+
label="This week"
600+
onClick={() => {
601+
const now = new Date();
602+
setFromValue(startOfWeek(now, { weekStartsOn: 1 }));
603+
setToValue(now);
604+
setActiveSection("dateRange");
605+
setValidationError(null);
606+
}}
607+
/>
608+
<QuickDateButton
609+
label="Last week"
610+
onClick={() => {
611+
const lastWeek = subWeeks(new Date(), 1);
612+
setFromValue(startOfWeek(lastWeek, { weekStartsOn: 1 }));
613+
setToValue(endOfWeek(lastWeek, { weekStartsOn: 1 }));
614+
setActiveSection("dateRange");
615+
setValidationError(null);
616+
}}
617+
/>
618+
<QuickDateButton
619+
label="Last weekend"
620+
onClick={() => {
621+
const now = new Date();
622+
let saturday: Date;
623+
if (isSaturday(now)) {
624+
saturday = subDays(now, 7);
625+
} else if (isSunday(now)) {
626+
saturday = subDays(now, 8);
627+
} else {
628+
saturday = previousSaturday(now);
629+
}
630+
const sunday = endOfDay(subDays(saturday, -1));
631+
setFromValue(startOfDay(saturday));
632+
setToValue(sunday);
633+
setActiveSection("dateRange");
634+
setValidationError(null);
635+
}}
636+
/>
637+
<QuickDateButton
638+
label="This month"
639+
onClick={() => {
640+
const now = new Date();
641+
setFromValue(startOfMonth(now));
642+
setToValue(now);
643+
setActiveSection("dateRange");
644+
setValidationError(null);
645+
}}
646+
/>
647+
<QuickDateButton
648+
label="Last month"
649+
onClick={() => {
650+
const lastMonth = subMonths(new Date(), 1);
651+
setFromValue(startOfMonth(lastMonth));
652+
setToValue(endOfMonth(lastMonth));
653+
setActiveSection("dateRange");
654+
setValidationError(null);
655+
}}
656+
/>
657+
<QuickDateButton
658+
label="Year to date"
659+
onClick={() => {
660+
const now = new Date();
661+
setFromValue(startOfYear(now));
662+
setToValue(now);
663+
setActiveSection("dateRange");
664+
setValidationError(null);
665+
}}
561666
/>
562667
</div>
563668
{validationError && activeSection === "dateRange" && (
@@ -628,3 +733,15 @@ export function dateFromString(value: string | undefined | null): Date | undefin
628733

629734
return new Date(value);
630735
}
736+
737+
function QuickDateButton({ label, onClick }: { label: string; onClick: () => void }) {
738+
return (
739+
<button
740+
type="button"
741+
onClick={onClick}
742+
className="rounded border border-charcoal-650 bg-charcoal-750 px-2 py-0.5 text-xs text-text-dimmed transition hover:border-charcoal-600 hover:text-text-bright"
743+
>
744+
{label}
745+
</button>
746+
);
747+
}

apps/webapp/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
"cronstrue": "^2.21.0",
138138
"cross-env": "^7.0.3",
139139
"cuid": "^2.1.8",
140+
"date-fns": "^4.1.0",
140141
"dompurify": "^3.2.6",
141142
"dotenv": "^16.4.5",
142143
"effect": "^3.11.7",
@@ -178,6 +179,7 @@
178179
"react": "^18.2.0",
179180
"react-aria": "^3.31.1",
180181
"react-collapse": "^5.1.1",
182+
"react-day-picker": "^9.13.0",
181183
"react-dom": "^18.2.0",
182184
"react-hotkeys-hook": "^4.4.1",
183185
"react-popper": "^2.3.0",

0 commit comments

Comments
 (0)