Calendar: search events from the search bar (ihasmail #70) #86

Merged
jcoffey-dev merged 1 commits from port/calendar-search-ihasmail-70 into main 2026-11-06 19:33:49 +00:00
17 changed files with 538 additions and 31 deletions

No files matched your search

+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 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<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));
}
@@ -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>): 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);
});
});
+101
View File
@@ -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<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: 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<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
@@ -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)",
+7
View File
@@ -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)",
+7
View File
@@ -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)",
+7
View File
@@ -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)": "(件名なし)",
+7
View File
@@ -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)",
+7
View File
@@ -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)",
+7
View File
@@ -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)": "(без темы)",
+7
View File
@@ -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",
+7
View File
@@ -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)": "(без теми)",
+7
View File
@@ -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)": "(无主题)",
+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`.
@@ -424,6 +441,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.
@@ -725,6 +743,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;
@@ -1049,6 +1154,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
@@ -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)); }
+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 };