This commit is contained in:
Linus Rath
2026-08-16 22:07:19 +02:00
7 changed files with 278 additions and 83 deletions
@@ -132,9 +132,13 @@ export function CalendarSidebarPanel({
const localAccounts = useAccountStore((s) => s.accounts);
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
// Tasks-only calendars (VTODO, no events) are kept in the store for the tasks
// view but hidden from the event calendar list here.
const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]);
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]);
const sharedAccountGroups = useMemo(() => {
const shared = calendars.filter(c => c.isShared);
const shared = eventCalendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
for (const cal of shared) {
const key = cal.accountId || cal.accountName || cal.id;
@@ -144,7 +148,7 @@ export function CalendarSidebarPanel({
groups.get(key)!.calendars.push(cal);
}
return Array.from(groups.values());
}, [calendars]);
}, [eventCalendars]);
/**
* Pro / multi-account grouping: every calendar bucketed by its owning
@@ -156,7 +160,7 @@ export function CalendarSidebarPanel({
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<string, Calendar[]>();
for (const cal of calendars) {
for (const cal of eventCalendars) {
const key = cal.localAccountId || '__other__';
const list = byAccount.get(key) ?? [];
list.push(cal);
@@ -191,7 +195,7 @@ export function CalendarSidebarPanel({
ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) });
}
return ordered;
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
}, [multiAccountMode, eventCalendars, localAccounts, activeLocalAccountId, t]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { isTaskLikeObject, findTasksOnlyCalendarIds, type ScannedCalendarObject } from '@/lib/calendar-component-detection';
describe('isTaskLikeObject', () => {
it('treats an explicit @type Task as a task', () => {
expect(isTaskLikeObject({ '@type': 'Task' })).toBe(true);
expect(isTaskLikeObject({ '@type': 'task' })).toBe(true);
});
it('treats an @type Event as not a task even with a stray field', () => {
expect(isTaskLikeObject({ '@type': 'Event' })).toBe(false);
expect(isTaskLikeObject({ '@type': 'Event', due: '2026-01-01T00:00:00' })).toBe(false);
});
it('detects a CalDAV task lacking @type by its task-only fields', () => {
expect(isTaskLikeObject({ due: '2026-01-01T00:00:00' })).toBe(true);
expect(isTaskLikeObject({ progress: 'needs-action' })).toBe(true);
expect(isTaskLikeObject({ percentComplete: 0 })).toBe(true);
});
it('treats a plain object with no task markers as an event', () => {
expect(isTaskLikeObject({})).toBe(false);
expect(isTaskLikeObject({ '@type': 'Event', title: 'Standup' })).toBe(false);
});
});
describe('findTasksOnlyCalendarIds', () => {
it('flags a calendar whose objects are all tasks', () => {
const objects: ScannedCalendarObject[] = [
{ '@type': 'Task', calendarIds: { 'cal-tasks': true } },
{ due: '2026-01-01T00:00:00', calendarIds: { 'cal-tasks': true } },
];
expect([...findTasksOnlyCalendarIds(objects, ['cal-tasks'])]).toEqual(['cal-tasks']);
});
it('does not flag a calendar that has at least one event', () => {
const objects: ScannedCalendarObject[] = [
{ '@type': 'Task', calendarIds: { 'cal-mixed': true } },
{ '@type': 'Event', calendarIds: { 'cal-mixed': true } },
];
expect(findTasksOnlyCalendarIds(objects, ['cal-mixed']).size).toBe(0);
});
it('does not flag an empty calendar (no objects)', () => {
expect(findTasksOnlyCalendarIds([], ['cal-empty']).size).toBe(0);
const objects: ScannedCalendarObject[] = [{ '@type': 'Event', calendarIds: { 'cal-other': true } }];
expect(findTasksOnlyCalendarIds(objects, ['cal-empty']).size).toBe(0);
});
it('classifies each calendar independently in a mixed account', () => {
const objects: ScannedCalendarObject[] = [
{ '@type': 'Event', calendarIds: { work: true } },
{ '@type': 'Task', calendarIds: { todos: true } },
{ percentComplete: 50, calendarIds: { todos: true } },
];
const result = findTasksOnlyCalendarIds(objects, ['work', 'todos', 'empty']);
expect([...result]).toEqual(['todos']);
});
it('handles an object that belongs to several calendars', () => {
// An event shared into two calendars keeps both off the tasks-only list.
const objects: ScannedCalendarObject[] = [
{ '@type': 'Event', calendarIds: { a: true, b: true } },
{ '@type': 'Task', calendarIds: { b: true } },
];
expect(findTasksOnlyCalendarIds(objects, ['a', 'b']).size).toBe(0);
});
});
+66 -76
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, unlink, writeFileSync } from "fs";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -6,8 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// The route consults admin-dashboard overrides (ADMIN_CONFIG_DIR, default
// data/admin) before env vars. Point it at an empty temp dir so local admin
// state on the developer's machine can't leak into these env-driven
// assertions. Must happen before the first GET, because the config manager
// singleton loads the directory once and caches it.
// assertions. Set directly (not via vi.stubEnv) so unstubAllEnvs() between
// tests can't clear it - the config-manager singleton caches it on first load.
process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-config-route-'));
// Mock NextResponse before importing the route
@@ -21,35 +21,37 @@ vi.mock('@/lib/logger', () => ({
logger: { debug: vi.fn() },
}));
// Env vars these tests drive. Stubbed to undefined before each test so the
// developer's / CI's real environment (and any other test file sharing this
// worker's process.env) can't leak in.
const MANAGED_ENV = [
'APP_NAME', 'NEXT_PUBLIC_APP_NAME', 'JMAP_SERVER_URL', 'NEXT_PUBLIC_JMAP_SERVER_URL',
'OAUTH_ENABLED', 'OAUTH_CLIENT_ID', 'OAUTH_ISSUER_URL', 'SESSION_SECRET',
'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP',
'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME',
'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING',
] as const;
describe('config API route', () => {
const originalEnv = { ...process.env };
// Unique per-test dirs so the SESSION_SECRET_FILE tests never share a path;
// removed synchronously in afterEach (the old fire-and-forget async unlink of
// a shared ./session-secret raced across tests under the full suite).
const tempDirs: string[] = [];
function secretFile(contents = 'test-secret'): string {
const dir = mkdtempSync(path.join(tmpdir(), 'bw-secret-'));
tempDirs.push(dir);
const file = path.join(dir, 'session-secret');
writeFileSync(file, contents);
return file;
}
beforeEach(() => {
// Clear all relevant env vars before each test
delete process.env.APP_NAME;
delete process.env.NEXT_PUBLIC_APP_NAME;
delete process.env.JMAP_SERVER_URL;
delete process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
delete process.env.OAUTH_ENABLED;
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET;
delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP;
delete process.env.FAVICON_URL;
delete process.env.APP_LOGO_LIGHT_URL;
delete process.env.APP_LOGO_DARK_URL;
delete process.env.LOGIN_COMPANY_NAME;
delete process.env.LOGIN_IMPRINT_URL;
delete process.env.LOGIN_PRIVACY_POLICY_URL;
delete process.env.LOGIN_WEBSITE_URL;
delete process.env.DOMAIN_BRANDING;
for (const key of MANAGED_ENV) vi.stubEnv(key, undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
vi.unstubAllEnvs();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function mockRequest(headers: Record<string, string> = {}): unknown {
@@ -93,8 +95,8 @@ describe('config API route', () => {
});
it('should use runtime env vars over defaults', async () => {
process.env.APP_NAME = 'My Mail';
process.env.JMAP_SERVER_URL = 'https://mail.example.com';
vi.stubEnv('APP_NAME', 'My Mail');
vi.stubEnv('JMAP_SERVER_URL', 'https://mail.example.com');
const config = await getConfig();
@@ -103,8 +105,8 @@ describe('config API route', () => {
});
it('should fall back to NEXT_PUBLIC_ vars when runtime vars are unset', async () => {
process.env.NEXT_PUBLIC_APP_NAME = 'Legacy Mail';
process.env.NEXT_PUBLIC_JMAP_SERVER_URL = 'https://legacy.example.com';
vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'Legacy Mail');
vi.stubEnv('NEXT_PUBLIC_JMAP_SERVER_URL', 'https://legacy.example.com');
const config = await getConfig();
@@ -113,8 +115,8 @@ describe('config API route', () => {
});
it('should prefer runtime vars over NEXT_PUBLIC_ vars', async () => {
process.env.APP_NAME = 'Runtime';
process.env.NEXT_PUBLIC_APP_NAME = 'BuildTime';
vi.stubEnv('APP_NAME', 'Runtime');
vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'BuildTime');
const config = await getConfig();
@@ -122,10 +124,10 @@ describe('config API route', () => {
});
it('should return login page customization values', async () => {
process.env.LOGIN_COMPANY_NAME = 'Acme Corp';
process.env.LOGIN_IMPRINT_URL = 'https://acme.com/imprint';
process.env.LOGIN_PRIVACY_POLICY_URL = 'https://acme.com/privacy';
process.env.LOGIN_WEBSITE_URL = 'https://acme.com';
vi.stubEnv('LOGIN_COMPANY_NAME', 'Acme Corp');
vi.stubEnv('LOGIN_IMPRINT_URL', 'https://acme.com/imprint');
vi.stubEnv('LOGIN_PRIVACY_POLICY_URL', 'https://acme.com/privacy');
vi.stubEnv('LOGIN_WEBSITE_URL', 'https://acme.com');
const config = await getConfig();
@@ -136,7 +138,7 @@ describe('config API route', () => {
});
it('should handle partial login customization', async () => {
process.env.LOGIN_COMPANY_NAME = 'Partial Corp';
vi.stubEnv('LOGIN_COMPANY_NAME', 'Partial Corp');
// Leave URLs unset
const config = await getConfig();
@@ -148,55 +150,43 @@ describe('config API route', () => {
});
it('should enable rememberMe when SESSION_SECRET is set', async () => {
process.env.SESSION_SECRET = 'test-secret';
vi.stubEnv('SESSION_SECRET', 'test-secret');
const config = await getConfig();
expect(config.rememberMeEnabled).toBe(true);
});
});
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
vi.stubEnv('SESSION_SECRET_FILE', secretFile());
const config = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config.rememberMeEnabled).toBe(true);
});
it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true';
vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true');
const config1 = await getConfig();
expect(config1.settingsSyncEnabled).toBe(false);
process.env.SESSION_SECRET = 'test-secret';
vi.stubEnv('SESSION_SECRET', 'test-secret');
const config2 = await getConfig();
expect(config2.settingsSyncEnabled).toBe(true);
});
});
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true';
vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true');
const config1 = await getConfig();
expect(config1.settingsSyncEnabled).toBe(false);
writeFileSync('./session-secret', 'test-secret');
process.env.SESSION_SECRET_FILE = './session-secret';
vi.stubEnv('SESSION_SECRET_FILE', secretFile());
const config2 = await getConfig();
unlink('./session-secret', (err) => {
if (err) throw err;
});
expect(config2.settingsSyncEnabled).toBe(true);
});
it('should disable stalwart features when explicitly set to false', async () => {
process.env.STALWART_FEATURES = 'false';
vi.stubEnv('STALWART_FEATURES', 'false');
const config = await getConfig();
@@ -204,9 +194,9 @@ describe('config API route', () => {
});
it('should return custom favicon and app logo URLs', async () => {
process.env.FAVICON_URL = '/branding/custom-favicon.svg';
process.env.APP_LOGO_LIGHT_URL = '/branding/my-logo.svg';
process.env.APP_LOGO_DARK_URL = '/branding/my-logo-white.svg';
vi.stubEnv('FAVICON_URL', '/branding/custom-favicon.svg');
vi.stubEnv('APP_LOGO_LIGHT_URL', '/branding/my-logo.svg');
vi.stubEnv('APP_LOGO_DARK_URL', '/branding/my-logo-white.svg');
const config = await getConfig();
@@ -217,15 +207,15 @@ describe('config API route', () => {
describe('per-domain branding overrides', () => {
it('applies overrides for the matching host', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
vi.stubEnv('LOGIN_WEBSITE_URL', 'https://default.example');
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
{
host: 'mail1.example.com',
loginCompanyName: 'Brand One',
loginWebsiteUrl: 'https://one.example',
},
]);
]));
const config = await getConfig({ host: 'mail1.example.com' });
@@ -234,10 +224,10 @@ describe('config API route', () => {
});
it('falls through to the global value when the host has no entry', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.DOMAIN_BRANDING = JSON.stringify([
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
]));
const config = await getConfig({ host: 'unmapped.example.com' });
@@ -245,11 +235,11 @@ describe('config API route', () => {
});
it('falls through field-by-field when the matching entry omits a field', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
vi.stubEnv('LOGIN_WEBSITE_URL', 'https://default.example');
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
]));
const config = await getConfig({ host: 'mail1.example.com' });
@@ -258,9 +248,9 @@ describe('config API route', () => {
});
it('prefers X-Forwarded-Host over Host', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
{ host: 'public.example.com', loginCompanyName: 'Public' },
]);
]));
const config = await getConfig({
host: 'internal.example.com',
@@ -271,9 +261,9 @@ describe('config API route', () => {
});
it('strips the port from the host header before matching', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
]));
const config = await getConfig({ host: 'mail1.example.com:8443' });
+66
View File
@@ -0,0 +1,66 @@
/**
* Helpers for deciding whether a calendar holds only tasks (VTODO) and so
* should be hidden from the *event* calendar UI (#761).
*
* JMAP's Calendar/get on Stalwart exposes no per-calendar supported-component
* set, so "tasks-only" has to be derived from the objects a calendar contains,
* mirroring how task objects are already told apart from events elsewhere.
*/
/**
* A calendar object as seen with the minimal properties we scan. The index
* signature keeps it tolerant of the other fields the server returns (id, etc.).
*/
export interface ScannedCalendarObject {
'@type'?: string;
due?: unknown;
progress?: unknown;
percentComplete?: unknown;
calendarIds?: Record<string, boolean> | null;
[key: string]: unknown;
}
/**
* Is this calendar object a task rather than an event? Matches an explicit
* `@type: "Task"`, and CalDAV-created tasks (which may lack the type) by the
* presence of RFC 8984 §5.2 Task-only keys (`due` / `progress` /
* `percentComplete`) - a VEVENT never carries those. Mirrors the existing
* event/task split so classification stays consistent.
*/
export function isTaskLikeObject(obj: ScannedCalendarObject): boolean {
const type = obj['@type'];
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
if (type !== 'Event' && (
('progress' in obj && typeof obj.progress === 'string') ||
('due' in obj && obj.due != null) ||
('percentComplete' in obj)
)) return true;
return false;
}
/**
* Given every object in an account and the calendar ids under consideration,
* return the ids of calendars that hold at least one object and whose objects
* are ALL tasks (no event). Empty calendars are deliberately NOT included -
* a brand-new event calendar with nothing in it must stay visible.
*/
export function findTasksOnlyCalendarIds(
objects: ScannedCalendarObject[],
calendarIds: string[],
): Set<string> {
const withAny = new Set<string>();
const withEvent = new Set<string>();
for (const obj of objects) {
const task = isTaskLikeObject(obj);
const ids = obj.calendarIds ? Object.keys(obj.calendarIds) : [];
for (const id of ids) {
withAny.add(id);
if (!task) withEvent.add(id);
}
}
const tasksOnly = new Set<string>();
for (const id of calendarIds) {
if (withAny.has(id) && !withEvent.has(id)) tasksOnly.add(id);
}
return tasksOnly;
}
+61 -1
View File
@@ -5,6 +5,7 @@ import { toWildcardQuery } from "./search-utils";
import { batched, itemsPerRequest } from "./request-limits";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
/**
@@ -4882,7 +4883,9 @@ export class JMAPClient implements IJMAPClient {
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
return (response.methodResponses[0][1].list || []) as Calendar[];
const calendars = (response.methodResponses[0][1].list || []) as Calendar[];
const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, calendars.map((c) => c.id));
return calendars.map((cal) => ({ ...cal, isTasksOnly: tasksOnly.has(cal.id) }));
}
return [];
} catch (error) {
@@ -4891,6 +4894,61 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Ids of the given calendars that hold only tasks and no events, so the event
* calendar UI can hide them. Stalwart's Calendar/get exposes no
* supported-component set, so this is derived by scanning the objects.
*
* Cheap in the common case and safe on the edges: each page is classified as
* it arrives and, as soon as EVERY calendar has been seen to contain an event,
* the scan stops (a normal event account exits after one page). It only marks
* a calendar tasks-only after scanning ALL of that account's objects; if the
* account has more than the scan cap, it fails OPEN (marks nothing) rather
* than risk hiding a calendar whose events sit past the cap. Any error also
* fails open. Empty calendars are never marked (they must stay visible).
*/
private async getTasksOnlyCalendarIds(accountId: string, rawCalendarIds: string[]): Promise<Set<string>> {
if (rawCalendarIds.length === 0) return new Set();
const QUERY_PAGE = 500;
const MAX_SCAN = 5000; // safety bound; beyond this we fail open
const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds'];
const timeZone = getUserTimeZone();
const objects: ScannedCalendarObject[] = [];
const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event
try {
let exhausted = false;
for (let position = 0; position < MAX_SCAN;) {
const queryArgs: Record<string, unknown> = { accountId, limit: QUERY_PAGE, position };
if (timeZone) queryArgs.timeZone = timeZone;
const qResp = await this.request([["CalendarEvent/query", queryArgs, "0"]], this.calendarUsing());
if (qResp.methodResponses?.[0]?.[0] === "error") return new Set(); // fail open
const pageIds: string[] = qResp.methodResponses?.[0]?.[1]?.ids || [];
if (pageIds.length === 0) { exhausted = true; break; }
const getResp = await this.request([
["CalendarEvent/get", { accountId, ids: pageIds, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"]
], this.calendarUsing());
const list = (getResp.methodResponses?.[0]?.[1]?.list || []) as ScannedCalendarObject[];
for (const obj of list) {
objects.push(obj);
if (!isTaskLikeObject(obj) && obj.calendarIds) {
for (const id of Object.keys(obj.calendarIds)) remaining.delete(id);
}
}
// Every calendar has at least one event -> none can be tasks-only.
if (remaining.size === 0) return new Set();
if (pageIds.length < QUERY_PAGE) { exhausted = true; break; }
position += pageIds.length;
}
if (!exhausted) return new Set(); // hit the cap without finishing -> hide nothing
return findTasksOnlyCalendarIds(objects, rawCalendarIds);
} catch {
return new Set(); // fail open
}
}
async getAllCalendars(): Promise<Calendar[]> {
try {
const allCalendars: Calendar[] = [];
@@ -4909,6 +4967,7 @@ export class JMAPClient implements IJMAPClient {
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, rawCalendars.map((c) => c.id));
const calendars = rawCalendars.map((cal) => ({
...cal,
id: isPrimary ? cal.id : `${accountId}:${cal.id}`,
@@ -4916,6 +4975,7 @@ export class JMAPClient implements IJMAPClient {
accountId,
accountName: account?.name || (isPrimary ? this.username : accountId),
isShared: !isPrimary,
isTasksOnly: tasksOnly.has(cal.id),
}));
allCalendars.push(...calendars);
}
+4
View File
@@ -546,6 +546,10 @@ export interface Calendar {
// shared calendar (see lib/shared-calendar-colors). When true, the override
// wins over per-event colors so the whole shared calendar paints uniformly.
colorIsLocalOverride?: boolean;
// True when the calendar holds only tasks (VTODO) and no events, so it is
// hidden from the event calendar UI while remaining available to the tasks
// view (#761). Undefined means "not determined" (treated as not tasks-only).
isTasksOnly?: boolean;
}
export interface CalendarRights {
+4 -1
View File
@@ -330,10 +330,13 @@ export const useCalendarStore = create<CalendarStore>()(
const calendars = await client.getAllCalendars();
const { selectedCalendarIds } = get();
const stillValid = reconcileSelectedIds(selectedCalendarIds, calendars);
// Default the visible selection to event calendars only - tasks-only
// calendars stay out of the event grid.
const defaultSelected = calendars.filter(c => !c.isTasksOnly).map(c => c.id);
set({
calendars,
isLoading: false,
selectedCalendarIds: stillValid.length > 0 ? stillValid : calendars.map(c => c.id),
selectedCalendarIds: stillValid.length > 0 ? stillValid : defaultSelected,
});
} catch (error) {
debug.error('Failed to fetch calendars:', error);