From 759a9a7b509aeb12008ac15346eea312216f3fbb Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 6 Nov 2026 11:21:22 -0700 Subject: [PATCH] Calendar: search events from the search bar (#69) In the calendar the search bar becomes "Search events" and opens /calendar/search?q=. It sends CalendarEvent/query with a text filter to the reader's own account and each account with an added shared calendar, and matches subscribed calendars in the browser. Results are one list: upcoming soonest first, then past most recent first and faded, under their days as in the agenda. A result opens the usual event popover. A series comes back once, as its master at its first date, so its next date (or its latest, once it has ended) is asked for by uid with an expanded query, one per series, in one request. The window stays a day inside maxExpandedQueryDuration: 1.0 refuses a span over its limit with expandDurationTooLarge. Checked against 0.16.27 and 1.0.0: text matches title, description and location by whole word in any case; 1.0 indexes in the background, so the compat test waits for it. The mock now honors text, sort and limit on CalendarEvent/query. Seven new strings in every catalog; the calendar's literal "(untitled)" and the agenda's "All day" go through translation too. --- FEATURES.md | 11 ++ server/src/mock/handlers.ts | 36 ++++- .../jmap/__tests__/stalwart-compat.test.ts | 58 ++++++++ web/src/lib/calendar/__tests__/search.test.ts | 96 ++++++++++++++ web/src/lib/calendar/search.ts | 100 ++++++++++++++ web/src/locales/de.ts | 7 + web/src/locales/es.ts | 7 + web/src/locales/fr.ts | 7 + web/src/locales/ja.ts | 7 + web/src/locales/nl.ts | 7 + web/src/locales/pt-BR.ts | 7 + web/src/locales/ru.ts | 7 + web/src/locales/tr.ts | 7 + web/src/locales/uk.ts | 7 + web/src/locales/zh-Hans.ts | 7 + web/src/store/calendar.ts | 106 +++++++++++++++ web/src/styles/app.css | 6 + web/src/views/SearchBar.tsx | 30 +++-- web/src/views/calendar/CalendarView.tsx | 124 +++++++++++++++--- 19 files changed, 606 insertions(+), 31 deletions(-) create mode 100644 web/src/lib/calendar/__tests__/search.test.ts create mode 100644 web/src/lib/calendar/search.ts diff --git a/FEATURES.md b/FEATURES.md index 3109a9e..1005700 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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: diff --git a/server/src/mock/handlers.ts b/server/src/mock/handlers.ts index 8a48fe2..a27139e 100644 --- a/server/src/mock/handlers.ts +++ b/server/src/mock/handlers.ts @@ -383,15 +383,25 @@ export const handlers: Record = { "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 = { }, }; + +/* + * `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 | 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)); +} diff --git a/web/src/jmap/__tests__/stalwart-compat.test.ts b/web/src/jmap/__tests__/stalwart-compat.test.ts index 06b5aae..7706df3 100644 --- a/web/src/jmap/__tests__/stalwart-compat.test.ts +++ b/web/src/jmap/__tests__/stalwart-compat.test.ts @@ -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[])[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).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 = {}) => ({ + "@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; + 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 }> }; + 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 diff --git a/web/src/lib/calendar/__tests__/search.test.ts b/web/src/lib/calendar/__tests__/search.test.ts new file mode 100644 index 0000000..0bb86ef --- /dev/null +++ b/web/src/lib/calendar/__tests__/search.test.ts @@ -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 => ({ 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); + }); +}); diff --git a/web/src/lib/calendar/search.ts b/web/src/lib/calendar/search.ts new file mode 100644 index 0000000..069522f --- /dev/null +++ b/web/src/lib/calendar/search.ts @@ -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, 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; last: Record } { + 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(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)); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index d6e8068..ad8e045 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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)", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index d69438b..211b5ff 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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)", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index b5cf8c7..e7124be 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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)", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 1711792..f1055e7 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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)": "(件名なし)", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 09176fe..833890f 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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)", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 17e5595..e54a810 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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)", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 53d137b..bf56cf9 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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)": "(без темы)", diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index 74a1ec8..a64f780 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -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", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index e5c240a..ac11262 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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)": "(без теми)", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index b1a7409..f72de84 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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)": "(无主题)", diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 0d29864..dc55cbb 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -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): { 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; loadCalendars(): Promise; @@ -301,6 +314,10 @@ interface CalendarState { instancesIn(start: Date, end: Date): EventInstance[]; /** Re-fetch every subscribed calendar. */ refreshSubscriptions(): Promise; + /** Events matching `text`, from the reader's own calendars and the shared ones drawn beside them. */ + searchEvents(text: string): Promise; + /** 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((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((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> = {}; + 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; 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 => { + try { + const res = await client.chain(searchCalls(accountId, text, tz, EVENT_PROPS)); + const found = (res.get("g")?.[0] as unknown as GetResponse).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("CalendarEvent/query", q.next).catch(() => null), + client.call("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(); + for (const part of chunk(ids, client.maxObjectsInGet)) { + const g = await client.call>("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((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); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index f5d858b..b6f34ae 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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)); } diff --git a/web/src/views/SearchBar.tsx b/web/src/views/SearchBar.tsx index 69b6886..93adeb7 100644 --- a/web/src/views/SearchBar.tsx +++ b/web/src/views/SearchBar.tsx @@ -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(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() {
- setQ(e.target.value)} aria-label={t("Search mail")} enterKeyHint="search" /> + setQ(e.target.value)} aria-label={events ? t("Search events") : t("Search mail")} enterKeyHint="search" /> {q && ( - )} - + {!events && ( + + )}
- {adv && ( + {adv && !events && (
diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 4cf9926..1314da0 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -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?:
- - + {!searching && } + {!searching && }

{title}

{cal.loading && }
{(["day", "week", "month", "agenda"] as View[]).filter((v) => !(isMobile && v === "week")).map((v) => ( - + ))}
{!isMobile && }
{cal.error &&
{cal.error}
} - {effectiveView === "month" && 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") && 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" && } + {searching && } + {!searching && effectiveView === "month" && 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") && 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" && } {ctx && 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 && } {popover && 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
{ e.stopPropagation(); if (suppressClick?.()) return; onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}> {!spansDay && } {!spansDay && {formatTime(inst.start)}} - {inst.event.title || "(untitled)"} + {inst.event.title || translate("(untitled)")}
); } @@ -527,7 +534,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa does not have to guess which one was meant. */
beginDrag(inst, "resize", column, e)} aria-hidden="true" /> )} -
{inst.event.title || "(untitled)"}
+
{inst.event.title || translate("(untitled)")}
{height > 30 &&
{formatTime(inst.start)} – {formatTime(inst.end)}
}
); @@ -630,8 +637,8 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent: {items.map((i) => (
onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}> - {i.allDay ? "All day" : `${formatTime(i.start)} – ${formatTime(i.end)}`} - {i.event.title || "(untitled)"} + {i.allDay ? translate("All day") : `${formatTime(i.start)} – ${formatTime(i.end)}`} + {i.event.title || translate("(untitled)")} {Object.values(i.event.locations ?? {})[0]?.name && {Object.values(i.event.locations ?? {})[0]!.name}}
))} @@ -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(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 } title={translate("Search events")} />; + if (found?.query !== query) return
; + if (error) return
{error}
; + if (!upcoming.length && !past.length) { + return } title={translate("No matching events")}>{translate("Nothing in your calendars matches “{query}”.", { query })}; + } + return ( +
+ {upcoming.length > 0 &&

{translate("Upcoming")}

} + + {past.length > 0 &&

{translate("Past")}

} + +
+ ); +} + +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 }) => ( +
+
+ {formatWeekday(day, "long")} + {formatDateLong(day, true)} +
+
+ {items.map(({ inst: i, rule }) => ( +
onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}> + + {i.allDay ? translate("All day") : `${formatTime(i.start)} – ${formatTime(i.end)}`} + {i.event.title || translate("(untitled)")} + {rule && {describeRule(rule)}} + {Object.values(i.event.locations ?? {})[0]?.name && {Object.values(i.event.locations ?? {})[0]!.name}} +
+ ))} +
+
+ ))} + + ); +} + export { endOfDay }; -- 2.54.0