fix: show a shared calendar in the color the server gives it #1414

This commit is contained in:
Linus Rath
2026-10-29 14:35:22 +01:00
parent 828d0acc0b
commit 2dbe5d0244
35 changed files with 165 additions and 26 deletions
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import { useSettingsStore } from '@/stores/settings-store';
import { sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar } from '@/lib/jmap/types';
// Dropping a shared calendar's own color brings back the server's color when
// it has one (#1414), so the menu says so instead of promising a random one.
const family = {
id: 'owner:family', originalId: 'family', accountId: 'owner', isShared: true,
name: 'Family', color: '#22c55e', colorIsLocalOverride: true, sortOrder: 0,
isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: 'all',
timeZone: null, shareWith: null,
myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false },
} as unknown as Calendar;
function openMenu(cal: Calendar, onResetColor: (cal: Calendar) => void) {
const client = { getAccountId: () => 'me' } as unknown as IJMAPClient;
render(
<CalendarSidebarPanel
calendars={[cal]}
selectedCalendarIds={[cal.id]}
onToggleVisibility={() => {}}
onResetColor={onResetColor}
client={client}
/>,
);
fireEvent.contextMenu(screen.getByTestId('calendar-item'), { clientX: 20, clientY: 20 });
}
describe('CalendarSidebarPanel shared calendar color reset', () => {
beforeEach(() => {
useSettingsStore.setState({ sharedCalendarColors: { [sharedCalendarColorKey(family)]: '#22c55e' } });
});
it('offers to reset to the server color when the server has one', async () => {
const onResetColor = vi.fn();
openMenu({ ...family, serverColor: '#CD1526' }, onResetColor);
const reset = await screen.findByRole('menuitem', { name: 'reset_color' });
expect(screen.queryByRole('menuitem', { name: 'random_color' })).toBeNull();
fireEvent.click(reset);
expect(onResetColor).toHaveBeenCalledTimes(1);
});
it('offers a new random color when the server has none', async () => {
openMenu({ ...family, serverColor: null }, vi.fn());
expect(await screen.findByRole('menuitem', { name: 'random_color' })).toBeTruthy();
expect(screen.queryByRole('menuitem', { name: 'reset_color' })).toBeNull();
});
});
+11 -23
View File
@@ -79,7 +79,7 @@ import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes, getP
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import type { RevealTime } from "@/hooks/use-display-hours";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
@@ -1659,31 +1659,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (!cal.isShared) return cal;
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
return { ...cal, color: override, colorIsLocalOverride: true, serverColor: cal.color };
});
}, [calendars, sharedCalendarColors]);
// Auto-assign a random, not-yet-used palette color to any freshly shared
// calendar so multiple shared calendars don't collide on one color. Runs
// once per calendar (guarded by the presence of an existing key), and the
// user can still overwrite it from the sidebar.
// calendar the server gives no color, so multiple shared calendars don't
// collide on one color. Runs once per calendar (guarded by the presence of
// an existing key), and the user can still overwrite it from the sidebar.
useEffect(() => {
const shared = calendars.filter((c) => c.isShared);
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
if (missing.length === 0) return;
// Seed "used" with personal calendar colors plus already-assigned shared
// overrides so the picks stay distinct from what's already on screen.
const used = new Set<string>();
for (const c of calendars) {
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
}
for (const color of Object.values(sharedCalendarColors)) {
if (color) used.add(color.toLowerCase());
}
for (const cal of missing) {
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
const assigned = autoAssignSharedCalendarColors(calendars, sharedCalendarColors);
for (const [key, color] of Object.entries(assigned)) {
setSharedCalendarColor(key, color);
}
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
@@ -2155,8 +2142,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
updateCalendar(client, calendarId, { color });
} : undefined}
onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a
// fresh unused color (so it never reverts to a collision).
// Drop the local override. A calendar with a server color goes
// back to it; one without gets a fresh unused color from the
// auto-assign effect (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal));
}}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
@@ -4,7 +4,7 @@ import { useMemo, useRef, useState } from "react";
import { useLongPress } from "@/hooks/use-long-press";
import { IconButton } from "@/components/ui/icon-button";
import { useTranslations } from "next-intl";
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, RotateCcw, Shuffle } from "@/components/icons";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -441,8 +441,8 @@ export function CalendarSidebarPanel({
)}
{canResetColor && (
<ContextMenuItem
icon={Shuffle}
label={tMgmt('random_color')}
icon={cal.serverColor ? RotateCcw : Shuffle}
label={tMgmt(cal.serverColor ? 'reset_color' : 'random_color')}
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
/>
)}
@@ -4,6 +4,7 @@ import {
CALENDAR_COLORS,
sharedCalendarColorKey,
pickUnusedCalendarColor,
autoAssignSharedCalendarColors,
} from '../shared-calendar-colors';
function makeCal(overrides: Partial<Calendar>): Calendar {
@@ -75,3 +76,36 @@ describe('pickUnusedCalendarColor', () => {
}
});
});
describe('autoAssignSharedCalendarColors', () => {
const shared = (id: string, color: string | null) =>
makeCal({ id, originalId: id, accountId: 'owner', isShared: true, color });
it('keeps the color the server gives a shared calendar (#1414)', () => {
const family = shared('family', '#CD1526');
expect(autoAssignSharedCalendarColors([family], {})).toEqual({});
});
it('picks a palette color for a shared calendar without a server color', () => {
const cal = shared('plain', null);
const assigned = autoAssignSharedCalendarColors([cal], {});
expect(Object.keys(assigned)).toEqual([sharedCalendarColorKey(cal)]);
expect(CALENDAR_COLORS).toContain(assigned[sharedCalendarColorKey(cal)]);
});
it('leaves calendars with a local override and personal calendars alone', () => {
const overridden = shared('mine', null);
const personal = makeCal({ id: 'own', color: null });
const overrides = { [sharedCalendarColorKey(overridden)]: '#ef4444' };
expect(autoAssignSharedCalendarColors([overridden, personal], overrides)).toEqual({});
});
it('avoids colors already on screen, server colors of shared calendars included', () => {
// Every palette color but the last is taken by a shared calendar's server
// color, so the one calendar without a color must get the last one.
const taken = CALENDAR_COLORS.slice(0, -1).map((color, i) => shared(`c${i}`, color));
const plain = shared('plain', null);
const assigned = autoAssignSharedCalendarColors([...taken, plain], {});
expect(assigned).toEqual({ [sharedCalendarColorKey(plain)]: CALENDAR_COLORS[CALENDAR_COLORS.length - 1] });
});
});
+3
View File
@@ -661,6 +661,9 @@ 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;
// The server's own color, kept next to a local override so the sidebar can
// tell whether dropping the override brings it back or picks a new one.
serverColor?: string | null;
// 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).
+32
View File
@@ -53,3 +53,35 @@ export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const pool = available.length > 0 ? available : CALENDAR_COLORS;
return pool[Math.floor(Math.random() * pool.length)];
}
/**
* Pick an unused palette color for every shared calendar that has neither a
* color from the server nor a local override, so several of them don't
* collide on the default color. A shared calendar the server gives a color
* keeps it until the viewer picks their own (#1414). Returns only the new
* overrides, keyed by sharedCalendarColorKey().
*/
export function autoAssignSharedCalendarColors(
calendars: Calendar[],
overrides: Record<string, string>,
): Record<string, string> {
// Seed "used" with every color already on screen: personal and server
// colors plus the existing shared overrides.
const used = new Set<string>();
for (const c of calendars) {
if (c.color) used.add(c.color.toLowerCase());
}
for (const color of Object.values(overrides)) {
if (color) used.add(color.toLowerCase());
}
const assigned: Record<string, string> = {};
for (const cal of calendars) {
if (!cal.isShared || cal.color) continue;
const key = sharedCalendarColorKey(cal);
if (overrides[key] || assigned[key]) continue;
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
assigned[key] = color;
}
return assigned;
}
+1
View File
@@ -4146,6 +4146,7 @@
"color": "اللون",
"change_color": "تغيير اللون",
"random_color": "لون عشوائي جديد",
"reset_color": "إعادة تعيين اللون",
"add_calendar": "تقويم جديد",
"edit": "تعديل",
"delete": "حذف",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Color",
"change_color": "Canvia el color",
"random_color": "Color aleatori nou",
"reset_color": "Restableix el color",
"add_calendar": "Calendari nou",
"edit": "Edita",
"delete": "Suprimeix",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Barva",
"change_color": "Změnit barvu",
"random_color": "Nová náhodná barva",
"reset_color": "Obnovit barvu",
"add_calendar": "Nový kalendář",
"edit": "Upravit",
"delete": "Odstranit",
+1
View File
@@ -4143,6 +4143,7 @@
"color": "Farve",
"change_color": "Skift farve",
"random_color": "Ny tilfældig farve",
"reset_color": "Nulstil farve",
"add_calendar": "Ny kalender",
"edit": "Redigér",
"delete": "Slet",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Farbe",
"change_color": "Farbe ändern",
"random_color": "Neue zufällige Farbe",
"reset_color": "Farbe zurücksetzen",
"add_calendar": "Neuer Kalender",
"edit": "Bearbeiten",
"delete": "Löschen",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Color",
"change_color": "Change color",
"random_color": "New random color",
"reset_color": "Reset color",
"add_calendar": "New calendar",
"edit": "Edit",
"delete": "Delete",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Color",
"change_color": "Cambiar color",
"random_color": "Nuevo color aleatorio",
"reset_color": "Restablecer color",
"add_calendar": "Nuevo calendario",
"edit": "Editar",
"delete": "Eliminar",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Värv",
"change_color": "Muuda värvi",
"random_color": "Uus juhuslik värv",
"reset_color": "Lähtesta värv",
"add_calendar": "Uus kalender",
"edit": "Muuda",
"delete": "Kustuta",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "رنگ",
"change_color": "تغییر رنگ",
"random_color": "رنگ تصادفی جدید",
"reset_color": "بازنشانی رنگ",
"add_calendar": "تقویم جدید",
"edit": "ویرایش",
"delete": "حذف",
+1
View File
@@ -4143,6 +4143,7 @@
"color": "Couleur",
"change_color": "Changer la couleur",
"random_color": "Nouvelle couleur aléatoire",
"reset_color": "Réinitialiser la couleur",
"add_calendar": "Nouveau calendrier",
"edit": "Modifier",
"delete": "Supprimer",
+1
View File
@@ -4049,6 +4049,7 @@
"copy_url": "העתק כתובת URL של CalDAV",
"url_copied": "כתובת האתר של CalDAV הועתקה ללוח",
"random_color": "צבע אקראי חדש",
"reset_color": "איפוס צבע",
"set_default": "הגדר כברירת מחדל",
"default_updated": "לוח שנה ברירת המחדל עודכן",
"error_default": "נכשל בהגדרת לוח שנה ברירת מחדל",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Szín",
"change_color": "Szín módosítása",
"random_color": "Véletlenszerű szín",
"reset_color": "Szín visszaállítása",
"add_calendar": "Új naptár",
"edit": "Szerkesztés",
"delete": "Törlés",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Colore",
"change_color": "Cambia colore",
"random_color": "Nuovo colore casuale",
"reset_color": "Ripristina colore",
"add_calendar": "Nuovo calendario",
"edit": "Modifica",
"delete": "Elimina",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "色",
"change_color": "色を変更",
"random_color": "新しいランダムな色",
"reset_color": "色をリセット",
"add_calendar": "新しいカレンダー",
"edit": "編集",
"delete": "削除",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "색상",
"change_color": "색상 변경",
"random_color": "새 무작위 색상",
"reset_color": "색상 재설정",
"add_calendar": "새 캘린더",
"edit": "수정",
"delete": "삭제",
+1
View File
@@ -4116,6 +4116,7 @@
"color": "Krāsa",
"change_color": "Mainīt krāsu",
"random_color": "Jauna nejauša krāsa",
"reset_color": "Atiestatīt krāsu",
"add_calendar": "Jauns kalendārs",
"edit": "Rediģēt",
"delete": "Dzēst",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Өнгө",
"change_color": "Өнгийг өөрчлөх",
"random_color": "Шинэ санамсаргүй өнгө",
"reset_color": "Өнгийг сэргээх",
"add_calendar": "Шинэ хуанли",
"edit": "Засварлах",
"delete": "Устгах",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Farge",
"change_color": "Endre farge",
"random_color": "Ny tilfeldig farge",
"reset_color": "Tilbakestill farge",
"add_calendar": "Ny kalender",
"edit": "Rediger",
"delete": "Slett",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Kleur",
"change_color": "Kleur wijzigen",
"random_color": "Nieuwe willekeurige kleur",
"reset_color": "Kleur herstellen",
"add_calendar": "Nieuwe agenda",
"edit": "Bewerken",
"delete": "Verwijderen",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Kolor",
"change_color": "Zmień kolor",
"random_color": "Nowy losowy kolor",
"reset_color": "Przywróć kolor",
"add_calendar": "Nowy kalendarz",
"edit": "Edytuj",
"delete": "Usuń",
+1
View File
@@ -4143,6 +4143,7 @@
"color": "Cor",
"change_color": "Alterar cor",
"random_color": "Nova cor aleatória",
"reset_color": "Repor cor",
"add_calendar": "Novo calendário",
"edit": "Editar",
"delete": "Excluir",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Culoare",
"change_color": "Schimbă culoarea",
"random_color": "Culoare aleatorie nouă",
"reset_color": "Resetează culoarea",
"add_calendar": "Calendar nou",
"edit": "Editare",
"delete": "Șterge",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "Цвет",
"change_color": "Изменить цвет",
"random_color": "Новый случайный цвет",
"reset_color": "Сбросить цвет",
"add_calendar": "Новый календарь",
"edit": "Редактировать",
"delete": "Удалить",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "Farba",
"change_color": "Zmeniť farbu",
"random_color": "Nová náhodná farba",
"reset_color": "Obnoviť farbu",
"add_calendar": "Nový kalendár",
"edit": "Upraviť",
"delete": "Odstrániť",
+1
View File
@@ -3398,6 +3398,7 @@
"color": "สี",
"change_color": "เปลี่ยนสี",
"random_color": "สุ่มสีใหม่",
"reset_color": "รีเซ็ตสี",
"add_calendar": "ปฏิทินใหม่",
"edit": "แก้ไข",
"delete": "ลบ",
+1
View File
@@ -4143,6 +4143,7 @@
"color": "Renk",
"change_color": "Rengi değiştir",
"random_color": "Yeni rastgele renk",
"reset_color": "Rengi sıfırla",
"add_calendar": "Yeni takvim",
"edit": "Düzenle",
"delete": "Sil",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "колір",
"change_color": "Змінити колір",
"random_color": "Новий випадковий колір",
"reset_color": "Скинути колір",
"add_calendar": "Новий календар",
"edit": "Редагувати",
"delete": "Видалити",
+1
View File
@@ -4146,6 +4146,7 @@
"color": "顏色",
"change_color": "變更顏色",
"random_color": "隨機新顏色",
"reset_color": "重設顏色",
"add_calendar": "新增行事曆",
"edit": "編輯",
"delete": "刪除",
+1
View File
@@ -4117,6 +4117,7 @@
"color": "颜色",
"change_color": "更改颜色",
"random_color": "随机新颜色",
"reset_color": "重置颜色",
"add_calendar": "新建日历",
"edit": "编辑",
"delete": "删除",