diff --git a/server/src/mock/handlers.ts b/server/src/mock/handlers.ts index 8a48fe2..d92b587 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 inbuxa-server matches it (probed on + * 27.1-rc.9, 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/lib/calendar/__tests__/search.test.ts b/web/src/lib/calendar/__tests__/search.test.ts new file mode 100644 index 0000000..1e70c32 --- /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 the server advertises", () => { + // P52W1D is 365 days. + 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..6c568fd --- /dev/null +++ b/web/src/lib/calendar/search.ts @@ -0,0 +1,101 @@ +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 a test can send + * exactly what the app sends. Found against inbuxa-server 27.1-rc.9 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 is capped by `maxExpandedQueryDuration` (P52W1D). The server took + * 366 days, but other JMAP servers refuse past their limit with + * `expandDurationTooLarge`, so the window stays inside it. + * - The search index is written 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: a server that enforces its limit can + * refuse a window of exactly that length, 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 6346972..99c35bf 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1210,6 +1210,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 466010e..956f84d 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1183,6 +1183,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 42d91f2..43f3995 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1188,6 +1188,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 2d1d8a1..40ab537 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1191,6 +1191,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 0e1cb55..040e4a7 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1183,6 +1183,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 85bb6fd..b688aad 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1186,6 +1186,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 186ed5f..a02bf95 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1185,6 +1185,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 e1ba2e3..61f6d61 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -120,6 +120,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 f4419c0..59a3fe2 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1179,6 +1179,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 c6badd0..2042393 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1190,6 +1190,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 675e961..9e15593 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`. @@ -424,6 +441,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. @@ -725,6 +743,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; @@ -1049,6 +1154,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 6b3a704..2af55e0 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -2043,6 +2043,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 };