Feature: per-viewer colors for shared calendars (#345)

This commit is contained in:
Linus Rath
2026-05-31 15:52:27 +02:00
parent 55be19ede7
commit ae66f8d89d
25 changed files with 272 additions and 32 deletions
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import type { Calendar } from '@/lib/jmap/types';
import {
CALENDAR_COLORS,
sharedCalendarColorKey,
pickUnusedCalendarColor,
} from '../shared-calendar-colors';
function makeCal(overrides: Partial<Calendar>): Calendar {
return {
id: 'cal-1',
name: 'Cal',
description: null,
color: null,
sortOrder: 0,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {} as Calendar['myRights'],
...overrides,
};
}
describe('sharedCalendarColorKey', () => {
it('is built from local account, JMAP account, and the original id', () => {
const cal = makeCal({
id: 'acct-9:cal-7',
originalId: 'cal-7',
accountId: 'acct-9',
localAccountId: 'slot-2',
});
expect(sharedCalendarColorKey(cal)).toBe('slot-2|acct-9|cal-7');
});
it('is stable regardless of the Pro-shell id prefix', () => {
// Same underlying calendar, shown once under the active account (bare id)
// and once cross-account (prefixed id) - both must map to one key.
const active = makeCal({ id: 'acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
const prefixed = makeCal({ id: 'slot-2::acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
expect(sharedCalendarColorKey(active)).toBe(sharedCalendarColorKey(prefixed));
});
it('falls back to the id when originalId is absent', () => {
const cal = makeCal({ id: 'cal-7', accountId: 'acct-9' });
expect(sharedCalendarColorKey(cal)).toBe('|acct-9|cal-7');
});
});
describe('pickUnusedCalendarColor', () => {
it('returns a palette color not present in usedColors', () => {
const used = CALENDAR_COLORS.slice(0, CALENDAR_COLORS.length - 1);
const picked = pickUnusedCalendarColor(used);
expect(picked).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
});
it('ignores case when comparing used colors', () => {
const used = CALENDAR_COLORS.slice(0, -1).map((c) => c.toUpperCase());
expect(pickUnusedCalendarColor(used)).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
});
it('still returns a palette color once every color is taken', () => {
expect(CALENDAR_COLORS).toContain(pickUnusedCalendarColor(CALENDAR_COLORS));
});
it('always returns a valid palette color for a small used set', () => {
for (let i = 0; i < 50; i++) {
const picked = pickUnusedCalendarColor(['#3b82f6']);
expect(CALENDAR_COLORS).toContain(picked);
expect(picked).not.toBe('#3b82f6');
}
});
});
+4
View File
@@ -488,6 +488,10 @@ export interface Calendar {
// can route mutations to the right client. Distinct from `accountId`
// which is the JMAP server's own account UUID.
localAccountId?: string;
// Set when `color` has been replaced by the viewer's local override for a
// 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;
}
export interface CalendarRights {
+55
View File
@@ -0,0 +1,55 @@
import type { Calendar } from '@/lib/jmap/types';
/**
* Palette of calendar colors offered in the color picker. Defined here (rather
* than in the settings UI component) so non-React modules can reuse it without
* pulling in component code. The settings color picker re-exports this.
*/
export const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
/**
* Stable key for a shared calendar's local color override. Independent of the
* Pro-shell id prefix (which changes with the active account), so the override
* survives shell-mode and account switches. Built from the owning JMAP account
* + the calendar's original server id.
*/
export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'originalId' | 'accountId' | 'localAccountId'>,
): string {
const localAccount = cal.localAccountId ?? '';
const account = cal.accountId ?? '';
const id = cal.originalId ?? cal.id;
return `${localAccount}|${account}|${id}`;
}
/**
* Pick a random palette color not present in `usedColors`. Once every palette
* entry is taken, fall back to a random palette color (collisions are
* unavoidable past CALENDAR_COLORS.length calendars).
*/
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const used = new Set<string>();
for (const c of usedColors) {
if (c) used.add(c.toLowerCase());
}
const available = CALENDAR_COLORS.filter((c) => !used.has(c.toLowerCase()));
const pool = available.length > 0 ? available : CALENDAR_COLORS;
return pool[Math.floor(Math.random() * pool.length)];
}