Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RegistryContext } from "@effect/atom-react";
import { RegistryContext, useAtomValue } from "@effect/atom-react";
import type { TimestampFormat } from "@t3tools/contracts/settings";
import { Atom, AtomRegistry } from "effect/reactivity";
import {
DEFAULT_UNIFIED_SETTINGS,
Expand All @@ -15,6 +16,7 @@ import { page, userEvent } from "vite-plus/test/browser";
import "~/index.css";
import { act, render } from "../../../test/render";
import { ScheduledTasksSettings } from "./ScheduledTasksSettings";
import { resolveHour12 } from "../../timestampFormat";

const environmentId = EnvironmentId.make("laptop");
const projectId = ProjectId.make("project");
Expand All @@ -40,21 +42,32 @@ const task: ScheduledTask = {
lastRunError: null,
runCount: 0,
};
const fixedTask: ScheduledTask = {
...task,
schedule: { type: "fixed_time", timeOfDay: "17:30" },
};

// Stands in for the thread family read the editor makes when the shell lacks the bound thread.
const threadFamilyRead = {
snapshotSequence: 1,
threads: [{ id: task.threadId, title: "Fix login bug" }],
};

const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false }));
const state = vi.hoisted(() => ({
connected: true,
tasksLoaded: true,
secondary: false,
fixed: false,
}));
const settingsAtom = Atom.make(DEFAULT_UNIFIED_SETTINGS);
const secondaryId = EnvironmentId.make("secondary");
const permissions = new Map<string, Atom.Writable<boolean>>();

