From c4649e0084791be29d6072f089b7fc8560d42d11 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 09:43:41 -0700 Subject: [PATCH] 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. --- server/src/mock/index.ts | 15 +++- .../lib/__tests__/availabilityWindow.test.ts | 90 +++++++++++++++++++ web/src/lib/availabilityWindow.ts | 89 ++++++++++++++++++ web/src/styles/app.css | 10 +++ web/src/views/calendar/EventEditor.tsx | 88 ++++++++++++------ 5 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 web/src/lib/__tests__/availabilityWindow.test.ts create mode 100644 web/src/lib/availabilityWindow.ts diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 131158b..9fe22dd 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -1019,7 +1019,20 @@ const handlers: Record = { "ParticipantIdentity/get": genericGet(participantIdentities), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), "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/set": (a) => { /* Stalwart refuses any update to a book shared read-only, `isSubscribed` diff --git a/web/src/lib/__tests__/availabilityWindow.test.ts b/web/src/lib/__tests__/availabilityWindow.test.ts new file mode 100644 index 0000000..4871007 --- /dev/null +++ b/web/src/lib/__tests__/availabilityWindow.test.ts @@ -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()); + } + }); +}); diff --git a/web/src/lib/availabilityWindow.ts b/web/src/lib/availabilityWindow.ts new file mode 100644 index 0000000..3383764 --- /dev/null +++ b/web/src/lib/availabilityWindow.ts @@ -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 }; +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 14a3406..10c609d 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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-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; } +/* 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 diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index cf4b65a..acf0c46 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -14,8 +14,9 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { newKey } from "@/lib/contacts"; +import { availabilityWindow } from "@/lib/availabilityWindow"; import { askEditScope, droppedMessage, runScoped } from "./scope"; -import { t as translate } from "@/lib/i18n"; +import { plural, t as translate } from "@/lib/i18n"; export interface EditorInit { event?: CalendarEvent; @@ -154,15 +155,19 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : ""); 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 useEffect(() => { if (!attendees.length || !contacts.principalsLoaded) { if (!contacts.principalsLoaded) void contacts.loadPrincipals(); return; } - const dayStart = new Date(start); - dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(dayStart.getTime() + DAY_MS); let cancelled = false; (async () => { const out: Record = {}; @@ -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()); if (!p) continue; try { - out[a.email] = await cal.availability(p.id, dayStart, dayEnd); + out[a.email] = await cal.availability(p.id, fbWindow.start, fbWindow.end); } catch { /* ignore */ } @@ -181,7 +186,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(","), start.getTime(), contacts.principalsLoaded]); + }, [attendees.map((a) => a.email).join(","), fbWindow.start.getTime(), fbWindow.end.getTime(), contacts.principalsLoaded]); const onStartChange = (d: Date) => { 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 dayWindow = useMemo(() => { - const ds = new Date(start); - ds.setHours(0, 0, 0, 0); - return { ds, de: new Date(ds.getTime() + DAY_MS) }; - }, [start]); return ( }> @@ -368,25 +368,57 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {attendees.length > 0 && ( <> - {Object.keys(fb).length > 0 && ( -
-
{translate("Availability on {date}", { date: formatNumericDate(start) })}
- {attendees.filter((a) => fb[a.email]).map((a) => ( -
- {a.name ?? a.email} -
- {fb[a.email]!.map((b, i) => { - const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime()); - const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime()); - if (be <= bs) return null; - return ; - })} - {!allDay && } + {Object.keys(fb).length > 0 && (() => { + /** 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}%`, + }); + return ( +
+
+ {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)) })} +
+ {/* The axis answers "what am I looking at" -- without it the bar + could as easily have been working hours as a whole day. */} +
+ +
+ {fbWindow.ticks.filter((tk) => tk.major).map((tk) => ( + + {fbWindow.scale === "hours" ? formatClock(tk.time) : formatWeekday(tk.time, "short")} + + ))} + {fbWindow.scale === "hours" ? formatClock(fbWindow.end) : formatNumericDate(new Date(fbWindow.end.getTime() - 1))}
- ))} -
- )} + {attendees.filter((a) => fb[a.email]).map((a) => ( +
+ {a.name ?? a.email} +
+ {/* 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 : + ))} + {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 ; + })} + {!allDay && } +
+
+ ))} + {fbWindow.daysHidden > 0 && ( +
{plural(fbWindow.daysHidden, { one: "The event runs {n} day longer than this shows.", other: "The event runs {n} days longer than this shows." })}
+ )} +
+ ); + })()} )}