Merge pull request #183 from Coffey-Labs/feat/scheduling-panel

Schedule against the whole guest list, and say who cannot be read
This commit is contained in:
Coffey Labs
2026-09-01 10:46:57 -07:00
committed by GitHub
4 changed files with 174 additions and 41 deletions
@@ -88,3 +88,26 @@ describe("the span an availability bar covers", () => {
} }
}); });
}); });
describe("looking around the event without changing it", () => {
it("slides the whole window forward, keeping its width", () => {
const here = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
const later = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"), { offsetDays: 3 });
expect(later.days).toBe(here.days);
expect(later.start.getDate()).toBe(5);
expect(later.end.getDate()).toBe(8);
});
it("slides backwards, across the end of a month", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: -3 });
expect(w.start.getMonth()).toBe(7); // August
expect(w.start.getDate()).toBe(30);
expect(w.days).toBe(1);
});
it("keeps the marks in step with where the window moved to", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: 1 });
expect(w.ticks[0]!.time.getDate()).toBe(3);
expect(w.ticks[0]!.at).toBe(0);
});
});
+9 -3
View File
@@ -65,13 +65,19 @@ function spacing(days: number): { every: number; label: number } {
return { every: 24, label: 24 }; return { every: 24, label: 24 };
} }
export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number } = {}): AvailabilityWindow { export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number; offsetDays?: number } = {}): AvailabilityWindow {
const maxDays = opts.maxDays ?? 7; const maxDays = opts.maxDays ?? 7;
const from = startOfDay(start); /*
* Days moved from where the event sits, for looking around it without
* changing it. The whole window slides rather than growing: keeping the span
* fixed means what you compare when you step forward is the same width as
* what you were looking at, which is the point of stepping.
*/
const from = addDays(startOfDay(start), opts.offsetDays ?? 0);
// The last day is the one the event ends *on*. An event ending exactly at // The last day is the one the event ends *on*. An event ending exactly at
// midnight ends on the day before, not at the start of a day it never // midnight ends on the day before, not at the start of a day it never
// touches -- that is the whole of what all-day events do. // touches -- that is the whole of what all-day events do.
const lastDay = startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))); const lastDay = addDays(startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))), opts.offsetDays ?? 0);
const total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1); const total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1);
const days = Math.min(total, maxDays); const days = Math.min(total, maxDays);
const to = addDays(from, days); const to = addDays(from, days);
+15
View File
@@ -918,6 +918,21 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.freebusy .fb-axis-label.end { left: auto; right: 0; transform: none; } .freebusy .fb-axis-label.end { left: auto; right: 0; transform: none; }
.freebusy .fb-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); } .freebusy .fb-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); }
.freebusy .fb-tick.major { background: var(--fg-faint); } .freebusy .fb-tick.major { background: var(--fg-faint); }
.freebusy .fb-head { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; font-size: .85em; }
.freebusy .fb-range { font-weight: 600; }
.freebusy .fb-self { font-weight: 600; }
/* A bar is somewhere to put the event, not only something to read. */
.freebusy .fb-bar { cursor: crosshair; }
.freebusy .fb-guide { position: absolute; top: -2px; bottom: -2px; width: 1px; background: var(--fg); opacity: .55; pointer-events: none; }
/*
* "We cannot say", which is not the same as "free" and must not look like it.
* Somebody outside this server has no free/busy to read, and an empty bar in a
* grid reads as an empty diary -- the one reading that is certainly wrong.
*/
.freebusy .fb-bar.no-data {
background-image: repeating-linear-gradient(135deg, var(--bg-sunken), var(--bg-sunken) 4px, var(--border) 4px, var(--border) 8px);
cursor: crosshair;
}
/* ========================================================================== /* ==========================================================================
Files Files
+117 -28
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Trash2, Users } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Trash2, Users } from "lucide-react";
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types"; import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar"; import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { CAP } from "@/jmap/client";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { Dialog } from "@/ui/dialog"; import { Dialog } from "@/ui/dialog";
import { ColorSwatches, Switch } from "@/ui/misc"; import { ColorSwatches, Switch } from "@/ui/misc";
@@ -11,7 +12,7 @@ import { toast } from "@/ui/toast";
import { RecipientInput } from "../compose/RecipientInput"; import { RecipientInput } from "../compose/RecipientInput";
import { DateField, DateTimeField } from "@/ui/datefield"; import { DateField, DateTimeField } from "@/ui/datefield";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates"; import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; import { formatClock, formatNumericDate, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts"; import { newKey } from "@/lib/contacts";
import { availabilityWindow } from "@/lib/availabilityWindow"; import { availabilityWindow } from "@/lib/availabilityWindow";
@@ -148,7 +149,12 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
*/ */
const [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length); const [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({}); /** By address: the busy periods, or `null` for "there is no free/busy to read". */
const [fb, setFb] = useState<Record<string, BusyPeriod[] | null>>({});
/** Days the panel has been stepped away from the event, for looking around it. */
const [fbOffset, setFbOffset] = useState(0);
/** Where the pointer is over the grid, so the time a click would set is visible before it does. */
const [fbHover, setFbHover] = useState<Date | null>(null);
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length))); const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));
const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0]; const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0];
@@ -160,24 +166,65 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
* it ends. It used to be the start day and nothing else, which meant an event * it ends. It used to be the start day and nothing else, which meant an event
* spanning two days showed availability for one of them without saying so. * spanning two days showed availability for one of them without saying so.
*/ */
const fbWindow = useMemo(() => availabilityWindow(start, end), [start, end]); const fbWindow = useMemo(() => availabilityWindow(start, end, { offsetDays: fbOffset }), [start, end, fbOffset]);
/** Stalwart answers for your own account under its own id, which is the fallback when the directory does not list you. */
const selfPrincipalId = useSession((st) => st.accountFor(CAP.principals));
// Free/busy lookup for attendees that are directory principals /*
* Everyone the event concerns, you first. Scheduling around the other people
* and not around yourself is how two things end up at the same time, and the
* organiser's own calendar was the one row the panel never showed.
*/
const people = useMemo(() => {
const seen = new Set<string>();
const out: { email: string; name: string; self: boolean }[] = [];
if (myPlainEmail) {
seen.add(myPlainEmail.toLowerCase());
out.push({ email: myPlainEmail, name: translate("You"), self: true });
}
for (const a of attendees) {
if (seen.has(a.email.toLowerCase())) continue;
seen.add(a.email.toLowerCase());
out.push({ email: a.email, name: a.name ?? a.email, self: false });
}
return out;
}, [myPlainEmail, attendees]);
/*
* Free/busy, for everyone we can read it for.
*
* Only people the directory knows have any: free/busy is answered per
* principal, and somebody outside the server -- a customer, anyone at another
* domain -- is not one. Those are recorded as `null` rather than left out,
* because a row that is missing from a grid reads as a row with nothing in
* it, which is to say "free", which is the one thing we do not know.
*/
useEffect(() => { useEffect(() => {
if (!attendees.length || !contacts.principalsLoaded) { if (!people.length || !contacts.principalsLoaded) {
if (!contacts.principalsLoaded) void contacts.loadPrincipals(); if (!contacts.principalsLoaded) void contacts.loadPrincipals();
return; return;
} }
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const out: Record<string, BusyPeriod[]> = {}; const out: Record<string, BusyPeriod[] | null> = {};
for (const a of attendees) { for (const person of people) {
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase()); /*
if (!p) continue; * Your own row is never unknown. If the directory does not list you
* under the address the identity sends from -- an alias, a name that
* differs from the login -- the account is still yours to read, and
* Stalwart answers for it under the account's own id.
*/
const principalId =
contacts.principals.find((x) => x.email?.toLowerCase() === person.email.toLowerCase())?.id ??
(person.self ? selfPrincipalId : undefined);
if (!principalId) {
out[person.email] = null;
continue;
}
try { try {
out[a.email] = await cal.availability(p.id, fbWindow.start, fbWindow.end); out[person.email] = await cal.availability(principalId, fbWindow.start, fbWindow.end);
} catch { } catch {
/* ignore */ out[person.email] = null;
} }
} }
if (!cancelled) setFb(out); if (!cancelled) setFb(out);
@@ -186,7 +233,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
cancelled = true; cancelled = true;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [attendees.map((a) => a.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded]); }, [people.map((p) => p.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded, selfPrincipalId]);
const onStartChange = (d: Date) => { const onStartChange = (d: Date) => {
if (Number.isNaN(d.getTime())) return; if (Number.isNaN(d.getTime())) return;
@@ -368,21 +415,40 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{attendees.length > 0 && ( {attendees.length > 0 && (
<> <>
<Switch checked={sendInvites} onChange={setSendInvites} label={translate("Send invitation emails to guests")} /> <Switch checked={sendInvites} onChange={setSendInvites} label={translate("Send invitation emails to guests")} />
{Object.keys(fb).length > 0 && (() => { {people.length > 1 && (() => {
/** Everything on a bar is placed as a fraction of the span it covers. */ /** Everything on a bar is placed as a fraction of the span it covers. */
const pct = (from: number, to: number) => ({ const pct = (from: number, to: number) => ({
left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`, left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`,
width: `${((to - from) / fbWindow.span) * 100}%`, width: `${((to - from) / fbWindow.span) * 100}%`,
}); });
/** Where along the window a pointer is, as a moment. */
const timeAt = (e: { clientX: number; currentTarget: Element }) => {
const box = e.currentTarget.getBoundingClientRect();
const frac = Math.min(Math.max((e.clientX - box.left) / box.width, 0), 1);
// Half-hourly: a bar is a few hundred pixels wide, and a
// minute of it is not something anybody can aim at.
const SNAP = 30 * 60_000;
return new Date(Math.round((fbWindow.start.getTime() + frac * fbWindow.span) / SNAP) * SNAP);
};
const known = people.filter((p) => fb[p.email]);
// You are not a guest, so you are not counted as one -- and if
// your own row cannot be read, that is not what this sentence
// is about.
const unknown = people.filter((p) => !p.self && fb[p.email] === null);
const rangeLabel = fbWindow.days === 1
? formatWeekdayDate(fbWindow.start)
: `${formatNumericDate(fbWindow.start)} ${formatNumericDate(new Date(fbWindow.end.getTime() - 1))}`;
return ( return (
<div className="freebusy"> <div className="freebusy">
<div className="hint"> <div className="fb-head">
{fbWindow.days === 1 <button type="button" className="icon-btn sm" aria-label={translate("Earlier")} onClick={() => setFbOffset(fbOffset - fbWindow.days)}><ChevronLeft size={16} /></button>
? translate("Availability on {date}", { date: formatNumericDate(fbWindow.start) }) <span className="fb-range">{rangeLabel}</span>
: translate("Availability, {from} to {to}", { from: formatNumericDate(fbWindow.start), to: formatNumericDate(new Date(fbWindow.end.getTime() - 1)) })} <button type="button" className="icon-btn sm" aria-label={translate("Later")} onClick={() => setFbOffset(fbOffset + fbWindow.days)}><ChevronRight size={16} /></button>
{fbOffset !== 0 && <button type="button" className="btn btn-ghost btn-sm" onClick={() => setFbOffset(0)}>{translate("Back to the event")}</button>}
<span className="spacer" />
{/* The time a click would set, so placing an event is aimed rather than guessed. */}
<span className="hint">{fbHover ? (fbWindow.days === 1 ? formatClock(fbHover) : `${formatWeekday(fbHover, "short")} ${formatClock(fbHover)}`) : translate("Click to move the event")}</span>
</div> </div>
{/* The axis answers "what am I looking at" -- without it the bar
could as easily have been working hours as a whole day. */}
<div className="fb-row fb-axis-row"> <div className="fb-row fb-axis-row">
<span style={{ width: 140, flex: "none" }} /> <span style={{ width: 140, flex: "none" }} />
<div className="fb-axis"> <div className="fb-axis">
@@ -394,28 +460,51 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
<span className="fb-axis-label end">{fbWindow.scale === "hours" ? formatClock(fbWindow.end) : formatNumericDate(new Date(fbWindow.end.getTime() - 1))}</span> <span className="fb-axis-label end">{fbWindow.scale === "hours" ? formatClock(fbWindow.end) : formatNumericDate(new Date(fbWindow.end.getTime() - 1))}</span>
</div> </div>
</div> </div>
{attendees.filter((a) => fb[a.email]).map((a) => ( {people.map((person) => {
<div key={a.email} className="fb-row"> const periods = fb[person.email];
<span className="truncate" style={{ width: 140, flex: "none" }}>{a.name ?? a.email}</span> const noData = periods === null;
<div className="fb-bar"> return (
{/* Drawn under the blocks, so a block can be read against <div key={person.email} className="fb-row">
the hour it starts at rather than guessed at. */} <span className={`truncate ${person.self ? "fb-self" : ""}`} style={{ width: 140, flex: "none" }} title={person.email}>{person.name}</span>
<div
className={`fb-bar ${noData ? "no-data" : ""}`}
role="button"
tabIndex={-1}
title={noData ? translate("No availability information for {who}", { who: person.email }) : undefined}
onMouseMove={(e) => setFbHover(timeAt(e))}
onMouseLeave={() => setFbHover(null)}
onClick={(e) => { onStartChange(timeAt(e)); setFbOffset(0); }}
>
{fbWindow.ticks.map((tk) => ( {fbWindow.ticks.map((tk) => (
tk.at === 0 ? null : <span key={tk.time.getTime()} className={`fb-tick ${tk.major ? "major" : ""}`} style={{ left: `${tk.at * 100}%` }} /> tk.at === 0 ? null : <span key={tk.time.getTime()} className={`fb-tick ${tk.major ? "major" : ""}`} style={{ left: `${tk.at * 100}%` }} />
))} ))}
{fb[a.email]!.map((b, i) => { {(periods ?? []).map((b, i) => {
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime()); const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime()); const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
if (be <= bs) return null; if (be <= bs) return null;
return <span key={i} className="fb-busy" style={pct(bs, be)} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} ${formatClock(new Date(b.utcEnd))}`} />; return <span key={i} className="fb-busy" style={pct(bs, be)} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} ${formatClock(new Date(b.utcEnd))}`} />;
})} })}
{fbHover && <span className="fb-guide" style={{ left: `${((fbHover.getTime() - fbWindow.start.getTime()) / fbWindow.span) * 100}%` }} />}
{!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />} {!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />}
</div> </div>
</div> </div>
))} );
})}
{fbWindow.daysHidden > 0 && ( {fbWindow.daysHidden > 0 && (
<div className="hint">{plural(fbWindow.daysHidden, { one: "The event runs {n} day longer than this shows.", other: "The event runs {n} days longer than this shows." })}</div> <div className="hint">{plural(fbWindow.daysHidden, { one: "The event runs {n} day longer than this shows.", other: "The event runs {n} days longer than this shows." })}</div>
)} )}
{/* Said once, under the grid, rather than repeated on every
row that has nothing to show. */}
{unknown.length > 0 && (
<div className="hint">
{known.length === 0
? translate("Nobody here has free/busy on this server, so none of these rows can say whether anyone is free.")
: plural(unknown.length, {
one: "{n} guest is not on this server, so there is no free/busy to read for them.",
other: "{n} guests are not on this server, so there is no free/busy to read for them.",
})}
</div>
)}
</div> </div>
); );
})()} })()}