Skip to content

Commit d54909e

Browse files
committed
feat(webapp): add custom time interval input for run filters
Allow users to specify custom time intervals (e.g., 90 minutes) instead of being limited to preset values. Adds a numeric input field with a unit dropdown (minutes/hours/days) that works with the existing parse-duration backend. Slack thread: https://triggerdotdev.slack.com/archives/C032WA2S43F/p1767877958977499?thread_ts=1767877851.170929&cid=C032WA2S43F
1 parent 7d29f5a commit d54909e

File tree

1 file changed

+113
-5
lines changed

1 file changed

+113
-5
lines changed

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

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ const timePeriods = [
9595
},
9696
];
9797

98+
const timeUnits = [
99+
{ label: "minutes", value: "m", singular: "minute" },
100+
{ label: "hours", value: "h", singular: "hour" },
101+
{ label: "days", value: "d", singular: "day" },
102+
];
103+
104+
// Parse a period string (e.g., "90m", "2h", "7d") into value and unit
105+
function parsePeriodString(period: string): { value: number; unit: string } | null {
106+
const match = period.match(/^(\d+)([mhd])$/);
107+
if (match) {
108+
return { value: parseInt(match[1], 10), unit: match[2] };
109+
}
110+
return null;
111+
}
112+
98113
const defaultPeriod = "7d";
99114
const defaultPeriodMs = parse(defaultPeriod);
100115
if (!defaultPeriodMs) {
@@ -175,9 +190,29 @@ export function timeFilterRenderValues({
175190

176191
let valueLabel: ReactNode;
177192
switch (rangeType) {
178-
case "period":
179-
valueLabel = timePeriods.find((t) => t.value === period)?.label ?? period ?? defaultPeriod;
193+
case "period": {
194+
// First check if it's a preset period
195+
const preset = timePeriods.find((t) => t.value === period);
196+
if (preset) {
197+
valueLabel = preset.label;
198+
} else if (period) {
199+
// Parse custom period and format nicely (e.g., "90m" -> "90 mins")
200+
const parsed = parsePeriodString(period);
201+
if (parsed) {
202+
const unit = timeUnits.find((u) => u.value === parsed.unit);
203+
if (unit) {
204+
valueLabel = `${parsed.value} ${parsed.value === 1 ? unit.singular : unit.label}`;
205+
} else {
206+
valueLabel = period;
207+
}
208+
} else {
209+
valueLabel = period;
210+
}
211+
} else {
212+
valueLabel = defaultPeriod;
213+
}
180214
break;
215+
}
181216
case "range":
182217
valueLabel = (
183218
<span>
@@ -237,6 +272,17 @@ export function TimeFilter() {
237272
);
238273
}
239274

275+
// Get initial custom duration state from a period string
276+
function getInitialCustomDuration(period?: string): { value: string; unit: string } {
277+
if (period) {
278+
const parsed = parsePeriodString(period);
279+
if (parsed) {
280+
return { value: parsed.value.toString(), unit: parsed.unit };
281+
}
282+
}
283+
return { value: "", unit: "m" };
284+
}
285+
240286
export function TimeDropdown({
241287
trigger,
242288
period,
@@ -253,7 +299,12 @@ export function TimeDropdown({
253299
const [fromValue, setFromValue] = useState(from);
254300
const [toValue, setToValue] = useState(to);
255301

256-
const apply = useCallback(() => {
302+
// Custom duration state
303+
const initialCustom = getInitialCustomDuration(period);
304+
const [customValue, setCustomValue] = useState(initialCustom.value);
305+
const [customUnit, setCustomUnit] = useState(initialCustom.unit);
306+
307+
const applyDateRange = useCallback(() => {
257308
replace({
258309
period: undefined,
259310
cursor: undefined,
@@ -283,6 +334,20 @@ export function TimeDropdown({
283334
[replace]
284335
);
285336

337+
const applyCustomDuration = useCallback(() => {
338+
const value = parseInt(customValue, 10);
339+
if (isNaN(value) || value <= 0) {
340+
return;
341+
}
342+
const periodString = `${value}${customUnit}`;
343+
handlePeriodClick(periodString);
344+
}, [customValue, customUnit, handlePeriodClick]);
345+
346+
const isCustomDurationValid = (() => {
347+
const value = parseInt(customValue, 10);
348+
return !isNaN(value) && value > 0;
349+
})();
350+
286351
return (
287352
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
288353
{trigger}
@@ -318,7 +383,50 @@ export function TimeDropdown({
318383
</div>
319384
</div>
320385

321-
<div className="flex flex-col gap-4">
386+
<div className="flex flex-col gap-1">
387+
<Label>Custom duration</Label>
388+
<div className="flex items-center gap-2">
389+
<input
390+
type="number"
391+
min="1"
392+
placeholder="e.g. 90"
393+
value={customValue}
394+
onChange={(e) => setCustomValue(e.target.value)}
395+
onKeyDown={(e) => {
396+
if (e.key === "Enter" && isCustomDurationValid) {
397+
e.preventDefault();
398+
applyCustomDuration();
399+
}
400+
}}
401+
className="h-8 w-20 rounded border border-grid-bright bg-background-bright px-2 text-sm text-text-bright placeholder:text-text-dimmed focus:border-indigo-500 focus:outline-none"
402+
/>
403+
<select
404+
value={customUnit}
405+
onChange={(e) => setCustomUnit(e.target.value)}
406+
className="h-8 rounded border border-grid-bright bg-background-bright px-2 text-sm text-text-bright focus:border-indigo-500 focus:outline-none"
407+
>
408+
{timeUnits.map((unit) => (
409+
<option key={unit.value} value={unit.value}>
410+
{unit.label}
411+
</option>
412+
))}
413+
</select>
414+
<Button
415+
variant="secondary/small"
416+
disabled={!isCustomDurationValid}
417+
onClick={(e) => {
418+
e.preventDefault();
419+
applyCustomDuration();
420+
}}
421+
type="button"
422+
>
423+
Apply
424+
</Button>
425+
</div>
426+
</div>
427+
428+
<div className="flex flex-col gap-4 border-t border-grid-bright pt-4">
429+
<Label className="text-text-dimmed">Or specify exact time range</Label>
322430
<div className="flex flex-col gap-1">
323431
<Label>
324432
From <span className="text-text-dimmed">(local time)</span>
@@ -370,7 +478,7 @@ export function TimeDropdown({
370478
disabled={!fromValue && !toValue}
371479
onClick={(e) => {
372480
e.preventDefault();
373-
apply();
481+
applyDateRange();
374482
}}
375483
type="button"
376484
>

0 commit comments

Comments
 (0)