Merge pull request #183 from Coffey-Labs/feat/scheduling-panel
Schedule against the whole guest list, and say who cannot be read
This commit is contained in:
4 files changed
+164
-31
No files matched your search
@@ -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>
|
||||||
|
<div
|
||||||
|
className={`fb-bar ${noData ? "no-data" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
title={noData ? translate("No availability information for {who}", { who: person.email }) : undefined}
|
||||||
|
onMouseMove={(e) => setFbHover(timeAt(e))}
|
||||||
|
onMouseLeave={() => setFbHover(null)}
|
||||||
|
onClick={(e) => { onStartChange(timeAt(e)); setFbOffset(0); }}
|
||||||
|
>
|
||||||
{fbWindow.ticks.map((tk) => (
|
{fbWindow.ticks.map((tk) => (
|
||||||
tk.at === 0 ? null : <span key={tk.time.getTime()} className={`fb-tick ${tk.major ? "major" : ""}`} style={{ left: `${tk.at * 100}%` }} />
|
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) => {
|
{(periods ?? []).map((b, i) => {
|
||||||
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
|
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
|
||||||
const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.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={pct(bs, be)} 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))}`} />;
|
||||||
})}
|
})}
|
||||||
|
{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())} />}
|
{!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