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:
@@ -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,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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,18 +13,18 @@
|
||||
* 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]
|
||||
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('')
|
||||
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),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user