feat(02-03): colorUtils + calendarConfig; turn RED calendarConfig stubs green

- 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
This commit is contained in:
Lucas Berger
2026-06-05 09:44:19 -04:00
parent 0911a2330a
commit 43554f491b
3 changed files with 272 additions and 0 deletions
+92
View File
@@ -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<string, ScheduleXCalendarEntry>
}
/**
* 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<string, ScheduleXCalendarEntry> = {}
// 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,
}
}
+108
View File
@@ -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),
]
}
+72
View File
@@ -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 0255 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] (0255) 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 (01).
* 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),
}
}