Merge pull request #182 from Coffey-Labs/feat/availability-labels-multiday

Say what the availability bar is showing, and show all of it
This commit is contained in:
Coffey Labs
2026-09-01 09:58:25 -07:00
committed by GitHub
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>