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:
@@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && (() => {
|
||||||
|
/** 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 (
|
||||||
<div className="freebusy">
|
<div className="freebusy">
|
||||||
<div className="hint">{translate("Availability on {date}", { date: formatNumericDate(start) })}</div>
|
<div className="hint">
|
||||||
|
{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)) })}
|
||||||
|
</div>
|
||||||
|
{/* 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>
|
||||||
{attendees.filter((a) => fb[a.email]).map((a) => (
|
{attendees.filter((a) => fb[a.email]).map((a) => (
|
||||||
<div key={a.email} className="fb-row">
|
<div key={a.email} className="fb-row">
|
||||||
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
|
<span className="truncate" style={{ width: 140, flex: "none" }}>{a.name ?? a.email}</span>
|
||||||
<div className="fb-bar">
|
<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) => {
|
{fb[a.email]!.map((b, i) => {
|
||||||
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
|
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
|
||||||
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
|
const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
|
||||||
if (be <= bs) return null;
|
if (be <= bs) return null;
|
||||||
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))}`} />;
|
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={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
|
{!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user