Schedule against the whole guest list, and say who cannot be read

The availability bar showed the guests it had free/busy for and quietly
left out the ones it did not. In one row that is survivable. As a grid it
would be a lie: a row with nothing in it reads as a diary with nothing in
it, and "we cannot see this person's calendar" is the one thing that must
not look like "this person is free".

So everyone the event concerns now gets a row, and the ones with no
free/busy to read are drawn hatched rather than empty, with a line under
the grid saying how many and why. You get a row too, first. Scheduling
around the other people and not around yourself is how two things end up
at the same time, and the organiser was the one calendar the panel never
showed.

Your own row is never unknown. Where the directory does not list you under
the address your identity sends from -- an alias, a login that differs
from the address -- the account is still yours to read, and Stalwart
answers for it under the account's own id.

The window steps backwards and forwards a screenful at a time without
touching the event, which is the "movable forwards & backwards" the report
asks for, and offers its way back when you have wandered off.

And the bars are somewhere to put the event rather than only something to
read: the pointer shows the half hour it is over, and a click moves the
event there keeping its length. Clicking while stepped away moves the
event to where you clicked and returns the view to it, so it lands where
you were looking instead of jumping.

Free/busy is answered per principal, and only the server's own accounts
are principals. Somebody at another domain has none to read -- which is
not a gap to be closed, it is what the protocol can see -- so the grid
says so rather than drawing them blank.

Closes #172
This commit is contained in:
2026-09-01 10:05:50 -07:00
parent fb1ad3c5c8
commit e1402e472f
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 };
}
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 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
// 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.
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 days = Math.min(total, maxDays);
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-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); }
.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
+117 -28
View File
@@ -1,9 +1,10 @@
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 { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session";
import { CAP } from "@/jmap/client";
import { useContacts } from "@/store/contacts";
import { Dialog } from "@/ui/dialog";
import { ColorSwatches, Switch } from "@/ui/misc";
@@ -11,7 +12,7 @@ import { toast } from "@/ui/toast";
import { RecipientInput } from "../compose/RecipientInput";
import { DateField, DateTimeField } from "@/ui/datefield";
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 { newKey } from "@/lib/contacts";
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 [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 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
* 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(() => {
if (!attendees.length || !contacts.principalsLoaded) {
if (!people.length || !contacts.principalsLoaded) {
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
return;
}
let cancelled = false;
(async () => {
const out: Record<string, BusyPeriod[]> = {};
for (const a of attendees) {
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
if (!p) continue;
const out: Record<string, BusyPeriod[] | null> = {};
for (const person of people) {
/*
* 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 {
out[a.email] = await cal.availability(p.id, fbWindow.start, fbWindow.end);
out[person.email] = await cal.availability(principalId, fbWindow.start, fbWindow.end);
} catch {
/* ignore */
out[person.email] = null;
}
}
if (!cancelled) setFb(out);
@@ -186,7 +233,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
cancelled = true;
};
// 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) => {
if (Number.isNaN(d.getTime())) return;
@@ -368,21 +415,40 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{attendees.length > 0 && (
<>
<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. */
const pct = (from: number, to: number) => ({
left: `${((from - fbWindow.start.getTime()) / 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 (
<div className="freebusy">
<div className="hint">
{fbWindow.days === 1
? translate("Availability on {date}", { date: formatNumericDate(fbWindow.start) })
: translate("Availability, {from} to {to}", { from: formatNumericDate(fbWindow.start), to: formatNumericDate(new Date(fbWindow.end.getTime() - 1)) })}
<div className="fb-head">
<button type="button" className="icon-btn sm" aria-label={translate("Earlier")} onClick={() => setFbOffset(fbOffset - fbWindow.days)}><ChevronLeft size={16} /></button>
<span className="fb-range">{rangeLabel}</span>
<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>
{/* 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">
<span style={{ width: 140, flex: "none" }} />
<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>
</div>
</div>
{attendees.filter((a) => fb[a.email]).map((a) => (
<div key={a.email} className="fb-row">
<span className="truncate" style={{ width: 140, flex: "none" }}>{a.name ?? a.email}</span>
<div className="fb-bar">
{/* Drawn under the blocks, so a block can be read against
the hour it starts at rather than guessed at. */}
{people.map((person) => {
const periods = fb[person.email];
const noData = periods === null;
return (
<div key={person.email} className="fb-row">
<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) => (
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 be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
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))}`} />;
})}
{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())} />}
</div>
</div>
))}
);
})}
{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>
)}
{/* 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>
);
})()}