Calendar: search events from the search bar #70
No files matched your search
+11
@@ -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:
|
||||
|
||||
@@ -383,15 +383,25 @@ export const handlers: Record<string, Handler> = {
|
||||
"CalendarEvent/query": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
const filter = (a.filter as Obj) ?? {};
|
||||
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
|
||||
const wanted = typeof filter.text === "string" ? eventWords(filter.text) : [];
|
||||
const matching = list.filter((e) => (!filter.uid || e.uid === filter.uid) && (!wanted.length || matchesEventText(e, wanted)));
|
||||
// Sorted by start and paged, as event search asks: its "next date of this
|
||||
// series" is the first occurrence, by start, with a limit of one.
|
||||
const descending = (a.sort as { isAscending?: boolean }[] | undefined)?.[0]?.isAscending === false;
|
||||
const order = (x: { start: string }, y: { start: string }) => (descending ? -1 : 1) * x.start.localeCompare(y.start);
|
||||
const page = (ids: string[]) => {
|
||||
const position = Number(a.position ?? 0);
|
||||
const limit = a.limit == null ? ids.length : Number(a.limit);
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position, ids: ids.slice(position, position + limit), total: ids.length };
|
||||
};
|
||||
if (!a.expandRecurrences) {
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
|
||||
return page(matching.map((e) => ({ id: e.id as string, start: String(e.start ?? "") })).sort(order).map((e) => e.id));
|
||||
}
|
||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||
const ids: string[] = [];
|
||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
|
||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||
const found: { id: string; start: string }[] = [];
|
||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) found.push({ id: syntheticId(e.id as string, occ.recurrenceId), start: occ.start });
|
||||
return page(found.sort(order).map((o) => o.id));
|
||||
},
|
||||
"CalendarEvent/get": (a) => {
|
||||
const list = eventsFor(a.accountId);
|
||||
@@ -514,3 +524,19 @@ export const handlers: Record<string, Handler> = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
* `CalendarEvent/query`'s `text`, as Stalwart matches it (probed on 0.16.27
|
||||
* and 1.0.0, 2026-11-06): every word of the query is a whole word of the
|
||||
* title, the description or a location's name, in any case. "japan" does not
|
||||
* find "Japanese".
|
||||
*/
|
||||
function eventWords(s: string): string[] {
|
||||
return s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||
}
|
||||
|
||||
function matchesEventText(e: Obj, wanted: string[]): boolean {
|
||||
const locations = Object.values((e.locations as Record<string, Obj> | undefined) ?? {}).map((l) => String(l.name ?? ""));
|
||||
const have = new Set(eventWords([e.title, e.description, ...locations].filter(Boolean).join(" ")));
|
||||
return wanted.every((w) => have.has(w));
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { CAP, STALWART_CAP, usingFor } from "@/jmap/client";
|
||||
import { BODY_PROPS, FULL_PROPS, LIST_PROPS, MAILBOX_PROPS } from "@/store/mail/props";
|
||||
import { CALENDAR_PROPS, EVENT_PROPS, SERIES_PROPS } from "@/store/calendar";
|
||||
import { expandWindowMs, occurrenceQueries, searchCalls } from "@/lib/calendar/search";
|
||||
import { ADDRESS_BOOK_PROPS } from "@/store/contacts";
|
||||
import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe } from "@/jmap/fileNodeShape";
|
||||
import { APP_FOLDER, folderProps, isAppFolder } from "@/lib/appFolder";
|
||||
@@ -237,6 +238,63 @@ describe.skipIf(!BASE)("a real Stalwart server", () => {
|
||||
expect((series[0]![1].list as Record<string, unknown>[])[0]!.recurrenceRule).toBeTruthy();
|
||||
});
|
||||
|
||||
it("finds events the way event search asks, and each series' next date", async () => {
|
||||
const calendars = (await jmap(alice, [["Calendar/get", { accountId: aid, properties: CALENDAR_PROPS }, "c"]]))[0]![1].list as { id: string }[];
|
||||
let calendarId = calendars[0]?.id;
|
||||
if (!calendarId) {
|
||||
const made = await jmap(alice, [["Calendar/set", { accountId: aid, create: { c: { name: "Compat" } } }, "c"]]);
|
||||
calendarId = (made[0]![1].created as Record<string, { id: string }>).c!.id;
|
||||
}
|
||||
// A word of letters nobody else used, so a rerun on the same server finds only its own.
|
||||
const word = Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => String.fromCharCode(97 + (b % 26))).join("");
|
||||
const event = (title: string, start: string, extra: Record<string, unknown> = {}) => ({
|
||||
"@type": "Event", uid: crypto.randomUUID(), calendarIds: { [calendarId!]: true }, title, start, timeZone: "Etc/UTC", duration: "PT1H", ...extra,
|
||||
});
|
||||
const made = await jmap(alice, [["CalendarEvent/set", { accountId: aid, create: {
|
||||
past: event(`${word} lunch`, "2025-03-01T12:00:00"),
|
||||
series: event(`Weekly ${word} class`, "2026-01-05T18:00:00", { recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly" } }),
|
||||
described: event("Dentist", "2030-03-01T09:00:00", { description: `Then dinner at the ${word} place` }),
|
||||
other: event("Unrelated", "2030-03-02T09:00:00"),
|
||||
} }, "e"]]);
|
||||
const created = made[0]![1].created as Record<string, { id: string }>;
|
||||
expect(Object.keys(created ?? {}).sort(), JSON.stringify(made[0]![1]).slice(0, 300)).toEqual(["described", "other", "past", "series"]);
|
||||
|
||||
// The app's own request. 1.0 indexes in the background, so give it a moment.
|
||||
let found: { id: string; uid: string; recurrenceRule?: unknown; recurrenceId?: string }[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const res = await jmap(alice, searchCalls(aid, word, "Etc/UTC", EVENT_PROPS));
|
||||
expect(res[0]![0], JSON.stringify(res[0]![1]).slice(0, 300)).toBe("CalendarEvent/query");
|
||||
expect(res[1]![0], JSON.stringify(res[1]![1]).slice(0, 300)).toBe("CalendarEvent/get");
|
||||
found = res[1]![1].list as typeof found;
|
||||
if (found.length >= 3) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
// The title, the description, and the series once, as its master.
|
||||
expect(found.map((e) => e.id).sort()).toEqual([created.described!.id, created.past!.id, created.series!.id].sort());
|
||||
const master = found.find((e) => e.id === created.series!.id)!;
|
||||
expect(master.recurrenceRule).toBeTruthy();
|
||||
expect(master.recurrenceId ?? null).toBeNull();
|
||||
|
||||
// Its next date and its latest, by uid, in the window the server allows.
|
||||
const session = await (await fetch(`${BASE}/jmap/session`, { headers: { authorization: auth(alice) } })).json() as { accounts: Record<string, { accountCapabilities: Record<string, { maxExpandedQueryDuration?: string }> }> };
|
||||
const windowMs = expandWindowMs(session.accounts[aid]!.accountCapabilities[CAP.calendars]?.maxExpandedQueryDuration);
|
||||
const now = new Date();
|
||||
const q = occurrenceQueries(aid, master.uid, now, windowMs, "Etc/UTC");
|
||||
const lookups = await jmap(alice, [["CalendarEvent/query", q.next, "n"], ["CalendarEvent/query", q.last, "l"]]);
|
||||
for (const r of lookups) expect(r[0], JSON.stringify(r[1]).slice(0, 300)).toBe("CalendarEvent/query");
|
||||
const [nextId] = lookups[0]![1].ids as string[];
|
||||
const [lastId] = lookups[1]![1].ids as string[];
|
||||
expect(nextId && lastId, JSON.stringify(lookups.map((r) => r[1].ids))).toBeTruthy();
|
||||
const got = (await jmap(alice, [["CalendarEvent/get", { accountId: aid, ids: [nextId, lastId], properties: EVENT_PROPS, timeZone: "Etc/UTC" }, "g"]]))[0]![1].list as { id: string; baseEventId?: string; utcStart?: string }[];
|
||||
const next = got.find((e) => e.id === nextId)!;
|
||||
const last = got.find((e) => e.id === lastId)!;
|
||||
// Placed by the master it came from, which is how the app pairs them.
|
||||
expect(next.baseEventId).toBe(master.id);
|
||||
expect(last.baseEventId).toBe(master.id);
|
||||
expect(new Date(next.utcStart!).getTime()).toBeGreaterThan(now.getTime());
|
||||
expect(new Date(last.utcStart!).getTime()).toBeLessThan(now.getTime());
|
||||
}, 60_000);
|
||||
|
||||
it("keeps the settings file the way the app does", async () => {
|
||||
// The client's own translation, against whichever shape this server wants
|
||||
// (fileNodeShape.ts). Stalwart 1.0 renamed nodeType and type, and refused
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expandWindowMs, icsEventMatches, occurrenceQueries, searchCalls, splitByNow, SEARCH_LIMIT } from "@/lib/calendar/search";
|
||||
import type { IcsEvent } from "@/lib/calendar/ics";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe("expandWindowMs", () => {
|
||||
it("stays a day inside what both Stalwart versions advertise", () => {
|
||||
// P52W1D is 365 days; 1.0 refused 366 with expandDurationTooLarge.
|
||||
expect(expandWindowMs("P52W1D")).toBe(364 * DAY);
|
||||
});
|
||||
|
||||
it("uses a smaller limit as it is, less the day", () => {
|
||||
expect(expandWindowMs("P30D")).toBe(29 * DAY);
|
||||
});
|
||||
|
||||
it("never goes past a year, and falls back to one when nothing is advertised", () => {
|
||||
expect(expandWindowMs("P104W")).toBe(364 * DAY);
|
||||
expect(expandWindowMs(undefined)).toBe(364 * DAY);
|
||||
expect(expandWindowMs("nonsense")).toBe(364 * DAY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchCalls", () => {
|
||||
it("asks for the newest matches first, with a limit, and reads them in the same request", () => {
|
||||
const calls = searchCalls("a1", "japanese", "Europe/Berlin", ["id", "title"]);
|
||||
const query = calls[0]!;
|
||||
const get = calls[1]!;
|
||||
expect(query[0]).toBe("CalendarEvent/query");
|
||||
expect(query[1]).toMatchObject({ accountId: "a1", filter: { text: "japanese" }, sort: [{ property: "start", isAscending: false }], limit: SEARCH_LIMIT });
|
||||
// A series must come back once, as its master: no expansion here.
|
||||
expect(query[1]).not.toHaveProperty("expandRecurrences");
|
||||
expect(get[1]).toMatchObject({ accountId: "a1", "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: ["id", "title"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrenceQueries", () => {
|
||||
const now = new Date(2026, 10, 6, 12, 0, 0);
|
||||
const { next, last } = occurrenceQueries("a1", "uid-1", now, 10 * DAY, "Etc/UTC");
|
||||
|
||||
it("asks for the first occurrence after now", () => {
|
||||
expect(next).toMatchObject({
|
||||
filter: { uid: "uid-1", after: "2026-11-06T12:00:00", before: "2026-11-16T12:00:00" },
|
||||
sort: [{ property: "start", isAscending: true }],
|
||||
expandRecurrences: true,
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("and the latest one before it", () => {
|
||||
expect(last).toMatchObject({
|
||||
filter: { uid: "uid-1", after: "2026-10-27T12:00:00", before: "2026-11-06T12:00:00" },
|
||||
sort: [{ property: "start", isAscending: false }],
|
||||
expandRecurrences: true,
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitByNow", () => {
|
||||
const now = new Date(2026, 10, 6, 12, 0);
|
||||
const at = (day: number, hour: number, name: string) => ({ name, start: new Date(2026, 10, day, hour), end: new Date(2026, 10, day, hour + 1) });
|
||||
|
||||
it("puts upcoming soonest first and past most recent first", () => {
|
||||
const { upcoming, past } = splitByNow([at(1, 9, "a"), at(20, 9, "d"), at(8, 9, "c"), at(4, 9, "b")], now);
|
||||
expect(upcoming.map((r) => r.name)).toEqual(["c", "d"]);
|
||||
expect(past.map((r) => r.name)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("counts an event still in progress as upcoming", () => {
|
||||
const inProgress = { name: "now", start: new Date(2026, 10, 6, 11, 30), end: new Date(2026, 10, 6, 12, 30) };
|
||||
const { upcoming, past } = splitByNow([inProgress], now);
|
||||
expect(upcoming.map((r) => r.name)).toEqual(["now"]);
|
||||
expect(past).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("icsEventMatches", () => {
|
||||
const ev = (o: Partial<IcsEvent>): IcsEvent => ({ uid: "u", summary: "", start: new Date(), end: new Date(), allDay: false, recurring: false, ...o });
|
||||
|
||||
it("matches whole words in any case, in the title, description or location, as the server does", () => {
|
||||
expect(icsEventMatches(ev({ summary: "Japanese class" }), "JAPANESE")).toBe(true);
|
||||
expect(icsEventMatches(ev({ summary: "Dentist", description: "then the Japanese place" }), "japanese")).toBe(true);
|
||||
expect(icsEventMatches(ev({ summary: "Lunch", location: "Japanese Garden Cafe" }), "garden")).toBe(true);
|
||||
expect(icsEventMatches(ev({ summary: "Japanese class" }), "japan")).toBe(false);
|
||||
});
|
||||
|
||||
it("needs every word of the query", () => {
|
||||
expect(icsEventMatches(ev({ summary: "Weekly Japanese class" }), "japanese class")).toBe(true);
|
||||
expect(icsEventMatches(ev({ summary: "Weekly Japanese class" }), "japanese dinner")).toBe(false);
|
||||
});
|
||||
|
||||
it("finds nothing for a query with no words", () => {
|
||||
expect(icsEventMatches(ev({ summary: "Anything" }), " — ")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Id } from "@/jmap/types";
|
||||
import type { IcsEvent } from "./ics";
|
||||
import { DAY_MS, parseDuration, toLocalDateTime } from "@/lib/dates";
|
||||
|
||||
/*
|
||||
* Event search: the requests, and the decisions about what comes back.
|
||||
*
|
||||
* The requests are built here rather than in the store so the compatibility
|
||||
* test sends exactly what the app sends. Found against 0.16.27 and 1.0.0 on
|
||||
* 2026-11-06:
|
||||
*
|
||||
* - `filter: { text }` matches the title, the description and the locations,
|
||||
* in any case, by whole word: "japanese" finds "Japanese Garden Cafe" and
|
||||
* "japan" finds nothing. Several words must all match.
|
||||
* - Without `expandRecurrences` a series comes back once, as its master, at
|
||||
* its first date. With it, both `after` and `before` are required, and the
|
||||
* span must not exceed `maxExpandedQueryDuration` (P52W1D on both): 1.0
|
||||
* refuses 366 days with `expandDurationTooLarge`.
|
||||
* - 1.0 indexes in the background, so an event saved a moment ago can be
|
||||
* missing from the first search after it.
|
||||
*
|
||||
* So a series is found as its master, and its next date (or, once it has
|
||||
* ended, its latest) is asked for by uid in a window the server allows.
|
||||
*/
|
||||
|
||||
/** Matches asked for from each account. */
|
||||
export const SEARCH_LIMIT = 100;
|
||||
|
||||
type Call = [method: string, args: Record<string, unknown>, id: string];
|
||||
|
||||
/** The text query and the read of what it found, as one request. */
|
||||
export function searchCalls(accountId: Id, text: string, timeZone: string, properties: string[]): Call[] {
|
||||
return [
|
||||
["CalendarEvent/query", { accountId, filter: { text }, sort: [{ property: "start", isAscending: false }], limit: SEARCH_LIMIT, timeZone }, "q"],
|
||||
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties, timeZone }, "g"],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How far either side of now a series is expanded to find its next and
|
||||
* latest dates: the server's limit less a day, and never more than a year.
|
||||
* The day is margin, not arithmetic: 1.0 refused a window of exactly its
|
||||
* advertised limit plus a day, and the cost of a shorter one is only that a
|
||||
* series meeting less than once a year shows its first date.
|
||||
*/
|
||||
export function expandWindowMs(maxExpandedQueryDuration: string | null | undefined): number {
|
||||
const year = 365 * DAY_MS;
|
||||
const limit = parseDuration(maxExpandedQueryDuration) * 1000;
|
||||
return (limit > 0 ? Math.min(limit, year) : year) - DAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query for a series' next date after `now`, and the one for its latest
|
||||
* before it: one occurrence each, by uid, expanded.
|
||||
*/
|
||||
export function occurrenceQueries(accountId: Id, uid: string, now: Date, windowMs: number, timeZone: string): { next: Record<string, unknown>; last: Record<string, unknown> } {
|
||||
const nowLocal = toLocalDateTime(now);
|
||||
return {
|
||||
next: {
|
||||
accountId,
|
||||
filter: { uid, after: nowLocal, before: toLocalDateTime(new Date(now.getTime() + windowMs)) },
|
||||
timeZone,
|
||||
sort: [{ property: "start", isAscending: true }],
|
||||
expandRecurrences: true,
|
||||
limit: 1,
|
||||
},
|
||||
last: {
|
||||
accountId,
|
||||
filter: { uid, after: toLocalDateTime(new Date(now.getTime() - windowMs)), before: nowLocal },
|
||||
timeZone,
|
||||
sort: [{ property: "start", isAscending: false }],
|
||||
expandRecurrences: true,
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Results split around now: upcoming soonest first, then past most recent first. */
|
||||
export function splitByNow<T extends { start: Date; end: Date }>(results: T[], now: Date): { upcoming: T[]; past: T[] } {
|
||||
const upcoming = results.filter((r) => r.end > now).sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const past = results.filter((r) => r.end <= now).sort((a, b) => b.start.getTime() - a.start.getTime());
|
||||
return { upcoming, past };
|
||||
}
|
||||
|
||||
/** Lowercase words, the way the query is split. */
|
||||
function words(s: string): string[] {
|
||||
return s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a subscribed calendar's event matches, decided here because those
|
||||
* events never reach the server. Same rule as the server's: every word of the
|
||||
* query is a whole word of the title, the description or the location.
|
||||
*/
|
||||
export function icsEventMatches(e: IcsEvent, text: string): boolean {
|
||||
const wanted = words(text);
|
||||
if (!wanted.length) return false;
|
||||
const have = new Set(words([e.summary, e.description ?? "", e.location ?? ""].join(" ")));
|
||||
return wanted.every((w) => have.has(w));
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)": "(件名なし)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)": "(без темы)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)": "(без теми)",
|
||||
|
||||
@@ -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)": "(无主题)",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useContacts } from "./contacts";
|
||||
import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays";
|
||||
import { looksLikeCalendar, parseIcs, toIcs, type IcsEvent } from "@/lib/calendar/ics";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { expandWindowMs, icsEventMatches, occurrenceQueries, searchCalls } from "@/lib/calendar/search";
|
||||
import { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
|
||||
@@ -226,6 +227,16 @@ export function occurrencePatch(patch: Record<string, unknown>): { patch: Record
|
||||
return { patch: out, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* One event search result. A series is one result, placed at its next date
|
||||
* (or its latest, once it has ended), and `rule` says how it repeats: the
|
||||
* occurrence it is placed at carries no rule of its own.
|
||||
*/
|
||||
export interface SearchHit {
|
||||
inst: EventInstance;
|
||||
rule?: JSCalendarRecurrenceRule;
|
||||
}
|
||||
|
||||
/** A calendar somebody else shared, and the account it lives in. */
|
||||
export interface SharedCalendar {
|
||||
accountId: Id;
|
||||
@@ -289,6 +300,8 @@ interface CalendarState {
|
||||
subscriptionsLoading: boolean;
|
||||
/** Waiting to be opened in the editor; see `EventDraft`. */
|
||||
draft: EventDraft | null;
|
||||
/** Bumped whenever events are loaded again after a change, for views that hold their own copy. */
|
||||
changes: number;
|
||||
|
||||
init(): Promise<void>;
|
||||
loadCalendars(): Promise<void>;
|
||||
@@ -301,6 +314,10 @@ interface CalendarState {
|
||||
instancesIn(start: Date, end: Date): EventInstance[];
|
||||
/** Re-fetch every subscribed calendar. */
|
||||
refreshSubscriptions(): Promise<void>;
|
||||
/** Events matching `text`, from the reader's own calendars and the shared ones drawn beside them. */
|
||||
searchEvents(text: string): Promise<SearchHit[]>;
|
||||
/** The same search over subscribed calendars, which the server never sees. */
|
||||
searchSubscriptions(text: string): SearchHit[];
|
||||
/*
|
||||
* The event calls take the account the event lives in -- `EventInstance`'s
|
||||
* `accountId` -- and default to the reader's own. See `EventInstance`.
|
||||
@@ -440,6 +457,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
subscriptionErrors: {},
|
||||
subscriptionsLoading: false,
|
||||
draft: null,
|
||||
changes: 0,
|
||||
|
||||
async init() {
|
||||
// The reader's own: a shared calendar is shown beside theirs, not instead.
|
||||
@@ -740,6 +758,93 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
return [...out, ...birthdays];
|
||||
},
|
||||
|
||||
/*
|
||||
* Searched account by account: the reader's own, and each account that
|
||||
* shared a calendar the reader draws. Everything the reader has, hidden
|
||||
* calendars included, because a search is for finding what isn't on screen.
|
||||
* A shared account that refuses is left out rather than failing the rest;
|
||||
* the reader's own refusing is an error worth showing.
|
||||
*/
|
||||
async searchEvents(text) {
|
||||
const own = get().accountId;
|
||||
const tz = settings().timeZone ?? browserTimeZone;
|
||||
const now = new Date();
|
||||
const added = new Set(settings().addedShares);
|
||||
const drawn: Record<Id, Record<Id, Calendar>> = {};
|
||||
for (const c of get().sharedCalendars) {
|
||||
// Reachable is not shared; see the same check in `instancesIn`.
|
||||
if (!c.calendar.isSubscribed && !added.has(sharedKey(c.accountId, c.calendar.id))) continue;
|
||||
(drawn[c.accountId] ??= {})[c.calendar.id] = c.calendar;
|
||||
}
|
||||
const accounts: { accountId: Id; calendars: Record<Id, Calendar>; shared: boolean }[] = [];
|
||||
if (own) accounts.push({ accountId: own, calendars: get().calendars, shared: false });
|
||||
for (const [accountId, calendars] of Object.entries(drawn)) accounts.push({ accountId, calendars, shared: true });
|
||||
|
||||
const answers = await Promise.all(accounts.map(async ({ accountId, calendars, shared }): Promise<SearchHit[]> => {
|
||||
try {
|
||||
const res = await client.chain(searchCalls(accountId, text, tz, EVENT_PROPS));
|
||||
const found = (res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>).list.filter((e) => {
|
||||
const calId = Object.keys(e.calendarIds ?? {})[0];
|
||||
return !shared || Boolean(calId && calendars[calId]);
|
||||
});
|
||||
|
||||
/* Each series' next date, or its latest: asked for by uid, every one
|
||||
in the same request (`client.call` batches a tick's calls). A
|
||||
series whose lookup fails is listed at its first date. */
|
||||
const windowMs = expandWindowMs(client.accountCapability<{ maxExpandedQueryDuration?: string }>(accountId, CAP.calendars)?.maxExpandedQueryDuration);
|
||||
const series = found.filter((e) => isRecurring(e) && e.uid);
|
||||
const wanted = await Promise.all(series.map(async (e) => {
|
||||
const q = occurrenceQueries(accountId, e.uid!, now, windowMs, tz);
|
||||
const [next, last] = await Promise.all([
|
||||
client.call<QueryResponse>("CalendarEvent/query", q.next).catch(() => null),
|
||||
client.call<QueryResponse>("CalendarEvent/query", q.last).catch(() => null),
|
||||
]);
|
||||
return next?.ids?.[0] ?? last?.ids?.[0];
|
||||
}));
|
||||
const ids = wanted.filter((id): id is Id => Boolean(id));
|
||||
const placed = new Map<Id, CalendarEvent>();
|
||||
for (const part of chunk(ids, client.maxObjectsInGet)) {
|
||||
const g = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: part, properties: EVENT_PROPS, timeZone: tz });
|
||||
// Keyed by the master it came from: a uid can name the same event
|
||||
// in two calendars, and only this master's own date will do.
|
||||
for (const occ of g.list) if (occ.baseEventId) placed.set(occ.baseEventId, occ);
|
||||
}
|
||||
|
||||
const hits: SearchHit[] = [];
|
||||
for (const e of found) {
|
||||
const recurring = isRecurring(e);
|
||||
const inst = toInstance((recurring && placed.get(e.id)) || e, calendars);
|
||||
if (!inst) continue;
|
||||
if (shared) {
|
||||
inst.key = sharedKey(accountId, inst.event.id);
|
||||
inst.accountId = accountId;
|
||||
}
|
||||
hits.push({ inst, rule: recurring ? eventRule(e) : undefined });
|
||||
}
|
||||
return hits;
|
||||
} catch (err) {
|
||||
if (!shared) throw err;
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
return answers.flat();
|
||||
},
|
||||
|
||||
searchSubscriptions(text) {
|
||||
const hits: SearchHit[] = [];
|
||||
for (const sub of settings().icalSubscriptions) {
|
||||
const calId = subscriptionCalendarId(sub.id);
|
||||
const cal = subscriptionCalendar(sub);
|
||||
for (const e of get().subscriptionEvents[sub.id] ?? []) {
|
||||
if (!icsEventMatches(e, text)) continue;
|
||||
hits.push({
|
||||
inst: { key: `${calId}:${e.uid}:${e.start.getTime()}`, event: synthesizeSubscriptionEvent(sub.id, e), start: e.start, end: e.end, allDay: e.allDay, calendar: cal },
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
|
||||
async getEvent(id, owner) {
|
||||
const accountId = owner ?? get().accountId;
|
||||
if (!accountId) return null;
|
||||
@@ -1064,6 +1169,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
* calendar go blank until the answers came back.
|
||||
*/
|
||||
invalidate() {
|
||||
set((s) => ({ changes: s.changes + 1 }));
|
||||
for (const k of Object.keys(get().ranges)) {
|
||||
const [s, e] = k.split("|").map(Number) as [number, number];
|
||||
void get().loadRange(new Date(s), new Date(e), true);
|
||||
|
||||
@@ -2023,6 +2023,12 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.agenda-ev:hover { background: var(--bg-hover); }
|
||||
.agenda-ev .ev-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.agenda-ev .ev-when { width: 140px; color: var(--fg-muted); font-size: .9em; flex: 0 0 auto; }
|
||||
/* Event search: Upcoming and Past headings, and past results faded the way
|
||||
the calendars people come from show them. */
|
||||
.event-search .search-section { margin: 16px 0 0; font-size: .82em; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--fg-muted); }
|
||||
.event-search .search-section:first-child { margin-top: 4px; }
|
||||
.event-search .agenda-day.past { opacity: .55; }
|
||||
.event-search .agenda-day.past:hover { opacity: .85; }
|
||||
.event-popover { width: 360px; max-width: calc(100vw - 24px); padding: 14px 16px; }
|
||||
.event-popover h3 { margin: 0 0 6px; font-size: 1.15em; font-weight: 650; padding-left: 16px; position: relative; }
|
||||
.event-popover h3::before { content: ""; position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 3px; background: var(--ev-color, var(--accent)); }
|
||||
|
||||
+20
-10
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user