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." })}
+ )} +
+ ); + })()} )}