Schedule against the whole guest list, and say who cannot be read
The availability bar showed the guests it had free/busy for and quietly left out the ones it did not. In one row that is survivable. As a grid it would be a lie: a row with nothing in it reads as a diary with nothing in it, and "we cannot see this person's calendar" is the one thing that must not look like "this person is free". So everyone the event concerns now gets a row, and the ones with no free/busy to read are drawn hatched rather than empty, with a line under the grid saying how many and why. You get a row too, first. Scheduling around the other people and not around yourself is how two things end up at the same time, and the organiser was the one calendar the panel never showed. Your own row is never unknown. Where the directory does not list you under the address your identity sends from -- an alias, a login that differs from the address -- the account is still yours to read, and Stalwart answers for it under the account's own id. The window steps backwards and forwards a screenful at a time without touching the event, which is the "movable forwards & backwards" the report asks for, and offers its way back when you have wandered off. And the bars are somewhere to put the event rather than only something to read: the pointer shows the half hour it is over, and a click moves the event there keeping its length. Clicking while stepped away moves the event to where you clicked and returns the view to it, so it lands where you were looking instead of jumping. Free/busy is answered per principal, and only the server's own accounts are principals. Somebody at another domain has none to read -- which is not a gap to be closed, it is what the protocol can see -- so the grid says so rather than drawing them blank. Closes #172
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,13 +65,19 @@ function spacing(days: number): { every: number; label: number } {
|
|||||||
return { every: 24, label: 24 };
|
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 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
|
// 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
|
// 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.
|
// 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 total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1);
|
||||||
const days = Math.min(total, maxDays);
|
const days = Math.min(total, maxDays);
|
||||||
const to = addDays(from, days);
|
const to = addDays(from, days);
|
||||||
|
|||||||
@@ -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-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 { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); }
|
||||||
.freebusy .fb-tick.major { background: var(--fg-faint); }
|
.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
|
Files
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 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 { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
|
import { CAP } from "@/jmap/client";
|
||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import { ColorSwatches, Switch } from "@/ui/misc";
|
import { ColorSwatches, Switch } from "@/ui/misc";
|
||||||
@@ -11,7 +12,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { RecipientInput } from "../compose/RecipientInput";
|
import { RecipientInput } from "../compose/RecipientInput";
|
||||||
import { DateField, DateTimeField } from "@/ui/datefield";
|
import { DateField, DateTimeField } from "@/ui/datefield";
|
||||||
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
|
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 { 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 { 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 [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({});
|
/** By address: the busy periods, or `null` for "there is no free/busy to read". */
|
||||||
|
const [fb, setFb] = useState<Record<string, BusyPeriod[] | null>>({});
|
||||||
|
/** 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<Date | null>(null);
|
||||||
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));
|
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];
|
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
|
* 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.
|
* 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<string>();
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!attendees.length || !contacts.principalsLoaded) {
|
if (!people.length || !contacts.principalsLoaded) {
|
||||||
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
|
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const out: Record<string, BusyPeriod[]> = {};
|
const out: Record<string, BusyPeriod[] | null> = {};
|
||||||
for (const a of attendees) {
|
for (const person of people) {
|
||||||
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
|
/*
|
||||||
if (!p) continue;
|
* 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 {
|
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 {
|
} catch {
|
||||||
/* ignore */
|
out[person.email] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cancelled) setFb(out);
|
if (!cancelled) setFb(out);
|
||||||
@@ -186,7 +233,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(","), 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) => {
|
const onStartChange = (d: Date) => {
|
||||||
if (Number.isNaN(d.getTime())) return;
|
if (Number.isNaN(d.getTime())) return;
|
||||||
@@ -368,21 +415,40 @@ 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 && (() => {
|
{people.length > 1 && (() => {
|
||||||
/** Everything on a bar is placed as a fraction of the span it covers. */
|
/** Everything on a bar is placed as a fraction of the span it covers. */
|
||||||
const pct = (from: number, to: number) => ({
|
const pct = (from: number, to: number) => ({
|
||||||
left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`,
|
left: `${((from - fbWindow.start.getTime()) / fbWindow.span) * 100}%`,
|
||||||
width: `${((to - from) / 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 (
|
return (
|
||||||
<div className="freebusy">
|
<div className="freebusy">
|
||||||
<div className="hint">
|
<div className="fb-head">
|
||||||
{fbWindow.days === 1
|
<button type="button" className="icon-btn sm" aria-label={translate("Earlier")} onClick={() => setFbOffset(fbOffset - fbWindow.days)}><ChevronLeft size={16} /></button>
|
||||||
? translate("Availability on {date}", { date: formatNumericDate(fbWindow.start) })
|
<span className="fb-range">{rangeLabel}</span>
|
||||||
: translate("Availability, {from} to {to}", { from: formatNumericDate(fbWindow.start), to: formatNumericDate(new Date(fbWindow.end.getTime() - 1)) })}
|
<button type="button" className="icon-btn sm" aria-label={translate("Later")} onClick={() => setFbOffset(fbOffset + fbWindow.days)}><ChevronRight size={16} /></button>
|
||||||
|
{fbOffset !== 0 && <button type="button" className="btn btn-ghost btn-sm" onClick={() => setFbOffset(0)}>{translate("Back to the event")}</button>}
|
||||||
|
<span className="spacer" />
|
||||||
|
{/* The time a click would set, so placing an event is aimed rather than guessed. */}
|
||||||
|
<span className="hint">{fbHover ? (fbWindow.days === 1 ? formatClock(fbHover) : `${formatWeekday(fbHover, "short")} ${formatClock(fbHover)}`) : translate("Click to move the event")}</span>
|
||||||
</div>
|
</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">
|
<div className="fb-row fb-axis-row">
|
||||||
<span style={{ width: 140, flex: "none" }} />
|
<span style={{ width: 140, flex: "none" }} />
|
||||||
<div className="fb-axis">
|
<div className="fb-axis">
|
||||||
@@ -394,28 +460,51 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
|||||||
<span className="fb-axis-label end">{fbWindow.scale === "hours" ? formatClock(fbWindow.end) : formatNumericDate(new Date(fbWindow.end.getTime() - 1))}</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) => (
|
{people.map((person) => {
|
||||||
<div key={a.email} className="fb-row">
|
const periods = fb[person.email];
|
||||||
<span className="truncate" style={{ width: 140, flex: "none" }}>{a.name ?? a.email}</span>
|
const noData = periods === null;
|
||||||
<div className="fb-bar">
|
return (
|
||||||
{/* Drawn under the blocks, so a block can be read against
|
<div key={person.email} className="fb-row">
|
||||||
the hour it starts at rather than guessed at. */}
|
<span className={`truncate ${person.self ? "fb-self" : ""}`} style={{ width: 140, flex: "none" }} title={person.email}>{person.name}</span>
|
||||||
{fbWindow.ticks.map((tk) => (
|
<div
|
||||||
tk.at === 0 ? null : <span key={tk.time.getTime()} className={`fb-tick ${tk.major ? "major" : ""}`} style={{ left: `${tk.at * 100}%` }} />
|
className={`fb-bar ${noData ? "no-data" : ""}`}
|
||||||
))}
|
role="button"
|
||||||
{fb[a.email]!.map((b, i) => {
|
tabIndex={-1}
|
||||||
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
|
title={noData ? translate("No availability information for {who}", { who: person.email }) : undefined}
|
||||||
const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
|
onMouseMove={(e) => setFbHover(timeAt(e))}
|
||||||
if (be <= bs) return null;
|
onMouseLeave={() => setFbHover(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))}`} />;
|
onClick={(e) => { onStartChange(timeAt(e)); setFbOffset(0); }}
|
||||||
})}
|
>
|
||||||
{!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />}
|
{fbWindow.ticks.map((tk) => (
|
||||||
|
tk.at === 0 ? null : <span key={tk.time.getTime()} className={`fb-tick ${tk.major ? "major" : ""}`} style={{ left: `${tk.at * 100}%` }} />
|
||||||
|
))}
|
||||||
|
{(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 <span key={i} className="fb-busy" style={pct(bs, be)} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} – ${formatClock(new Date(b.utcEnd))}`} />;
|
||||||
|
})}
|
||||||
|
{fbHover && <span className="fb-guide" style={{ left: `${((fbHover.getTime() - fbWindow.start.getTime()) / fbWindow.span) * 100}%` }} />}
|
||||||
|
{!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
{fbWindow.daysHidden > 0 && (
|
{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 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>
|
||||||
)}
|
)}
|
||||||
|
{/* Said once, under the grid, rather than repeated on every
|
||||||
|
row that has nothing to show. */}
|
||||||
|
{unknown.length > 0 && (
|
||||||
|
<div className="hint">
|
||||||
|
{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.",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user