style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+19 -20
View File
@@ -10,42 +10,41 @@
* Passing 0 would silently default to Monday in Schedule-X v4.
*/
import { describe, it, expect } from 'vitest'
import { describe, it, expect } from 'vitest';
// Not yet built — import will fail (RED state) until Plan 03 implements calendarConfig.ts
import { WEEK_START_DAY, buildCalendarConfig } from './calendarConfig.js'
import { WEEK_START_DAY, buildCalendarConfig } from './calendarConfig.js';
describe('calendarConfig — RED stubs (Wave 0)', () => {
it('WEEK_START_DAY constant equals 0 (Sunday in JS/date-fns convention)', () => {
// The project uses 0 = Sunday (JS/date-fns convention).
// This constant is translated to 7 before passing to Schedule-X.
expect(WEEK_START_DAY).toBe(0)
})
expect(WEEK_START_DAY).toBe(0);
});
it('WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek === 7', () => {
// Schedule-X v4 uses Temporal numbering: 1=Mon, 7=Sun.
// WEEK_START_DAY=0 (JS Sunday) must become 7 (Temporal Sunday).
// This avoids the Pitfall 1 silent failure where weeks start on Monday.
const config = buildCalendarConfig([])
expect(config.firstDayOfWeek).toBe(7)
})
const config = buildCalendarConfig([]);
expect(config.firstDayOfWeek).toBe(7);
});
it('buildCalendarConfig includes shared-family calendar with id "shared"', () => {
const config = buildCalendarConfig([])
expect(config.calendars).toHaveProperty('shared')
expect(config.calendars['shared'].colorName).toBe('shared')
})
const config = buildCalendarConfig([]);
expect(config.calendars).toHaveProperty('shared');
expect(config.calendars['shared'].colorName).toBe('shared');
});
it('buildCalendarConfig includes per-member calendars keyed by String(userId)', () => {
const members = [
{ id: '1', name: 'Lucas', color: '#4A90D9' },
{ id: '2', name: 'Spouse', color: '#E8734A' },
]
const config = buildCalendarConfig(members)
expect(config.calendars).toHaveProperty('1')
expect(config.calendars).toHaveProperty('2')
expect(config.calendars['1'].lightColors.main).toBe('#4A90D9')
expect(config.calendars['2'].lightColors.main).toBe('#E8734A')
})
})
];
const config = buildCalendarConfig(members);
expect(config.calendars).toHaveProperty('1');
expect(config.calendars).toHaveProperty('2');
expect(config.calendars['1'].lightColors.main).toBe('#4A90D9');
expect(config.calendars['2'].lightColors.main).toBe('#E8734A');
});
});
+17 -17
View File
@@ -17,10 +17,10 @@
* Source: https://schedule-x.dev/docs/calendar/calendars
*/
import { deriveScheduleXColors } from './colorUtils.js'
import { deriveScheduleXColors } from './colorUtils.js';
/** Week start day in JS/date-fns convention: 0 = Sunday. */
export const WEEK_START_DAY = 0
export const WEEK_START_DAY = 0;
/**
* firstDayOfWeek in Schedule-X/Temporal convention: 7 = Sunday, 1 = Monday.
@@ -28,7 +28,7 @@ export const WEEK_START_DAY = 0
* 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
export const SX_FIRST_DAY_OF_WEEK: number = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY;
/**
* Per-member calendar config entry passed to buildCalendarConfig.
@@ -38,23 +38,23 @@ export const SX_FIRST_DAY_OF_WEEK: number = WEEK_START_DAY === 0 ? 7 : WEEK_STAR
* color = users.color hex
*/
export interface MemberCalendarConfig {
id: string // String(users.id)
name: string // users.displayName
color: string // hex from users.color
id: string; // String(users.id)
name: string; // users.displayName
color: string; // hex from users.color
}
export interface ScheduleXCalendarEntry {
colorName: string
colorName: string;
lightColors: {
main: string
container: string
onContainer: string
}
main: string;
container: string;
onContainer: string;
};
}
export interface CalendarConfig {
firstDayOfWeek: number
calendars: Record<string, ScheduleXCalendarEntry>
firstDayOfWeek: number;
calendars: Record<string, ScheduleXCalendarEntry>;
}
/**
@@ -69,24 +69,24 @@ export interface CalendarConfig {
* isShared:false → String(ownerUserId) (NOT String(db calendarId))
*/
export function buildCalendarConfig(members: MemberCalendarConfig[]): CalendarConfig {
const calendars: Record<string, ScheduleXCalendarEntry> = {}
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,
}
};
}
+63 -64
View File
@@ -1,108 +1,107 @@
import { describe, it, expect } from 'vitest'
import { hexToContainer, hexToOnContainer, deriveScheduleXColors } from './colorUtils.js'
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')
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)
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)
})
expect(r).toBeGreaterThan(200);
expect(g).toBeGreaterThan(200);
expect(b).toBeGreaterThan(200);
});
it('blends #4A90D9 at 15% over white correctly', () => {
const container = hexToContainer('#4A90D9')
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)
})
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')
})
})
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)
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)
})
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)
})
})
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')
})
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)
})
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)
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)
})
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)
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)
})
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)
})
})
})
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('#', '')
const clean = hex.replace('#', '');
return [
parseInt(clean.slice(0, 2), 16),
parseInt(clean.slice(2, 4), 16),
parseInt(clean.slice(4, 6), 16),
]
];
}
+17 -17
View File
@@ -13,18 +13,18 @@
* 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]
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('')
return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/**
@@ -35,10 +35,10 @@ function rgbToHex(r: number, g: number, b: number): string {
* 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))
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));
}
/**
@@ -46,9 +46,9 @@ export function hexToContainer(hex: string): string {
* 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)
const [r, g, b] = hexToRgb(hex);
const factor = 1 - 0.4; // darken 40%
return rgbToHex(r * factor, g * factor, b * factor);
}
/**
@@ -60,13 +60,13 @@ export function hexToOnContainer(hex: string): string {
* onContainer — main darkened 40% (event chip text, passed to Schedule-X)
*/
export function deriveScheduleXColors(main: string): {
main: string
container: string
onContainer: string
main: string;
container: string;
onContainer: string;
} {
return {
main,
container: hexToContainer(main),
onContainer: hexToOnContainer(main),
}
};
}
+84 -58
View File
@@ -12,103 +12,129 @@
* string passed through verbatim, and never an instant that loses the local hour.
*/
import { describe, it, expect } from 'vitest'
import { serializeEventDateTime, localWallClockToUtcIso, computeNewTimedEnd, computeNewAllDayEnd } from './eventDateTime.js'
import { describe, it, expect } from 'vitest';
import {
serializeEventDateTime,
localWallClockToUtcIso,
computeNewTimedEnd,
computeNewAllDayEnd,
} from './eventDateTime.js';
describe('serializeEventDateTime (BUG A — write-path TZ)', () => {
it('serializes a timed start to a UTC instant (ends in Z)', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start.endsWith('Z')).toBe(true)
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00');
expect(start.endsWith('Z')).toBe(true);
// It must NOT be the naive wall-clock string (the original bug shape).
expect(start).not.toBe('2026-06-07T09:00:00')
})
expect(start).not.toBe('2026-06-07T09:00:00');
});
it('the serialized instant round-trips back to the SAME local wall clock', () => {
// This is the heart of BUG A: 09:00 in → 09:00 back out in the operator's zone.
const { start, end } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:30')
const startBack = new Date(start)
expect(startBack.getHours()).toBe(9)
expect(startBack.getMinutes()).toBe(0)
const endBack = new Date(end)
expect(endBack.getHours()).toBe(10)
expect(endBack.getMinutes()).toBe(30)
})
const { start, end } = serializeEventDateTime(
false,
'2026-06-07',
'09:00',
'2026-06-07',
'10:30',
);
const startBack = new Date(start);
expect(startBack.getHours()).toBe(9);
expect(startBack.getMinutes()).toBe(0);
const endBack = new Date(end);
expect(endBack.getHours()).toBe(10);
expect(endBack.getMinutes()).toBe(30);
});
it('equals the instant new Date(local parts) produces — not a passthrough', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start).toBe(new Date('2026-06-07T09:00:00').toISOString())
})
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00');
expect(start).toBe(new Date('2026-06-07T09:00:00').toISOString());
});
it('leaves all-day events as DATE strings (no time, no Z) — D-13 contract', () => {
const { start, end } = serializeEventDateTime(true, '2026-06-07', '09:00', '2026-06-09', '10:00')
expect(start).toBe('2026-06-07')
expect(end).toBe('2026-06-09')
})
const { start, end } = serializeEventDateTime(
true,
'2026-06-07',
'09:00',
'2026-06-09',
'10:00',
);
expect(start).toBe('2026-06-07');
expect(end).toBe('2026-06-09');
});
it('localWallClockToUtcIso round-trips a local wall clock to a UTC instant', () => {
const iso = localWallClockToUtcIso('2026-06-07', '09:00')
expect(iso.endsWith('Z')).toBe(true)
expect(new Date(iso).getHours()).toBe(9)
})
})
const iso = localWallClockToUtcIso('2026-06-07', '09:00');
expect(iso.endsWith('Z')).toBe(true);
expect(new Date(iso).getHours()).toBe(9);
});
});
describe('computeNewTimedEnd (D-04 — end-tracking)', () => {
it('preserves a 1-hour timed delta when start moves forward', () => {
// Old: 2026-06-11 09:00 → 10:00 (1h duration)
// New start: 2026-06-11 11:00 → new end should be 12:00
const result = computeNewTimedEnd(
'2026-06-11', '11:00',
'2026-06-11', '09:00',
'2026-06-11', '10:00',
)
expect(result.endDate).toBe('2026-06-11')
expect(result.endTime).toBe('12:00')
})
'2026-06-11',
'11:00',
'2026-06-11',
'09:00',
'2026-06-11',
'10:00',
);
expect(result.endDate).toBe('2026-06-11');
expect(result.endTime).toBe('12:00');
});
it('preserves a multi-day timed delta (26h) when start moves', () => {
// Old: 2026-06-11 08:00 → 2026-06-12 10:00 (26h duration)
// New start: 2026-06-13 08:00 → new end should be 2026-06-14 10:00
const result = computeNewTimedEnd(
'2026-06-13', '08:00',
'2026-06-11', '08:00',
'2026-06-12', '10:00',
)
expect(result.endDate).toBe('2026-06-14')
expect(result.endTime).toBe('10:00')
})
'2026-06-13',
'08:00',
'2026-06-11',
'08:00',
'2026-06-12',
'10:00',
);
expect(result.endDate).toBe('2026-06-14');
expect(result.endTime).toBe('10:00');
});
it('floors to 1h when old end was already behind old start', () => {
// Old end (10:00) <= old start (11:00) — stale/invalid state
// New start: 2026-06-11 14:00 → new end should snap to +1h = 15:00
const result = computeNewTimedEnd(
'2026-06-11', '14:00',
'2026-06-11', '11:00',
'2026-06-11', '10:00',
)
expect(result.endDate).toBe('2026-06-11')
expect(result.endTime).toBe('15:00')
})
})
'2026-06-11',
'14:00',
'2026-06-11',
'11:00',
'2026-06-11',
'10:00',
);
expect(result.endDate).toBe('2026-06-11');
expect(result.endTime).toBe('15:00');
});
});
describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
it('preserves a 0-day span (single-day all-day event)', () => {
// Old: 2026-06-11 → 2026-06-11 (0-day span, single day)
// New start: 2026-06-15 → new end should be 2026-06-15
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-11')
expect(result).toBe('2026-06-15')
})
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-11');
expect(result).toBe('2026-06-15');
});
it('preserves a 3-day span when start moves', () => {
// Old: 2026-06-11 → 2026-06-14 (3-day span)
// New start: 2026-06-20 → new end should be 2026-06-23
const result = computeNewAllDayEnd('2026-06-20', '2026-06-11', '2026-06-14')
expect(result).toBe('2026-06-23')
})
const result = computeNewAllDayEnd('2026-06-20', '2026-06-11', '2026-06-14');
expect(result).toBe('2026-06-23');
});
it('floors to same day when old end was already behind old start', () => {
// Old end (2026-06-10) < old start (2026-06-11) — stale/invalid state
// New start: 2026-06-15 → new end should snap to same day 2026-06-15
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-10')
expect(result).toBe('2026-06-15')
})
})
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-10');
expect(result).toBe('2026-06-15');
});
});
+19 -19
View File
@@ -39,7 +39,7 @@ export function serializeEventDateTime(
): { start: string; end: string } {
if (allDay) {
// DATE contract (D-13): no time component, timezone-independent.
return { start: startDate, end: endDate }
return { start: startDate, end: endDate };
}
// Timed: build the instant from local wall-clock parts (browser is in the
@@ -48,7 +48,7 @@ export function serializeEventDateTime(
return {
start: localWallClockToUtcIso(startDate, startTime),
end: localWallClockToUtcIso(endDate, endTime),
}
};
}
/**
@@ -57,7 +57,7 @@ export function serializeEventDateTime(
* local timezone per ECMAScript, giving the correct instant for the operator.
*/
export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString()
return new Date(`${date}T${time}:00`).toISOString();
}
// ─── Private date helpers (local accessor pattern — WR-05 constraint) ────────
@@ -65,7 +65,7 @@ export function localWallClockToUtcIso(date: string, time: string): string {
// local date. Always use getFullYear/getMonth/getDate/getHours/getMinutes.
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`
return n < 10 ? `0${n}` : `${n}`;
}
/**
@@ -74,12 +74,12 @@ function pad2(n: number): string {
* (initialCalendarRange/todayIso) can drop its banned toISOString().slice(0,10).
*/
export function localDateISO(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** Format a Date as 'HH:MM' using LOCAL clock accessors. */
function localTimeHHMM(d: Date): string {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/**
@@ -88,9 +88,9 @@ function localTimeHHMM(d: Date): string {
* Uses midnight-local Date arithmetic to stay in the local calendar.
*/
function dateDiffDays(startDate: string, endDate: string): number {
const startMs = new Date(`${startDate}T00:00:00`).getTime()
const endMs = new Date(`${endDate}T00:00:00`).getTime()
return Math.round((endMs - startMs) / (24 * 60 * 60 * 1000))
const startMs = new Date(`${startDate}T00:00:00`).getTime();
const endMs = new Date(`${endDate}T00:00:00`).getTime();
return Math.round((endMs - startMs) / (24 * 60 * 60 * 1000));
}
/**
@@ -98,9 +98,9 @@ function dateDiffDays(startDate: string, endDate: string): number {
* 'YYYY-MM-DD' string. Uses local midnight to avoid DST-boundary issues.
*/
function addDaysISO(dateStr: string, days: number): string {
const d = new Date(`${dateStr}T00:00:00`)
d.setDate(d.getDate() + days)
return localDateISO(d)
const d = new Date(`${dateStr}T00:00:00`);
d.setDate(d.getDate() + days);
return localDateISO(d);
}
// ─── D-04: Duration-preservation helpers ─────────────────────────────────────
@@ -123,14 +123,14 @@ export function computeNewTimedEnd(
oldEndDate: string,
oldEndTime: string,
): { endDate: string; endTime: string } {
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime()
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime()
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime();
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime();
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000; // 1h floor
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs);
return {
endDate: localDateISO(newEndDate),
endTime: localTimeHHMM(newEndDate),
}
};
}
/**
@@ -148,6 +148,6 @@ export function computeNewAllDayEnd(
oldStartDate: string,
oldEndDate: string,
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate));
return addDaysISO(newStartDate, span);
}
+54 -55
View File
@@ -14,26 +14,26 @@
* This assertion locks the Plan 03 routing fix.
*/
import 'temporal-polyfill/global'
import { describe, it, expect } from 'vitest'
import 'temporal-polyfill/global';
import { describe, it, expect } from 'vitest';
import { hydrateEvents } from './hydrateEvents.js'
import { hydrateEvents } from './hydrateEvents.js';
// Minimal CalendarOccurrence shape for test purposes
interface TestOccurrence {
id: string
uid: string
calendarId: number
calendarName: string
ownerUserId: number
color: string
isShared: boolean
title: string
start: string
end: string
allDay: boolean
location: string | null
description: string | null
id: string;
uid: string;
calendarId: number;
calendarName: string;
ownerUserId: number;
color: string;
isShared: boolean;
title: string;
start: string;
end: string;
allDay: boolean;
location: string | null;
description: string | null;
}
function makeOccurrence(overrides: Partial<TestOccurrence>): TestOccurrence {
@@ -52,11 +52,10 @@ function makeOccurrence(overrides: Partial<TestOccurrence>): TestOccurrence {
location: null,
description: null,
...overrides,
}
};
}
describe('hydrateEvents — RED stubs (Wave 0)', () => {
describe('Temporal type conversion', () => {
it('converts all-day occurrence (allDay:true) to Temporal.PlainDate for start and end', () => {
const occurrences = [
@@ -65,33 +64,33 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
start: '2026-06-15',
end: '2026-06-15',
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
const evt = result[0]
const evt = result[0];
// Schedule-X requires Temporal.PlainDate for all-day events
expect(evt.start).toBeInstanceOf(Temporal.PlainDate)
expect(evt.end).toBeInstanceOf(Temporal.PlainDate)
})
expect(evt.start).toBeInstanceOf(Temporal.PlainDate);
expect(evt.end).toBeInstanceOf(Temporal.PlainDate);
});
it('converts an all-day exclusive DTEND to an inclusive last day for Schedule-X', () => {
// Single-day event: iCal DTSTART:24 / DTEND:25 (exclusive). Schedule-X end is
// inclusive, so a 1-day event must have start === end (renders on one day only).
const single = hydrateEvents([
makeOccurrence({ allDay: true, start: '2026-06-24', end: '2026-06-25' }),
])[0]
expect((single.start as Temporal.PlainDate).toString()).toBe('2026-06-24')
expect((single.end as Temporal.PlainDate).toString()).toBe('2026-06-24')
])[0];
expect((single.start as Temporal.PlainDate).toString()).toBe('2026-06-24');
expect((single.end as Temporal.PlainDate).toString()).toBe('2026-06-24');
// Two-day event: DTSTART:26 / DTEND:28 (exclusive) → inclusive last day = 27.
const multi = hydrateEvents([
makeOccurrence({ allDay: true, start: '2026-06-26', end: '2026-06-28' }),
])[0]
expect((multi.start as Temporal.PlainDate).toString()).toBe('2026-06-26')
expect((multi.end as Temporal.PlainDate).toString()).toBe('2026-06-27')
})
])[0];
expect((multi.start as Temporal.PlainDate).toString()).toBe('2026-06-26');
expect((multi.end as Temporal.PlainDate).toString()).toBe('2026-06-27');
});
it('converts timed occurrence (allDay:false) to Temporal.ZonedDateTime for start and end', () => {
const occurrences = [
@@ -100,16 +99,16 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T11:00:00-04:00[America/New_York]',
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
const evt = result[0]
expect(evt.start).toBeInstanceOf(Temporal.ZonedDateTime)
expect(evt.end).toBeInstanceOf(Temporal.ZonedDateTime)
})
})
const evt = result[0];
expect(evt.start).toBeInstanceOf(Temporal.ZonedDateTime);
expect(evt.end).toBeInstanceOf(Temporal.ZonedDateTime);
});
});
describe('calendarId routing — Plan 03 contract', () => {
it('shared occurrence (isShared:true) gets Schedule-X calendarId "shared"', () => {
@@ -119,14 +118,14 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
calendarId: 5,
ownerUserId: 2,
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
// Shared-family events must route to the 'shared' calendar slot in Schedule-X config
expect(result[0].calendarId).toBe('shared')
})
expect(result[0].calendarId).toBe('shared');
});
it('personal occurrence (isShared:false) gets Schedule-X calendarId === String(ownerUserId), NOT String(calendarId)', () => {
// This assertion locks the routing decision: personal events are grouped by MEMBER (ownerUserId),
@@ -134,17 +133,17 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
const occurrences = [
makeOccurrence({
isShared: false,
calendarId: 99, // DB calendar row id
ownerUserId: 7, // DB user id — this is the correct Schedule-X key
calendarId: 99, // DB calendar row id
ownerUserId: 7, // DB user id — this is the correct Schedule-X key
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
// Must be '7' (String(ownerUserId)), NOT '99' (String(calendarId))
expect(result[0].calendarId).toBe('7')
expect(result[0].calendarId).not.toBe('99')
})
})
})
expect(result[0].calendarId).toBe('7');
expect(result[0].calendarId).not.toBe('99');
});
});
});
+32 -33
View File
@@ -24,41 +24,41 @@
*/
export interface CalendarOccurrence {
id: string // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string
calendarId: number // DB calendar-row id — NOT used for calendarId routing
calendarName: string
ownerUserId: number // DB user id — this IS the routing key for personal events
color: string // hex from users.color or shared-family constant
isShared: boolean // true when this event belongs to the shared-family calendar
title: string
start: string // 'YYYY-MM-DD' for all-day; ISO 8601 with IANA tz for timed
end: string
allDay: boolean
location: string | null
description: string | null
id: string; // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string;
calendarId: number; // DB calendar-row id — NOT used for calendarId routing
calendarName: string;
ownerUserId: number; // DB user id — this IS the routing key for personal events
color: string; // hex from users.color or shared-family constant
isShared: boolean; // true when this event belongs to the shared-family calendar
title: string;
start: string; // 'YYYY-MM-DD' for all-day; ISO 8601 with IANA tz for timed
end: string;
allDay: boolean;
location: string | null;
description: string | null;
}
export interface ScheduleXEvent {
id: string
title: string
start: Temporal.ZonedDateTime | Temporal.PlainDate
end: Temporal.ZonedDateTime | Temporal.PlainDate
id: string;
title: string;
start: Temporal.ZonedDateTime | Temporal.PlainDate;
end: Temporal.ZonedDateTime | Temporal.PlainDate;
/**
* Schedule-X calendarId — keys into the calendars config built by
* buildCalendarConfig(). Routing:
* 'shared' when isShared === true
* String(ownerUserId) when isShared === false
*/
calendarId: string
location?: string
description?: string
calendarId: string;
location?: string;
description?: string;
/** FamilySync custom fields — carried through for popover rendering */
_familySync: {
uid: string
color: string
isShared: boolean
}
uid: string;
color: string;
isShared: boolean;
};
}
/**
@@ -71,7 +71,7 @@ export interface ScheduleXEvent {
export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
return occurrences.map((occ) => {
// calendarId routing contract — must match buildCalendarConfig() keys
const calendarId: string = occ.isShared ? 'shared' : String(occ.ownerUserId)
const calendarId: string = occ.isShared ? 'shared' : String(occ.ownerUserId);
if (occ.allDay) {
// All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from
@@ -84,11 +84,10 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
// straight through renders every all-day event one day too long (a 1-day event
// showed across two days). Subtract one day to get the inclusive last day,
// clamped to never precede start.
const startPd = Temporal.PlainDate.from(occ.start)
const endExclusive = Temporal.PlainDate.from(occ.end)
const endInclusive = endExclusive.subtract({ days: 1 })
const end =
Temporal.PlainDate.compare(endInclusive, startPd) < 0 ? startPd : endInclusive
const startPd = Temporal.PlainDate.from(occ.start);
const endExclusive = Temporal.PlainDate.from(occ.end);
const endInclusive = endExclusive.subtract({ days: 1 });
const end = Temporal.PlainDate.compare(endInclusive, startPd) < 0 ? startPd : endInclusive;
return {
id: occ.id,
title: occ.title,
@@ -100,7 +99,7 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
color: occ.color,
isShared: occ.isShared,
},
} satisfies ScheduleXEvent
} satisfies ScheduleXEvent;
}
// Timed: use ZonedDateTime from the offset+IANA-annotated ISO string the server returns.
@@ -118,6 +117,6 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
color: occ.color,
isShared: occ.isShared,
},
} satisfies ScheduleXEvent
})
} satisfies ScheduleXEvent;
});
}
+38 -38
View File
@@ -7,65 +7,65 @@
* - clearLoginRedirect() removes the flag so a subsequent call redirects again
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Stub window.location with a writable href before importing the module.
// jsdom sets window.location to a read-only getter backed by a Location object;
// we need to replace it with a plain object so href assignment is detectable.
const locationStub = { href: '' }
const locationStub = { href: '' };
Object.defineProperty(window, 'location', {
value: locationStub,
writable: true,
})
});
import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js'
import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js';
describe('maybeRedirectToLogin', () => {
beforeEach(() => {
sessionStorage.clear()
locationStub.href = ''
vi.clearAllMocks()
})
sessionStorage.clear();
locationStub.href = '';
vi.clearAllMocks();
});
it('sets window.location.href to /api/login and returns true on first call', () => {
const result = maybeRedirectToLogin()
expect(result).toBe(true)
expect(locationStub.href).toBe('/api/login')
})
const result = maybeRedirectToLogin();
expect(result).toBe(true);
expect(locationStub.href).toBe('/api/login');
});
it('sets the sessionStorage flag after first call', () => {
maybeRedirectToLogin()
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1')
})
maybeRedirectToLogin();
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1');
});
it('does NOT change href on second call and returns false (one-shot guard)', () => {
maybeRedirectToLogin() // first call — redirects
locationStub.href = '' // reset the stub to detect a second assignment
const result = maybeRedirectToLogin() // second call — should NOT redirect
expect(result).toBe(false)
expect(locationStub.href).toBe('') // href must NOT have been reassigned
})
})
maybeRedirectToLogin(); // first call — redirects
locationStub.href = ''; // reset the stub to detect a second assignment
const result = maybeRedirectToLogin(); // second call — should NOT redirect
expect(result).toBe(false);
expect(locationStub.href).toBe(''); // href must NOT have been reassigned
});
});
describe('clearLoginRedirect', () => {
beforeEach(() => {
sessionStorage.clear()
locationStub.href = ''
vi.clearAllMocks()
})
sessionStorage.clear();
locationStub.href = '';
vi.clearAllMocks();
});
it('removes the sessionStorage flag', () => {
sessionStorage.setItem('familysync.loginRedirectAttempted', '1')
clearLoginRedirect()
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull()
})
sessionStorage.setItem('familysync.loginRedirectAttempted', '1');
clearLoginRedirect();
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull();
});
it('allows maybeRedirectToLogin to redirect again after the flag is cleared', () => {
maybeRedirectToLogin() // first call — sets flag
clearLoginRedirect() // clear the flag
locationStub.href = '' // reset stub
const result = maybeRedirectToLogin() // should redirect again
expect(result).toBe(true)
expect(locationStub.href).toBe('/api/login')
})
})
maybeRedirectToLogin(); // first call — sets flag
clearLoginRedirect(); // clear the flag
locationStub.href = ''; // reset stub
const result = maybeRedirectToLogin(); // should redirect again
expect(result).toBe(true);
expect(locationStub.href).toBe('/api/login');
});
});
+11 -11
View File
@@ -13,7 +13,7 @@
* maybeRedirectToLogin() call is a no-op and the "Sign-in required" UI is shown.
*/
export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted';
/**
* Navigate to /api/login if this is the first attempt.
@@ -26,21 +26,21 @@ export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
* sessionStorage is unavailable.
*/
export function maybeRedirectToLogin(): boolean {
if (typeof window === 'undefined') return false
if (typeof sessionStorage === 'undefined') return false
if (typeof window === 'undefined') return false;
if (typeof sessionStorage === 'undefined') return false;
try {
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) {
// Already attempted — do not redirect again (loop guard).
return false
return false;
}
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
window.location.href = '/api/login'
return true
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1');
window.location.href = '/api/login';
return true;
} catch {
// sessionStorage access can throw in private-browsing mode or with storage quota
// exceeded. Fail open: do not redirect, let the caller render the error.
return false
return false;
}
}
@@ -53,11 +53,11 @@ export function maybeRedirectToLogin(): boolean {
* Safe under SSR/test environments (guarded).
*/
export function clearLoginRedirect(): void {
if (typeof window === 'undefined') return
if (typeof sessionStorage === 'undefined') return
if (typeof window === 'undefined') return;
if (typeof sessionStorage === 'undefined') return;
try {
sessionStorage.removeItem(LOGIN_REDIRECT_KEY)
sessionStorage.removeItem(LOGIN_REDIRECT_KEY);
} catch {
// Ignore storage errors — clearing the flag is best-effort.
}