Calendar: search events from the search bar #70

Merged
jcoffey-dev merged 1 commits from feat/calendar-search into main 2026-11-06 18:24:33 +00:00
19 changed files with 606 additions and 31 deletions

No files matched your search

+11
View File
@@ -556,6 +556,17 @@ A mini calendar for jumping, *Today*, and next/previous by keyboard (`n`/`p`) or
button. The default view, week start, working hours and default event duration
are settings.
## Search
In the calendar the search bar searches events (`/calendar/search?q=`), through
`CalendarEvent/query` with a `text` filter: titles, descriptions and locations,
across your own calendars, hidden ones included, the shared calendars you've
added, and subscribed calendars, which are matched in the browser. Results
come in one list, upcoming soonest first, then past most recent first and
faded. A repeating event is listed once, at its next date (or its latest, once
it has ended), with how it repeats. Stalwart matches whole words, so "japan"
does not find "Japanese".
## Calendars
The sidebar keeps three groups apart:
+31 -5
View File
@@ -383,15 +383,25 @@ export const handlers: Record<string, Handler> = {
"CalendarEvent/query": (a) => {
const list = eventsFor(a.accountId);
const filter = (a.filter as Obj) ?? {};
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
const wanted = typeof filter.text === "string" ? eventWords(filter.text) : [];
const matching = list.filter((e) => (!filter.uid || e.uid === filter.uid) && (!wanted.length || matchesEventText(e, wanted)));
// Sorted by start and paged, as event search asks: its "next date of this
// series" is the first occurrence, by start, with a limit of one.
const descending = (a.sort as { isAscending?: boolean }[] | undefined)?.[0]?.isAscending === false;
const order = (x: { start: string }, y: { start: string }) => (descending ? -1 : 1) * x.start.localeCompare(y.start);
const page = (ids: string[]) => {
const position = Number(a.position ?? 0);
const limit = a.limit == null ? ids.length : Number(a.limit);
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position, ids: ids.slice(position, position + limit), total: ids.length };
};
if (!a.expandRecurrences) {
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
return page(matching.map((e) => ({ id: e.id as string, start: String(e.start ?? "") })).sort(order).map((e) => e.id));
}
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
const found: { id: string; start: string }[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) found.push({ id: syntheticId(e.id as string, occ.recurrenceId), start: occ.start });
return page(found.sort(order).map((o) => o.id));
},
"CalendarEvent/get": (a) => {
const list = eventsFor(a.accountId);
@@ -514,3 +524,19 @@ export const handlers: Record<string, Handler> = {
},
};
/*
* `CalendarEvent/query`'s `text`, as Stalwart matches it (probed on 0.16.27
* and 1.0.0, 2026-11-06): every word of the query is a whole word of the
* title, the description or a location's name, in any case. "japan" does not
* find "Japanese".
*/
function eventWords(s: string): string[] {
return s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
}
function matchesEventText(e: Obj, wanted: string[]): boolean {
const locations = Object.values((e.locations as Record<string, Obj> | undefined) ?? {}).map((l) => String(l.name ?? ""));
const have = new Set(eventWords([e.title, e.description, ...locations].filter(Boolean).join(" ")));
return wanted.every((w) => have.has(w));
}
@@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from "vitest";
import { CAP, STALWART_CAP, usingFor } from "@/jmap/client";
import { BODY_PROPS, FULL_PROPS, LIST_PROPS, MAILBOX_PROPS } from "@/store/mail/props";
import { CALENDAR_PROPS, EVENT_PROPS, SERIES_PROPS } from "@/store/calendar";
import { expandWindowMs, occurrenceQueries, searchCalls } from "@/lib/calendar/search";
import { ADDRESS_BOOK_PROPS } from "@/store/contacts";
import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe } from "@/jmap/fileNodeShape";
import { APP_FOLDER, folderProps, isAppFolder } from "@/lib/appFolder";
@@ -237,6 +238,63 @@ describe.skipIf(!BASE)("a real Stalwart server", () => {
expect((series[0]![1].list as Record<string, unknown>[])[0]!.recurrenceRule).toBeTruthy();
});
it("finds events the way event search asks, and each series' next date", async () => {
const calendars = (await jmap(alice, [["Calendar/get", { accountId: aid, properties: CALENDAR_PROPS }, "c"]]))[0]![1].list as { id: string }[];
let calendarId = calendars[0]?.id;
if (!calendarId) {
const made = await jmap(alice, [["Calendar/set", { accountId: aid, create: { c: { name: "Compat" } } }, "c"]]);
calendarId = (made[0]![1].created as Record<string, { id: string }>).c!.id;
}
// A word of letters nobody else used, so a rerun on the same server finds only its own.
const word = Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => String.fromCharCode(97 + (b % 26))).join("");
const event = (title: string, start: string, extra: Record<string, unknown> = {}) => ({
"@type": "Event", uid: crypto.randomUUID(), calendarIds: { [calendarId!]: true }, title, start, timeZone: "Etc/UTC", duration: "PT1H", ...extra,
});
const made = await jmap(alice, [["CalendarEvent/set", { accountId: aid, create: {
past: event(`${word} lunch`, "2025-03-01T12:00:00"),
series: event(`Weekly ${word} class`, "2026-01-05T18:00:00", { recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly" } }),
described: event("Dentist", "2030-03-01T09:00:00", { description: `Then dinner at the ${word} place` }),
other: event("Unrelated", "2030-03-02T09:00:00"),
} }, "e"]]);
const created = made[0]![1].created as Record<string, { id: string }>;
expect(Object.keys(created ?? {}).sort(), JSON.stringify(made[0]![1]).slice(0, 300)).toEqual(["described", "other", "past", "series"]);
// The app's own request. 1.0 indexes in the background, so give it a moment.
let found: { id: string; uid: string; recurrenceRule?: unknown; recurrenceId?: string }[] = [];
for (let i = 0; i < 30; i++) {
const res = await jmap(alice, searchCalls(aid, word, "Etc/UTC", EVENT_PROPS));
expect(res[0]![0], JSON.stringify(res[0]![1]).slice(0, 300)).toBe("CalendarEvent/query");
expect(res[1]![0], JSON.stringify(res[1]![1]).slice(0, 300)).toBe("CalendarEvent/get");
found = res[1]![1].list as typeof found;
if (found.length >= 3) break;
await new Promise((r) => setTimeout(r, 1000));
}
// The title, the description, and the series once, as its master.
expect(found.map((e) => e.id).sort()).toEqual([created.described!.id, created.past!.id, created.series!.id].sort());
const master = found.find((e) => e.id === created.series!.id)!;
expect(master.recurrenceRule).toBeTruthy();
expect(master.recurrenceId ?? null).toBeNull();
// Its next date and its latest, by uid, in the window the server allows.
const session = await (await fetch(`${BASE}/jmap/session`, { headers: { authorization: auth(alice) } })).json() as { accounts: Record<string, { accountCapabilities: Record<string, { maxExpandedQueryDuration?: string }> }> };
const windowMs = expandWindowMs(session.accounts[aid]!.accountCapabilities[CAP.calendars]?.maxExpandedQueryDuration);
const now = new Date();
const q = occurrenceQueries(aid, master.uid, now, windowMs, "Etc/UTC");
const lookups = await jmap(alice, [["CalendarEvent/query", q.next, "n"], ["CalendarEvent/query", q.last, "l"]]);
for (const r of lookups) expect(r[0], JSON.stringify(r[1]).slice(0, 300)).toBe("CalendarEvent/query");
const [nextId] = lookups[0]![1].ids as string[];
const [lastId] = lookups[1]![1].ids as string[];
expect(nextId && lastId, JSON.stringify(lookups.map((r) => r[1].ids))).toBeTruthy();
const got = (await jmap(alice, [["CalendarEvent/get", { accountId: aid, ids: [nextId, lastId], properties: EVENT_PROPS, timeZone: "Etc/UTC" }, "g"]]))[0]![1].list as { id: string; baseEventId?: string; utcStart?: string }[];
const next = got.find((e) => e.id === nextId)!;
const last = got.find((e) => e.id === lastId)!;
// Placed by the master it came from, which is how the app pairs them.
expect(next.baseEventId).toBe(master.id);
expect(last.baseEventId).toBe(master.id);
expect(new Date(next.utcStart!).getTime()).toBeGreaterThan(now.getTime());
expect(new Date(last.utcStart!).getTime()).toBeLessThan(now.getTime());
}, 60_000);
it("keeps the settings file the way the app does", async () => {
// The client's own translation, against whichever shape this server wants
// (fileNodeShape.ts). Stalwart 1.0 renamed nodeType and type, and refused
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { expandWindowMs, icsEventMatches, occurrenceQueries, searchCalls, splitByNow, SEARCH_LIMIT } from "@/lib/calendar/search";
import type { IcsEvent } from "@/lib/calendar/ics";
const DAY = 86_400_000;
describe("expandWindowMs", () => {
it("stays a day inside what both Stalwart versions advertise", () => {
// P52W1D is 365 days; 1.0 refused 366 with expandDurationTooLarge.
expect(expandWindowMs("P52W1D")).toBe(364 * DAY);
});
it("uses a smaller limit as it is, less the day", () => {
expect(expandWindowMs("P30D")).toBe(29 * DAY);
});
it("never goes past a year, and falls back to one when nothing is advertised", () => {
expect(expandWindowMs("P104W")).toBe(364 * DAY);
expect(expandWindowMs(undefined)).toBe(364 * DAY);
expect(expandWindowMs("nonsense")).toBe(364 * DAY);
});
});
describe("searchCalls", () => {
it("asks for the newest matches first, with a limit, and reads them in the same request", () => {
const calls = searchCalls("a1", "japanese", "Europe/Berlin", ["id", "title"]);
const query = calls[0]!;
const get = calls[1]!;
expect(query[0]).toBe("CalendarEvent/query");
expect(query[1]).toMatchObject({ accountId: "a1", filter: { text: "japanese" }, sort: [{ property: "start", isAscending: false }], limit: SEARCH_LIMIT });
// A series must come back once, as its master: no expansion here.
expect(query[1]).not.toHaveProperty("expandRecurrences");
expect(get[1]).toMatchObject({ accountId: "a1", "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: ["id", "title"] });
});
});
describe("occurrenceQueries", () => {
const now = new Date(2026, 10, 6, 12, 0, 0);
const { next, last } = occurrenceQueries("a1", "uid-1", now, 10 * DAY, "Etc/UTC");
it("asks for the first occurrence after now", () => {
expect(next).toMatchObject({
filter: { uid: "uid-1", after: "2026-11-06T12:00:00", before: "2026-11-16T12:00:00" },
sort: [{ property: "start", isAscending: true }],
expandRecurrences: true,
limit: 1,
});
});
it("and the latest one before it", () => {
expect(last).toMatchObject({
filter: { uid: "uid-1", after: "2026-10-27T12:00:00", before: "2026-11-06T12:00:00" },
sort: [{ property: "start", isAscending: false }],
expandRecurrences: true,
limit: 1,
});
});
});
describe("splitByNow", () => {
const now = new Date(2026, 10, 6, 12, 0);
const at = (day: number, hour: number, name: string) => ({ name, start: new Date(2026, 10, day, hour), end: new Date(2026, 10, day, hour + 1) });
it("puts upcoming soonest first and past most recent first", () => {
const { upcoming, past } = splitByNow([at(1, 9, "a"), at(20, 9, "d"), at(8, 9, "c"), at(4, 9, "b")], now);
expect(upcoming.map((r) => r.name)).toEqual(["c", "d"]);
expect(past.map((r) => r.name)).toEqual(["b", "a"]);
});
it("counts an event still in progress as upcoming", () => {
const inProgress = { name: "now", start: new Date(2026, 10, 6, 11, 30), end: new Date(2026, 10, 6, 12, 30) };
const { upcoming, past } = splitByNow([inProgress], now);
expect(upcoming.map((r) => r.name)).toEqual(["now"]);
expect(past).toEqual([]);
});
});
describe("icsEventMatches", () => {
const ev = (o: Partial<IcsEvent>): IcsEvent => ({ uid: "u", summary: "", start: new Date(), end: new Date(), allDay: false, recurring: false, ...o });
it("matches whole words in any case, in the title, description or location, as the server does", () => {
expect(icsEventMatches(ev({ summary: "Japanese class" }), "JAPANESE")).toBe(true);
expect(icsEventMatches(ev({ summary: "Dentist", description: "then the Japanese place" }), "japanese")).toBe(true);
expect(icsEventMatches(ev({ summary: "Lunch", location: "Japanese Garden Cafe" }), "garden")).toBe(true);
expect(icsEventMatches(ev({ summary: "Japanese class" }), "japan")).toBe(false);
});
it("needs every word of the query", () => {
expect(icsEventMatches(ev({ summary: "Weekly Japanese class" }), "japanese class")).toBe(true);
expect(icsEventMatches(ev({ summary: "Weekly Japanese class" }), "japanese dinner")).toBe(false);
});
it("finds nothing for a query with no words", () => {
expect(icsEventMatches(ev({ summary: "Anything" }), " — ")).toBe(false);
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { Id } from "@/jmap/types";
import type { IcsEvent } from "./ics";
import { DAY_MS, parseDuration, toLocalDateTime } from "@/lib/dates";
/*
* Event search: the requests, and the decisions about what comes back.
*
* The requests are built here rather than in the store so the compatibility
* test sends exactly what the app sends. Found against 0.16.27 and 1.0.0 on
* 2026-11-06:
*
* - `filter: { text }` matches the title, the description and the locations,
* in any case, by whole word: "japanese" finds "Japanese Garden Cafe" and
* "japan" finds nothing. Several words must all match.
* - Without `expandRecurrences` a series comes back once, as its master, at
* its first date. With it, both `after` and `before` are required, and the
* span must not exceed `maxExpandedQueryDuration` (P52W1D on both): 1.0
* refuses 366 days with `expandDurationTooLarge`.
* - 1.0 indexes in the background, so an event saved a moment ago can be
* missing from the first search after it.
*
* So a series is found as its master, and its next date (or, once it has
* ended, its latest) is asked for by uid in a window the server allows.
*/
/** Matches asked for from each account. */
export const SEARCH_LIMIT = 100;
type Call = [method: string, args: Record<string, unknown>, id: string];
/** The text query and the read of what it found, as one request. */
export function searchCalls(accountId: Id, text: string, timeZone: string, properties: string[]): Call[] {
return [
["CalendarEvent/query", { accountId, filter: { text }, sort: [{ property: "start", isAscending: false }], limit: SEARCH_LIMIT, timeZone }, "q"],
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties, timeZone }, "g"],
];
}
/**
* How far either side of now a series is expanded to find its next and
* latest dates: the server's limit less a day, and never more than a year.
* The day is margin, not arithmetic: 1.0 refused a window of exactly its
* advertised limit plus a day, and the cost of a shorter one is only that a
* series meeting less than once a year shows its first date.
*/
export function expandWindowMs(maxExpandedQueryDuration: string | null | undefined): number {
const year = 365 * DAY_MS;
const limit = parseDuration(maxExpandedQueryDuration) * 1000;
return (limit > 0 ? Math.min(limit, year) : year) - DAY_MS;
}
/**
* The query for a series' next date after `now`, and the one for its latest
* before it: one occurrence each, by uid, expanded.
*/
export function occurrenceQueries(accountId: Id, uid: string, now: Date, windowMs: number, timeZone: string): { next: Record<string, unknown>; last: Record<string, unknown> } {
const nowLocal = toLocalDateTime(now);
return {
next: {
accountId,
filter: { uid, after: nowLocal, before: toLocalDateTime(new Date(now.getTime() + windowMs)) },
timeZone,
sort: [{ property: "start", isAscending: true }],
expandRecurrences: true,
limit: 1,
},
last: {
accountId,
filter: { uid, after: toLocalDateTime(new Date(now.getTime() - windowMs)), before: nowLocal },
timeZone,
sort: [{ property: "start", isAscending: false }],
expandRecurrences: true,
limit: 1,
},
};
}
/** Results split around now: upcoming soonest first, then past most recent first. */
export function splitByNow<T extends { start: Date; end: Date }>(results: T[], now: Date): { upcoming: T[]; past: T[] } {
const upcoming = results.filter((r) => r.end > now).sort((a, b) => a.start.getTime() - b.start.getTime());
const past = results.filter((r) => r.end <= now).sort((a, b) => b.start.getTime() - a.start.getTime());
return { upcoming, past };
}
/** Lowercase words, the way the query is split. */
function words(s: string): string[] {
return s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
}
/**
* Whether a subscribed calendar's event matches, decided here because those
* events never reach the server. Same rule as the server's: every word of the
* query is a whole word of the title, the description or the location.
*/
export function icsEventMatches(e: IcsEvent, text: string): boolean {
const wanted = words(text);
if (!wanted.length) return false;
const have = new Set(words([e.summary, e.description ?? "", e.location ?? ""].join(" ")));
return wanted.every((w) => have.has(w));
}
+7
View File
@@ -1218,6 +1218,13 @@ export const catalog: Catalog = {
"Saved {when}": "Gespeichert {when}",
"Unsaved": "Nicht gespeichert",
"Agenda from {date}": "Agenda ab {date}",
"Search events": "Termine durchsuchen",
"Results for “{query}”": "Ergebnisse für „{query}“",
"No matching events": "Keine passenden Termine",
"Nothing in your calendars matches “{query}”.": "Nichts in Ihren Kalendern passt zu „{query}“.",
"Upcoming": "Bevorstehend",
"Past": "Vergangen",
"(untitled)": "(ohne Titel)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(kein Betreff)",
+7
View File
@@ -1191,6 +1191,13 @@ export const catalog: Catalog = {
"Saved {when}": "Guardado {when}",
"Unsaved": "Sin guardar",
"Agenda from {date}": "Agenda desde {date}",
"Search events": "Buscar eventos",
"Results for “{query}”": "Resultados para “{query}”",
"No matching events": "No hay eventos que coincidan",
"Nothing in your calendars matches “{query}”.": "Nada en sus calendarios coincide con “{query}”.",
"Upcoming": "Próximos",
"Past": "Pasados",
"(untitled)": "(sin título)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sin asunto)",
+7
View File
@@ -1196,6 +1196,13 @@ export const catalog: Catalog = {
"Saved {when}": "Enregistré {when}",
"Unsaved": "Non enregistré",
"Agenda from {date}": "Agenda à partir du {date}",
"Search events": "Rechercher des événements",
"Results for “{query}”": "Résultats pour « {query} »",
"No matching events": "Aucun événement correspondant",
"Nothing in your calendars matches “{query}”.": "Rien dans vos calendriers ne correspond à « {query} ».",
"Upcoming": "À venir",
"Past": "Passés",
"(untitled)": "(sans titre)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sans objet)",
+7
View File
@@ -1199,6 +1199,13 @@ export const catalog: Catalog = {
"Saved {when}": "{when} に保存",
"Unsaved": "未保存",
"Agenda from {date}": "{date}からの予定",
"Search events": "予定を検索",
"Results for “{query}”": "「{query}」の検索結果",
"No matching events": "一致する予定はありません",
"Nothing in your calendars matches “{query}”.": "カレンダーに「{query}」に一致する予定はありません。",
"Upcoming": "今後の予定",
"Past": "過去の予定",
"(untitled)": "(無題)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(件名なし)",
+7
View File
@@ -1191,6 +1191,13 @@ export const catalog: Catalog = {
"Saved {when}": "Opgeslagen {when}",
"Unsaved": "Niet opgeslagen",
"Agenda from {date}": "Agenda vanaf {date}",
"Search events": "Afspraken zoeken",
"Results for “{query}”": "Resultaten voor ‘{query}’",
"No matching events": "Geen overeenkomende afspraken",
"Nothing in your calendars matches “{query}”.": "Niets in uw agenda's komt overeen met ‘{query}’.",
"Upcoming": "Komend",
"Past": "Voorbij",
"(untitled)": "(zonder titel)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(geen onderwerp)",
+7
View File
@@ -1194,6 +1194,13 @@ export const catalog: Catalog = {
"Saved {when}": "Salvo {when}",
"Unsaved": "Não salvo",
"Agenda from {date}": "Agenda a partir de {date}",
"Search events": "Pesquisar eventos",
"Results for “{query}”": "Resultados para “{query}”",
"No matching events": "Nenhum evento correspondente",
"Nothing in your calendars matches “{query}”.": "Nada nas suas agendas corresponde a “{query}”.",
"Upcoming": "Próximos",
"Past": "Passados",
"(untitled)": "(sem título)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sem assunto)",
+7
View File
@@ -1193,6 +1193,13 @@ export const catalog: Catalog = {
"Saved {when}": "Сохранено {when}",
"Unsaved": "Не сохранено",
"Agenda from {date}": "Расписание с {date}",
"Search events": "Поиск событий",
"Results for “{query}”": "Результаты по запросу «{query}»",
"No matching events": "Подходящих событий нет",
"Nothing in your calendars matches “{query}”.": "В ваших календарях нет ничего по запросу «{query}».",
"Upcoming": "Предстоящие",
"Past": "Прошедшие",
"(untitled)": "(без названия)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(без темы)",
+7
View File
@@ -121,6 +121,13 @@ export const catalog: Catalog = {
"After archiving or deleting": "Arşivledikten veya sildikten sonra",
"Agenda": "Gündem",
"Agenda from {date}": "{date} tarihinden itibaren gündem",
"Search events": "Etkinlik ara",
"Results for “{query}”": "“{query}” için sonuçlar",
"No matching events": "Eşleşen etkinlik yok",
"Nothing in your calendars matches “{query}”.": "Takvimlerinizde “{query}” ile eşleşen bir şey yok.",
"Upcoming": "Yaklaşan",
"Past": "Geçmiş",
"(untitled)": "(başlıksız)",
"Align left": "Sola hizala",
"Align right": "Sağa hizala",
"All address books": "Tüm adres defterleri",
+7
View File
@@ -1187,6 +1187,13 @@ export const catalog: Catalog = {
"Saved {when}": "Збережено {when}",
"Unsaved": "Не збережено",
"Agenda from {date}": "Розклад від {date}",
"Search events": "Пошук подій",
"Results for “{query}”": "Результати за запитом «{query}»",
"No matching events": "Відповідних подій немає",
"Nothing in your calendars matches “{query}”.": "У ваших календарях немає нічого за запитом «{query}».",
"Upcoming": "Майбутні",
"Past": "Минулі",
"(untitled)": "(без назви)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(без теми)",
+7
View File
@@ -1198,6 +1198,13 @@ export const catalog: Catalog = {
"Saved {when}": "已保存 {when}",
"Unsaved": "未保存",
"Agenda from {date}": "从 {date} 起的日程",
"Search events": "搜索日程",
"Results for “{query}”": "“{query}”的搜索结果",
"No matching events": "没有匹配的日程",
"Nothing in your calendars matches “{query}”.": "您的日历中没有与“{query}”匹配的内容。",
"Upcoming": "即将到来",
"Past": "过去",
"(untitled)": "(无标题)",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(无主题)",
+106
View File
@@ -7,6 +7,7 @@ import { useContacts } from "./contacts";
import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays";
import { looksLikeCalendar, parseIcs, toIcs, type IcsEvent } from "@/lib/calendar/ics";
import { withBase } from "@/lib/basePath";
import { expandWindowMs, icsEventMatches, occurrenceQueries, searchCalls } from "@/lib/calendar/search";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
@@ -226,6 +227,16 @@ export function occurrencePatch(patch: Record<string, unknown>): { patch: Record
return { patch: out, dropped };
}
/**
* One event search result. A series is one result, placed at its next date
* (or its latest, once it has ended), and `rule` says how it repeats: the
* occurrence it is placed at carries no rule of its own.
*/
export interface SearchHit {
inst: EventInstance;
rule?: JSCalendarRecurrenceRule;
}
/** A calendar somebody else shared, and the account it lives in. */
export interface SharedCalendar {
accountId: Id;
@@ -289,6 +300,8 @@ interface CalendarState {
subscriptionsLoading: boolean;
/** Waiting to be opened in the editor; see `EventDraft`. */
draft: EventDraft | null;
/** Bumped whenever events are loaded again after a change, for views that hold their own copy. */
changes: number;
init(): Promise<void>;
loadCalendars(): Promise<void>;
@@ -301,6 +314,10 @@ interface CalendarState {
instancesIn(start: Date, end: Date): EventInstance[];
/** Re-fetch every subscribed calendar. */
refreshSubscriptions(): Promise<void>;
/** Events matching `text`, from the reader's own calendars and the shared ones drawn beside them. */
searchEvents(text: string): Promise<SearchHit[]>;
/** The same search over subscribed calendars, which the server never sees. */
searchSubscriptions(text: string): SearchHit[];
/*
* The event calls take the account the event lives in -- `EventInstance`'s
* `accountId` -- and default to the reader's own. See `EventInstance`.
@@ -440,6 +457,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
subscriptionErrors: {},
subscriptionsLoading: false,
draft: null,
changes: 0,
async init() {
// The reader's own: a shared calendar is shown beside theirs, not instead.
@@ -740,6 +758,93 @@ export const useCalendar = create<CalendarState>((set, get) => ({
return [...out, ...birthdays];
},
/*
* Searched account by account: the reader's own, and each account that
* shared a calendar the reader draws. Everything the reader has, hidden
* calendars included, because a search is for finding what isn't on screen.
* A shared account that refuses is left out rather than failing the rest;
* the reader's own refusing is an error worth showing.
*/
async searchEvents(text) {
const own = get().accountId;
const tz = settings().timeZone ?? browserTimeZone;
const now = new Date();
const added = new Set(settings().addedShares);
const drawn: Record<Id, Record<Id, Calendar>> = {};
for (const c of get().sharedCalendars) {
// Reachable is not shared; see the same check in `instancesIn`.
if (!c.calendar.isSubscribed && !added.has(sharedKey(c.accountId, c.calendar.id))) continue;
(drawn[c.accountId] ??= {})[c.calendar.id] = c.calendar;
}
const accounts: { accountId: Id; calendars: Record<Id, Calendar>; shared: boolean }[] = [];
if (own) accounts.push({ accountId: own, calendars: get().calendars, shared: false });
for (const [accountId, calendars] of Object.entries(drawn)) accounts.push({ accountId, calendars, shared: true });
const answers = await Promise.all(accounts.map(async ({ accountId, calendars, shared }): Promise<SearchHit[]> => {
try {
const res = await client.chain(searchCalls(accountId, text, tz, EVENT_PROPS));
const found = (res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>).list.filter((e) => {
const calId = Object.keys(e.calendarIds ?? {})[0];
return !shared || Boolean(calId && calendars[calId]);
});
/* Each series' next date, or its latest: asked for by uid, every one
in the same request (`client.call` batches a tick's calls). A
series whose lookup fails is listed at its first date. */
const windowMs = expandWindowMs(client.accountCapability<{ maxExpandedQueryDuration?: string }>(accountId, CAP.calendars)?.maxExpandedQueryDuration);
const series = found.filter((e) => isRecurring(e) && e.uid);
const wanted = await Promise.all(series.map(async (e) => {
const q = occurrenceQueries(accountId, e.uid!, now, windowMs, tz);
const [next, last] = await Promise.all([
client.call<QueryResponse>("CalendarEvent/query", q.next).catch(() => null),
client.call<QueryResponse>("CalendarEvent/query", q.last).catch(() => null),
]);
return next?.ids?.[0] ?? last?.ids?.[0];
}));
const ids = wanted.filter((id): id is Id => Boolean(id));
const placed = new Map<Id, CalendarEvent>();
for (const part of chunk(ids, client.maxObjectsInGet)) {
const g = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: part, properties: EVENT_PROPS, timeZone: tz });
// Keyed by the master it came from: a uid can name the same event
// in two calendars, and only this master's own date will do.
for (const occ of g.list) if (occ.baseEventId) placed.set(occ.baseEventId, occ);
}
const hits: SearchHit[] = [];
for (const e of found) {
const recurring = isRecurring(e);
const inst = toInstance((recurring && placed.get(e.id)) || e, calendars);
if (!inst) continue;
if (shared) {
inst.key = sharedKey(accountId, inst.event.id);
inst.accountId = accountId;
}
hits.push({ inst, rule: recurring ? eventRule(e) : undefined });
}
return hits;
} catch (err) {
if (!shared) throw err;
return [];
}
}));
return answers.flat();
},
searchSubscriptions(text) {
const hits: SearchHit[] = [];
for (const sub of settings().icalSubscriptions) {
const calId = subscriptionCalendarId(sub.id);
const cal = subscriptionCalendar(sub);
for (const e of get().subscriptionEvents[sub.id] ?? []) {
if (!icsEventMatches(e, text)) continue;
hits.push({
inst: { key: `${calId}:${e.uid}:${e.start.getTime()}`, event: synthesizeSubscriptionEvent(sub.id, e), start: e.start, end: e.end, allDay: e.allDay, calendar: cal },
});
}
}
return hits;
},
async getEvent(id, owner) {
const accountId = owner ?? get().accountId;
if (!accountId) return null;
@@ -1064,6 +1169,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* calendar go blank until the answers came back.
*/
invalidate() {
set((s) => ({ changes: s.changes + 1 }));
for (const k of Object.keys(get().ranges)) {
const [s, e] = k.split("|").map(Number) as [number, number];
void get().loadRange(new Date(s), new Date(e), true);
+6
View File
@@ -2023,6 +2023,12 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.agenda-ev:hover { background: var(--bg-hover); }
.agenda-ev .ev-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
.agenda-ev .ev-when { width: 140px; color: var(--fg-muted); font-size: .9em; flex: 0 0 auto; }
/* Event search: Upcoming and Past headings, and past results faded the way
the calendars people come from show them. */
.event-search .search-section { margin: 16px 0 0; font-size: .82em; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--fg-muted); }
.event-search .search-section:first-child { margin-top: 4px; }
.event-search .agenda-day.past { opacity: .55; }
.event-search .agenda-day.past:hover { opacity: .85; }
.event-popover { width: 360px; max-width: calc(100vw - 24px); padding: 14px 16px; }
.event-popover h3 { margin: 0 0 6px; font-size: 1.15em; font-weight: 650; padding-left: 16px; position: relative; }
.event-popover h3::before { content: ""; position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 3px; background: var(--ev-color, var(--accent)); }
+20 -10
View File
@@ -6,22 +6,30 @@ import { keyboard } from "@/lib/input/keyboard";
import { DateField } from "@/ui/datefield";
import { t } from "@/lib/i18n";
/*
* One bar, two searches. In the calendar it searches events
* (`/calendar/search?q=`), with no advanced panel: mail's operators mean
* nothing to an event, and keyword filters for events are left for later.
* Everywhere else it searches mail.
*/
export function SearchBar() {
const [location, navigate] = useLocation();
const search = useSearch();
const events = location.startsWith("/calendar");
const resultsPath = events ? "/calendar/search" : "/search";
const [q, setQ] = useState("");
const [adv, setAdv] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const mailboxes = useMail((s) => s.mailboxes);
const [advFields, setAdvFields] = useState({ from: "", to: "", subject: "", words: "", hasAttachment: false, unread: false, folder: "", after: "", before: "" });
// Sync from URL when on /search
// Sync from URL when on a results page
useEffect(() => {
if (location.startsWith("/search")) {
if (location.startsWith(resultsPath)) {
const params = new URLSearchParams(search);
setQ(params.get("q") ?? "");
} else setQ("");
}, [location, search]);
}, [location, search, resultsPath]);
useEffect(() => keyboard.pushScope("search", [{ keys: "/", description: "Search mail", group: "Navigation", handler: () => inputRef.current?.focus() }]), []);
@@ -30,7 +38,7 @@ export function SearchBar() {
const query = q.trim();
if (!query) return;
setAdv(false);
navigate(`/search?q=${encodeURIComponent(query)}`);
navigate(`${resultsPath}?q=${encodeURIComponent(query)}`);
inputRef.current?.blur();
};
@@ -56,17 +64,19 @@ export function SearchBar() {
<form className="searchbar" role="search" onSubmit={submit}>
<div className="search-input">
<Search size={18} className="muted" />
<input ref={inputRef} type="search" placeholder={t("Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)")} value={q} onChange={(e) => setQ(e.target.value)} aria-label={t("Search mail")} enterKeyHint="search" />
<input ref={inputRef} type="search" placeholder={events ? t("Search events") : t("Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)")} value={q} onChange={(e) => setQ(e.target.value)} aria-label={events ? t("Search events") : t("Search mail")} enterKeyHint="search" />
{q && (
<button type="button" className="icon-btn sm" aria-label={t("Clear")} onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
<button type="button" className="icon-btn sm" aria-label={t("Clear")} onClick={() => { setQ(""); if (location.startsWith(resultsPath)) navigate(events ? "/calendar" : "/mail"); }}>
<X size={16} />
</button>
)}
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label={t("Advanced search")} title={t("Advanced search")} onClick={() => setAdv((v) => !v)}>
<SlidersHorizontal size={16} />
</button>
{!events && (
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label={t("Advanced search")} title={t("Advanced search")} onClick={() => setAdv((v) => !v)}>
<SlidersHorizontal size={16} />
</button>
)}
</div>
{adv && (
{adv && !events && (
<div className="search-panel">
<div className="grid">
<label className="field"><span className="label">{t("From")}</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
+108 -16
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
import { useLocation, useSearch } from "wouter";
import { ChevronLeft, ChevronRight, Plus, Repeat, Search as SearchIcon, Calendar as CalIcon } from "lucide-react";
import { useCalendar, participantAddresses, type EventInstance, type SearchHit } from "@/store/calendar";
import { splitByNow } from "@/lib/calendar/search";
import { describeRule } from "@/lib/calendar/recurrence";
import { useSettings } from "@/store/settings";
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays} from "@/lib/dates";
import { useSwipeNav } from "@/lib/input/touch";
@@ -44,6 +46,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
const cal = useCalendar();
const settings = useSettings((s) => s.settings);
const isMobile = useIsMobile();
// `/calendar/search?q=`: the search bar's results, in place of a period.
const searching = viewParam === "search";
const query = (new URLSearchParams(useSearch()).get("q") ?? "").trim();
const view: View = (["month", "week", "day", "agenda"].includes(viewParam ?? "") ? viewParam : settings.calendarDefaultView) as View;
const anchor = useMemo(() => {
const d = date ? new Date(`${date}T00:00:00`) : new Date();
@@ -70,9 +75,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
}, [effectiveView, anchor, weekStart]);
useEffect(() => {
if (cal.available) void cal.loadRange(range.start, range.end);
if (cal.available && !searching) void cal.loadRange(range.start, range.end);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cal.available, range.start.getTime(), range.end.getTime()]);
}, [cal.available, searching, range.start.getTime(), range.end.getTime()]);
/*
* An event begun elsewhere -- from a message -- opens here, because this is
@@ -177,7 +182,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
}
const title =
effectiveView === "month" ? formatMonthYear(anchor)
searching ? translate("Results for “{query}”", { query })
: effectiveView === "month" ? formatMonthYear(anchor)
: effectiveView === "week" ? `${formatDayMonth(range.start)} – ${formatDate(addDays(range.end, -1))}`
: effectiveView === "day" ? formatWeekdayDate(anchor, true)
: translate("Agenda from {date}", { date: formatDayMonth(anchor) });
@@ -201,22 +207,23 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
<div className="cal-main" ref={setMainEl}>
<div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
<button className="icon-btn sm" onClick={() => step(1)} aria-label={translate("Next")}><ChevronRight size={18} /></button>
{!searching && <button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>}
{!searching && <button className="icon-btn sm" onClick={() => step(1)} aria-label={translate("Next")}><ChevronRight size={18} /></button>}
<h2 className="truncate">{title}</h2>
<span className="spacer" />
{cal.loading && <span className="spinner" />}
<div className="view-switch">
{(["day", "week", "month", "agenda"] as View[]).filter((v) => !(isMobile && v === "week")).map((v) => (
<button key={v} className={effectiveView === v ? "active" : ""} onClick={() => go(v, anchor)}>{VIEW_LABELS[v]()}</button>
<button key={v} className={!searching && effectiveView === v ? "active" : ""} onClick={() => go(v, anchor)}>{VIEW_LABELS[v]()}</button>
))}
</div>
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> {translate("Event")}</button>}
</div>
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
{effectiveView === "month" && <MonthView onDragCommit={(i, patch) => void commitDrag(i, patch)} anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid onDragCommit={(i, patch) => void commitDrag(i, patch)} days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
{searching && <EventSearch query={query} onEvent={onEvent} onEventContext={onEventContext} />}
{!searching && effectiveView === "month" && <MonthView onDragCommit={(i, patch) => void commitDrag(i, patch)} anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
{!searching && (effectiveView === "week" || effectiveView === "day") && <TimeGrid onDragCommit={(i, patch) => void commitDrag(i, patch)} days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
{!searching && effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, accountId: inst.accountId, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
{isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, accountId: popover.inst.accountId, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
@@ -347,7 +354,7 @@ function EventChip({ inst, day, onClick, onContext, onDragStart, dragging, suppr
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)} ${dragging ? "dragging" : ""} ${onDragStart ? "draggable" : ""}`} style={{ background: color, borderColor: color }} onPointerDown={onDragStart} onClick={(e) => { e.stopPropagation(); if (suppressClick?.()) return; onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
{!spansDay && <span className="ev-dot" style={{ background: color }} />}
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
<span className="truncate">{inst.event.title || "(untitled)"}</span>
<span className="truncate">{inst.event.title || translate("(untitled)")}</span>
</div>
);
}
@@ -527,7 +534,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
does not have to guess which one was meant. */
<div className="ev-resize" onPointerDown={(e) => beginDrag(inst, "resize", column, e)} aria-hidden="true" />
)}
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
<div className="ev-title">{inst.event.title || translate("(untitled)")}</div>
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
</div>
);
@@ -630,8 +637,8 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
{items.map((i) => (
<div key={i.key + day.toISOString()} className={`agenda-ev ${statusClass(i)}`} onClick={(e) => onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}>
<span className="ev-dot" style={{ background: colorOf(i) }} />
<span className="ev-when">{i.allDay ? "All day" : `${formatTime(i.start)} – ${formatTime(i.end)}`}</span>
<span className="grow truncate">{i.event.title || "(untitled)"}</span>
<span className="ev-when">{i.allDay ? translate("All day") : `${formatTime(i.start)} – ${formatTime(i.end)}`}</span>
<span className="grow truncate">{i.event.title || translate("(untitled)")}</span>
{Object.values(i.event.locations ?? {})[0]?.name && <span className="hint truncate" style={{ maxWidth: 200 }}>{Object.values(i.event.locations ?? {})[0]!.name}</span>}
</div>
))}
@@ -642,4 +649,89 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
);
}
/* ---------------- Search ---------------- */
/*
* The search bar's results: upcoming soonest first, then past most recent
* first and faded, each under its day as in the agenda. Asked for again when
* events change, so an edit made from a result shows in the list.
*/
function EventSearch({ query, onEvent, onEventContext }: { query: string; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx }) {
const searchEvents = useCalendar((s) => s.searchEvents);
const searchSubscriptions = useCalendar((s) => s.searchSubscriptions);
const changes = useCalendar((s) => s.changes);
const subscriptionEvents = useCalendar((s) => s.subscriptionEvents);
const [found, setFound] = useState<{ query: string; hits: SearchHit[] } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) return;
let current = true;
setError(null);
searchEvents(query).then(
(hits) => { if (current) setFound({ query, hits }); },
(err: Error) => { if (current) { setFound({ query, hits: [] }); setError(err.message); } },
);
return () => { current = false; };
}, [query, changes, searchEvents]);
// Subscriptions are matched here, so ones that finish loading after the search still appear.
const subscribed = useMemo(() => (query ? searchSubscriptions(query) : []), [query, subscriptionEvents, searchSubscriptions]);
const { upcoming, past } = useMemo(() => {
const hits = found?.query === query ? [...found.hits, ...subscribed] : [];
const split = splitByNow(hits.map((h) => ({ ...h, start: h.inst.start, end: h.inst.end })), new Date());
return { upcoming: split.upcoming, past: split.past };
}, [found, query, subscribed]);
if (!query) return <Empty icon={<SearchIcon size={36} />} title={translate("Search events")} />;
if (found?.query !== query) return <div className="p-16 row" style={{ justifyContent: "center" }}><span className="spinner" /></div>;
if (error) return <div className="error-box" style={{ margin: 12 }}>{error}</div>;
if (!upcoming.length && !past.length) {
return <Empty icon={<SearchIcon size={36} />} title={translate("No matching events")}>{translate("Nothing in your calendars matches “{query}”.", { query })}</Empty>;
}
return (
<div className="agenda event-search">
{upcoming.length > 0 && <h3 className="search-section">{translate("Upcoming")}</h3>}
<SearchRows hits={upcoming} onEvent={onEvent} onEventContext={onEventContext} />
{past.length > 0 && <h3 className="search-section">{translate("Past")}</h3>}
<SearchRows hits={past} past onEvent={onEvent} onEventContext={onEventContext} />
</div>
);
}
function SearchRows({ hits, past, onEvent, onEventContext }: { hits: SearchHit[]; past?: boolean; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx }) {
const colorOf = useEventColor();
// Consecutive results on one day share its heading; the list is already in order.
const byDay: { day: Date; hits: SearchHit[] }[] = [];
for (const h of hits) {
const day = startOfDay(h.inst.start);
const last = byDay[byDay.length - 1];
if (last && isSameDay(last.day, day)) last.hits.push(h);
else byDay.push({ day, hits: [h] });
}
return (
<>
{byDay.map(({ day, hits: items }) => (
<div key={`${past ? "p" : "u"}${day.toISOString()}`} className={`agenda-day ${past ? "past" : ""}`}>
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
{formatWeekday(day, "long")}
<small>{formatDateLong(day, true)}</small>
</div>
<div>
{items.map(({ inst: i, rule }) => (
<div key={i.key} className={`agenda-ev ${statusClass(i)}`} onClick={(e) => onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}>
<span className="ev-dot" style={{ background: colorOf(i) }} />
<span className="ev-when">{i.allDay ? translate("All day") : `${formatTime(i.start)} – ${formatTime(i.end)}`}</span>
<span className="grow truncate">{i.event.title || translate("(untitled)")}</span>
{rule && <span className="hint truncate row" style={{ maxWidth: 220, gap: 4 }}><Repeat size={13} />{describeRule(rule)}</span>}
{Object.values(i.event.locations ?? {})[0]?.name && <span className="hint truncate" style={{ maxWidth: 200 }}>{Object.values(i.event.locations ?? {})[0]!.name}</span>}
</div>
))}
</div>
</div>
))}
</>
);
}
export { endOfDay };