Fix scheduler connections and date overrides
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled

This commit is contained in:
2026-06-14 15:48:47 -06:00
parent 10bdafafee
commit 2bd706d439
8 changed files with 165 additions and 24 deletions
+5
View File
@@ -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"]);
});
});
+8 -3
View File
@@ -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)
);
}
+22 -18
View File
@@ -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.