function setup() {
state.connected = true;
state.tasksLoaded = true;
state.secondary = false;
state.fixed = false;
permissions.clear();
permissions.set(environmentId, Atom.make(true));
permissions.set(secondaryId, Atom.make(true));
Expand Down Expand Up @@ -125,7 +138,9 @@ vi.mock(import("../../state/query"), async (original) => ({
data:
request === null || request === threadFamilyRead
? request
: { tasks: state.tasksLoaded ? [task] : [] },
: {
tasks: state.tasksLoaded ? [state.fixed ? fixedTask : task] : [],
},
error: null,
isPending: false,
refresh: vi.fn(),
Expand All @@ -142,10 +157,69 @@ vi.mock(import("../../state/entities"), async (original) => ({
}));
vi.mock(import("../../hooks/useSettings"), async (original) => ({
...(await original()),
useEnvironmentSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never,
useEnvironmentSettings: (() => useAtomValue(settingsAtom)) as never,
}));

describe("ScheduledTasksSettings", () => {
it("honors time format while preserving the fixed time through noon and midnight", async () => {
setup();
state.fixed = true;
const registry = AtomRegistry.make();
using _registry = { [Symbol.dispose]: () => registry.dispose() };
registry.set(settingsAtom, { ...DEFAULT_UNIFIED_SETTINGS, timestampFormat: "24-hour" });
await using _screen = await render(
<RegistryContext.Provider value={registry}>
<ScheduledTasksSettings taskId={task.id} />
</RegistryContext.Provider>,
);
const setFormat = (timestampFormat: TimestampFormat) =>
act(() => registry.set(settingsAtom, { ...DEFAULT_UNIFIED_SETTINGS, timestampFormat }));
const hours = page.getByRole("combobox", { name: "Run at hours", exact: true });
const minutes = page.getByRole("combobox", { name: "Run at minutes", exact: true });
const period = page.getByRole("combobox", { name: "Run at period", exact: true });
await expect.element(page.getByRole("dialog")).toBeVisible();
await expect.element(hours).toHaveTextContent("17");
await expect.element(minutes).toHaveTextContent("30");
await expect.element(period).not.toBeInTheDocument();

await setFormat("12-hour");
await expect.element(hours).toHaveTextContent("05");
await expect.element(period).toHaveTextContent("PM");
await hours.click();
await page.getByRole("option", { name: "12", exact: true }).click();
await minutes.click();
await page.getByRole("option", { name: "00", exact: true }).click();

await setFormat("24-hour");
await expect.element(hours).toHaveTextContent("12");
await expect.element(minutes).toHaveTextContent("00");

await setFormat("12-hour");
await period.click();
await page.getByRole("option", { name: "AM", exact: true }).click();
await setFormat("24-hour");
await expect.element(hours).toHaveTextContent("00");
await expect.element(minutes).toHaveTextContent("00");

await setFormat("locale");
await expect.element(hours).toHaveTextContent(resolveHour12("locale") ? "12" : "00");
if (resolveHour12("locale")) {
await expect.element(period).toHaveTextContent("AM");
} else {
await expect.element(period).not.toBeInTheDocument();
}

await setFormat("24-hour");
await hours.click();
await page.getByRole("option", { name: "23", exact: true }).click();
await minutes.click();
await page.getByRole("option", { name: "59", exact: true }).click();
await setFormat("12-hour");
await expect.element(hours).toHaveTextContent("11");
await expect.element(minutes).toHaveTextContent("59");
await expect.element(period).toHaveTextContent("PM");
});

it("creates on a permitted secondary and reacts to permission changes", async () => {
setup();
state.secondary = true;
Expand Down
12 changes: 5 additions & 7 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionS
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
import { readEnvironmentScope } from "~/state/session";
import { useSettingsScope } from "./SettingsScopeContext";
import { TimeOfDayInput } from "./TimeOfDayInput";
import { EnvironmentRow, useEnvironmentTransportLabel } from "./EnvironmentRow";
import { SettingsGroup } from "./SettingsGroup";
import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
Expand Down Expand Up @@ -1829,15 +1830,12 @@ function ScheduledTaskEditorForm({
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="scheduled-task-time">Run at</Label>
<Input
type="time"
<TimeOfDayInput
id="scheduled-task-time"
nativeInput
className="w-32"
label="Run at"
value={draft.timeOfDay}
onChange={(event) =>
setDraft((current) => ({ ...current, timeOfDay: event.target.value }))
}
timestampFormat={settings.timestampFormat}
onChange={(timeOfDay) => setDraft((current) => ({ ...current, timeOfDay }))}
/>
<span className="text-xs text-muted-foreground">on</span>
</div>
Expand Down
26 changes: 12 additions & 14 deletions apps/web/src/components/settings/SnoozePresetsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useNowMinute } from "../../hooks/useNowMinute";
import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic";
import { formatShortTimestamp } from "../../timestampFormat";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { TimeOfDayInput } from "./TimeOfDayInput";
import {
NumberField,
NumberFieldDecrement,
Expand Down Expand Up @@ -284,7 +284,11 @@ function SnoozePresetList(props: {
aria-label={`Snooze option ${index + 1}`}
className="flex flex-wrap items-center gap-2"
>
<SnoozePresetFields preset={preset} onChange={(next) => props.onChange(index, next)} />
<SnoozePresetFields
preset={preset}
timestampFormat={props.timestampFormat}
onChange={(next) => props.onChange(index, next)}
/>
<div className="ml-auto flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{preview.tone === "menu" ? (
<span className="truncate">
Expand Down Expand Up @@ -331,6 +335,7 @@ function SnoozePresetList(props: {

function SnoozePresetFields(props: {
preset: SnoozePresetSetting;
timestampFormat: Parameters<typeof formatShortTimestamp>[1];
onChange: (preset: SnoozePresetSetting) => void;
}) {
const { preset, onChange } = props;
Expand All @@ -339,7 +344,7 @@ function SnoozePresetFields(props: {
// misclick on "When" can be undone without retyping it.
const [memory, setMemory] = useState<KindMemory>({ minutes: 60, time: "09:00" });
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Select
value={when}
items={WHEN_LABELS}
Expand Down Expand Up @@ -375,18 +380,11 @@ function SnoozePresetFields(props: {
) : (
<>
<span className="text-xs text-muted-foreground">at</span>
<Input
nativeInput
size="sm"
type="time"
className="w-32"
aria-label="Time"
<TimeOfDayInput
label="Time"
value={preset.time}
onChange={(event) => {
if (/^\d{2}:\d{2}$/.test(event.target.value)) {
onChange({ ...preset, time: event.target.value });
}
}}
timestampFormat={props.timestampFormat}
onChange={(time) => onChange({ ...preset, time })}
/>
</>
)}
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/components/settings/TimeOfDayInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { TimestampFormat } from "@t3tools/contracts/settings";

import { resolveHour12 } from "../../timestampFormat";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";

const pad = (value: number) => String(value).padStart(2, "0");
const HOURS_24 = Array.from({ length: 24 }, (_, hour) => pad(hour));
const HOURS_12 = Array.from({ length: 12 }, (_, hour) => pad(hour + 1));
const MINUTES = Array.from({ length: 60 }, (_, minute) => pad(minute));
const PERIODS = ["AM", "PM"];

export function TimeOfDayInput({
id,
label,
value,
timestampFormat,
onChange,
}: {
id?: string;
label: string;
value: string;
timestampFormat: TimestampFormat;
onChange: (value: string) => void;
}) {
const hour12 = resolveHour12(timestampFormat);
const separator = value.indexOf(":");
const hour = Number(value.slice(0, separator));
const minute = value.slice(separator + 1);
const period = hour < 12 ? "AM" : "PM";

return (
<div role="group" aria-label={label} className="flex items-center gap-1">
<TimePart
id={id}
label={`${label} hours`}
value={pad(hour12 ? hour % 12 || 12 : hour)}
options={hour12 ? HOURS_12 : HOURS_24}
onChange={(next) => {
const nextHour = hour12 ? (Number(next) % 12) + (hour >= 12 ? 12 : 0) : Number(next);
onChange(`${pad(nextHour)}:${minute}`);
}}
/>
<span aria-hidden="true">:</span>
<TimePart
label={`${label} minutes`}
value={minute}
options={MINUTES}
onChange={(next) => onChange(`${pad(hour)}:${next}`)}
/>
{hour12 ? (
<TimePart
label={`${label} period`}
value={period}
options={PERIODS}
onChange={(next) => onChange(`${pad((hour % 12) + (next === "PM" ? 12 : 0))}:${minute}`)}
/>
) : null}
</div>
);
}

function TimePart({
id,
label,
value,
options,
onChange,
}: {
id?: string | undefined;
label: string;
value: string;
options: ReadonlyArray<string>;
onChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={(next) => next !== null && onChange(next)}>
<SelectTrigger id={id} size="sm" className="w-auto min-w-0" aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectPopup>
</Select>
);
}
Loading