diff --git a/web/src/lib/__tests__/availabilityWindow.test.ts b/web/src/lib/__tests__/availabilityWindow.test.ts index 4871007..3989c68 100644 --- a/web/src/lib/__tests__/availabilityWindow.test.ts +++ b/web/src/lib/__tests__/availabilityWindow.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/availabilityWindow.ts b/web/src/lib/availabilityWindow.ts index 3383764..1082c0e 100644 --- a/web/src/lib/availabilityWindow.ts +++ b/web/src/lib/availabilityWindow.ts @@ -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); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 10c609d..84b69a7 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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 diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index acf0c46..8f5ee96 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -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>({}); + /** By address: the busy periods, or `null` for "there is no free/busy to read". */ + const [fb, setFb] = useState>({}); + /** 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(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(); + 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 = {}; - for (const a of attendees) { - const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase()); - if (!p) continue; + const out: Record = {}; + 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 && ( <> - {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 (
-
- {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)) })} +
+ + {rangeLabel} + + {fbOffset !== 0 && } + + {/* The time a click would set, so placing an event is aimed rather than guessed. */} + {fbHover ? (fbWindow.days === 1 ? formatClock(fbHover) : `${formatWeekday(fbHover, "short")} ${formatClock(fbHover)}`) : translate("Click to move the event")}
- {/* The axis answers "what am I looking at" -- without it the bar - could as easily have been working hours as a whole day. */}
@@ -394,28 +460,51 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {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 && } + {people.map((person) => { + const periods = fb[person.email]; + const noData = periods === null; + return ( +
+ {person.name} +
setFbHover(timeAt(e))} + onMouseLeave={() => setFbHover(null)} + onClick={(e) => { onStartChange(timeAt(e)); setFbOffset(0); }} + > + {fbWindow.ticks.map((tk) => ( + tk.at === 0 ? null : + ))} + {(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 ; + })} + {fbHover && } + {!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." })}
)} + {/* Said once, under the grid, rather than repeated on every + row that has nothing to show. */} + {unknown.length > 0 && ( +
+ {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.", + })} +
+ )}
); })()}