Say what the availability bar is showing, and show all of it

The bar was a day wide whatever it was drawing. It began at midnight on
the event's start day and stopped 24 hours later, so an event running over
two days showed availability for the first of them and gave no sign there
was more. And it carried no marks at all, which left "is this the whole
day or only working hours" unanswerable without dragging the event about
to see where its own outline moved.

It now covers whole days from the day the event starts to the day it ends,
and the free/busy lookup asks for the same range it draws. Above the bars
is an axis: hours every three across a single day, every six across two,
day names beyond that. The marks are drawn down the bars too, so a busy
block can be read against the hour it starts at rather than guessed at.

Whole days, always. A bar starting at the event's own start time would
move under the reader every time they adjusted it, and "busy from about a
third of the way along" is not a time anybody can read.

A week is as far as it goes. Something running longer is not an event
anybody is hunting a free slot in, and a month at eight pixels a day would
say nothing; it says how many days it left out instead.

The span is measured between two real midnights rather than counted in
24-hour days, because twice a year they differ, and every position on the
bar is a fraction of it.

The mock answered with a single busy block on the first day whatever range
it was asked for -- all a day-wide bar could show -- which would have left
a multi-day bar looking like everyone was free from the second day on. It
now answers across the range.

This is parts 1 and 2 of #172. The separate multi-day scheduling view it
also asks for is still open, and needs an answer first on what to show for
participants who have no free/busy to read.
This commit is contained in:
2026-09-01 09:43:41 -07:00
parent df9349935d
commit c4649e0084
5 changed files with 263 additions and 29 deletions
+14 -1
View File
@@ -1019,7 +1019,20 @@ const handlers: Record<string, Handler> = {
"ParticipantIdentity/get": genericGet(participantIdentities), "ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals), "Principal/get": genericGet(principals),
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }), // One busy block a day across whatever range was asked for. It used to answer
// with a single block on the first day whatever the range, which was all an
// availability bar a day wide could show -- and left a bar covering several
// days looking as though everyone were free for all but the first of them.
"Principal/getAvailability": (a) => {
const from = new Date(String(a.utcStart));
const to = new Date(String(a.utcEnd));
const list: Obj[] = [];
for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) {
const date = day.toISOString().slice(0, 11);
list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null });
}
return { accountId: ACCOUNT, list };
},
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never, "AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
"AddressBook/set": (a) => { "AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed` /* Stalwart refuses any update to a book shared read-only, `isSubscribed`
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { availabilityWindow } from "@/lib/availabilityWindow";
const at = (s: string) => new Date(s);
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
describe("the span an availability bar covers", () => {
it("covers the whole day for an event inside one", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:30:00"));
expect(w.start.getHours()).toBe(0);
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
expect(w.end.getHours()).toBe(0);
});
it("stretches to cover an event running over several days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
expect(w.days).toBe(3);
expect(w.start.getDate()).toBe(2);
expect(w.end.getDate()).toBe(5);
});
it("ends an event on the day it ends on, not the midnight it stops at", () => {
// An all-day event on the 2nd runs to midnight starting the 3rd; it does
// not touch the 3rd and the bar should not show it.
const w = availabilityWindow(at("2026-09-02T00:00:00"), at("2026-09-03T00:00:00"));
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
});
it("never collapses to nothing, even when start and end are the same moment", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T09:00:00"));
expect(w.days).toBe(1);
expect(w.span).toBeGreaterThan(0);
});
it("marks a single day every three hours, labelling every six", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
expect(w.ticks.filter((t) => t.major).map((t) => t.time.getHours())).toEqual([0, 6, 12, 18]);
});
it("thins the marks out to every six hours across two days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-03T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@6", "2@12", "2@18", "3@0", "3@6", "3@12", "3@18"]);
});
it("marks day boundaries once there are more than two", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-05T10:00:00"));
expect(w.scale).toBe("days");
expect(hours(w)).toEqual(["2@0", "3@0", "4@0", "5@0"]);
expect(w.ticks.every((t) => t.major)).toBe(true);
});
it("puts every mark at its true fraction of the span", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.ticks[0]!.at).toBe(0);
expect(w.ticks[4]!.at).toBeCloseTo(0.5, 5); // noon
expect(w.ticks.every((t) => t.at >= 0 && t.at < 1)).toBe(true);
});
it("stops at a week and says how much it left out", () => {
const w = availabilityWindow(at("2026-09-01T09:00:00"), at("2026-09-30T17:00:00"));
expect(w.days).toBe(7);
expect(w.daysHidden).toBe(23);
});
it("hides nothing when the event fits", () => {
expect(availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")).daysHidden).toBe(0);
});
it("lands on real midnights, and measures the span between them", () => {
/*
* The span is what every position is a fraction of, so it has to be the
* distance between the two boundaries rather than a count of 24-hour days:
* on the day a clock changes those differ by an hour, which would end the
* bar early and put every block after the change in the wrong place. This
* asserts the relationship; whether the run happens to sit in a zone with
* DST is not something a test should depend on.
*/
for (const day of ["2026-03-29", "2026-10-25", "2026-09-02"]) {
const w = availabilityWindow(at(`${day}T09:00:00`), at(`${day}T10:00:00`));
expect(w.start.getHours(), day).toBe(0);
expect(w.end.getHours(), day).toBe(0);
expect(w.span, day).toBe(w.end.getTime() - w.start.getTime());
}
});
});
+89
View File
@@ -0,0 +1,89 @@
import { DAY_MS } from "@/lib/dates";
/**
* The span an availability bar covers, and the marks along it.
*
* The bar used to be a day wide whatever it was showing: it began at midnight
* on the event's start day and stopped 24 hours later, so an event running over
* two days showed availability for the first of them and gave no sign that
* there was more. It also carried no marks at all, which left "is this the
* whole day or only working hours" unanswerable without dragging the event
* around to see where its own outline moved. That is issue #172, parts 1 and 2.
*
* Whole days, always: a bar that started at the event's own start time would
* move under the reader every time they adjusted it, and "busy from about a
* third of the way along" is not a time anybody can read.
*/
export interface AvailabilityWindow {
/** Midnight at the start of the first day shown. */
start: Date;
/** Midnight at the end of the last day shown. */
end: Date;
/** Milliseconds between the two, which a DST change makes not a multiple of a day. */
span: number;
/** Days actually shown. */
days: number;
/**
* Marks along the bar. `at` is a fraction of the span, so a caller positions
* one with a percentage and never does date arithmetic of its own. Only
* `major` marks are worth a label; the rest are there to read a block against.
*/
ticks: { at: number; time: Date; major: boolean }[];
/** Whether marks fall on hours or on days, which decides how to label them. */
scale: "hours" | "days";
/**
* Days the event covers that the bar does not. An event long enough to need
* this is not one anybody is checking for a free slot, and drawing a month at
* eight pixels a day would say nothing; saying how much was left out is more
* use than showing it.
*/
daysHidden: number;
}
/** Midnight starting the day `d` falls in, in local time. */
function startOfDay(d: Date): Date {
const out = new Date(d);
out.setHours(0, 0, 0, 0);
return out;
}
/**
* `n` days on from `d`, by the calendar rather than by arithmetic: a day is 23
* or 25 hours twice a year, and adding 24 of them lands an hour off.
*/
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
out.setHours(0, 0, 0, 0);
return out;
}
/** How far apart the marks go, in hours, and which of them get a label. */
function spacing(days: number): { every: number; label: number } {
if (days <= 1) return { every: 3, label: 6 };
if (days <= 2) return { every: 6, label: 12 };
return { every: 24, label: 24 };
}
export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number } = {}): AvailabilityWindow {
const maxDays = opts.maxDays ?? 7;
const from = startOfDay(start);
// 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 total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1);
const days = Math.min(total, maxDays);
const to = addDays(from, days);
const span = to.getTime() - from.getTime();
const { every, label } = spacing(days);
const ticks: AvailabilityWindow["ticks"] = [];
for (let hour = 0; ; hour += every) {
const time = new Date(from.getTime() + hour * 3600_000);
if (time.getTime() >= to.getTime()) break;
ticks.push({ at: (time.getTime() - from.getTime()) / span, time, major: hour % label === 0 });
}
return { start: from, end: to, span, days, ticks, scale: days <= 2 ? "hours" : "days", daysHidden: total - days };
}
+10
View File
@@ -908,6 +908,16 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.freebusy .fb-bar { flex: 1; height: 14px; background: var(--bg-sunken); border-radius: 4px; position: relative; overflow: hidden; } .freebusy .fb-bar { flex: 1; height: 14px; background: var(--bg-sunken); border-radius: 4px; position: relative; overflow: hidden; }
.freebusy .fb-busy { position: absolute; top: 0; bottom: 0; background: var(--danger); opacity: .65; } .freebusy .fb-busy { position: absolute; top: 0; bottom: 0; background: var(--danger); opacity: .65; }
.freebusy .fb-window { position: absolute; top: 0; bottom: 0; border: 2px solid var(--accent); border-radius: 3px; } .freebusy .fb-window { position: absolute; top: 0; bottom: 0; border: 2px solid var(--accent); border-radius: 3px; }
/* The hour and day marks, and the labels above them. Without these the bar says
only "somewhere in here", and which hours it covered was a guess (issue #172). */
.freebusy .fb-axis-row { height: 14px; }
.freebusy .fb-axis { flex: 1; position: relative; height: 100%; }
.freebusy .fb-axis-label { position: absolute; top: 0; font-size: .78em; color: var(--fg-muted); transform: translateX(-50%); white-space: nowrap; }
/* Midnight and the far edge sit on the ends, where half of each would be cut off. */
.freebusy .fb-axis-label[style*="left: 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.major { background: var(--fg-faint); }
/* ========================================================================== /* ==========================================================================
Files Files
+60 -28
View File
@@ -14,8 +14,9 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; import { formatClock, formatNumericDate, formatWeekday } 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 { askEditScope, droppedMessage, runScoped } from "./scope"; import { askEditScope, droppedMessage, runScoped } from "./scope";
import { t as translate } from "@/lib/i18n"; import { plural, t as translate } from "@/lib/i18n";
export interface EditorInit { export interface EditorInit {
event?: CalendarEvent; event?: CalendarEvent;
@@ -154,15 +155,19 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : ""); const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : "");
const myPlainEmail = myAddress.replace(/^mailto:/i, ""); const myPlainEmail = myAddress.replace(/^mailto:/i, "");
/*
* What the bars cover: whole days, from the day the event starts to the day
* 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]);
// Free/busy lookup for attendees that are directory principals // Free/busy lookup for attendees that are directory principals
useEffect(() => { useEffect(() => {
if (!attendees.length || !contacts.principalsLoaded) { if (!attendees.length || !contacts.principalsLoaded) {
if (!contacts.principalsLoaded) void contacts.loadPrincipals(); if (!contacts.principalsLoaded) void contacts.loadPrincipals();
return; return;
} }
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const out: Record<string, BusyPeriod[]> = {}; const out: Record<string, BusyPeriod[]> = {};
@@ -170,7 +175,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase()); const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
if (!p) continue; if (!p) continue;
try { try {
out[a.email] = await cal.availability(p.id, dayStart, dayEnd); out[a.email] = await cal.availability(p.id, fbWindow.start, fbWindow.end);
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -181,7 +186,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(","), start.getTime(), contacts.principalsLoaded]); }, [attendees.map((a) => a.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded]);
const onStartChange = (d: Date) => { const onStartChange = (d: Date) => {
if (Number.isNaN(d.getTime())) return; if (Number.isNaN(d.getTime())) return;
@@ -271,11 +276,6 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
}; };
const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const }; const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const };
const dayWindow = useMemo(() => {
const ds = new Date(start);
ds.setHours(0, 0, 0, 0);
return { ds, de: new Date(ds.getTime() + DAY_MS) };
}, [start]);
return ( return (
<Dialog open onClose={onClose} title={editing ? translate("Edit event") : translate("New event")} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? translate("Saving…") : editing ? translate("Save") : attendees.length && sendInvites ? translate("Send invites") : translate("Create")}</button></>}> <Dialog open onClose={onClose} title={editing ? translate("Edit event") : translate("New event")} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? translate("Saving…") : editing ? translate("Save") : attendees.length && sendInvites ? translate("Send invites") : translate("Create")}</button></>}>
@@ -368,25 +368,57 @@ 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 && ( {Object.keys(fb).length > 0 && (() => {
<div className="freebusy"> /** Everything on a bar is placed as a fraction of the span it covers. */
<div className="hint">{translate("Availability on {date}", { date: formatNumericDate(start) })}</div> const pct = (from: number, to: number) => ({
{attendees.filter((a) => fb[a.email]).map((a) => ( left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`,
<div key={a.email} className="fb-row"> width: `${((to - from) / fbWindow.span) * 100}%`,
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span> });
<div className="fb-bar"> return (
{fb[a.email]!.map((b, i) => { <div className="freebusy">
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime()); <div className="hint">
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime()); {fbWindow.days === 1
if (be <= bs) return null; ? translate("Availability on {date}", { date: formatNumericDate(fbWindow.start) })
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} ${formatClock(new Date(b.utcEnd))}`} />; : translate("Availability, {from} to {to}", { from: formatNumericDate(fbWindow.start), to: formatNumericDate(new Date(fbWindow.end.getTime() - 1)) })}
})} </div>
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />} {/* 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">
{fbWindow.ticks.filter((tk) => tk.major).map((tk) => (
<span key={tk.time.getTime()} className="fb-axis-label" style={{ left: `${tk.at * 100}%` }}>
{fbWindow.scale === "hours" ? formatClock(tk.time) : formatWeekday(tk.time, "short")}
</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) => (
</div> <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. */}
{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) => {
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))}`} />;
})}
{!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>
)}
</div>
);
})()}
</> </>
)} )}
</div> </div>