Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions client/src/components/CalendarWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ const CalendarWidget = ({
source_id: event.source_id,
source_name: event.source_name,
source_color: event.source_color,
// Per-event color set in Google; null unless the event was
// individually recolored, in which case it wins over source_color.
event_color: event.event_color || null,
// Cross-calendar dedup metadata (issue #125): which other calendars
// this event was merged from — drives the pie dot in the day view.
merged_from: Array.isArray(event.merged_from) ? event.merged_from : undefined
Expand Down Expand Up @@ -1443,7 +1446,7 @@ const CalendarWidget = ({
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<Box sx={{ display: 'flex', gap: 1, height: '100%' }}>
{getNext7Days().map((day, index) => {
const getPillPalette = (event) => getEventPillPalette(event.source_color || eventColors.backgroundColor, colorMode);
const getPillPalette = (event) => getEventPillPalette(event.event_color || event.source_color || eventColors.backgroundColor, colorMode);
const renderPill = (event, key) => {
if (!event) {
return (
Expand Down Expand Up @@ -1676,7 +1679,7 @@ const CalendarWidget = ({
) : (
<List>
{selectedDateEvents.map((event, index) => {
const eventPalette = getEventPillPalette(event.source_color || eventColors.backgroundColor, colorMode);
const eventPalette = getEventPillPalette(event.event_color || event.source_color || eventColors.backgroundColor, colorMode);
// Cross-calendar dedup (issue #125): the dot becomes a pie of
// every calendar this event appears on (winner first, up to
// four); the text chip keeps the winning calendar's color.
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/MonthDayCell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const MonthDayCell = ({
shownCount++;
const dayDate = day.toDate();
const { isStart, isEnd } = getMultiDayPosition(event, dayDate);
const palette = getEventPillPalette(event.source_color || eventColors.backgroundColor, colorMode);
const palette = getEventPillPalette(event.event_color || event.source_color || eventColors.backgroundColor, colorMode);
const isContinuing = !isStart;
return (
<Box
Expand Down Expand Up @@ -159,7 +159,7 @@ const MonthDayCell = ({
{dayAllDaySingle.map((event, evIdx) => {
if (shownCount >= maxItems) return null;
shownCount++;
const palette = getEventPillPalette(event.source_color || eventColors.backgroundColor, colorMode);
const palette = getEventPillPalette(event.event_color || event.source_color || eventColors.backgroundColor, colorMode);
return (
<Box key={`allday-${evIdx}`} onClick={(e) => { e.stopPropagation(); onEventClick(event); }}
sx={{ mb: 0.25, height: pillHeight, minHeight: pillHeight, display: 'flex', alignItems: 'stretch', cursor: 'pointer' }}>
Expand All @@ -184,7 +184,7 @@ const MonthDayCell = ({
{dayTimed.map((event, evIdx) => {
if (shownCount >= maxItems) return null;
shownCount++;
const palette = getEventPillPalette(event.source_color || eventColors.backgroundColor, colorMode);
const palette = getEventPillPalette(event.event_color || event.source_color || eventColors.backgroundColor, colorMode);
return (
<Box key={`timed-${evIdx}`} onClick={(e) => { e.stopPropagation(); onEventClick(event); }}
sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25, cursor: 'pointer', borderRadius: 0.5, px: 0.25, '&:hover': { bgcolor: timedRowHoverColor } }}>
Expand Down
31 changes: 29 additions & 2 deletions server/services/calendarSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ class CalendarSyncService {
const timeMin = new Date(now - 13 * 30 * 24 * 60 * 60 * 1000);
const timeMax = new Date(now + 13 * 30 * 24 * 60 * 60 * 1000);
const items = await googleCalendar.listEvents(this.db, account.id, calendarId, { timeMin, timeMax });
const eventColors = await googleCalendar.listEventColors(this.db, account.id);

const out = [];
for (const item of items) {
Expand All @@ -243,6 +244,10 @@ class CalendarSyncService {
if (start.allDay) {
endDate = new Date(endDate.getTime() - 24 * 60 * 60 * 1000);
}
// colorId is only set when the event was individually recolored in
// Google; events on the calendar's default color omit it entirely, and
// those fall through to the source color at read time.
const colorId = item.colorId || null;
out.push({
uid: item.id,
title: item.summary || 'Untitled Event',
Expand All @@ -251,7 +256,13 @@ class CalendarSyncService {
description: item.description || null,
location: item.location || null,
all_day: !!start.allDay,
raw: { googleEventId: item.id, htmlLink: item.htmlLink, etag: item.etag },
raw: {
googleEventId: item.id,
htmlLink: item.htmlLink,
etag: item.etag,
colorId,
eventColor: colorId ? (eventColors[colorId] || null) : null,
},
});
}
return out;
Expand All @@ -277,6 +288,19 @@ class CalendarSyncService {
return results;
}

// Pulls the per-event color out of a cached row's raw_data. Rows written
// before this field existed, and non-Google sources, simply have no value.
parseEventColor(rawData) {
if (!rawData) return null;
try {
const parsed = JSON.parse(rawData);
const color = parsed && parsed.eventColor;
return typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null;
} catch {
return null;
}
}

getCachedEvents(startDate, endDate) {
const sources = this.db.prepare(`
SELECT id, name, color FROM calendar_sources WHERE enabled = 1
Expand Down Expand Up @@ -315,7 +339,10 @@ class CalendarSyncService {
all_day: row.all_day === 1,
source_id: row.source_id,
source_name: source?.name || 'Unknown',
source_color: source?.color || '#6e44ff'
source_color: source?.color || '#6e44ff',
// Set only for events individually recolored in Google; null otherwise
// so the client falls back to source_color.
event_color: this.parseEventColor(row.raw_data)
};
});

Expand Down
32 changes: 32 additions & 0 deletions server/services/googleCalendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,37 @@ async function listCalendars(db, accountId) {
}));
}

// Google's event palette is effectively static, so a long TTL avoids an extra
// API round trip on every sync.
const EVENT_COLOR_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const eventColorCache = new Map();

// Maps Google's per-event colorId ('1'...'11') to its background hex. Returns
// an empty map if the palette can't be fetched, which leaves events falling
// back to their calendar's color rather than showing a wrong one.
async function listEventColors(db, accountId) {
const cached = eventColorCache.get(accountId);
if (cached && Date.now() - cached.fetchedAt < EVENT_COLOR_CACHE_TTL_MS) {
return cached.colors;
}

let colors = {};
try {
const data = await googleFetch(db, accountId, 'GET', '/colors');
if (data && data.event) {
for (const [colorId, value] of Object.entries(data.event)) {
if (value && value.background) colors[colorId] = value.background;
}
}
} catch (error) {
console.error('Error fetching Google event colors:', error.message);
colors = {};
}

eventColorCache.set(accountId, { colors, fetchedAt: Date.now() });
return colors;
}

function parseEventDate(dt) {
if (!dt) return null;
if (dt.date) {
Expand Down Expand Up @@ -103,6 +134,7 @@ async function deleteEvent(db, accountId, calendarId, eventId) {

module.exports = {
listCalendars,
listEventColors,
listEvents,
createEvent,
updateEvent,
Expand Down
123 changes: 123 additions & 0 deletions server/tests/calendarSync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,126 @@ test('getCachedEvents maps cached rows with source metadata', () => {
assert.equal(mapped[1].source_color, '#6e44ff');
assert.equal(mapped[1].all_day, true);
});

test('parseEventColor extracts a valid hex and rejects anything else', () => {
const service = new CalendarSyncService({}, () => null);

assert.equal(service.parseEventColor(JSON.stringify({ eventColor: '#dc2127' })), '#dc2127');
assert.equal(service.parseEventColor(JSON.stringify({ eventColor: null })), null);
assert.equal(service.parseEventColor(JSON.stringify({ googleEventId: 'x' })), null);
assert.equal(service.parseEventColor(JSON.stringify({ eventColor: 'red' })), null);
assert.equal(service.parseEventColor('not json'), null);
assert.equal(service.parseEventColor(null), null);
});

test('getCachedEvents surfaces per-event color and leaves it null otherwise', () => {
const rows = [
{
source_id: 1, event_uid: 'recolored', title: 'Recolored',
start_time: '2026-05-01T13:00:00.000Z', end_time: '2026-05-01T14:00:00.000Z',
description: null, location: null, all_day: 0,
raw_data: JSON.stringify({ googleEventId: 'a', colorId: '11', eventColor: '#dc2127' }),
},
{
source_id: 1, event_uid: 'default-color', title: 'Default',
start_time: '2026-05-02T13:00:00.000Z', end_time: '2026-05-02T14:00:00.000Z',
description: null, location: null, all_day: 0,
raw_data: JSON.stringify({ googleEventId: 'b', colorId: null, eventColor: null }),
},
];

const fakeDb = {
prepare(query) {
// The events query also mentions calendar_sources in a subselect,
// so match the cache table first.
if (query.includes('FROM calendar_events_cache')) {
return { all: () => rows };
}
if (query.includes('FROM calendar_sources')) {
return { all: () => [{ id: 1, name: 'Family', color: '#123456' }] };
}
throw new Error(`Unexpected query: ${query}`);
},
};

const service = new CalendarSyncService(fakeDb, () => null);
const mapped = service.getCachedEvents();

assert.equal(mapped.length, 2);
assert.equal(mapped[0].event_color, '#dc2127');
assert.equal(mapped[0].source_color, '#123456');
assert.equal(mapped[1].event_color, null);
assert.equal(mapped[1].source_color, '#123456');
});

test('fetchGoogleEvents resolves colorId to a hex via the Google palette', async () => {
const googleCalendar = require('../services/googleCalendar');
const googleConnection = require('../services/googleConnection');

const originalGetAccount = googleConnection.getConnectedAccount;
const originalListEvents = googleCalendar.listEvents;
const originalListEventColors = googleCalendar.listEventColors;

googleConnection.getConnectedAccount = () => ({ id: 'acct-1' });
googleCalendar.listEventColors = async () => ({ '11': '#dc2127' });
googleCalendar.listEvents = async () => ([
{
id: 'evt-recolored', status: 'confirmed', summary: 'Recolored',
start: { dateTime: '2026-05-01T13:00:00Z' },
end: { dateTime: '2026-05-01T14:00:00Z' },
colorId: '11',
},
{
id: 'evt-default', status: 'confirmed', summary: 'Default',
start: { dateTime: '2026-05-02T13:00:00Z' },
end: { dateTime: '2026-05-02T14:00:00Z' },
},
]);

try {
const service = new CalendarSyncService({}, () => null);
const events = await service.fetchGoogleEvents({ id: 1, url: 'primary' });

assert.equal(events.length, 2);
assert.equal(events[0].raw.colorId, '11');
assert.equal(events[0].raw.eventColor, '#dc2127');
assert.equal(events[1].raw.colorId, null);
assert.equal(events[1].raw.eventColor, null);
} finally {
googleConnection.getConnectedAccount = originalGetAccount;
googleCalendar.listEvents = originalListEvents;
googleCalendar.listEventColors = originalListEventColors;
}
});

test('fetchGoogleEvents leaves color null when the palette is unavailable', async () => {
const googleCalendar = require('../services/googleCalendar');
const googleConnection = require('../services/googleConnection');

const originalGetAccount = googleConnection.getConnectedAccount;
const originalListEvents = googleCalendar.listEvents;
const originalListEventColors = googleCalendar.listEventColors;

googleConnection.getConnectedAccount = () => ({ id: 'acct-1' });
googleCalendar.listEventColors = async () => ({});
googleCalendar.listEvents = async () => ([
{
id: 'evt-recolored', status: 'confirmed', summary: 'Recolored',
start: { dateTime: '2026-05-01T13:00:00Z' },
end: { dateTime: '2026-05-01T14:00:00Z' },
colorId: '11',
},
]);

try {
const service = new CalendarSyncService({}, () => null);
const events = await service.fetchGoogleEvents({ id: 1, url: 'primary' });

assert.equal(events[0].raw.colorId, '11');
assert.equal(events[0].raw.eventColor, null);
} finally {
googleConnection.getConnectedAccount = originalGetAccount;
googleCalendar.listEvents = originalListEvents;
googleCalendar.listEventColors = originalListEventColors;
}
});