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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user