Fix scheduler connections and date overrides
This commit is contained in:
@@ -69,6 +69,11 @@ a { color: inherit; text-decoration: none; }
|
||||
.unavailable-label { font-style: italic; }
|
||||
.override-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.override-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid #f0f1f3; font-size: 13px; }
|
||||
.override-editor { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; padding-bottom: 14px; }
|
||||
.override-date { margin: 0; }
|
||||
.override-unavailable { margin: 0 4px 8px 0; }
|
||||
.override-time-range { margin-bottom: 1px; }
|
||||
.override-add { margin-bottom: 1px; }
|
||||
.save-bar { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Connections */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { upsertDateOverride } from "@scheduler/lib/scheduler/overrides";
|
||||
import type { DateOverride, SchedulerSchedule, WeeklyRange } from "@scheduler/lib/scheduler/types";
|
||||
|
||||
type AvailabilityEditorProps = {
|
||||
@@ -34,6 +35,11 @@ function buildRows(weekly: WeeklyRange[]): WeeklyRange[] {
|
||||
export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
|
||||
const [rows, setRows] = useState<WeeklyRange[]>(() => buildRows(schedule.weekly));
|
||||
const [overrides, setOverrides] = useState<DateOverride[]>(schedule.overrides);
|
||||
const [overrideDate, setOverrideDate] = useState("");
|
||||
const [overrideUnavailable, setOverrideUnavailable] = useState(true);
|
||||
const [overrideStart, setOverrideStart] = useState(DEFAULT_START);
|
||||
const [overrideEnd, setOverrideEnd] = useState(DEFAULT_END);
|
||||
const [overrideError, setOverrideError] = useState("");
|
||||
const [saveState, setSaveState] = useState<SaveState>({ kind: "idle" });
|
||||
const timeZone = schedule.timeZone;
|
||||
|
||||
@@ -47,6 +53,24 @@ export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
|
||||
setOverrides((current) => current.filter((override) => override.date !== date));
|
||||
};
|
||||
|
||||
const addOverride = () => {
|
||||
if (!overrideDate) {
|
||||
setOverrideError("Choose a date.");
|
||||
return;
|
||||
}
|
||||
if (!overrideUnavailable && overrideStart >= overrideEnd) {
|
||||
setOverrideError("End time must be after start time.");
|
||||
return;
|
||||
}
|
||||
|
||||
const next: DateOverride = overrideUnavailable
|
||||
? { date: overrideDate, unavailable: true }
|
||||
: { date: overrideDate, unavailable: false, startTime: overrideStart, endTime: overrideEnd };
|
||||
setOverrides((current) => upsertDateOverride(current, next));
|
||||
setOverrideDate("");
|
||||
setOverrideError("");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaveState({ kind: "saving" });
|
||||
try {
|
||||
@@ -118,6 +142,41 @@ export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) {
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Date overrides</h2>
|
||||
<div className="override-editor">
|
||||
<label className="field override-date">
|
||||
Date
|
||||
<input type="date" value={overrideDate} onChange={(event) => setOverrideDate(event.target.value)} />
|
||||
</label>
|
||||
<label className="field-inline override-unavailable">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overrideUnavailable}
|
||||
onChange={(event) => setOverrideUnavailable(event.target.checked)}
|
||||
/>
|
||||
Unavailable all day
|
||||
</label>
|
||||
{!overrideUnavailable && (
|
||||
<div className="time-range override-time-range">
|
||||
<input
|
||||
type="time"
|
||||
value={overrideStart}
|
||||
onChange={(event) => setOverrideStart(event.target.value)}
|
||||
aria-label="Override start"
|
||||
/>
|
||||
<span className="dash">–</span>
|
||||
<input
|
||||
type="time"
|
||||
value={overrideEnd}
|
||||
onChange={(event) => setOverrideEnd(event.target.value)}
|
||||
aria-label="Override end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="secondary-button override-add" onClick={addOverride}>
|
||||
Add override
|
||||
</button>
|
||||
</div>
|
||||
{overrideError && <p className="notice notice-error">{overrideError}</p>}
|
||||
{overrides.length === 0 ? (
|
||||
<p className="muted">No date overrides.</p>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,11 @@ type ConnectionsViewProps = {
|
||||
};
|
||||
|
||||
const CONFERENCING_CATEGORIES = new Set(["conferencing", "video"]);
|
||||
const CAL_SETTINGS_URL = process.env.CAL_SETTINGS_URL ?? "https://scheduler.internal.vyntehome.com";
|
||||
|
||||
function settingsHref(path: string) {
|
||||
return `${CAL_SETTINGS_URL.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function providerLabel(connection: SchedulerConnection): string {
|
||||
return connection.appId ?? connection.type;
|
||||
@@ -17,7 +22,7 @@ function ProviderRow({ connection }: { connection: SchedulerConnection }) {
|
||||
{connection.connected ? (
|
||||
<span className="provider-status connected">Connected</span>
|
||||
) : (
|
||||
<a className="secondary-button" href="/apps/installed">
|
||||
<a className="secondary-button" href={settingsHref("/apps/installed")}>
|
||||
Connect
|
||||
</a>
|
||||
)}
|
||||
@@ -45,7 +50,7 @@ export function ConnectionsView({ connections }: ConnectionsViewProps) {
|
||||
<ProviderRow key={`${connection.type}-${connection.appId}`} connection={connection} />
|
||||
))
|
||||
)}
|
||||
<a className="primary-button browse-button" href="/apps/categories/calendar">
|
||||
<a className="primary-button browse-button" href={settingsHref("/apps/installed/calendar")}>
|
||||
Browse calendar providers
|
||||
</a>
|
||||
</section>
|
||||
@@ -59,7 +64,7 @@ export function ConnectionsView({ connections }: ConnectionsViewProps) {
|
||||
<ProviderRow key={`${connection.type}-${connection.appId}`} connection={connection} />
|
||||
))
|
||||
)}
|
||||
<a className="primary-button browse-button" href="/apps/categories/conferencing">
|
||||
<a className="primary-button browse-button" href={settingsHref("/apps/installed/conferencing")}>
|
||||
Browse conferencing providers
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@@ -89,4 +89,34 @@ describe("computeMutualSlots", () => {
|
||||
slots[0].attendeeIds.push(999);
|
||||
expect(slots[1].attendeeIds).toEqual(originalSecondIds);
|
||||
});
|
||||
|
||||
it("removes slots on a date marked unavailable", () => {
|
||||
const slots = computeMutualSlots({
|
||||
schedules: schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
overrides: [{ date: "2026-06-15", unavailable: true }],
|
||||
})),
|
||||
busy: [],
|
||||
rangeStart: "2026-06-15T00:00:00.000Z",
|
||||
rangeEnd: "2026-06-16T00:00:00.000Z",
|
||||
durationMinutes: 30,
|
||||
});
|
||||
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses custom override hours instead of weekly hours", () => {
|
||||
const slots = computeMutualSlots({
|
||||
schedules: schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
overrides: [{ date: "2026-06-15", unavailable: false, startTime: "13:00", endTime: "14:00" }],
|
||||
})),
|
||||
busy: [],
|
||||
rangeStart: "2026-06-15T00:00:00.000Z",
|
||||
rangeEnd: "2026-06-16T00:00:00.000Z",
|
||||
durationMinutes: 30,
|
||||
});
|
||||
|
||||
expect(slots.map((slot) => slot.start)).toEqual(["2026-06-15T19:00:00.000Z", "2026-06-15T19:30:00.000Z"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,12 +22,17 @@ function dateAtLocalMinutes(day: Date, minutes: number) {
|
||||
}
|
||||
|
||||
function windowsForSchedule(schedule: SchedulerSchedule, rangeStart: Date, rangeEnd: Date) {
|
||||
// v1: weekly ranges only. TODO: apply schedule.overrides (unavailable dates / one-off hours).
|
||||
const windows: { start: Date; end: Date; userId: number }[] = [];
|
||||
for (let cursor = new Date(rangeStart); cursor < rangeEnd; cursor = addMinutes(cursor, 24 * 60)) {
|
||||
const day = cursor.getUTCDay();
|
||||
const weekly = schedule.weekly.filter((range) => range.day === day && range.enabled);
|
||||
for (const range of weekly) {
|
||||
const override = schedule.overrides.find((item) => item.date === cursor.toISOString().slice(0, 10));
|
||||
if (override?.unavailable) continue;
|
||||
|
||||
const ranges =
|
||||
override?.startTime && override.endTime
|
||||
? [{ startTime: override.startTime, endTime: override.endTime }]
|
||||
: schedule.weekly.filter((range) => range.day === day && range.enabled);
|
||||
for (const range of ranges) {
|
||||
windows.push({
|
||||
userId: schedule.userId,
|
||||
start: dateAtLocalMinutes(cursor, minutesFromTime(range.startTime)),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { upsertDateOverride } from "./overrides";
|
||||
|
||||
describe("upsertDateOverride", () => {
|
||||
it("adds a new override in date order", () => {
|
||||
expect(
|
||||
upsertDateOverride(
|
||||
[{ date: "2026-06-20", unavailable: true }],
|
||||
{ date: "2026-06-18", unavailable: false, startTime: "10:00", endTime: "14:00" }
|
||||
)
|
||||
).toEqual([
|
||||
{ date: "2026-06-18", unavailable: false, startTime: "10:00", endTime: "14:00" },
|
||||
{ date: "2026-06-20", unavailable: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces an existing override for the same date", () => {
|
||||
expect(
|
||||
upsertDateOverride(
|
||||
[{ date: "2026-06-18", unavailable: false, startTime: "09:00", endTime: "17:00" }],
|
||||
{ date: "2026-06-18", unavailable: true }
|
||||
)
|
||||
).toEqual([{ date: "2026-06-18", unavailable: true }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { DateOverride } from "./types";
|
||||
|
||||
export function upsertDateOverride(overrides: DateOverride[], next: DateOverride): DateOverride[] {
|
||||
return [...overrides.filter((override) => override.date !== next.date), next].sort((a, b) =>
|
||||
a.date.localeCompare(b.date)
|
||||
);
|
||||
}
|
||||
@@ -71,11 +71,13 @@ function buildSchedule(
|
||||
|
||||
for (const row of schedule?.availability ?? []) {
|
||||
if (row.date) {
|
||||
const startTime = timeToHhMm(row.startTime);
|
||||
const endTime = timeToHhMm(row.endTime);
|
||||
const unavailable = startTime === "00:00" && endTime === "00:00";
|
||||
overrides.push({
|
||||
date: row.date.toISOString().slice(0, 10),
|
||||
unavailable: false,
|
||||
startTime: timeToHhMm(row.startTime),
|
||||
endTime: timeToHhMm(row.endTime),
|
||||
unavailable,
|
||||
...(!unavailable && { startTime, endTime }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -205,12 +207,16 @@ const weeklyRangeSchema = z.object({
|
||||
endTime: hhMm,
|
||||
});
|
||||
|
||||
const dateOverrideSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD date"),
|
||||
unavailable: z.boolean(),
|
||||
startTime: hhMm.optional(),
|
||||
endTime: hhMm.optional(),
|
||||
});
|
||||
const dateOverrideSchema = z
|
||||
.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD date"),
|
||||
unavailable: z.boolean(),
|
||||
startTime: hhMm.optional(),
|
||||
endTime: hhMm.optional(),
|
||||
})
|
||||
.refine((override) => override.unavailable || (override.startTime && override.endTime), {
|
||||
message: "Available overrides require start and end times",
|
||||
});
|
||||
|
||||
const updateScheduleSchema = z.object({
|
||||
timeZone: z.string().min(1).optional(),
|
||||
@@ -257,15 +263,13 @@ export async function updateSchedule(userId: number, body: unknown): Promise<Sch
|
||||
date: null as Date | null,
|
||||
userId,
|
||||
})),
|
||||
...parsed.overrides
|
||||
.filter((override) => !override.unavailable && override.startTime && override.endTime)
|
||||
.map((override) => ({
|
||||
days: [] as number[],
|
||||
startTime: hhMmToTime(override.startTime as string),
|
||||
endTime: hhMmToTime(override.endTime as string),
|
||||
date: new Date(`${override.date}T00:00:00.000Z`),
|
||||
userId,
|
||||
})),
|
||||
...parsed.overrides.map((override) => ({
|
||||
days: [] as number[],
|
||||
startTime: hhMmToTime(override.unavailable ? "00:00" : (override.startTime as string)),
|
||||
endTime: hhMmToTime(override.unavailable ? "00:00" : (override.endTime as string)),
|
||||
date: new Date(`${override.date}T00:00:00.000Z`),
|
||||
userId,
|
||||
})),
|
||||
];
|
||||
|
||||
// Replace the schedule's availability atomically; immutable from the caller's view.
|
||||
|
||||
Reference in New Issue
Block a user