From e6a89ad07ae8af7e06c7c9ead0aff7e4bd8db0c5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:22:59 +0100 Subject: [PATCH 1/4] feat: show invited participants' free/busy in the event editor --- .../__tests__/calendar-availability.test.ts | 213 ++++++++++++ .../__tests__/live-stalwart-free-busy.test.ts | 157 +++++++++ src/api/calendar-availability.ts | 322 ++++++++++++++++++ src/components/calendar/EventModal.tsx | 9 + .../calendar/ParticipantAvailability.tsx | 178 ++++++++++ .../participant-availability-wiring.test.ts | 21 ++ src/lib/calendar-free-busy.ts | 92 +++++ 7 files changed, 992 insertions(+) create mode 100644 src/api/__tests__/calendar-availability.test.ts create mode 100644 src/api/__tests__/live-stalwart-free-busy.test.ts create mode 100644 src/api/calendar-availability.ts create mode 100644 src/components/calendar/ParticipantAvailability.tsx create mode 100644 src/components/calendar/__tests__/participant-availability-wiring.test.ts create mode 100644 src/lib/calendar-free-busy.ts diff --git a/src/api/__tests__/calendar-availability.test.ts b/src/api/__tests__/calendar-availability.test.ts new file mode 100644 index 00000000..a39b9685 --- /dev/null +++ b/src/api/__tests__/calendar-availability.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'u', + request: vi.fn(), + currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown, + hasCapability: vi.fn(() => true), + getAccountCapability: vi.fn(() => ({ currentUserPrincipalId: 'u' })), + getMaxCallsInRequest: vi.fn(() => 16), + }, +})); + +vi.mock('../files', () => ({ + listPrincipals: vi.fn(), +})); + +import { jmapClient } from '../jmap-client'; +import { listPrincipals } from '../files'; +import { + checkAvailabilityAccess, + getPrincipalsAvailability, + lookUpAvailabilityStatuses, + mayGetAvailability, + resetAvailabilityCaches, + supportsAvailability, +} from '../calendar-availability'; +import { editorAvailabilityWindow, availabilityFor } from '../../lib/calendar-free-busy'; + +// Ported from the webmail's lib/jmap/__tests__/free-busy.test.ts and the +// availability cases of components/calendar/__tests__/participant-availability.test.tsx. + +type Call = [string, Record, string]; +const mockRequest = jmapClient.request as ReturnType; +const mockHasCapability = jmapClient.hasCapability as ReturnType; +const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType; +const mockListPrincipals = listPrincipals as ReturnType; + +const DIRECTORY = { + status: 'ok', + principals: [ + { id: 'a', type: 'individual', name: 'Alice', email: 'alice@example.org' }, + { id: 'b', type: 'individual', name: 'Bob', email: 'Bob@example.org' }, + { id: 'c', type: 'individual', name: 'Carol', email: 'carol@example.org' }, + ], +}; + +// The busy periods each principal has on the server. +let busy: Record> = {}; +let refuse: Record = {}; + +function answerAvailability(calls: Call[]) { + return { + methodResponses: calls.map(([method, args, id]) => { + if (method !== 'Principal/getAvailability') return ['error', { type: 'unknownMethod' }, id]; + const pid = args.id as string; + if (refuse[pid]) return ['error', { type: refuse[pid] }, id]; + return ['Principal/getAvailability', { list: busy[pid] ?? [] }, id]; + }), + }; +} + +const start = new Date('2027-03-10T09:00:00Z'); +const end = new Date('2027-03-10T10:00:00Z'); + +beforeEach(() => { + resetAvailabilityCaches(); + mockRequest.mockReset(); + mockRequest.mockImplementation(async (calls: Call[]) => answerAvailability(calls)); + mockHasCapability.mockImplementation(() => true); + mockMaxCalls.mockReturnValue(16); + mockListPrincipals.mockReset(); + mockListPrincipals.mockResolvedValue(DIRECTORY); + busy = {}; + refuse = {}; +}); + +describe('free/busy statuses of the invited attendees', () => { + it('tells busy, tentative, free, no busy times and outside users apart', async () => { + busy = { + a: [{ utcStart: '2027-03-10T09:30:00Z', utcEnd: '2027-03-10T11:00:00Z', busyStatus: 'confirmed' }], + b: [{ utcStart: '2027-03-10T08:00:00Z', utcEnd: '2027-03-10T09:15:00Z', busyStatus: 'tentative' }], + // Busy only after the event: free for its window. + c: [{ utcStart: '2027-03-10T10:00:00Z', utcEnd: '2027-03-10T11:00:00Z' }], + }; + const statuses = await lookUpAvailabilityStatuses( + ['alice@example.org', 'bob@example.org', 'carol@example.org', 'dave@elsewhere.net'], + start, + end, + ); + expect(statuses).toEqual({ + 'alice@example.org': 'busy', + 'bob@example.org': 'tentative', + 'carol@example.org': 'free', + 'dave@elsewhere.net': 'unknown', + }); + }); + + it('says no busy times rather than available for an empty answer', async () => { + const statuses = await lookUpAvailabilityStatuses(['alice@example.org'], start, end); + expect(statuses).toEqual({ 'alice@example.org': 'no_busy_times' }); + }); + + it('asks for the whole local days, without event details, and in one request', async () => { + await lookUpAvailabilityStatuses(['alice@example.org', 'bob@example.org'], start, end); + expect(mockRequest).toHaveBeenCalledTimes(1); + const [calls, using] = mockRequest.mock.calls[0] as [Call[], string[]]; + expect(using).toContain('urn:ietf:params:jmap:principals:availability'); + expect(calls.map((c) => c[1].id)).toEqual(['a', 'b']); + for (const [, args] of calls) { + expect(args.showDetails).toBe(false); + expect(args.utcStart).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); + expect(new Date(args.utcStart as string).getTime()).toBeLessThanOrEqual(start.getTime()); + expect(new Date(args.utcEnd as string).getTime()).toBeGreaterThanOrEqual(end.getTime()); + } + }); + + it('answers a move within the same day from the cache', async () => { + await lookUpAvailabilityStatuses(['alice@example.org'], start, end); + await lookUpAvailabilityStatuses(['alice@example.org'], new Date('2027-03-10T13:00:00Z'), new Date('2027-03-10T14:00:00Z')); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('splits the lookups across requests by maxCallsInRequest', async () => { + mockMaxCalls.mockReturnValue(2); + const result = await getPrincipalsAvailability(['a', 'b', 'c'], start, end); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(Object.keys(result)).toEqual(['a', 'b', 'c']); + }); + + it('stops asking for a minute after the server rate-limits', async () => { + refuse = { a: 'rateLimit' }; + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' }); + refuse = {}; + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' }); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('gives up on free/busy when Stalwart 1.0 has directory queries off', async () => { + mockListPrincipals.mockResolvedValue({ status: 'restricted', directoryQueriesOff: true, principals: [] }); + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toBeNull(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('gives up on free/busy when a lookup is refused as forbidden', async () => { + refuse = { a: 'forbidden' }; + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toBeNull(); + }); + + it('cannot call an address unknown when the directory listing was incomplete', async () => { + mockListPrincipals.mockResolvedValue({ status: 'error', principals: DIRECTORY.principals.slice(0, 1) }); + expect(await lookUpAvailabilityStatuses(['zed@example.org'], start, end)).toEqual({ 'zed@example.org': 'error' }); + }); + + it('counts a failed request as unknown, never as available', async () => { + mockRequest.mockRejectedValue(new Error('offline')); + expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' }); + }); +}); + +describe('whether free/busy is offered', () => { + it('needs the principals:availability capability', () => { + mockHasCapability.mockImplementation((urn: string) => urn !== 'urn:ietf:params:jmap:principals:availability'); + expect(supportsAvailability()).toBe(false); + }); + + it('reads mayGetAvailability from the user\'s own principal (1.0)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Principal/get', { + list: [{ id: 'u', capabilities: { 'urn:ietf:params:jmap:calendars': { mayGetAvailability: false } } }], + }, '0']], + }); + expect(await mayGetAvailability()).toBe(false); + }); + + it('goes ahead on an empty calendars capability (0.16)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Principal/get', { list: [{ id: 'u', capabilities: { 'urn:ietf:params:jmap:calendars': {} } }] }, '0']], + }); + expect(await mayGetAvailability()).toBe(true); + }); + + it('asks once per session', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Principal/get', { list: [{ id: 'u', capabilities: {} }] }, '0']], + }); + await Promise.all([checkAvailabilityAccess(), checkAvailabilityAccess()]); + await checkAvailabilityAccess(); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('the event window of the editor', () => { + it('spans the whole days of an all-day event, its inclusive last day included', () => { + const window = editorAvailabilityWindow({ + allDay: true, + start: new Date(2027, 2, 10, 15, 0), + end: new Date(2027, 2, 11, 15, 0), + })!; + expect(window.end.getTime() - window.start.getTime()).toBe(2 * 86_400_000 - 1000); + }); + + it('has no window while the end is before the start', () => { + expect(editorAvailabilityWindow({ allDay: false, start: new Date(2027, 2, 10, 10), end: new Date(2027, 2, 10, 9) })).toBeNull(); + }); + + it('lets an unavailable overlap win over a tentative one', () => { + expect(availabilityFor([ + { utcStart: '2027-03-10T09:00:00Z', utcEnd: '2027-03-10T09:30:00Z', busyStatus: 'tentative' }, + { utcStart: '2027-03-10T09:30:00Z', utcEnd: '2027-03-10T10:00:00Z', busyStatus: 'unavailable' }, + ], start, end)).toBe('busy'); + }); +}); diff --git a/src/api/__tests__/live-stalwart-free-busy.test.ts b/src/api/__tests__/live-stalwart-free-busy.test.ts new file mode 100644 index 00000000..746e9e0d --- /dev/null +++ b/src/api/__tests__/live-stalwart-free-busy.test.ts @@ -0,0 +1,157 @@ +/** + * Live free/busy checks (Principal/getAvailability) against a real Stalwart. + * + * Opt-in like live-stalwart-calendar.test.ts: skipped unless + * `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same + * password) are set. The second account gets a busy event in 2027, which the + * first one looks up the way the event editor does; the event carries the + * run's marker and is removed afterwards. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \ + * BULWARK_LIVE_USER=e2e-perf@example.org \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-free-busy.test.ts + * + * Verified against Stalwart 0.16.27 and 1.0.1. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { + checkAvailabilityAccess, + loadPrincipalDirectory, + lookUpAvailabilityStatuses, + resetAvailabilityCaches, + supportsAvailability, +} from '../calendar-availability'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; +const RUN = `live-fb-${Date.now().toString(36)}`; + +type Response = [string, Record, string]; + +async function raw(c: JMAPClient, calls: [string, Record, string][]): Promise { + return (await c.request(calls, USING)).methodResponses as Response[]; +} + +/** Stalwart 1.0 advertises JMAP Metadata; 0.16 does not. */ +function isStalwart10(c: JMAPClient): boolean { + return Object.values(c.currentSession?.accounts ?? {}).some( + (account) => 'urn:ietf:params:jmap:metadata' in (account.accountCapabilities ?? {}), + ); +} + +liveTwo('live Stalwart free/busy', () => { + let other: JMAPClient; + let client: JMAPClient; + const busyIds: string[] = []; + let sharedCalendarId = ''; + + async function addBusy(calendarId: string, start: string): Promise { + const created = await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + create: { + busy: { + '@type': 'Event', + title: `${RUN} busy`, + calendarIds: { [calendarId]: true }, + start, + timeZone: 'UTC', + duration: 'PT1H', + freeBusyStatus: 'busy', + }, + }, + }, '0']]); + const id = created[0][1].created?.busy?.id; + expect(id, JSON.stringify(created[0][1].notCreated)).toBeTruthy(); + busyIds.push(id); + } + + beforeAll(async () => { + const mod = await import('../jmap-client'); + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); + resetAvailabilityCaches(); + + other = new JMAPClient(); + await other.connect(SERVER!, USER2!, PASS); + const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]); + const list = cals[0][1].list as { id: string; isDefault?: boolean }[]; + // A busy hour in the default calendar, which shares nothing. + await addBusy((list.find((c) => c.isDefault) ?? list[0]).id, '2027-04-14T10:00:00'); + if (!isStalwart10(client)) { + // And one in a calendar of its own that lets the first account see + // free/busy, which is what 0.16 asks before it answers. + const found = await other.request([['Principal/query', { accountId: other.accountId, filter: { email: USER } }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]); + const me = (found.methodResponses[0][1].ids as string[])[0]; + const made = await raw(other, [['Calendar/set', { + accountId: other.accountId, + create: { fb: { name: `${RUN} free/busy`, shareWith: { [me]: { mayReadFreeBusy: true } } } }, + }, '0']]); + sharedCalendarId = made[0][1].created?.fb?.id ?? ''; + expect(sharedCalendarId, JSON.stringify(made[0][1].notCreated)).toBeTruthy(); + await addBusy(sharedCalendarId, '2027-04-15T10:00:00'); + } + }, 60_000); + + afterAll(async () => { + if (busyIds.length > 0) { + await raw(other, [['CalendarEvent/set', { accountId: other.accountId, destroy: busyIds }, '0']]).catch(() => undefined); + } + if (sharedCalendarId) { + await raw(other, [['Calendar/set', { accountId: other.accountId, destroy: [sharedCalendarId], onDestroyRemoveEvents: true }, '0']]) + .catch(() => undefined); + } + }, 60_000); + + it('is advertised by the server', () => { + expect(supportsAvailability()).toBe(true); + }); + + it('says free/busy is not available on 1.0 with directory queries off, without looking anyone up', async () => { + if (!isStalwart10(client)) return; + // 1.0 keeps directory queries off by default: no address can be + // matched to a principal, and the editor says so in one line. + const directory = await loadPrincipalDirectory(); + expect(directory.status).toBe('restricted'); + expect(directory.directoryQueriesOff).toBe(true); + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase()], + new Date('2027-04-14T10:30:00Z'), + new Date('2027-04-14T11:30:00Z'), + )).toBeNull(); + }); + + it('shows no busy times for someone whose calendars share no free/busy (0.16)', async () => { + if (isStalwart10(client)) return; + expect(await checkAvailabilityAccess()).toBe(true); + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase()], + new Date('2027-04-14T10:30:00Z'), + new Date('2027-04-14T11:30:00Z'), + )).toEqual({ [USER2!.toLowerCase()]: 'no_busy_times' }); + }); + + it('shows the attendee busy during a shared busy hour and free next to it (0.16)', async () => { + if (isStalwart10(client)) return; + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase()], + new Date('2027-04-15T10:30:00Z'), + new Date('2027-04-15T11:30:00Z'), + )).toEqual({ [USER2!.toLowerCase()]: 'busy' }); + expect(await lookUpAvailabilityStatuses( + [USER2!.toLowerCase(), 'nobody@elsewhere.invalid'], + new Date('2027-04-15T12:00:00Z'), + new Date('2027-04-15T13:00:00Z'), + )).toEqual({ [USER2!.toLowerCase()]: 'free', 'nobody@elsewhere.invalid': 'unknown' }); + }); +}); diff --git a/src/api/calendar-availability.ts b/src/api/calendar-availability.ts new file mode 100644 index 00000000..d6f5f5bc --- /dev/null +++ b/src/api/calendar-availability.ts @@ -0,0 +1,322 @@ +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import type { Principal } from './types'; +import { batched } from './jmap-result'; +import { listPrincipals, type PrincipalListing } from './files'; +import { + availabilityWindow, + statusFromAnswer, + type AvailabilityStatus, + type BusyPeriod, + type PrincipalAvailability, +} from '../lib/calendar-free-busy'; + +// Free/busy (Principal/getAvailability) lookups for the event editor. Ported +// from the webmail's lib/jmap/free-busy.ts, lib/jmap/principal-directory.ts, +// the availability methods of lib/jmap/client.ts and lookUpStatuses in +// components/calendar/participant-availability.tsx. +// +// Whether the server answers free/busy for this user at all is asked once +// per session. Stalwart 1.0 says so on the user's own principal +// (`mayGetAvailability`), and refuses to list its directory while the +// administrator keeps directory queries off (its default): without the +// directory no address can be matched to a principal, so the editor says +// free/busy isn't available. Busy periods are cached per principal and window +// for a few minutes: the editor looks everyone up again whenever the time or +// the attendees change, and Stalwart 1.0 allows 100 getAvailability calls per +// account a minute, counting every call however they are batched. + +export const PRINCIPALS_AVAILABILITY_CAPABILITY = 'urn:ietf:params:jmap:principals:availability'; +export const AVAILABILITY_TTL_MS = 3 * 60 * 1000; +/** + * Stalwart counts availability calls in fixed one-minute windows and refuses + * every call until the window is over, without saying when that is. A full + * minute after a refusal is always past it. + */ +export const RATE_LIMIT_BACKOFF_MS = 60 * 1000; +export const PRINCIPAL_DIRECTORY_TTL_MS = 5 * 60 * 1000; + +function principalsUsing(): string[] { + return [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]; +} + +function availabilityUsing(): string[] { + return [...principalsUsing(), PRINCIPALS_AVAILABILITY_CAPABILITY]; +} + +/** The session the caches belong to: another account or server starts over. */ +function sessionKey(): string { + return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`; +} + +/** Whether the server can answer free/busy queries for its principals. */ +export function supportsAvailability(): boolean { + return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS) + && jmapClient.hasCapability(PRINCIPALS_AVAILABILITY_CAPABILITY); +} + +/** + * Whether this user may ask for other people's free/busy, from the user's + * own principal: Stalwart 1.0 sets `capabilities[calendars].mayGetAvailability` + * to false when it won't answer. 0.16 leaves that object empty, which means + * go ahead. A `forbidden` answer (a role without the principal permissions) + * means no as well; other failures throw. + */ +export async function mayGetAvailability(): Promise { + if (!supportsAvailability()) return false; + const accountId = jmapClient.accountId; + const principals = jmapClient.getAccountCapability(CAPABILITIES.PRINCIPALS, accountId) as + { currentUserPrincipalId?: string | null } | undefined; + const ownId = principals?.currentUserPrincipalId || accountId; + const response = await jmapClient.request( + [['Principal/get', { accountId, ids: [ownId], properties: ['id', 'capabilities'] }, '0']], + principalsUsing(), + ); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== 'Principal/get') { + if (result?.type === 'forbidden') return false; + throw new Error(result?.description || "Failed to read the user's principal"); + } + const own = ((result?.list ?? []) as Array<{ id: string; capabilities?: Record }>) + .find((p) => p.id === ownId); + const calendars = own?.capabilities?.[CAPABILITIES.CALENDARS] as { mayGetAvailability?: boolean } | undefined; + return calendars?.mayGetAvailability !== false; +} + +/** The busy periods of a Principal/getAvailability response. */ +function busyPeriodsOf(result: { list?: unknown } | undefined): BusyPeriod[] { + const list = (result?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }>; + return list.map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null })); +} + +function availabilityArgs(principalId: string, utcStart: Date, utcEnd: Date): Record { + return { + accountId: jmapClient.accountId, + id: principalId, + utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'), + utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'), + // Only whether someone is free, never what they are doing. + showDetails: false, + }; +} + +/** + * The busy periods of several principals over one window, batched into as + * few requests as maxCallsInRequest allows: Principal/getAvailability takes + * a single id. Each principal gets its periods, or the error type its call + * was refused with. A failed request throws. + */ +export async function getPrincipalsAvailability( + principalIds: string[], + utcStart: Date, + utcEnd: Date, +): Promise> { + const results: Record = {}; + if (!supportsAvailability()) return results; + for (const batch of batched(principalIds, jmapClient.getMaxCallsInRequest())) { + const response = await jmapClient.request( + batch.map((id, i): [string, Record, string] => [ + 'Principal/getAvailability', availabilityArgs(id, utcStart, utcEnd), String(i), + ]), + availabilityUsing(), + ); + const byCallId = new Map(((response.methodResponses ?? []) as [string, any, string][]).map((r) => [r[2], r])); + batch.forEach((id, i) => { + const [method, result] = byCallId.get(String(i)) ?? []; + results[id] = method === 'Principal/getAvailability' + ? { periods: busyPeriodsOf(result) } + : { error: (result as { type?: string } | undefined)?.type || 'serverFail' }; + }); + } + return results; +} + +// ── Access check, once per session ─────────────────────────────── + +const access = new Map(); +const pendingChecks = new Map>(); + +/** What an earlier check learned, or undefined when nothing has been asked yet. */ +export function knownAvailabilityAccess(): boolean | undefined { + return access.get(sessionKey()); +} + +/** Asks the server once per session whether free/busy lookups are allowed. Never rejects. */ +export function checkAvailabilityAccess(): Promise { + const key = sessionKey(); + const known = access.get(key); + if (known !== undefined) return Promise.resolve(known); + let check = pendingChecks.get(key); + if (!check) { + check = mayGetAvailability() + .then((allowed) => { + access.set(key, allowed); + return allowed; + }) + // A failed check proves nothing either way: look up this time and ask + // again next time. + .catch(() => true) + .finally(() => pendingChecks.delete(key)); + pendingChecks.set(key, check); + } + return check; +} + +/** The server refused a lookup outright; stop asking in this session. */ +export function revokeAvailabilityAccess(): void { + access.set(sessionKey(), false); +} + +// ── Principal directory, shared while fresh ────────────────────── + +const directories = new Map }>(); + +/** + * The server's principal listing, shared while it is fresh; callers that + * arrive while it loads get the same request. Never rejects. + */ +export function loadPrincipalDirectory(): Promise { + const key = sessionKey(); + const cached = directories.get(key); + if (cached && Date.now() - cached.loadedAt < PRINCIPAL_DIRECTORY_TTL_MS) return cached.listing; + const listing = listPrincipals().catch((): PrincipalListing => ({ principals: [], status: 'error' })); + const entry = { loadedAt: Date.now(), listing }; + directories.set(key, entry); + void listing.then(({ status }) => { + // A failed listing isn't kept, so the next caller asks again. + if (status === 'error' && directories.get(key) === entry) directories.delete(key); + }); + return listing; +} + +// ── Cached lookups ─────────────────────────────────────────────── + +interface CachedLookup { + requestedAt: number; + result: Promise; +} + +interface LookupState { + cache: Map; + backoffUntil: number; +} + +const lookups = new Map(); + +function lookupState(): LookupState { + const key = sessionKey(); + let state = lookups.get(key); + if (!state) { + state = { cache: new Map(), backoffUntil: 0 }; + lookups.set(key, state); + } + return state; +} + +/** + * The busy periods of each principal over the local days from `start` to + * `end`, for the caller to match against the exact event window. Fresh + * answers come from the cache, lookups already on their way are shared, and + * the rest go out as one batched request. Never rejects: a failed request + * counts as `serverUnavailable`, and while the server is rate limiting, the + * answer is `rateLimit` without asking. + */ +export async function lookUpAvailability( + principalIds: string[], + start: Date, + end: Date, +): Promise> { + const state = lookupState(); + const { cache } = state; + const now = Date.now(); + for (const [key, entry] of cache) { + if (now - entry.requestedAt >= AVAILABILITY_TTL_MS) cache.delete(key); + } + + const days = availabilityWindow(start, end); + const keyOf = (id: string) => `${id}|${days.start.getTime()}|${days.end.getTime()}`; + const results = new Map>(); + const missing: string[] = []; + for (const id of new Set(principalIds)) { + const cached = cache.get(keyOf(id)); + if (cached) results.set(id, cached.result); + else missing.push(id); + } + + if (missing.length > 0 && now < state.backoffUntil) { + for (const id of missing) results.set(id, Promise.resolve({ error: 'rateLimit' })); + } else if (missing.length > 0) { + const batch = getPrincipalsAvailability(missing, days.start, days.end) + .catch((): Record => ({})); + for (const id of missing) { + const key = keyOf(id); + const entry: CachedLookup = { + requestedAt: now, + result: batch.then((answers) => answers[id] ?? { error: 'serverUnavailable' }), + }; + cache.set(key, entry); + results.set(id, entry.result); + void entry.result.then((result) => { + if (!('error' in result)) return; + // Only answers are kept: a refused lookup is asked again next time. + if (cache.get(key) === entry) cache.delete(key); + if (result.error === 'rateLimit') { + state.backoffUntil = Math.max(state.backoffUntil, Date.now() + RATE_LIMIT_BACKOFF_MS); + } + }); + } + } + + const settled = await Promise.all([...results].map(async ([id, result]) => [id, await result] as const)); + return Object.fromEntries(settled); +} + +/** + * The status of every address for the window, or null when the server won't + * answer free/busy for this user: Stalwart 1.0 with directory queries off, + * or a lookup refused with `forbidden`. + */ +export async function lookUpAvailabilityStatuses( + emails: string[], + start: Date, + end: Date, +): Promise | null> { + const directory = await loadPrincipalDirectory(); + // With directory queries off, Stalwart 1.0 can't match an address to a + // principal. Any other refused listing is a role without the permission + // to list principals, which is not the one for free/busy: the principals + // Principal/get still returned are looked up, and a lookup refused in turn + // says it. + if (directory.status === 'restricted' && directory.directoryQueriesOff) return null; + const byEmail = new Map(); + for (const p of directory.principals) { + if (p.email) byEmail.set(p.email.toLowerCase(), p); + } + const principalIds = emails.flatMap((email) => byEmail.get(email)?.id ?? []); + const answers = await lookUpAvailability(principalIds, start, end); + const statuses: Record = {}; + for (const email of emails) { + const principal = byEmail.get(email); + if (!principal) { + // Only a complete listing shows that an address is not a user here. + statuses[email] = directory.status === 'ok' ? 'unknown' : 'error'; + continue; + } + const answer = answers[principal.id]; + if ('error' in answer) { + if (answer.error === 'forbidden') return null; + statuses[email] = 'error'; + } else { + statuses[email] = statusFromAnswer(answer.periods, start, end); + } + } + return statuses; +} + +/** Forget every cached answer (tests, sign-out). */ +export function resetAvailabilityCaches(): void { + access.clear(); + pendingChecks.clear(); + directories.clear(); + lookups.clear(); +} diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 316596bc..15587b71 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -73,6 +73,8 @@ import { import type { SeriesEdit } from '../../lib/recurrence-scope-edit'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; +import { ParticipantAvailability } from './ParticipantAvailability'; +import { editorAvailabilityWindow } from '../../lib/calendar-free-busy'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { RecurrenceEditor } from './RecurrenceEditor'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; @@ -257,6 +259,12 @@ export function EventModal({ const [showEndTime, setShowEndTime] = React.useState(false); const [saving, setSaving] = React.useState(false); + // The event window the attendees' free/busy is checked against. + const availabilityWindow = React.useMemo( + () => editorAvailabilityWindow({ allDay, start, end }), + [allDay, start, end], + ); + // What the form holds against what it opened with: Close and Back ask // before they drop changes (webmail 13f9f27c4). const formSnapshot = React.useMemo(() => editorFormSnapshot({ @@ -901,6 +909,7 @@ export function EventModal({ )} + {organizes && attendees.length > 0 && ( diff --git a/src/components/calendar/ParticipantAvailability.tsx b/src/components/calendar/ParticipantAvailability.tsx new file mode 100644 index 00000000..61113f70 --- /dev/null +++ b/src/components/calendar/ParticipantAvailability.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useAuthStore } from '../../stores/auth-store'; +import type { Attendee } from '../../lib/calendar-participants'; +import { attendeeEmails, type AvailabilityStatus } from '../../lib/calendar-free-busy'; +import { + checkAvailabilityAccess, + knownAvailabilityAccess, + lookUpAvailabilityStatuses, + revokeAvailabilityAccess, + supportsAvailability, +} from '../../api/calendar-availability'; + +interface ParticipantAvailabilityProps { + attendees: Attendee[]; + /** The event window as real instants; null while the form has no valid dates. */ + window: { start: Date; end: Date } | null; +} + +const STATUS_FALLBACKS: Record = { + free: 'Available', + busy: 'Busy', + tentative: 'Tentative', + no_busy_times: 'No busy times shared', + unknown: 'Not a user on this server', + error: "Couldn't check", + checking: 'Checking…', +}; + +/** + * Free/busy of the invited attendees for the event window + * (Principal/getAvailability), as the webmail's + * components/calendar/participant-availability.tsx shows it. Only attendees + * that are principals on this server can be checked; external addresses show + * as unknown. When the server won't answer free/busy for this user, one line + * says so; nothing is shown when it doesn't offer free/busy at all. + */ +export function ParticipantAvailability({ attendees, window }: ParticipantAvailabilityProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + // Another account or server asks its own question. + const session = useAuthStore((s) => s.session); + const supported = !!session && supportsAvailability(); + const [statuses, setStatuses] = React.useState>({}); + const [access, setAccess] = React.useState<{ session: unknown; allowed: boolean } | null>(null); + const requestSeq = React.useRef(0); + + const emails = React.useMemo(() => attendeeEmails(attendees), [attendees]); + const emailsKey = emails.join(','); + const startMs = window?.start.getTime() ?? null; + const endMs = window?.end.getTime() ?? null; + const wanted = supported && emails.length > 0 && startMs !== null && endMs !== null && endMs > startMs; + // Undefined until the server has said whether it answers free/busy for + // this user; the block stays hidden until then, so a server that refuses + // never flashes a list of attendees being checked. + const allowed = !session ? undefined : access?.session === session ? access.allowed : knownAvailabilityAccess(); + + React.useEffect(() => { + if (!wanted || allowed !== undefined) return; + let cancelled = false; + void checkAvailabilityAccess().then((ok) => { + if (!cancelled) setAccess({ session, allowed: ok }); + }); + return () => { + cancelled = true; + }; + }, [session, wanted, allowed]); + + React.useEffect(() => { + if (!wanted || allowed !== true) { + setStatuses({}); + return; + } + const seq = ++requestSeq.current; + setStatuses(Object.fromEntries(emails.map((e) => [e, 'checking' as const]))); + // Debounce: changing a time or adding several people must not fire a + // query per change. + const timer = setTimeout(async () => { + const results = await lookUpAvailabilityStatuses(emails, new Date(startMs!), new Date(endMs!)); + if (seq !== requestSeq.current) return; + if (results === null) { + revokeAvailabilityAccess(); + setAccess({ session, allowed: false }); + return; + } + setStatuses(results); + }, 400); + return () => clearTimeout(timer); + // emailsKey stands in for the emails array identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session, wanted, allowed, emailsKey, startMs, endMs]); + + if (!wanted) return null; + if (allowed === false) { + return ( + + {t('calendar.participants.availability.unavailable_on_server', "Free/busy isn't available on this server")} + + ); + } + if (allowed !== true) return null; + + return ( + + {attendees.map((a) => { + const status = statuses[a.email.trim().toLowerCase()] ?? 'checking'; + const label = t(`calendar.participants.availability.${status}`, STATUS_FALLBACKS[status]); + return ( + + + {a.name || a.email} + + {status === 'checking' && } + {label} + + + ); + })} + + ); +} + +export function dotColor(status: AvailabilityStatus, c: ThemePalette): string { + if (status === 'free') return c.success; + if (status === 'busy') return c.error; + if (status === 'tentative') return c.warning; + return c.border; +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + unavailable: { + ...typography.caption, + color: c.textMuted, + marginTop: spacing.xs, + }, + list: { + marginTop: spacing.xs, + gap: 2, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 20, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + name: { + ...typography.caption, + color: c.text, + flexShrink: 1, + }, + statusWrap: { + marginStart: 'auto', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + spinner: { + transform: [{ scale: 0.6 }], + }, + status: { + ...typography.caption, + color: c.textMuted, + }, + }); +} diff --git a/src/components/calendar/__tests__/participant-availability-wiring.test.ts b/src/components/calendar/__tests__/participant-availability-wiring.test.ts new file mode 100644 index 00000000..8cb3bb66 --- /dev/null +++ b/src/components/calendar/__tests__/participant-availability-wiring.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Checked on the source: the suite has no React Native renderer. The lookups +// themselves are covered in src/api/__tests__/calendar-availability.test.ts. +const modal = fs.readFileSync(path.resolve(__dirname, '../EventModal.tsx'), 'utf8'); + +describe('free/busy in the event editor', () => { + it('shows the attendees’ availability under the participants for the event’s window', () => { + expect(modal).toMatch(//); + expect(modal).toMatch(/editorAvailabilityWindow\(\{ allDay, start, end \}\)/); + // Right after the participant list, as in the webmail's event form. + const input = modal.indexOf(' start.getTime()) { + if (p.busyStatus === 'tentative') { + if (status === 'free') status = 'tentative'; + } else { + return 'busy'; + } + } + } + return status; +} + +/** The answer for one attendee: an empty list doesn't mean available. */ +export function statusFromAnswer(periods: BusyPeriod[], start: Date, end: Date): AvailabilityStatus { + return periods.length === 0 ? 'no_busy_times' : availabilityFor(periods, start, end); +} + +/** + * The window a lookup asks the server for: the local days from `start` to + * `end`, so that moving an event within its day is answered from the cache. + */ +export function availabilityWindow(start: Date, end: Date): { start: Date; end: Date } { + const from = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const to = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + if (to.getTime() < end.getTime()) to.setDate(to.getDate() + 1); + return { start: from, end: to }; +} + +/** + * The event window the attendees' free/busy is checked against, as real + * instants, from the editor's display dates (wall clocks in the calendar's + * time zone). An all-day event spans its whole days, `end` being its + * inclusive last day. Null while the window is empty or backwards. + */ +export function editorAvailabilityWindow( + times: { allDay: boolean; start: Date; end: Date }, +): { start: Date; end: Date } | null { + let start = times.start; + let end = times.end; + if (times.allDay) { + start = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0); + end = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 23, 59, 59); + } + const from = fromDisplayDate(start); + const to = fromDisplayDate(end); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to.getTime() <= from.getTime()) return null; + return { start: from, end: to }; +} + +/** The distinct, trimmed, lower-cased addresses of the attendees. */ +export function attendeeEmails(attendees: { email: string }[]): string[] { + return Array.from(new Set(attendees.map((a) => a.email.trim().toLowerCase()).filter(Boolean))); +} From d663a5e28216243de74f50117bc5978295ecd34d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:28:18 +0100 Subject: [PATCH 2/4] feat: show when someone invites you to, changes or cancels a calendar event --- .../live-stalwart-event-notifications.test.ts | 171 ++++++++++++++++++ src/api/calendar-event-notifications.ts | 94 ++++++++++ .../calendar-event-notifications.test.ts | 166 +++++++++++++++++ src/lib/calendar-event-notifications.ts | 154 ++++++++++++++++ src/lib/calendar-notifications.ts | 3 + 5 files changed, 588 insertions(+) create mode 100644 src/api/__tests__/live-stalwart-event-notifications.test.ts create mode 100644 src/api/calendar-event-notifications.ts create mode 100644 src/lib/__tests__/calendar-event-notifications.test.ts create mode 100644 src/lib/calendar-event-notifications.ts diff --git a/src/api/__tests__/live-stalwart-event-notifications.test.ts b/src/api/__tests__/live-stalwart-event-notifications.test.ts new file mode 100644 index 00000000..6f35a0df --- /dev/null +++ b/src/api/__tests__/live-stalwart-event-notifications.test.ts @@ -0,0 +1,171 @@ +/** + * Live CalendarEventNotification checks against a real Stalwart. + * + * Opt-in like live-stalwart-calendar.test.ts: skipped unless + * `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same + * password) are set. The second account invites the first one to an event in + * 2027 and then cancels it; the first one reads the notifications the server + * keeps for that and acknowledges them. Only the notifications of this run + * are destroyed, and the event carries the run's marker and is removed from + * both accounts afterwards. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \ + * BULWARK_LIVE_USER=e2e-perf@example.org \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-event-notifications.test.ts + * + * Verified against Stalwart 0.16.27 and 1.0.1. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { + destroyCalendarEventNotifications, + getCalendarEventNotifications, + type CalendarEventNotification, +} from '../calendar-event-notifications'; +import { eventNotificationToast } from '../../lib/calendar-event-notifications'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; +const RUN = `live-cen-${Date.now().toString(36)}`; + +type Response = [string, Record, string]; + +async function raw(c: JMAPClient, calls: [string, Record, string][]): Promise { + return (await c.request(calls, USING)).methodResponses as Response[]; +} + +async function waitFor(read: () => Promise, done: (value: T) => boolean, ms = 15_000): Promise { + const until = Date.now() + ms; + let value = await read(); + while (!done(value) && Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 500)); + value = await read(); + } + return value; +} + +/** Destroy every event of `c`'s account whose title carries the run's marker. */ +async function removeRunEvents(c: JMAPClient): Promise { + const res = await raw(c, [ + ['CalendarEvent/query', { accountId: c.accountId }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'title'], + }, 'g'], + ]); + const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[]) + .filter((e) => e.title?.includes(RUN)) + .map((e) => e.id); + if (ids.length > 0) { + await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]); + } +} + +const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? '')); + +liveTwo('live Stalwart calendar event notifications', () => { + let other: JMAPClient; + let before = new Set(); + const ours = new Set(); + let organizerEventId = ''; + let attendeeEventId = ''; + + const fresh = async (): Promise => + (await getCalendarEventNotifications()).filter((n) => !before.has(n.id)); + + beforeAll(async () => { + const mod = await import('../jmap-client'); + const client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); + other = new JMAPClient(); + await other.connect(SERVER!, USER2!, PASS); + // Whatever is pending already belongs to someone else's run. + before = new Set((await getCalendarEventNotifications()).map((n) => n.id)); + }, 60_000); + + afterAll(async () => { + const leftover = (await getCalendarEventNotifications().catch(() => [])) + .filter((n) => ours.has(n.id) || (!before.has(n.id) && n.event?.title?.includes(RUN))); + await destroyCalendarEventNotifications(leftover.map((n) => n.id)).catch(() => undefined); + await removeRunEvents(other).catch(() => undefined); + const mod = await import('../jmap-client'); + await removeRunEvents(mod.jmapClient as unknown as JMAPClient).catch(() => undefined); + }, 60_000); + + it('lists the invitation another account sent, with who sent it and the event’s title', async () => { + const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]); + const list = cals[0][1].list as { id: string; isDefault?: boolean }[]; + const calendarId = (list.find((c) => c.isDefault) ?? list[0]).id; + const created = await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + create: { + inv: { + '@type': 'Event', + title: `${RUN} review`, + calendarIds: { [calendarId]: true }, + start: '2027-06-08T10:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT1H', + organizerCalendarAddress: `mailto:${USER2}`, + participants: { + org: { + '@type': 'Participant', calendarAddress: `mailto:${USER2}`, email: USER2, + roles: { owner: true, attendee: true }, participationStatus: 'accepted', kind: 'individual', + }, + att: { + '@type': 'Participant', calendarAddress: `mailto:${USER}`, email: USER, + roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true, kind: 'individual', + }, + }, + }, + }, + sendSchedulingMessages: true, + }, '0']]); + organizerEventId = created[0][1].created?.inv?.id ?? ''; + expect(organizerEventId, JSON.stringify(created[0][1].notCreated)).toBeTruthy(); + + const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'created')); + const invite = notes.find((n) => n.type === 'created'); + expect(invite, JSON.stringify(notes)).toBeTruthy(); + ours.add(invite!.id); + attendeeEventId = invite!.calendarEventId; + expect(invite!.changedBy?.email?.toLowerCase()).toBe(USER2!.toLowerCase()); + expect(invite!.isDraft).toBe(false); + // 1.0 returns the event with the notification; 0.16 doesn't, and the + // title is read from the attendee's copy instead. + expect(invite!.event?.title).toBe(`${RUN} review`); + expect(eventNotificationToast(invite!, t)!.title).toContain(`invited you to "${RUN} review"`); + }); + + it('lists the cancellation, and forgets both once acknowledged', async () => { + expect(organizerEventId).toBeTruthy(); + await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + destroy: [organizerEventId], + sendSchedulingMessages: true, + }, '0']]); + const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'destroyed' || (n.type === 'updated' && n.calendarEventId === attendeeEventId && !ours.has(n.id)))); + const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id)); + expect(cancel, JSON.stringify(notes)).toBeTruthy(); + ours.add(cancel!.id); + const toast = eventNotificationToast(cancel!, t)!; + if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning'); + + await destroyCalendarEventNotifications([...ours]); + const after = (await getCalendarEventNotifications()).map((n) => n.id); + for (const id of ours) expect(after).not.toContain(id); + // Nobody else's notifications went with them. + for (const id of before) expect(after).toContain(id); + }); +}); diff --git a/src/api/calendar-event-notifications.ts b/src/api/calendar-event-notifications.ts new file mode 100644 index 00000000..deaf3ae3 --- /dev/null +++ b/src/api/calendar-event-notifications.ts @@ -0,0 +1,94 @@ +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import { batched } from './jmap-result'; + +// CalendarEventNotification (draft-ietf-jmap-calendars §7): the server +// records every invitation, update and cancellation another participant made +// to an event this user takes part in. Ported from the webmail's +// getCalendarEventNotifications / destroyCalendarEventNotifications in +// lib/jmap/client.ts. + +export interface CalendarEventNotification { + id: string; + created: string; + changedBy?: { + name?: string | null; + email?: string | null; + principalId?: string | null; + } | null; + comment?: string | null; + type: 'created' | 'updated' | 'destroyed'; + calendarEventId: string; + isDraft?: boolean; + /** The event as it is now; 1.0 returns it, 0.16 leaves it out. */ + event?: { title?: string | null } | null; +} + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; + +/** + * Pending event notifications, oldest first. `event` is asked for its title + * only; Stalwart 0.16 doesn't return it, so the titles it lacks are read from + * the events themselves (a cancelled event is gone and stays untitled). + * Throws when the server refuses the listing. + */ +export async function getCalendarEventNotifications(): Promise { + const accountId = jmapClient.accountId; + const response = await jmapClient.request([ + ['CalendarEventNotification/query', { + accountId, + sort: [{ property: 'created', isAscending: true }], + }, '0'], + ['CalendarEventNotification/get', { + accountId, + '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' }, + properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'], + }, '1'], + ], USING); + const responses = (response.methodResponses ?? []) as [string, any, string][]; + const getResp = responses.find((r) => r[0] === 'CalendarEventNotification/get'); + if (!getResp) { + const error = responses.find((r) => r[0] === 'error')?.[1] as { description?: string } | undefined; + throw new Error(error?.description || 'Failed to load calendar event notifications'); + } + const list = (getResp[1]?.list ?? []) as CalendarEventNotification[]; + await fillMissingTitles(accountId, list); + return list; +} + +async function fillMissingTitles(accountId: string, list: CalendarEventNotification[]): Promise { + const ids = Array.from(new Set( + list.filter((n) => !n.isDraft && n.type !== 'destroyed' && !n.event?.title && n.calendarEventId) + .map((n) => n.calendarEventId), + )); + if (ids.length === 0) return; + try { + const response = await jmapClient.request( + [['CalendarEvent/get', { accountId, ids, properties: ['id', 'title'] }, '0']], + USING, + ); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== 'CalendarEvent/get') return; + const titles = new Map(); + for (const event of (result?.list ?? []) as { id: string; title?: string | null }[]) { + if (event.title) titles.set(event.id, event.title); + } + for (const n of list) { + const title = titles.get(n.calendarEventId); + if (title && !n.event?.title) n.event = { ...(n.event ?? {}), title }; + } + } catch { + // Untitled it is. + } +} + +/** Acknowledges (destroys) event notifications that have been shown. */ +export async function destroyCalendarEventNotifications(ids: string[]): Promise { + if (ids.length === 0) return; + const accountId = jmapClient.accountId; + for (const batch of batched(ids, jmapClient.getMaxObjectsInSet())) { + await jmapClient.request([ + ['CalendarEventNotification/set', { accountId, destroy: batch }, '0'], + ], USING); + } +} diff --git a/src/lib/__tests__/calendar-event-notifications.test.ts b/src/lib/__tests__/calendar-event-notifications.test.ts new file mode 100644 index 00000000..751a3c1d --- /dev/null +++ b/src/lib/__tests__/calendar-event-notifications.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + client: { isConnected: true, accountId: 'u', currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown }, + list: vi.fn(), + destroy: vi.fn(async (_ids: string[]) => undefined), + refresh: vi.fn(async () => undefined), + toast: { info: vi.fn(), warning: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() }, +})); + +vi.mock('react-native', () => ({ AppState: { addEventListener: vi.fn() } })); +vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client })); +vi.mock('../../api/calendar-event-notifications', () => ({ + getCalendarEventNotifications: h.list, + destroyCalendarEventNotifications: h.destroy, +})); +vi.mock('../../stores/auth-store', () => ({ useAuthStore: { subscribe: vi.fn() } })); +vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ refresh: h.refresh }) } })); +vi.mock('../../stores/toast-store', () => ({ toast: h.toast })); +vi.mock('../../stores/locale-store', () => ({ + t: (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => params?.[k] ?? ''), +})); +vi.mock('../capabilities', () => ({ hasCalendarCapability: () => true })); + +import fs from 'node:fs'; +import path from 'node:path'; +import { + eventNotificationToast, + pullCalendarEventNotifications, + resetCalendarEventNotifications, + startCalendarEventNotificationSync, +} from '../calendar-event-notifications'; +import { dispatchStateChange } from '../state-change-bus'; +import type { CalendarEventNotification } from '../../api/calendar-event-notifications'; + +// Ported from the webmail's components/layout/__tests__/calendar-event-notification-toaster.test.tsx +// and lib/__tests__/pending-notification-store.test.ts. + +const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? '')); + +function note(id: string, extra: Partial = {}): CalendarEventNotification { + return { + id, + created: '2026-10-31T05:00:00Z', + changedBy: { name: 'Land', email: 'land@example.org', principalId: 't' }, + comment: null, + type: 'updated', + calendarEventId: 'e1', + isDraft: false, + event: { title: 'Planning' }, + ...extra, + }; +} + +beforeEach(() => { + resetCalendarEventNotifications(); + h.client.accountId = 'u'; + h.list.mockReset(); + h.destroy.mockClear(); + h.refresh.mockClear(); + for (const fn of Object.values(h.toast)) fn.mockClear(); +}); + +describe('the toast for a calendar event notification', () => { + it('says who invited, changed or cancelled which event', () => { + expect(eventNotificationToast(note('1', { type: 'created' }), t)).toEqual({ type: 'info', title: 'Land invited you to "Planning"', message: undefined }); + expect(eventNotificationToast(note('2'), t)).toEqual({ type: 'info', title: 'Land updated "Planning"', message: undefined }); + expect(eventNotificationToast(note('3', { type: 'destroyed', comment: 'Sorry, ill' }), t)) + .toEqual({ type: 'warning', title: 'Land cancelled "Planning"', message: 'Sorry, ill' }); + }); + + it('falls back to the address, then "Someone", and to an untitled event', () => { + expect(eventNotificationToast(note('1', { changedBy: { email: 'x@example.org' }, event: null }), t)!.title) + .toBe('x@example.org updated "Untitled event"'); + expect(eventNotificationToast(note('1', { changedBy: null }), t)!.title).toBe('Someone updated "Planning"'); + }); + + it('shows nothing for a draft, the user’s own unsent change', () => { + expect(eventNotificationToast(note('1', { isDraft: true }), t)).toBeNull(); + }); +}); + +describe('pulling calendar event notifications', () => { + it('shows each new one, refreshes the calendar and acknowledges them on the server', async () => { + h.list.mockResolvedValue([note('a', { type: 'created' }), note('b', { type: 'destroyed' }), note('c', { isDraft: true })]); + await pullCalendarEventNotifications(); + expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined); + expect(h.toast.warning).toHaveBeenCalledWith('Land cancelled "Planning"', undefined); + expect(h.toast.info).toHaveBeenCalledTimes(1); + expect(h.refresh).toHaveBeenCalledTimes(1); + // Drafts are acknowledged too, as the webmail does. + expect(h.destroy).toHaveBeenCalledWith(['a', 'b', 'c']); + }); + + it('does not show one again when the server still lists it', async () => { + h.list.mockResolvedValue([note('a')]); + await pullCalendarEventNotifications(); + await pullCalendarEventNotifications(); + expect(h.toast.info).toHaveBeenCalledTimes(1); + expect(h.destroy).toHaveBeenCalledTimes(1); + }); + + it('does nothing when there is nothing new', async () => { + h.list.mockResolvedValue([]); + await pullCalendarEventNotifications(); + expect(h.refresh).not.toHaveBeenCalled(); + expect(h.destroy).not.toHaveBeenCalled(); + }); + + it('shows nothing from the previous account after a switch during the fetch', async () => { + h.list.mockImplementation(async () => { + h.client.accountId = 'other'; + return [note('a')]; + }); + await pullCalendarEventNotifications(); + expect(h.toast.info).not.toHaveBeenCalled(); + expect(h.destroy).not.toHaveBeenCalled(); + }); + + it('pulls again when a push arrives during a pull', async () => { + let release!: () => void; + h.list.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve([note('a')]); })); + h.list.mockResolvedValueOnce([note('a'), note('b')]); + const first = pullCalendarEventNotifications(); + const second = pullCalendarEventNotifications(); + release(); + await Promise.all([first, second]); + expect(h.list).toHaveBeenCalledTimes(2); + expect(h.toast.info).toHaveBeenCalledTimes(2); + }); + + it('keeps quiet when the server refuses the listing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + h.list.mockRejectedValue(new Error('unknownMethod')); + await expect(pullCalendarEventNotifications()).resolves.toBeUndefined(); + expect(h.toast.info).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('when notifications are pulled', () => { + it('pulls on a CalendarEventNotification push', async () => { + vi.useFakeTimers(); + try { + h.list.mockResolvedValue([]); + startCalendarEventNotificationSync(); + await vi.advanceTimersByTimeAsync(1500); + h.list.mockClear(); + h.list.mockResolvedValue([note('p', { type: 'created' })]); + dispatchStateChange({ '@type': 'StateChange', changed: { u: { CalendarEventNotification: 's2' } } }); + await vi.advanceTimersByTimeAsync(1500); + expect(h.list).toHaveBeenCalledTimes(1); + expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined); + } finally { + vi.useRealTimers(); + } + }); + + it('starts with the reminder sync, which the app starts at launch', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../calendar-notifications.ts'), 'utf8'); + const start = source.slice(source.indexOf('export function startCalendarNotificationSync')); + expect(start).toMatch(/syncStarted = true;[\s\S]*startCalendarEventNotificationSync\(\);/); + }); +}); diff --git a/src/lib/calendar-event-notifications.ts b/src/lib/calendar-event-notifications.ts new file mode 100644 index 00000000..b25e7d2b --- /dev/null +++ b/src/lib/calendar-event-notifications.ts @@ -0,0 +1,154 @@ +import { AppState } from 'react-native'; +import { jmapClient } from '../api/jmap-client'; +import { + destroyCalendarEventNotifications, + getCalendarEventNotifications, + type CalendarEventNotification, +} from '../api/calendar-event-notifications'; +import { useAuthStore } from '../stores/auth-store'; +import { useCalendarStore } from '../stores/calendar-store'; +import { t as translate } from '../stores/locale-store'; +import { toast } from '../stores/toast-store'; +import { hasCalendarCapability } from './capabilities'; +import { onStateChangeType } from './state-change-bus'; + +// Someone invited the user, changed or cancelled an event the user takes +// part in: the server keeps a CalendarEventNotification for it. They are +// pulled at sign-in, on every `CalendarEventNotification` push and when the +// app comes back to the foreground, shown as toasts, the events in view are +// refreshed so the change shows, and the notifications are acknowledged, +// which destroys them on the server. Ported from the webmail's +// stores/calendar-event-notification-store.ts, +// lib/pending-notification-store.ts and +// components/layout/calendar-event-notification-toaster.tsx. + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +export interface EventNotificationToast { + type: 'info' | 'warning'; + title: string; + message?: string; +} + +/** The toast for one notification; null for a draft (the user's own unsent change). */ +export function eventNotificationToast(n: CalendarEventNotification, t: Translate): EventNotificationToast | null { + if (n.isDraft) return null; + const kind = n.type === 'created' ? 'invited' : n.type === 'destroyed' ? 'cancelled' : 'updated'; + const params = { + name: n.changedBy?.name || n.changedBy?.email || t('calendar_event_notifications.someone', 'Someone'), + title: n.event?.title || t('calendar_event_notifications.untitled', 'Untitled event'), + }; + const title = kind === 'invited' + ? t('calendar_event_notifications.invited', '{name} invited you to "{title}"', params) + : kind === 'cancelled' + ? t('calendar_event_notifications.cancelled', '{name} cancelled "{title}"', params) + : t('calendar_event_notifications.updated', '{name} updated "{title}"', params); + return { type: kind === 'cancelled' ? 'warning' : 'info', title, message: n.comment || undefined }; +} + +// Ids already shown in this session; a pull racing an acknowledgement must +// not show them again. +const seen = new Set(); +let seenFor: string | null = null; +let inFlight: Promise | null = null; +let again = false; +let syncStarted = false; +let pullTimer: ReturnType | null = null; + +function sessionKey(): string | null { + try { + return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`; + } catch { + return null; + } +} + +/** + * Fetch the pending notifications, show the new ones, refresh the calendar + * and acknowledge them. Concurrent calls coalesce; a push arriving while a + * pull runs pulls again afterwards. Never rejects. + */ +export function pullCalendarEventNotifications(): Promise { + if (inFlight) { + again = true; + return inFlight; + } + inFlight = (async () => { + try { + do { + again = false; + await pullOnce(); + } while (again); + } finally { + inFlight = null; + } + })(); + return inFlight; +} + +async function pullOnce(): Promise { + if (!jmapClient.isConnected || !hasCalendarCapability()) return; + const key = sessionKey(); + if (!key) return; + if (key !== seenFor) { + seen.clear(); + seenFor = key; + } + let list: CalendarEventNotification[]; + try { + list = await getCalendarEventNotifications(); + } catch (error) { + console.warn('[calendar] Failed to fetch calendar event notifications:', error); + return; + } + // Another account by now: its own pull shows its own. + if (sessionKey() !== key) return; + const fresh = list.filter((n) => !seen.has(n.id)); + if (fresh.length === 0) return; + for (const n of fresh) { + seen.add(n.id); + const shown = eventNotificationToast(n, translate as Translate); + if (shown) toast[shown.type](shown.title, shown.message); + } + void useCalendarStore.getState().refresh().catch(() => undefined); + try { + await destroyCalendarEventNotifications(fresh.map((n) => n.id)); + } catch (error) { + // Worst case they are shown again after a restart. + console.warn('[calendar] Failed to acknowledge calendar event notifications:', error); + } +} + +function pullSoon(): void { + if (pullTimer) clearTimeout(pullTimer); + // Sign-in fans out its own requests first; a burst of pushes is one pull. + pullTimer = setTimeout(() => { + pullTimer = null; + void pullCalendarEventNotifications(); + }, 1000); +} + +/** Pull event notifications for the rest of the session. Idempotent. */ +export function startCalendarEventNotificationSync(): void { + if (syncStarted) return; + syncStarted = true; + onStateChangeType('CalendarEventNotification', pullSoon); + useAuthStore.subscribe((state, prev) => { + if (state.session !== prev.session && state.session) pullSoon(); + }); + // Pushes stop while the app is in the background. + AppState.addEventListener('change', (state) => { + if (state === 'active') pullSoon(); + }); + pullSoon(); +} + +/** Forget what was shown (tests). */ +export function resetCalendarEventNotifications(): void { + seen.clear(); + seenFor = null; + inFlight = null; + again = false; + if (pullTimer) clearTimeout(pullTimer); + pullTimer = null; +} diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index b7f4312b..fd7aada6 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -11,6 +11,7 @@ import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; import type { CalendarReminderTarget } from '../navigation/pending-calendar-open'; import { onDeviceRemindersChange, remindedOnDevice } from '../device-sync/app/reminders'; +import { startCalendarEventNotificationSync } from './calendar-event-notifications'; // Local reminders for calendar events and tasks. The webmail polls // `getPendingAlerts` every minute while a tab is open; a phone is mostly @@ -369,6 +370,8 @@ export function startCalendarNotificationSync(options?: { askPermission?: boolea if (syncStarted) return; syncStarted = true; installForegroundHandler(); + // Invitations, changes and cancellations others made (CalendarEventNotification). + startCalendarEventNotificationSync(); // Edits in the app, a refresh after a push, tasks loading. Moving the // calendar to another window changes no reminder. useCalendarStore.subscribe((state, prev) => { From f1297b6be8e2677ab834cf80be986e4eb9e7e766 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:45:24 +0100 Subject: [PATCH 3/4] feat: show calendar months, days and dates in the Jalali calendar for Persian --- src/components/calendar/AgendaView.tsx | 26 +-- .../calendar/CalendarSearchSheet.tsx | 7 +- src/components/calendar/EventDetailSheet.tsx | 10 +- src/components/calendar/EventModal.tsx | 6 +- src/components/calendar/MonthDropdown.tsx | 10 +- src/components/calendar/MonthScrollView.tsx | 23 +- src/components/calendar/MonthView.tsx | 39 ++-- src/components/calendar/RecurrenceEditor.tsx | 7 +- src/components/calendar/TasksSheet.tsx | 8 +- .../calendar/TimeGridScrollView.tsx | 4 +- src/components/calendar/WeekView.tsx | 5 +- src/lib/__tests__/calendar-system.test.ts | 153 ++++++++++++++ src/lib/calendar-a11y.ts | 10 +- src/lib/calendar-locale.ts | 10 +- src/lib/calendar-month-scroll.ts | 11 +- src/lib/calendar-scroll-window.ts | 4 + src/lib/calendar-system.ts | 198 ++++++++++++++++++ src/lib/jalali.ts | 137 ++++++++++++ src/screens/CalendarScreen.tsx | 33 ++- 19 files changed, 606 insertions(+), 95 deletions(-) create mode 100644 src/lib/__tests__/calendar-system.test.ts create mode 100644 src/lib/calendar-system.ts create mode 100644 src/lib/jalali.ts diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index 937c6a10..b5072c7f 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -23,6 +23,7 @@ import { import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; import { buildAgendaDays, findAgendaFocusIndex, type AgendaDay } from '../../lib/calendar-agenda'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { dayNumberLabel, dayRangeLabel, fullDateLabel, monthTitle, type CalendarSystem } from '../../lib/calendar-system'; import { useLocaleStore } from '../../stores/locale-store'; import { EventCard } from './EventCard'; @@ -63,12 +64,12 @@ interface DaySection extends AgendaDay { weekTitle: string | null; } -// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months. -function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string { +// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months +// (Jalali days and months in Persian). +function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale, system: CalendarSystem): string { const start = startOfWeek(date, { weekStartsOn }); const end = endOfWeek(date, { weekStartsOn }); - const sameMonth = start.getMonth() === end.getMonth(); - return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`; + return dayRangeLabel(start, end, system, locale); } const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 }; @@ -102,7 +103,7 @@ export function AgendaView({ }: AgendaViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const tp = useLocaleStore((s) => s.t); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), @@ -115,15 +116,15 @@ export function AgendaView({ let lastMonth = ''; let lastWeek = ''; return buildAgendaDays(index, loaded, displayNow()).map((day) => { - const month = format(day.date, 'yyyy-MM'); + const month = String(system.monthKey(day.date)); const week = dayKey(startOfWeek(day.date, { weekStartsOn })); - const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null; - const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null; + const title = month !== lastMonth ? monthTitle(day.date, system, locale, true) : null; + const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale, system) : null; lastMonth = month; lastWeek = week; - return { ...day, key: dayKey(day.date), monthTitle, weekTitle }; + return { ...day, key: dayKey(day.date), monthTitle: title, weekTitle }; }); - }, [index, loaded, weekStartsOn, locale]); + }, [index, loaded, weekStartsOn, locale, system]); const listRef = React.useRef>(null); const sectionsRef = React.useRef(sections); @@ -245,7 +246,7 @@ export function AgendaView({ sizeRef.current.content = h; }, []); - const formatRangeDate = (date: Date) => format(date, 'PP', { locale }); + const formatRangeDate = (date: Date) => (system.jalali ? fullDateLabel(date, system, locale) : format(date, 'PP', { locale })); const header = ( @@ -383,6 +384,7 @@ function DateColumn({ locale: Locale; styles: AgendaStyles; }) { + const { system } = useCalendarLocale(); if (!show) return ; const today = isDisplayToday(date); return ( @@ -392,7 +394,7 @@ function DateColumn({ - {format(date, 'd')} + {dayNumberLabel(date, system)} diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx index 67671056..c2a7e085 100644 --- a/src/components/calendar/CalendarSearchSheet.tsx +++ b/src/components/calendar/CalendarSearchSheet.tsx @@ -17,6 +17,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { displayNow } from '../../lib/calendar-timezone'; import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search'; import { @@ -62,7 +63,7 @@ export function CalendarSearchSheet({ }: CalendarSearchSheetProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const loaded = useCalendarStore((s) => s.events); const [query, setQuery] = React.useState(''); const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null); @@ -149,7 +150,9 @@ export function CalendarSearchSheet({ > - {format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })} + {system.jalali + ? formatCalendarDay(start, locale, system.year(start) === system.year(new Date()) ? 'weekdayMonthDay' : 'monthDayYear') + : format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })} {event.showWithoutTime diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index a03ab3a8..3a52bcc1 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -57,7 +57,7 @@ import { import { getEventEditability } from '../../lib/calendar-editability'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { useCalendarLocale } from '../../lib/calendar-locale'; -import { formatCalendarDate } from '../../lib/date-format'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence'; import type { TranslateFn } from '../../stores/locale-store'; @@ -99,9 +99,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l start.getMonth() === end.getMonth() && start.getDate() === end.getDate() ) { - return formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear'); + return formatCalendarDay(start, locale, 'longWeekdayMonthDayYear'); } - return `${formatCalendarDate(start, locale.code, 'monthDay')} – ${formatCalendarDate(end, locale.code, 'monthDayYear')}`; + return `${formatCalendarDay(start, locale, 'monthDay')} – ${formatCalendarDay(end, locale, 'monthDayYear')}`; } const tp = timePattern(timeFormat); const sameDay = @@ -109,9 +109,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l start.getMonth() === end.getMonth() && start.getDate() === end.getDate(); if (sameDay) { - return `${formatCalendarDate(start, locale.code, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`; + return `${formatCalendarDay(start, locale, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`; } - return `${formatCalendarDate(start, locale.code, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDate(end, locale.code, 'monthDay')}, ${format(end, tp, opts)}`; + return `${formatCalendarDay(start, locale, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDay(end, locale, 'monthDay')}, ${format(end, tp, opts)}`; } function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null { diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 15587b71..7c45d440 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -48,7 +48,7 @@ import { useSettingsStore } from '../../stores/settings-store'; import { CalendarAccountChangedError } from '../../stores/calendar-store'; import { canCreateEventsIn } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; -import { formatCalendarDate } from '../../lib/date-format'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { alertsToReminders, calendarDefaultAlerts, @@ -641,7 +641,7 @@ export function EventModal({ style={styles.dateBtn} onPress={() => setShowStartDate(true)} > - {formatCalendarDate(start, locale.code, 'weekdayMonthDay')} + {formatCalendarDay(start, locale, 'weekdayMonthDay')} {!allDay && ( setShowEndDate(true)} > - {formatCalendarDate(end, locale.code, 'weekdayMonthDay')} + {formatCalendarDay(end, locale, 'weekdayMonthDay')} {!allDay && ( makeStyles(c), [c]); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const { system } = useCalendarLocale(); // Pages are as wide as the panel (the window, or a pane on a tablet) and // re-measure on rotation. In a short window the panel takes at most about // half of it and its month scrolls inside. @@ -58,9 +59,10 @@ export function MonthDropdown({ const width = measuredWidth || windowWidth; const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55)); // The months are fixed while the panel is open; it remounts on each open. + // Jalali months for Persian, as the month grid shows them. const [months] = React.useState(() => { - const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE); - return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i)); + const first = system.addMonths(system.startOfMonth(month), -MONTHS_EACH_SIDE); + return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => system.addMonths(first, i)); }); const lastIndexRef = React.useRef(MONTHS_EACH_SIDE); @@ -129,7 +131,7 @@ export function MonthDropdown({ } function monthKey(month: Date): string { - return `${month.getFullYear()}-${month.getMonth()}`; + return month.toISOString(); } function makeStyles(c: ThemePalette) { diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx index 5e2a07df..af033c86 100644 --- a/src/components/calendar/MonthScrollView.tsx +++ b/src/components/calendar/MonthScrollView.tsx @@ -94,12 +94,12 @@ function MonthScrollViewInner({ onLongPressDate, }: MonthScrollViewProps) { const styles = useMonthStyles(); - const { locale } = useCalendarLocale(); + const { locale, system } = useCalendarLocale(); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], ); - const opts = React.useMemo(() => ({ weekStartsOn }), [weekStartsOn]); + const opts = React.useMemo(() => ({ weekStartsOn, calendarSystem: system }), [weekStartsOn, system]); // One row per week of the window. A week keeps its array across window // changes, so growing the window doesn't re-render the rows already there. @@ -131,8 +131,16 @@ function MonthScrollViewInner({ // The month in focus: set by navigation, then by what scrolls under the // sample line. - const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date)); + const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date, system)); const activeMonthRef = React.useRef(activeMonth); + // Another calendar system (the language changed) counts months its own way. + const systemRef = React.useRef(system); + if (systemRef.current !== system) { + systemRef.current = system; + const key = monthKeyOf(focus.date, system); + activeMonthRef.current = key; + setActiveMonth(key); + } const onVisibleRef = React.useRef(onVisibleDateChange); onVisibleRef.current = onVisibleDateChange; const offsetRef = React.useRef(0); @@ -163,7 +171,7 @@ function MonthScrollViewInner({ if (!days) return; // Mid-week decides which month a row belongs to. const mid = days[3]; - const key = monthKeyOf(mid); + const key = monthKeyOf(mid, systemRef.current); if (key === activeMonthRef.current) return; activeMonthRef.current = key; setActiveMonth(key); @@ -191,14 +199,14 @@ function MonthScrollViewInner({ handledNonceRef.current = focus.nonce; const row = monthFocusRow(window, focus.date, weeks.length, opts); const currentRow = Math.round(offsetRef.current / rowHeight); - const key = monthKeyOf(focus.date); + const key = monthKeyOf(focus.date, system); activeMonthRef.current = key; setActiveMonth(key); listRef.current?.scrollToIndex({ index: row, animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS, }); - }, [focus, window, weeks.length, opts, rowHeight]); + }, [focus, window, weeks.length, opts, rowHeight, system]); const selectedKey = dayKey(selectedDate); // Today on a clock in the calendar's time zone. @@ -209,7 +217,7 @@ function MonthScrollViewInner({ return ( s.calendarTintedEvents); @@ -198,7 +196,7 @@ function MonthWeekRowInner({ const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS; const visible = dayEvents.slice(0, maxVisible); const overflow = Math.max(0, dayEvents.length - maxVisible); - const monthLabel = labelMonths && d.getDate() === 1 ? format(d, 'MMM', { locale }) : null; + const monthLabel = labelMonths && isFirstOfMonth(d, system) ? monthStartLabel(d, system, locale, false) : null; return ( - {format(d, 'd')} + {dayNumberLabel(d, system)} {dayEvents.length > 0 && !showTimeInMonthView && ( @@ -330,7 +328,7 @@ function FullWeekRow({ colors: ThemePalette; }) { const { width } = useWindowDimensions(); - const { t } = useCalendarLocale(); + const { t, system } = useCalendarLocale(); // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); const showTime = monthChipShowsTime(showTimeInMonthView, width / 7); @@ -345,7 +343,9 @@ function FullWeekRow({ const dayEvents = eventsOnDayFromIndex(index, d); const shown = fullCellChipCount(height, dayEvents.length); const overflow = dayEvents.length - shown; - const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd'); + const label = labelMonths && isFirstOfMonth(d, system) + ? monthStartLabel(d, system, locale, true) + : dayNumberLabel(d, system); return ( eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], ); - // The month grid as rows of 7 days. - const rows = React.useMemo(() => { - const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); - const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn }); - const out: Date[][] = []; - let day = calStart; - while (day <= calEnd) { - out.push(Array.from({ length: 7 }, (_, i) => addDays(day, i))); - day = addDays(day, 7); - } - return out; - }, [currentDate, weekStartsOn]); - const activeMonth = monthKeyOf(currentDate); + // The month grid as rows of 7 days, in the calendar system's months. + const rows = React.useMemo( + () => monthGridRows(currentDate, weekStartsOn, system), + [currentDate, weekStartsOn, system], + ); + const activeMonth = monthKeyOf(currentDate, system); // Today on a clock in the calendar's time zone. const today = displayNow(); @@ -463,7 +456,7 @@ function MonthViewInner({ variant={fill ? 'full' : 'compact'} height={fullRowHeight} days={days} - activeMask={monthMask(days, activeMonth)} + activeMask={monthMask(days, activeMonth, system)} selectedIndex={dayIndexIn(days, selectedDate)} todayIndex={dayIndexIn(days, today)} index={index} diff --git a/src/components/calendar/RecurrenceEditor.tsx b/src/components/calendar/RecurrenceEditor.tsx index e7d28cb8..e2bd7f56 100644 --- a/src/components/calendar/RecurrenceEditor.tsx +++ b/src/components/calendar/RecurrenceEditor.tsx @@ -8,6 +8,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { Button } from '..'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { EDITOR_FREQUENCIES, UNIT_LABEL_KEYS, @@ -113,7 +114,7 @@ function NumberInput({ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const [value, setValue] = React.useState(() => editorValueFromRule(rule, eventStart), @@ -245,7 +246,9 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre {value.endsMode === 'on' && ( setShowUntilPicker(true)}> - {format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })} + {system.jalali + ? formatCalendarDay(parseISO(value.untilDate), locale, 'monthDayYear') + : format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })} )} diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 9a44116f..046502ed 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -42,7 +42,7 @@ import { import { askBeforeDiscard } from '../../lib/calendar-editor-discard'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; -import { formatCalendarDate } from '../../lib/date-format'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetInsets } from '../../lib/use-layout'; @@ -233,8 +233,8 @@ export function TasksSheet({ if (!d || !task.due) return null; const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due); return hasTime - ? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}` - : formatCalendarDate(d, locale.code, 'weekdayMonthDay'); + ? `${formatCalendarDay(d, locale, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}` + : formatCalendarDay(d, locale, 'weekdayMonthDay'); }; const priorityColor = (level: PriorityLevel): string => @@ -321,7 +321,7 @@ export function TasksSheet({ > - {editor.due ? formatCalendarDate(editor.due, locale.code, 'monthDay') : t('calendar.tasks.due_date', 'Due date')} + {editor.due ? formatCalendarDay(editor.due, locale, 'monthDay') : t('calendar.tasks.due_date', 'Due date')} {editor.due && ( void; }) { + const { system } = useCalendarLocale(); return ( onPress?.(day)}> @@ -577,7 +579,7 @@ const DayHeaderCell = React.memo(function DayHeaderCell({ selected && !today && styles.dayHeaderNumberTextSelected, ]} > - {format(day, 'd')} + {dayNumberLabel(day, system)} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index cfa39e0f..fb47be4b 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -13,6 +13,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { dayNumberLabel } from '../../lib/calendar-system'; import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { buildEventDayIndex, @@ -70,7 +71,7 @@ function WeekViewInner({ // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const scrollRef = React.useRef(null); const index = React.useMemo( @@ -171,7 +172,7 @@ function WeekViewInner({ selected && !today && styles.dayHeaderNumberTextSelected, ]} > - {format(day, 'd')} + {dayNumberLabel(day, system)} diff --git a/src/lib/__tests__/calendar-system.test.ts b/src/lib/__tests__/calendar-system.test.ts new file mode 100644 index 00000000..96ec6301 --- /dev/null +++ b/src/lib/__tests__/calendar-system.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { enUS } from 'date-fns/locale/en-US'; +import { faIR } from 'date-fns/locale/fa-IR'; +import { fromJalali, isLeapJalaliYear, jalaliMonthLength, toJalali } from '../jalali'; +import { + calendarSystemFor, + dayNumberLabel, + dayRangeLabel, + formatCalendarDay, + gregorianCalendar, + isFirstOfMonth, + jalaliCalendar, + monthGridRows, + monthStartLabel, + monthTitle, +} from '../calendar-system'; +import { baseRange } from '../calendar-scroll-window'; +import { monthKeyOf, monthMask } from '../calendar-month-scroll'; + +// Ported from the webmail's lib/__tests__/jalali-utils.test.ts and +// hooks/__tests__/use-calendar-locale.test.ts. The conversion was also +// checked day by day from 1900 to 2200 against jalaali-js, which the webmail +// uses. + +const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()]; + +describe('Jalali dates', () => { + it('converts Gregorian days to Jalali and back', () => { + expect(toJalali(new Date(2026, 2, 21))).toEqual({ jy: 1405, jm: 1, jd: 1 }); + expect(toJalali(new Date(2026, 3, 29))).toEqual({ jy: 1405, jm: 2, jd: 9 }); + expect(toJalali(new Date(2025, 2, 20))).toEqual({ jy: 1403, jm: 12, jd: 30 }); + expect(ymd(fromJalali(1405, 7, 1))).toEqual([2026, 9, 23]); + expect(ymd(fromJalali(1403, 12, 30))).toEqual([2025, 3, 20]); + }); + + it('knows leap years and month lengths', () => { + expect(isLeapJalaliYear(1403)).toBe(true); + expect(isLeapJalaliYear(1404)).toBe(false); + expect(jalaliMonthLength(1404, 1)).toBe(31); + expect(jalaliMonthLength(1404, 7)).toBe(30); + expect(jalaliMonthLength(1404, 12)).toBe(29); + expect(jalaliMonthLength(1403, 12)).toBe(30); + }); +}); + +describe('the calendar system', () => { + it('is Jalali for the Persian UI only, as in the webmail', () => { + expect(calendarSystemFor('fa')).toBe(jalaliCalendar); + expect(calendarSystemFor('de')).toBe(gregorianCalendar); + expect(calendarSystemFor('ar')).toBe(gregorianCalendar); + }); + + it('builds the Jalali month grid from the week of 1 Mehr to the week of 30 Mehr, starting on Saturday', () => { + // 15 October 2026 is 23 Mehr 1405. + const rows = monthGridRows(new Date(2026, 9, 15), 6, jalaliCalendar); + const first = rows[0][0]; + const last = rows[rows.length - 1][6]; + expect(first.getDay()).toBe(6); + expect(rows.flat().some((d) => toJalali(d).jm === 7 && toJalali(d).jd === 1)).toBe(true); + expect(toJalali(rows[0][6]).jm === 7 || toJalali(rows[0][6]).jd === 1).toBe(true); + expect(rows.flat().some((d) => toJalali(d).jm === 7 && toJalali(d).jd === 30)).toBe(true); + // No whole week of another month at either end. + expect(rows[0].some((d) => toJalali(d).jm === 7)).toBe(true); + expect(rows[rows.length - 1].some((d) => toJalali(d).jm === 7)).toBe(true); + expect(last.getDay()).toBe(5); + }); + + it('starts a Gregorian month grid on Saturday when the week does', () => { + const rows = monthGridRows(new Date(2026, 9, 15), 6, gregorianCalendar); + expect(rows[0][0].getDay()).toBe(6); + expect(ymd(rows[0][0])).toEqual([2026, 9, 26]); + expect(ymd(rows[rows.length - 1][6])).toEqual([2026, 11, 6]); + }); + + it('gives the scrolling month the Jalali grid, and the Gregorian one without a calendar system', () => { + const date = new Date(2026, 9, 15); + const jalali = baseRange('month', date, { weekStartsOn: 6, calendarSystem: jalaliCalendar }); + const rows = monthGridRows(date, 6, jalaliCalendar); + expect(jalali.start.getTime()).toBe(rows[0][0].getTime()); + expect(jalali.end.getTime()).toBe(rows[rows.length - 1][6].getTime()); + const gregorian = baseRange('month', date, { weekStartsOn: 1 }); + expect(ymd(gregorian.start)).toEqual([2026, 9, 28]); + }); + + it('counts two days of Mehr in September and October as one month, and shades by it', () => { + const sept = new Date(2026, 8, 25); // 3 Mehr + const oct = new Date(2026, 9, 20); // 28 Mehr + expect(monthKeyOf(sept, jalaliCalendar)).toBe(monthKeyOf(oct, jalaliCalendar)); + expect(monthKeyOf(sept)).not.toBe(monthKeyOf(oct)); + const days = [new Date(2026, 9, 21), new Date(2026, 9, 22), new Date(2026, 9, 23)]; // 29, 30 Mehr, 1 Aban + expect(monthMask(days, monthKeyOf(oct, jalaliCalendar), jalaliCalendar)).toBe(0b011); + }); + + it('steps Jalali months, keeping the day where the month has it', () => { + // 31 Shahrivar 1405 -> 30 Mehr (Mehr has 30 days). + expect(toJalali(jalaliCalendar.addMonths(fromJalali(1405, 6, 31), 1))).toEqual({ jy: 1405, jm: 7, jd: 30 }); + expect(toJalali(jalaliCalendar.addMonths(fromJalali(1404, 12, 10), 1))).toEqual({ jy: 1405, jm: 1, jd: 10 }); + expect(toJalali(jalaliCalendar.addMonths(fromJalali(1405, 1, 10), -1))).toEqual({ jy: 1404, jm: 12, jd: 10 }); + const withTime = new Date(2026, 9, 15, 14, 30); + expect(jalaliCalendar.addMonths(withTime, 2).getHours()).toBe(14); + }); + + it('labels Jalali days and months as the webmail does', () => { + const day = new Date(2026, 3, 29); // 9 Ordibehesht 1405 + expect(dayNumberLabel(day, jalaliCalendar)).toBe('9'); + expect(dayNumberLabel(day, gregorianCalendar)).toBe('29'); + expect(monthTitle(day, jalaliCalendar, faIR, true)).toBe('اردیبهشت 1405'); + expect(monthTitle(day, jalaliCalendar, faIR, false)).toBe('اردیبهشت'); + expect(monthTitle(day, gregorianCalendar, enUS, true)).toBe('April 2026'); + expect(isFirstOfMonth(new Date(2026, 3, 21), jalaliCalendar)).toBe(true); + expect(monthStartLabel(new Date(2026, 3, 21), jalaliCalendar, faIR, true)).toBe('1 اردیبهشت'); + expect(monthStartLabel(new Date(2026, 4, 1), gregorianCalendar, enUS, true)).toBe('May 1'); + expect(dayRangeLabel(new Date(2026, 3, 25), new Date(2026, 4, 1), jalaliCalendar, faIR)).toBe('5 – 11 اردیبهشت'); + expect(dayRangeLabel(new Date(2026, 3, 18), new Date(2026, 3, 24), jalaliCalendar, faIR)).toBe('29 فروردین – 4 اردیبهشت'); + }); + + it('writes event dates with the Jalali day, month and year in Persian (useFormatEventDate)', () => { + const day = new Date(2026, 3, 29); + expect(formatCalendarDay(day, faIR, 'longWeekdayMonthDayYear')).toBe('چهارشنبه, 9 اردیبهشت 1405'); + expect(formatCalendarDay(day, faIR, 'monthDay')).toBe('9 اردیبهشت'); + expect(formatCalendarDay(day, enUS, 'monthDay')).toMatch(/Apr(il)? 29/); + }); +}); + +describe('the views follow the calendar system', () => { + const read = (p: string) => fs.readFileSync(path.resolve(__dirname, p), 'utf8'); + + it('builds the month grid and its shading from the calendar system', () => { + const month = read('../../components/calendar/MonthView.tsx'); + expect(month).toMatch(/monthGridRows\(currentDate, weekStartsOn, system\)/); + expect(month).toMatch(/monthMask\(days, activeMonth, system\)/); + expect(month).toMatch(/dayNumberLabel\(d, system\)/); + const scroll = read('../../components/calendar/MonthScrollView.tsx'); + expect(scroll).toMatch(/calendarSystem: system/); + expect(scroll).toMatch(/monthMask\(days, activeMonth, system\)/); + }); + + it('steps a month and titles the screen in the calendar system', () => { + const screen = read('../../screens/CalendarScreen.tsx'); + expect(screen).toMatch(/system\.addMonths\(base, -1\)/); + expect(screen).toMatch(/system\.addMonths\(base, 1\)/); + expect(screen).toMatch(/headerTitle\(titleDate, locale, system\)/); + expect(screen).toMatch(/calendarSystem: system/); + }); + + it('numbers the days of the week and day views in the calendar system', () => { + expect(read('../../components/calendar/WeekView.tsx')).toMatch(/dayNumberLabel\(day, system\)/); + expect(read('../../components/calendar/TimeGridScrollView.tsx')).toMatch(/dayNumberLabel\(day, system\)/); + expect(read('../../components/calendar/AgendaView.tsx')).toMatch(/dayNumberLabel\(date, system\)/); + }); +}); diff --git a/src/lib/calendar-a11y.ts b/src/lib/calendar-a11y.ts index 98cb478f..97fcbd6c 100644 --- a/src/lib/calendar-a11y.ts +++ b/src/lib/calendar-a11y.ts @@ -3,7 +3,7 @@ import type { Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../api/types'; import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils'; import { isDeclinedByUser } from './calendar-participants'; -import { formatCalendarDate } from './date-format'; +import { formatCalendarDay } from './calendar-system'; type Translate = (key: string, fallback?: string, params?: Record) => string; @@ -27,7 +27,7 @@ export function participantRemoveAccessibilityLabel(attendee: { name?: string; e /** "Monday, November 2, 2026, 3 events" for a month day cell. */ export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string { - const date = formatCalendarDate(day, locale.code, 'longWeekdayMonthDayYear'); + const date = formatCalendarDay(day, locale, 'longWeekdayMonthDayYear'); if (eventCount === 0) return date; return `${date}, ${t('calendar.a11y.event_count', '{count, plural, one {# event} other {# events}}', { count: eventCount })}`; } @@ -49,10 +49,10 @@ export function eventAccessibilityLabel( const end = getEventDisplayEndDate(event); const valid = (d: Date) => !isNaN(d.getTime()); if (valid(start)) { - const date = formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear'); + const date = formatCalendarDay(start, locale, 'longWeekdayMonthDayYear'); if (event.showWithoutTime) { const last = valid(end) && !isSameDay(start, end) - ? ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')}` + ? ` – ${formatCalendarDay(end, locale, 'longWeekdayMonthDayYear')}` : ''; parts.push(date + last, t('calendar.events.all_day', 'All day')); } else { @@ -61,7 +61,7 @@ export function eventAccessibilityLabel( if (valid(end) && end.getTime() > start.getTime()) { range += isSameDay(start, end) ? ` – ${format(end, time, { locale })}` - : ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`; + : ` – ${formatCalendarDay(end, locale, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`; } parts.push(date, range); } diff --git a/src/lib/calendar-locale.ts b/src/lib/calendar-locale.ts index 5f857c66..f929c24c 100644 --- a/src/lib/calendar-locale.ts +++ b/src/lib/calendar-locale.ts @@ -2,6 +2,7 @@ import type { Locale } from 'date-fns'; import { enUS } from 'date-fns/locale/en-US'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import type { LocaleCode } from '../i18n'; +import { calendarSystemFor, type CalendarSystem } from './calendar-system'; // date-fns locale for each UI language so month/day names, "EEE, MMM d" // headers and the like render in the user's language (the webmail localizes @@ -55,9 +56,12 @@ export function getDateFnsLocale(code: LocaleCode | string | null | undefined): return locale; } -/** `{ locale }` options for date-fns `format()` plus the translate function. */ -export function useCalendarLocale(): { locale: Locale; t: TranslateFn } { +/** + * `{ locale }` options for date-fns `format()`, the translate function and + * the calendar system (Jalali for Persian, as in the webmail). + */ +export function useCalendarLocale(): { locale: Locale; t: TranslateFn; system: CalendarSystem } { const code = useLocaleStore((s) => s.locale); const t = useLocaleStore((s) => s.t); - return { locale: getDateFnsLocale(code), t }; + return { locale: getDateFnsLocale(code), t, system: calendarSystemFor(code) }; } diff --git a/src/lib/calendar-month-scroll.ts b/src/lib/calendar-month-scroll.ts index 763c4d17..65423b5a 100644 --- a/src/lib/calendar-month-scroll.ts +++ b/src/lib/calendar-month-scroll.ts @@ -1,5 +1,6 @@ import { addDays, differenceInCalendarDays } from 'date-fns'; import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window'; +import { gregorianCalendar, type CalendarSystem } from './calendar-system'; /** * Geometry of the freely scrolling month view (#759, webmail @@ -63,9 +64,9 @@ export function sampledRow( return Math.max(0, Math.min(rowCount - 1, row)); } -/** Year and month as one comparable number. */ -export function monthKeyOf(date: Date): number { - return date.getFullYear() * 12 + date.getMonth(); +/** Year and month as one comparable number, in the calendar system's months. */ +export function monthKeyOf(date: Date, system: CalendarSystem = gregorianCalendar): number { + return system.monthKey(date); } /** @@ -73,10 +74,10 @@ export function monthKeyOf(date: Date): number { * compare this instead of the month itself, so moving to another month only * re-renders the rows whose shading changes. */ -export function monthMask(days: Date[], monthKey: number): number { +export function monthMask(days: Date[], monthKey: number, system: CalendarSystem = gregorianCalendar): number { let mask = 0; days.forEach((day, i) => { - if (monthKeyOf(day) === monthKey) mask |= 1 << i; + if (monthKeyOf(day, system) === monthKey) mask |= 1 << i; }); return mask; } diff --git a/src/lib/calendar-scroll-window.ts b/src/lib/calendar-scroll-window.ts index a8af49de..3e711b2c 100644 --- a/src/lib/calendar-scroll-window.ts +++ b/src/lib/calendar-scroll-window.ts @@ -8,6 +8,7 @@ import { subDays, } from 'date-fns'; import { dayKey } from './calendar-utils'; +import { monthGridRange, type CalendarSystem } from './calendar-system'; /** * The calendar views scroll freely (#759; webmail lib/calendar-scroll-window): @@ -50,6 +51,8 @@ export interface CalendarFocus { export interface ScrollWindowOptions { weekStartsOn: WeekStartsOn; + /** What a month is (Jalali for Persian); Gregorian when omitted. */ + calendarSystem?: CalendarSystem; } /** First growth step in days; every further step doubles the side. */ @@ -114,6 +117,7 @@ export function baseRange(mode: ScrollViewMode, date: Date, opts: ScrollWindowOp end: startOfDay(endOfWeek(day, { weekStartsOn: opts.weekStartsOn })), }; case 'month': + if (opts.calendarSystem) return monthGridRange(day, opts.weekStartsOn, opts.calendarSystem); return { start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }), end: startOfDay(endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn })), diff --git a/src/lib/calendar-system.ts b/src/lib/calendar-system.ts new file mode 100644 index 00000000..aade4d92 --- /dev/null +++ b/src/lib/calendar-system.ts @@ -0,0 +1,198 @@ +import { + addDays, + addMonths as addGregorianMonths, + endOfWeek, + format, + startOfDay, + startOfWeek, + type Locale, +} from 'date-fns'; +import { JALALI_MONTHS, fromJalali, jalaliMonthLength, toJalali } from './jalali'; +import { formatCalendarDate, type CalendarDateStyle } from './date-format'; + +// The calendar system the calendar's months, day numbers and headers follow: +// Gregorian, or Jalali for the Persian UI, as the webmail decides it +// (lib/jalali-utils.ts shouldUseJalaliCalendar, hooks/use-calendar-locale.ts). +// Dates stay Gregorian Dates everywhere; only what a month is, and how a day +// or a month is labelled, changes. + +export type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export interface CalendarSystem { + jalali: boolean; + /** Local midnight of the first day of `date`'s month. */ + startOfMonth(date: Date): Date; + /** Local midnight of the last day of `date`'s month. */ + lastDayOfMonth(date: Date): Date; + /** The same day `n` months away, clamped to the end of a shorter month. */ + addMonths(date: Date, n: number): Date; + /** Year and month as one comparable number. */ + monthKey(date: Date): number; + /** Day of the month, 1-31. */ + dayOfMonth(date: Date): number; + /** Month name, in the UI language (Persian names for Jalali). */ + monthName(date: Date, width: 'long' | 'short', locale: Locale): string; + /** Year of `date`'s month. */ + year(date: Date): number; +} + +export const gregorianCalendar: CalendarSystem = { + jalali: false, + startOfMonth: (date) => new Date(date.getFullYear(), date.getMonth(), 1), + lastDayOfMonth: (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0), + addMonths: (date, n) => addGregorianMonths(date, n), + monthKey: (date) => date.getFullYear() * 12 + date.getMonth(), + dayOfMonth: (date) => date.getDate(), + // The long name stands alone ("LLLL": some languages decline it inside a + // date); the short one labels the 1st in a month grid. + monthName: (date, width, locale) => format(date, width === 'long' ? 'LLLL' : 'MMM', { locale }), + year: (date) => date.getFullYear(), +}; + +export const jalaliCalendar: CalendarSystem = { + jalali: true, + startOfMonth: (date) => { + const { jy, jm } = toJalali(date); + return fromJalali(jy, jm, 1); + }, + lastDayOfMonth: (date) => { + const { jy, jm } = toJalali(date); + return fromJalali(jy, jm, jalaliMonthLength(jy, jm)); + }, + addMonths: (date, n) => { + const { jy, jm, jd } = toJalali(date); + const index = jy * 12 + (jm - 1) + n; + const y = Math.floor(index / 12); + const m = index - y * 12 + 1; + const day = fromJalali(y, m, Math.min(jd, jalaliMonthLength(y, m))); + // Keep the time of day, as date-fns addMonths does. + day.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + return day; + }, + monthKey: (date) => { + const { jy, jm } = toJalali(date); + return jy * 12 + (jm - 1); + }, + dayOfMonth: (date) => toJalali(date).jd, + // The webmail shows the Persian names, and the first three letters on a + // phone (formatMonthYearShort). + monthName: (date, width) => { + const name = JALALI_MONTHS[toJalali(date).jm - 1]; + return width === 'long' ? name : name.slice(0, 3); + }, + year: (date) => toJalali(date).jy, +}; + +/** Whether the calendar shows Jalali dates: keyed off the Persian UI, as in the webmail. */ +export function shouldUseJalaliCalendar(locale: string | null | undefined): boolean { + return locale === 'fa'; +} + +export function calendarSystemFor(locale: string | null | undefined): CalendarSystem { + return shouldUseJalaliCalendar(locale) ? jalaliCalendar : gregorianCalendar; +} + +/** The days of `date`'s month grid: whole weeks from the one with the 1st to the one with the last day. */ +export function monthGridRange( + date: Date, + weekStartsOn: WeekStart, + system: CalendarSystem, +): { start: Date; end: Date } { + return { + start: startOfWeek(system.startOfMonth(date), { weekStartsOn }), + end: startOfDay(endOfWeek(system.lastDayOfMonth(date), { weekStartsOn })), + }; +} + +/** `date`'s month grid as rows of seven days. */ +export function monthGridRows(date: Date, weekStartsOn: WeekStart, system: CalendarSystem): Date[][] { + const { start, end } = monthGridRange(date, weekStartsOn, system); + const rows: Date[][] = []; + for (let day = start; day <= end; day = addDays(day, 7)) { + rows.push(Array.from({ length: 7 }, (_, i) => addDays(day, i))); + } + return rows; +} + +/** Whether `date` is the first day of its month. */ +export function isFirstOfMonth(date: Date, system: CalendarSystem): boolean { + return system.dayOfMonth(date) === 1; +} + +/** "Mehr 1405" / "October 2026"; without the year when `withYear` is false. */ +export function monthTitle(date: Date, system: CalendarSystem, locale: Locale, withYear: boolean): string { + const name = system.monthName(date, 'long', locale); + if (!withYear) return name; + if (!system.jalali) return format(date, 'LLLL yyyy', { locale }); + return `${name} ${system.year(date)}`; +} + +/** Day number and month of `date`, e.g. "9 Ordibehesht" or "Apr 29". */ +export function shortDayLabel(date: Date, system: CalendarSystem, locale: Locale): string { + if (!system.jalali) return format(date, 'MMM d', { locale }); + return `${system.dayOfMonth(date)} ${system.monthName(date, 'long', locale)}`; +} + +/** + * A date with weekday, day, month and year, e.g. "Wed, Apr 29, 2026", or + * "چهارشنبه, 9 اردیبهشت 1405" for Jalali (webmail useFormatEventDate). + */ +export function fullDateLabel(date: Date, system: CalendarSystem, locale: Locale, gregorianPattern = 'EEE, PP'): string { + if (!system.jalali) return format(date, gregorianPattern, { locale }); + const { jy, jm, jd } = toJalali(date); + return `${format(date, 'EEEE', { locale })}, ${jd} ${JALALI_MONTHS[jm - 1]} ${jy}`; +} + +/** "6 – 12 Mehr" / "Oct 6 – 12": the days a range of days covers. */ +export function dayRangeLabel(start: Date, end: Date, system: CalendarSystem, locale: Locale): string { + if (!system.jalali) { + const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear(); + return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`; + } + const a = toJalali(start); + const b = toJalali(end); + if (a.jy === b.jy && a.jm === b.jm) return `${a.jd} – ${b.jd} ${JALALI_MONTHS[a.jm - 1]}`; + return `${a.jd} ${JALALI_MONTHS[a.jm - 1]} – ${b.jd} ${JALALI_MONTHS[b.jm - 1]}`; +} + +/** A day's number in the grid: "29", or "9" for 9 Ordibehesht. */ +export function dayNumberLabel(date: Date, system: CalendarSystem): string { + return String(system.dayOfMonth(date)); +} + +/** + * The label of a month's first day in a grid that runs through months: + * "Oct" or "Oct 1"; the Persian month name, with "1" before it, for Jalali. + */ +export function monthStartLabel(date: Date, system: CalendarSystem, locale: Locale, withDay: boolean): string { + if (!system.jalali) return format(date, withDay ? 'MMM d' : 'MMM', { locale }); + const name = system.monthName(date, 'long', locale); + return withDay ? `${system.dayOfMonth(date)} ${name}` : name; +} + +/** The calendar system of a date-fns locale: Jalali for Persian. */ +export function calendarSystemOfLocale(locale: Locale): CalendarSystem { + return calendarSystemFor(locale.code?.split('-')[0]); +} + +/** + * A calendar date in one of formatCalendarDate's styles. For Jalali, the + * Persian weekday and month with the Jalali day and year, as the webmail's + * useFormatEventDate writes them ("چهارشنبه, 9 اردیبهشت 1405"). + */ +export function formatCalendarDay(date: Date, locale: Locale, style: CalendarDateStyle): string { + const system = calendarSystemOfLocale(locale); + if (!system.jalali) return formatCalendarDate(date, locale.code, style); + const { jy, jm, jd } = toJalali(date); + const month = JALALI_MONTHS[jm - 1]; + switch (style) { + case 'longWeekdayMonthDayYear': + return `${format(date, 'EEEE', { locale })}, ${jd} ${month} ${jy}`; + case 'weekdayMonthDay': + return `${format(date, 'EEEE', { locale })}, ${jd} ${month}`; + case 'monthDay': + return `${jd} ${month}`; + case 'monthDayYear': + return `${jd} ${month} ${jy}`; + } +} diff --git a/src/lib/jalali.ts b/src/lib/jalali.ts new file mode 100644 index 00000000..cdd5d5d8 --- /dev/null +++ b/src/lib/jalali.ts @@ -0,0 +1,137 @@ +// Jalali (Persian, Solar Hijri) calendar arithmetic. The calendar keeps every +// date as a Gregorian Date; these functions convert at the display layer only, +// as the webmail's lib/jalali-utils.ts does. +// +// The conversion is a port of jalaali-js (https://github.com/jalaali/jalaali-js, +// MIT, Behrang Noruzi Niya), which the webmail uses as a dependency. The +// algorithm is Kazimierz M. Borkowski's, valid for Jalali years -61 to 3177. + +/** A Jalali date: year (e.g. 1405), month 1 (Farvardin) to 12 (Esfand), day 1-31. */ +export interface JalaliDate { + jy: number; + jm: number; + jd: number; +} + +const BREAKS = [ + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, +]; + +function div(a: number, b: number): number { + return Math.trunc(a / b); +} + +function mod(a: number, b: number): number { + return a - Math.trunc(a / b) * b; +} + +/** Julian day number of a Gregorian date. */ +function g2d(gy: number, gm: number, gd: number): number { + let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + + div(153 * mod(gm + 9, 12) + 2, 5) + + gd - 34840408; + d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; + return d; +} + +/** Gregorian date of a Julian day number. */ +function d2g(jdn: number): { gy: number; gm: number; gd: number } { + let j = 4 * jdn + 139361631; + j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; + const i = div(mod(j, 1461), 4) * 5 + 308; + const gd = div(mod(i, 153), 5) + 1; + const gm = mod(div(i, 153), 12) + 1; + const gy = div(j, 1461) - 100100 + div(8 - gm, 6); + return { gy, gm, gd }; +} + +/** + * For Jalali year `jy`: the Gregorian year it starts in, the day of March + * that is 1 Farvardin, and how many years have passed since the last leap + * year (0 = this one is a leap year). + */ +function jalCal(jy: number): { leap: number; gy: number; march: number } { + const bl = BREAKS.length; + const gy = jy + 621; + let leapJ = -14; + let jp = BREAKS[0]; + let jump = 0; + if (jy < jp || jy >= BREAKS[bl - 1]) throw new RangeError(`Invalid Jalali year ${jy}`); + for (let i = 1; i < bl; i += 1) { + const jm = BREAKS[i]; + jump = jm - jp; + if (jy < jm) break; + leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4); + jp = jm; + } + let n = jy - jp; + leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4); + if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; + const march = 20 + leapJ - leapG; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; + let leap = mod(mod(n + 1, 33) - 1, 4); + if (leap === -1) leap = 4; + return { leap, gy, march }; +} + +function j2d(jy: number, jm: number, jd: number): number { + const r = jalCal(jy); + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1; +} + +function d2j(jdn: number): JalaliDate { + const { gy } = d2g(jdn); + let jy = gy - 621; + const r = jalCal(jy); + const jdn1f = g2d(gy, 3, r.march); + let k = jdn - jdn1f; + if (k >= 0) { + if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 }; + k -= 186; + } else { + jy -= 1; + k += 179; + if (r.leap === 1) k += 1; + } + return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 }; +} + +/** Whether Jalali year `jy` has 366 days (Esfand 30). */ +export function isLeapJalaliYear(jy: number): boolean { + return jalCal(jy).leap === 0; +} + +/** Days in a Jalali month. */ +export function jalaliMonthLength(jy: number, jm: number): number { + if (jm <= 6) return 31; + if (jm <= 11) return 30; + return isLeapJalaliYear(jy) ? 30 : 29; +} + +/** The Jalali date of a Date's local calendar day. */ +export function toJalali(date: Date): JalaliDate { + return d2j(g2d(date.getFullYear(), date.getMonth() + 1, date.getDate())); +} + +/** Local midnight of a Jalali date. */ +export function fromJalali(jy: number, jm: number, jd: number): Date { + const { gy, gm, gd } = d2g(j2d(jy, jm, jd)); + return new Date(gy, gm - 1, gd); +} + +/** Persian month names, Farvardin to Esfand (webmail JALALI_MONTHS). */ +export const JALALI_MONTHS: readonly string[] = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +]; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 7b6a5177..7ba8289e 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -29,16 +29,14 @@ import { Square, } from '../components/icons'; import { - format, addDays, subDays, - addMonths, - subMonths, addWeeks, subWeeks, type Locale, } from 'date-fns'; import { useCalendarLocale } from '../lib/calendar-locale'; +import { dayNumberLabel, monthTitle, type CalendarSystem } from '../lib/calendar-system'; import { displayNow } from '../lib/calendar-timezone'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -153,10 +151,11 @@ type PendingAction = // the next arrow press or edge usually finds them there already. const RANGE_MARGIN_DAYS = 14; -// The month on screen; the year only when it isn't this year. -function headerTitle(date: Date, locale: Locale): string { - const sameYear = date.getFullYear() === displayNow().getFullYear(); - return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale }); +// The month on screen; the year only when it isn't this year. Jalali +// months and years in Persian, as the webmail's toolbar shows them. +export function headerTitle(date: Date, locale: Locale, system: CalendarSystem): string { + const sameYear = system.year(date) === system.year(displayNow()); + return monthTitle(date, system, locale, !sameYear); } export default function CalendarScreen() { @@ -165,7 +164,7 @@ export default function CalendarScreen() { const t = useLocaleStore((s) => s.t); const currentUserEmails = useUserCalendarAddresses(); const resolveContactName = useContactNameResolver(); - const { locale } = useCalendarLocale(); + const { locale, system } = useCalendarLocale(); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar); @@ -197,8 +196,8 @@ export default function CalendarScreen() { // shows exactly one period. Navigation moves the focus and, when that // leaves the window, starts a fresh window there. const windowOptions = React.useMemo( - () => ({ weekStartsOn: calendarFirstDayOfWeek }), - [calendarFirstDayOfWeek], + () => ({ weekStartsOn: calendarFirstDayOfWeek, calendarSystem: system }), + [calendarFirstDayOfWeek, system], ); const [focus, setFocus] = React.useState(() => ({ date: displayNow(), nonce: 0 })); // The day the scrolled view shows at its top, while it differs from the focus. @@ -501,9 +500,9 @@ export default function CalendarScreen() { viewMode === 'week' ? subWeeks(base, 1) : viewMode === '3day' ? subDays(base, multiDays) : viewMode === 'day' ? subDays(base, 1) - : subMonths(base, 1), + : system.addMonths(base, -1), ); - }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]); + }, [viewMode, visibleDate, focus.date, jumpTo, multiDays, system]); const goNext = React.useCallback(() => { const base = visibleDate ?? focus.date; @@ -511,9 +510,9 @@ export default function CalendarScreen() { viewMode === 'week' ? addWeeks(base, 1) : viewMode === '3day' ? addDays(base, multiDays) : viewMode === 'day' ? addDays(base, 1) - : addMonths(base, 1), + : system.addMonths(base, 1), ); - }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]); + }, [viewMode, visibleDate, focus.date, jumpTo, multiDays, system]); const goToday = React.useCallback(() => { jumpTo(displayNow()); @@ -1038,11 +1037,11 @@ export default function CalendarScreen() { style={styles.titleBtn} hitSlop={6} accessibilityRole="button" - accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale), t)} + accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale, system), t)} accessibilityState={{ expanded: monthPanelOpen }} > - {headerTitle(titleDate, locale)} + {headerTitle(titleDate, locale, system)} @@ -1095,7 +1094,7 @@ export default function CalendarScreen() { accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')} > - {format(displayNow(), 'd')} + {dayNumberLabel(displayNow(), system)} {enableCalendarTasks && ( Date: Sat, 31 Oct 2026 14:49:56 +0100 Subject: [PATCH 4/4] feat: play the Involved sound for calendar reminders --- .../main/res/raw/involved_notification.mp3 | Bin 0 -> 24658 bytes android/app/src/main/res/raw/keep.xml | 6 ++ app.config.js | 9 +++ assets/sounds/involved_notification.wav | Bin 0 -> 129298 bytes .../__tests__/calendar-notifications.test.ts | 64 ++++++++++++++++- src/lib/calendar-notifications.ts | 66 +++++++++++++++--- 6 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/res/raw/involved_notification.mp3 create mode 100644 android/app/src/main/res/raw/keep.xml create mode 100644 assets/sounds/involved_notification.wav diff --git a/android/app/src/main/res/raw/involved_notification.mp3 b/android/app/src/main/res/raw/involved_notification.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..22bd26f8667147d1e16b2a7f53119f928783326a GIT binary patch literal 24658 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 P?t$I-KZO1t+;sl|W{*sC literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..865f0412 --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,6 @@ + + + diff --git a/app.config.js b/app.config.js index bf40cb11..ee306356 100644 --- a/app.config.js +++ b/app.config.js @@ -106,6 +106,15 @@ module.exports = { }, ], ['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }], + // Bundles the Involved sound calendar reminders play on iOS + // (src/lib/calendar-notifications.ts). Android has it in res/raw. + [ + 'expo-notifications', + { + mode: APS_ENVIRONMENT, + sounds: ['./assets/sounds/involved_notification.wav'], + }, + ], ], extra: { commit: COMMIT, diff --git a/assets/sounds/involved_notification.wav b/assets/sounds/involved_notification.wav new file mode 100644 index 0000000000000000000000000000000000000000..284e743b6326bba478bf258c890812a90a807f3c GIT binary patch literal 129298 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, prev: Record) => void; @@ -24,6 +26,8 @@ const h = vi.hoisted(() => ({ // Device sync (#34): which events the device's calendar app reminds of. onDevice: null as ((event: { id: string }) => boolean) | null, reminderListener: null as (() => void) | null, + // The reminder channel earlier versions created, if any. + legacyChannel: null as { id: string; importance: number } | null, })); vi.mock('react-native', () => ({ @@ -35,6 +39,8 @@ vi.mock('expo-notifications', () => ({ getPermissionsAsync: vi.fn(async () => ({ ...h.permission })), requestPermissionsAsync: vi.fn(async () => ({ granted: true })), setNotificationChannelAsync: vi.fn(async () => undefined), + getNotificationChannelAsync: vi.fn(async (id: string) => (h.legacyChannel?.id === id ? h.legacyChannel : null)), + deleteNotificationChannelAsync: vi.fn(async () => undefined), getAllScheduledNotificationsAsync: vi.fn(async () => h.pending), cancelScheduledNotificationAsync: vi.fn(async () => undefined), scheduleNotificationAsync: vi.fn(async () => 'id'), @@ -46,7 +52,7 @@ vi.mock('expo-notifications', () => ({ return { remove: vi.fn() }; }), DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT', - AndroidImportance: { HIGH: 4 }, + AndroidImportance: { NONE: 2, DEFAULT: 5, HIGH: 6 }, IosAuthorizationStatus: { PROVISIONAL: 3 }, SchedulableTriggerInputTypes: { DATE: 'date' }, })); @@ -91,7 +97,7 @@ const upcoming = { const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`; function reminder(identifier: string, key: string) { - return { identifier, content: { data: { tag: TAG, key } } }; + return { identifier, content: { data: { tag: TAG, key, delivery: 'calendar-reminders-involved' } } }; } let mod: typeof import('../calendar-notifications'); @@ -115,6 +121,7 @@ beforeEach(async () => { h.calendarListener = null; h.onDevice = null; h.reminderListener = null; + h.legacyChannel = null; h.loadEventsInRange.mockResolvedValue([upcoming]); mod = await import('../calendar-notifications'); N = await import('expo-notifications'); @@ -322,6 +329,7 @@ describe('tapping a calendar reminder', () => { tag: TAG, key: expect.any(String), locale: 'en', + delivery: 'calendar-reminders-involved', eventId: 'acc-2:ev1:2026-09-24T10:00:00Z', kind: 'event', serverId: 'ev1', @@ -366,3 +374,55 @@ describe('tapping a calendar reminder', () => { expect(open).toHaveBeenCalledTimes(1); }); }); + +describe('the Involved sound on calendar reminders', () => { + it('schedules reminders on a channel of its own that plays the Involved sound', async () => { + await mod.rescheduleCalendarNotifications(); + expect(N.setNotificationChannelAsync).toHaveBeenCalledWith('calendar-reminders-involved', expect.objectContaining({ + sound: 'involved_notification.mp3', + })); + const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.trigger).toMatchObject({ channelId: 'calendar-reminders-involved' }); + expect(request.content.sound).toBe('involved_notification.mp3'); + }); + + it('leaves the channel of earlier versions alone', async () => { + h.legacyChannel = { id: 'calendar-reminders', importance: 6 }; + await mod.rescheduleCalendarNotifications(); + const ids = vi.mocked(N.setNotificationChannelAsync).mock.calls.map((call) => call[0]); + expect(ids).toEqual(['calendar-reminders-involved']); + expect(N.deleteNotificationChannelAsync).not.toHaveBeenCalled(); + expect(vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0].trigger).toMatchObject({ channelId: 'calendar-reminders-involved' }); + }); + + it('keeps reminders off for someone who switched the old channel off', async () => { + h.legacyChannel = { id: 'calendar-reminders', importance: 2 }; + await mod.rescheduleCalendarNotifications(); + expect(N.setNotificationChannelAsync).not.toHaveBeenCalled(); + expect(vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0].trigger).toMatchObject({ channelId: 'calendar-reminders' }); + }); + + it('schedules again a reminder pending from before, so it plays the new sound', async () => { + h.pending = [{ identifier: 'old', content: { data: { tag: TAG, key: upcomingKey, locale: 'en' } } }]; + await mod.rescheduleCalendarNotifications(); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('old'); + const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.data).toMatchObject({ key: upcomingKey, delivery: 'calendar-reminders-involved' }); + + // Once moved, it stays. + h.pending = [{ identifier: 'new', content: { data: request.content.data as Record } }]; + vi.mocked(N.scheduleNotificationAsync).mockClear(); + vi.mocked(N.cancelScheduledNotificationAsync).mockClear(); + await mod.rescheduleCalendarNotifications(); + expect(N.cancelScheduledNotificationAsync).not.toHaveBeenCalled(); + expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); + + it('ships the sound for Android, kept from the resource shrinker, and for iOS', () => { + const root = path.resolve(__dirname, '../../..'); + expect(fs.existsSync(path.join(root, 'android/app/src/main/res/raw/involved_notification.mp3'))).toBe(true); + expect(fs.readFileSync(path.join(root, 'android/app/src/main/res/raw/keep.xml'), 'utf8')).toContain('@raw/involved_notification'); + expect(fs.existsSync(path.join(root, 'assets/sounds/involved_notification.wav'))).toBe(true); + expect(fs.readFileSync(path.join(root, 'app.config.js'), 'utf8')).toContain("sounds: ['./assets/sounds/involved_notification.wav']"); + }); +}); diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index fd7aada6..835b9246 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -22,7 +22,17 @@ import { startCalendarEventNotificationSync } from './calendar-event-notificatio // don't depend on the Calendar tab being open. Honours the // `calendarNotificationsEnabled` setting. -const CHANNEL_ID = 'calendar-reminders'; +// Reminders play the webmail's default notification sound, Involved +// (webmail lib/notification-sound.ts, stores/settings-store.ts +// notificationSoundChoice). An Android channel keeps the sound it was created +// with, so the Involved channel has an id of its own; the channel earlier +// versions created stays as the user left it. Its sound file ships as +// android/app/src/main/res/raw/involved_notification.mp3 and, for iOS, +// assets/sounds/involved_notification.wav. +export const CHANNEL_ID = 'calendar-reminders-involved'; +export const LEGACY_CHANNEL_ID = 'calendar-reminders'; +const ANDROID_SOUND = 'involved_notification.mp3'; +const IOS_SOUND = 'involved_notification.wav'; const DAY_MS = 24 * 60 * 60 * 1000; const HORIZON_MS = 7 * DAY_MS; // The events loaded for the horizon: from a day back (an end-relative alert @@ -42,6 +52,8 @@ let permissionRequested = false; let mayAskPermission = false; // The language the Android channel was last named in; null until created. let channelLocale: string | null = null; +// The channel reminders go to; decided once per run. +let activeChannelId: string | null = null; let rescheduleTimer: ReturnType | null = null; let rescheduling: Promise | null = null; let queued = false; @@ -68,25 +80,48 @@ async function ensurePermission(): Promise { return permissionGranted; } +/** + * The channel reminders go to, given the channel earlier versions created + * (null when there is none). Someone who switched those reminders off in the + * system settings keeps them off: they stay on that channel. Everyone else + * gets the Involved channel. + */ +export function reminderChannelFor(legacy: { importance?: number } | null): string { + if (legacy && legacy.importance === Notifications.AndroidImportance.NONE) return LEGACY_CHANNEL_ID; + return CHANNEL_ID; +} + // The channel's name shows in the system notification settings, so it is // (re)named in the app language; setting an existing channel only renames it. +// The old channel is never touched. async function ensureChannel(): Promise { if (Platform.OS !== 'android') return; const { locale, t } = useLocaleStore.getState(); if (channelLocale === locale) return; try { - await Notifications.setNotificationChannelAsync(CHANNEL_ID, { - name: t('calendar.notifications.channel_name', 'Calendar reminders'), - importance: Notifications.AndroidImportance.HIGH, - sound: 'default', - vibrationPattern: [0, 250, 250, 250], - }); + if (activeChannelId === null) { + const legacy = await Notifications.getNotificationChannelAsync(LEGACY_CHANNEL_ID).catch(() => null); + activeChannelId = reminderChannelFor(legacy); + } + if (activeChannelId === CHANNEL_ID) { + await Notifications.setNotificationChannelAsync(CHANNEL_ID, { + name: t('calendar.notifications.channel_name', 'Calendar reminders'), + importance: Notifications.AndroidImportance.HIGH, + sound: ANDROID_SOUND, + vibrationPattern: [0, 250, 250, 250], + }); + } } catch { // Channel creation failing only degrades to the default channel. } channelLocale = locale; } +/** What a reminder is scheduled with: its channel on Android, its sound on iOS. */ +function deliveryTag(): string { + return Platform.OS === 'android' ? activeChannelId ?? CHANNEL_ID : IOS_SOUND; +} + function alertKeyOf(request: Notifications.NotificationRequest): string | null { const data = request.content.data as { tag?: string; key?: string } | undefined; return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null; @@ -99,6 +134,13 @@ function isStaleLanguage(request: Notifications.NotificationRequest, locale: str return typeof data?.locale === 'string' && data.locale !== locale; } +// A reminder scheduled for another channel or sound, such as the default one +// before the Involved sound: scheduled again, so it plays the new sound. +function isStaleDelivery(request: Notifications.NotificationRequest): boolean { + const data = request.content.data as { delivery?: unknown } | undefined; + return data?.delivery !== deliveryTag(); +} + /** Cancel every calendar reminder this app scheduled. */ export async function cancelAllCalendarNotifications(): Promise { try { @@ -119,6 +161,8 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise key: alert.key, // The language the title and body are in, to reword it on a switch. locale, + // The channel (Android) or sound (iOS) it goes out with. + delivery: deliveryTag(), eventId: alert.eventId, kind: alert.kind, // Enough to find the event again when the reminder is tapped. @@ -133,13 +177,15 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise content: { title: alert.title, body: alert.body, - sound: 'default', + // iOS plays the bundled file, or the default sound without it. Android + // takes the channel's sound. + sound: Platform.OS === 'ios' ? IOS_SOUND : ANDROID_SOUND, data, }, trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(alert.fireTimeMs), - ...(Platform.OS === 'android' ? { channelId: CHANNEL_ID } : {}), + ...(Platform.OS === 'android' ? { channelId: activeChannelId ?? CHANNEL_ID } : {}), }, }); } @@ -251,7 +297,7 @@ export async function rescheduleCalendarNotifications(): Promise { for (const request of pending) { const key = alertKeyOf(request); if (key === null) continue; - if (wantedByKey.has(key) && !isStaleLanguage(request, locale)) { + if (wantedByKey.has(key) && !isStaleLanguage(request, locale) && !isStaleDelivery(request)) { present.add(key); } else { await Notifications.cancelScheduledNotificationAsync(request.identifier);