From 43554f491b19698d3d9c4849e5447232fbaff8d0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 09:44:19 -0400 Subject: [PATCH] feat(02-03): colorUtils + calendarConfig; turn RED calendarConfig stubs green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create colorUtils.ts: hexToContainer (15% alpha over white), hexToOnContainer (darken 40%), deriveScheduleXColors() returning { main, container, onContainer } - Create colorUtils.test.ts: hex blend math assertions for #4A90D9 and #F25C7A - Create calendarConfig.ts: WEEK_START_DAY=0, SX_FIRST_DAY_OF_WEEK=7 (0→7 translation) buildCalendarConfig() keyed by String(userId) + 'shared'; returns { firstDayOfWeek, calendars } - calendarConfig.test.ts (Plan 01 RED stubs) now GREEN: all 4 assertions pass --- apps/pwa/src/lib/calendarConfig.ts | 92 ++++++++++++++++++++++++ apps/pwa/src/lib/colorUtils.test.ts | 108 ++++++++++++++++++++++++++++ apps/pwa/src/lib/colorUtils.ts | 72 +++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 apps/pwa/src/lib/calendarConfig.ts create mode 100644 apps/pwa/src/lib/colorUtils.test.ts create mode 100644 apps/pwa/src/lib/colorUtils.ts diff --git a/apps/pwa/src/lib/calendarConfig.ts b/apps/pwa/src/lib/calendarConfig.ts new file mode 100644 index 0000000..93e30c6 --- /dev/null +++ b/apps/pwa/src/lib/calendarConfig.ts @@ -0,0 +1,92 @@ +/** + * Schedule-X calendar configuration factory. + * + * Week-start-day translation: + * Project convention: WEEK_START_DAY = 0 (Sunday, JS/date-fns convention) + * Schedule-X v4: firstDayOfWeek = 7 (Sunday, Temporal convention) + * + * Passing 0 to Schedule-X silently defaults to Monday. The constant + * WEEK_START_DAY === 0 ? 7 translation below encodes this as the source of + * truth so the pitfall cannot recur. + * + * calendarId routing contract (must align with hydrateEvents.ts): + * Shared-family calendar → key 'shared' + * Per-member calendars → key String(userId) (NOT String(db calendarId)) + * + * Source: https://schedule-x.dev/docs/calendar/configuration + * Source: https://schedule-x.dev/docs/calendar/calendars + */ + +import { deriveScheduleXColors } from './colorUtils.js' + +/** Week start day in JS/date-fns convention: 0 = Sunday. */ +export const WEEK_START_DAY = 0 + +/** + * firstDayOfWeek in Schedule-X/Temporal convention: 7 = Sunday, 1 = Monday. + * + * Translation: WEEK_START_DAY === 0 (JS Sunday) → 7 (Temporal Sunday). + * Hard-code to the project convention; one edit here when the user wants Monday. + */ +export const SX_FIRST_DAY_OF_WEEK: number = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY + +/** + * Per-member calendar config entry passed to buildCalendarConfig. + * + * id = String(users.id) — the Schedule-X calendars config key + * name = users.displayName + * color = users.color hex + */ +export interface MemberCalendarConfig { + id: string // String(users.id) + name: string // users.displayName + color: string // hex from users.color +} + +export interface ScheduleXCalendarEntry { + colorName: string + lightColors: { + main: string + container: string + onContainer: string + } +} + +export interface CalendarConfig { + firstDayOfWeek: number + calendars: Record +} + +/** + * Build the Schedule-X calendars configuration object. + * + * Always includes a 'shared' entry (rose #F25C7A). + * Adds one entry per member, keyed by String(userId). + * + * The keys here are the routing contract: hydrateEvents() must produce + * calendarId values that exactly match these keys: + * isShared:true → 'shared' + * isShared:false → String(ownerUserId) (NOT String(db calendarId)) + */ +export function buildCalendarConfig(members: MemberCalendarConfig[]): CalendarConfig { + const calendars: Record = {} + + // Shared-family calendar: reserved rose color, confirmed by user + calendars['shared'] = { + colorName: 'shared', + lightColors: deriveScheduleXColors('#F25C7A'), + } + + // Per-member calendars keyed by String(userId) + for (const m of members) { + calendars[m.id] = { + colorName: `member-${m.id}`, + lightColors: deriveScheduleXColors(m.color), + } + } + + return { + firstDayOfWeek: SX_FIRST_DAY_OF_WEEK, // 7 = Sunday in Temporal convention + calendars, + } +} diff --git a/apps/pwa/src/lib/colorUtils.test.ts b/apps/pwa/src/lib/colorUtils.test.ts new file mode 100644 index 0000000..16ccadc --- /dev/null +++ b/apps/pwa/src/lib/colorUtils.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { hexToContainer, hexToOnContainer, deriveScheduleXColors } from './colorUtils.js' + +describe('colorUtils', () => { + + describe('hexToContainer — 15% opacity blend over white', () => { + it('produces a lighter color than the input', () => { + const container = hexToContainer('#4A90D9') + // Container should be lighter (higher R, G, B values) than original + // #4A90D9 = rgb(74, 144, 217). At 15% over white: + // R = 74 * 0.15 + 255 * 0.85 = 11.1 + 216.75 ≈ 228 → #E4 + // G = 144 * 0.15 + 255 * 0.85 = 21.6 + 216.75 ≈ 238 → #EE + // B = 217 * 0.15 + 255 * 0.85 = 32.55 + 216.75 ≈ 249 → #F9 + expect(container).toMatch(/^#[0-9a-f]{6}$/i) + const [r, g, b] = hexToRgbTest(container) + // Should be well above 200 in all channels (very light) + expect(r).toBeGreaterThan(200) + expect(g).toBeGreaterThan(200) + expect(b).toBeGreaterThan(200) + }) + + it('blends #4A90D9 at 15% over white correctly', () => { + const container = hexToContainer('#4A90D9') + // Expected: R≈228, G≈239, B≈249 + const [r, g, b] = hexToRgbTest(container) + expect(r).toBeCloseTo(228, -1) // within 5 + expect(g).toBeCloseTo(239, -1) + expect(b).toBeCloseTo(249, -1) + }) + + it('blends white (#FFFFFF) to white', () => { + const container = hexToContainer('#FFFFFF') + expect(container.toLowerCase()).toBe('#ffffff') + }) + }) + + describe('hexToOnContainer — darken 40%', () => { + it('produces a darker color than the input', () => { + const onContainer = hexToOnContainer('#4A90D9') + expect(onContainer).toMatch(/^#[0-9a-f]{6}$/i) + // 40% darker: each channel * 0.6 + // #4A90D9 = rgb(74, 144, 217) → rgb(44, 86, 130) ≈ #2C5682 + const [r, g, b] = hexToRgbTest(onContainer) + expect(r).toBeLessThan(74) + expect(g).toBeLessThan(144) + expect(b).toBeLessThan(217) + }) + + it('darkens #4A90D9 by 40% correctly', () => { + const onContainer = hexToOnContainer('#4A90D9') + const [r, g, b] = hexToRgbTest(onContainer) + expect(r).toBeCloseTo(74 * 0.6, -1) + expect(g).toBeCloseTo(144 * 0.6, -1) + expect(b).toBeCloseTo(217 * 0.6, -1) + }) + }) + + describe('deriveScheduleXColors', () => { + it('returns main unchanged', () => { + const result = deriveScheduleXColors('#4A90D9') + expect(result.main).toBe('#4A90D9') + }) + + it('returns container and onContainer as hex strings', () => { + const result = deriveScheduleXColors('#4A90D9') + expect(result.container).toMatch(/^#[0-9a-f]{6}$/i) + expect(result.onContainer).toMatch(/^#[0-9a-f]{6}$/i) + }) + + it('container is lighter than main', () => { + const result = deriveScheduleXColors('#4A90D9') + const [mr, mg, mb] = hexToRgbTest(result.main) + const [cr, cg, cb] = hexToRgbTest(result.container) + // All container channels should be >= main channels (blending with white) + expect(cr).toBeGreaterThanOrEqual(mr) + expect(cg).toBeGreaterThanOrEqual(mg) + expect(cb).toBeGreaterThanOrEqual(mb) + }) + + it('onContainer is darker than main', () => { + const result = deriveScheduleXColors('#4A90D9') + const [mr, mg, mb] = hexToRgbTest(result.main) + const [or, og, ob] = hexToRgbTest(result.onContainer) + // All onContainer channels should be <= main channels (darken) + expect(or).toBeLessThanOrEqual(mr) + expect(og).toBeLessThanOrEqual(mg) + expect(ob).toBeLessThanOrEqual(mb) + }) + + it('works for the shared-family rose color', () => { + const result = deriveScheduleXColors('#F25C7A') + expect(result.main).toBe('#F25C7A') + expect(result.container).toMatch(/^#[0-9a-f]{6}$/i) + expect(result.onContainer).toMatch(/^#[0-9a-f]{6}$/i) + }) + }) +}) + +// ── Test helper ──────────────────────────────────────────────────────────── + +function hexToRgbTest(hex: string): [number, number, number] { + const clean = hex.replace('#', '') + return [ + parseInt(clean.slice(0, 2), 16), + parseInt(clean.slice(2, 4), 16), + parseInt(clean.slice(4, 6), 16), + ] +} diff --git a/apps/pwa/src/lib/colorUtils.ts b/apps/pwa/src/lib/colorUtils.ts new file mode 100644 index 0000000..5cbd950 --- /dev/null +++ b/apps/pwa/src/lib/colorUtils.ts @@ -0,0 +1,72 @@ +/** + * Color derivation utilities for Schedule-X lightColors. + * + * Derives container (15% opacity over white) and onContainer (darkened 40%) + * from a member's hex color. No third-party color library — the math is simple. + * + * UI-SPEC §"Color derivation rule for event chips": + * container = MAIN at 15% opacity blended over #FFFFFF + * onContainer = MAIN darkened 40% + */ + +/** + * Parse a 6-digit hex color string to [r, g, b] in 0–255 range. + */ +function hexToRgb(hex: string): [number, number, number] { + const clean = hex.replace('#', '') + const r = parseInt(clean.slice(0, 2), 16) + const g = parseInt(clean.slice(2, 4), 16) + const b = parseInt(clean.slice(4, 6), 16) + return [r, g, b] +} + +/** + * Convert [r, g, b] (0–255) to a #RRGGBB hex string. + */ +function rgbToHex(r: number, g: number, b: number): string { + return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('') +} + +/** + * Blend a foreground color at `alpha` opacity over a white (#FFFFFF) background. + * Returns the resulting opaque hex color. + * + * Formula (standard alpha compositing over white): + * result = fg * alpha + 255 * (1 - alpha) + */ +export function hexToContainer(hex: string): string { + const [r, g, b] = hexToRgb(hex) + const alpha = 0.15 + const blendChannel = (fg: number) => fg * alpha + 255 * (1 - alpha) + return rgbToHex(blendChannel(r), blendChannel(g), blendChannel(b)) +} + +/** + * Darken a hex color by the given factor (0–1). + * A factor of 0.4 means "40% darker" — each channel is multiplied by (1 - factor). + */ +export function hexToOnContainer(hex: string): string { + const [r, g, b] = hexToRgb(hex) + const factor = 1 - 0.4 // darken 40% + return rgbToHex(r * factor, g * factor, b * factor) +} + +/** + * Derive all three Schedule-X lightColors values from a single member hex. + * + * Returns: + * main — the original hex + * container — main at 15% opacity blended over white (event chip background) + * onContainer — main darkened 40% (event chip text, passed to Schedule-X) + */ +export function deriveScheduleXColors(main: string): { + main: string + container: string + onContainer: string +} { + return { + main, + container: hexToContainer(main), + onContainer: hexToOnContainer(main), + } +}