From 24fb26a58fd9f086e1ea1deddadedc71352e897e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:46:01 -0700 Subject: [PATCH 1/3] feat: implement dynamic DST handling in date selector (#140) --- src/scripts/index.ts | 445 +++++++++++++++++++++++++++++++---- src/styles/styles.css | 50 ++++ test/dst-transitions.test.ts | 199 ++++++++++++++++ 3 files changed, 646 insertions(+), 48 deletions(-) create mode 100644 test/dst-transitions.test.ts diff --git a/src/scripts/index.ts b/src/scripts/index.ts index 4ef8456..952ebc8 100644 --- a/src/scripts/index.ts +++ b/src/scripts/index.ts @@ -70,13 +70,14 @@ export interface TimelineRow { /** * Get user's current timezone using Temporal API - * @returns TimeZone object with user's timezone details + * @param date Optional date to calculate timezone offset for (defaults to current date) + * @returns TimeZone object with user's timezone details for the specified date */ -export function getUserTimezone(): TimeZone { +export function getUserTimezone(date?: Date): TimeZone { // Get user's timezone ID using Temporal (polyfill ensures availability) const userTimezone = Temporal.Now.timeZoneId(); - const now = new Date(); + const now = date || new Date(); // Use Intl for offset calculation (proven compatibility) const formatter = new Intl.DateTimeFormat('en', { @@ -105,12 +106,12 @@ export function getUserTimezone(): TimeZone { displayFormatter.formatToParts(now).find(part => part.type === 'timeZoneName')?.value || userTimezone; return { - name: createTimezoneDisplayName(userTimezone, offset), + name: createTimezoneDisplayName(userTimezone, offset, now), offset, displayName, iana: userTimezone, cityName: extractCityName(userTimezone), - abbreviation: getTimezoneAbbreviation(displayName, userTimezone), + abbreviation: getTimezoneAbbreviation(displayName, userTimezone, now), }; } @@ -119,16 +120,17 @@ export function getUserTimezone(): TimeZone { * Enhanced to handle edge cases where user's timezone is at start/end of offset range * * @param numRows Number of timezone rows to display (default: 5, always uses odd numbers, minimum 3) + * @param date Optional date to calculate timezone offsets for (defaults to current date) * @returns Array of timezone objects ordered by offset with user's actual timezone guaranteed in the center */ -export function getTimezonesForTimeline(numRows = 5): TimeZone[] { - const userTz = getUserTimezone(); +export function getTimezonesForTimeline(numRows = 5, date?: Date): TimeZone[] { + const userTz = getUserTimezone(date); // Ensure we always use an odd number of timezones (minimum 3) so user timezone can be centered const oddNumRows = Math.max(3, numRows % 2 === 0 ? numRows + 1 : numRows); // Get all available timezones from the browser - const allTimezones = getAllTimezonesOrdered(); + const allTimezones = getAllTimezonesOrdered(date); // Create a map of offset -> timezone for quick lookup const timezonesByOffset = new Map(); @@ -399,9 +401,10 @@ function extractCityName(iana: string, timezone?: TimeZone): string { * Generate timezone abbreviation from full timezone name and IANA identifier * @param displayName Full timezone name (e.g., "Eastern Daylight Time") * @param iana IANA timezone identifier for fallback logic + * @param date Date to calculate timezone abbreviation for (defaults to current date) * @returns Timezone abbreviation (e.g., "EDT") */ -function getTimezoneAbbreviation(displayName: string, iana: string): string { +function getTimezoneAbbreviation(displayName: string, iana: string, date?: Date): string { try { // Use browser's Intl.DateTimeFormat to get timezone abbreviation in user's native language const formatter = new Intl.DateTimeFormat(undefined, { @@ -409,8 +412,8 @@ function getTimezoneAbbreviation(displayName: string, iana: string): string { timeZoneName: 'short', }); - // Format a date and extract the timezone abbreviation - const parts = formatter.formatToParts(new Date()); + // Format the provided date (or current date) and extract the timezone abbreviation + const parts = formatter.formatToParts(date || new Date()); const timeZonePart = parts.find(part => part.type === 'timeZoneName'); if (timeZonePart && timeZonePart.value && timeZonePart.value.length <= 5) { @@ -446,9 +449,10 @@ function getTimezoneAbbreviation(displayName: string, iana: string): string { * Create a timezone display name using browser's native localization * @param iana IANA timezone identifier * @param offset UTC offset in hours (fallback for display) + * @param date Date to calculate timezone name for (defaults to current date) * @returns Browser-localized timezone name */ -function createTimezoneDisplayName(iana: string, offset: number): string { +function createTimezoneDisplayName(iana: string, offset: number, date?: Date): string { try { // Use browser's native timezone display names in user's language const formatter = new Intl.DateTimeFormat(undefined, { @@ -456,8 +460,8 @@ function createTimezoneDisplayName(iana: string, offset: number): string { timeZoneName: 'long', }); - // Get the timezone name from a sample date - const parts = formatter.formatToParts(new Date()); + // Get the timezone name from the provided date (or current date) + const parts = formatter.formatToParts(date || new Date()); const timeZonePart = parts.find(part => part.type === 'timeZoneName'); if (timeZonePart && timeZonePart.value) { @@ -630,8 +634,8 @@ function getCurrentHourScrollPosition(): number { * @returns Array of timeline rows */ export function createTimelineData(numHours: number, numRows: number, baseDate?: Date): TimelineRow[] { - const userTz = getUserTimezone(); - const timezones = getTimezonesForTimeline(numRows); + const userTz = getUserTimezone(baseDate); + const timezones = getTimezonesForTimeline(numRows, baseDate); return timezones.map(timezone => ({ timezone, @@ -731,8 +735,9 @@ function adjustTimezoneLabelWidths(): void { /** * Render the timeline visualization + * @param baseDate Optional date to center the timeline on (defaults to current date) */ -export function renderTimeline(): void { +export function renderTimeline(baseDate?: Date): void { const container = document.getElementById('timeline-container'); if (!container) { console.error('Timeline container not found'); @@ -745,7 +750,7 @@ export function renderTimeline(): void { const settings = SettingsPanel.getCurrentSettings(); const timeFormat = settings?.timeFormat || '12h'; - const timelineData = createTimelineData(numHours, numRows); + const timelineData = createTimelineData(numHours, numRows, baseDate); // Clear container container.innerHTML = ''; @@ -853,8 +858,8 @@ export class TimelineManager { throw new Error('Timeline container not found'); } - // Initialize modal with callback - this.modal = new TimezoneModal((timezone: TimeZone) => this.addTimezone(timezone)); + // Initialize modal with callback and selected date + this.modal = new TimezoneModal((timezone: TimeZone) => this.addTimezone(timezone), this.selectedDate); // Initialize datetime modal with callback this.dateTimeModal = new DateTimeModal((dateTime: Date) => this.setSelectedDate(dateTime)); @@ -872,8 +877,8 @@ export class TimelineManager { // Get screen-appropriate number of timezone rows const { numRows } = getTimelineDimensions(); - // Get properly centered timezones with user timezone in the middle - this.selectedTimezones = getTimezonesForTimeline(numRows); + // Get properly centered timezones with user timezone in the middle using selected date + this.selectedTimezones = getTimezonesForTimeline(numRows, this.selectedDate); // Also load any saved custom timezones that were previously added to timeline const customTimezones = CustomTimezoneManager.getCustomTimezones(); @@ -912,6 +917,24 @@ export class TimelineManager { public setSelectedDate(date: Date): void { this.selectedDate = new Date(date); + + // Recalculate timezones for the new date to handle DST transitions + const { numRows } = getTimelineDimensions(); + this.selectedTimezones = getTimezonesForTimeline(numRows, this.selectedDate); + + // Re-add any custom timezones + const customTimezones = CustomTimezoneManager.getCustomTimezones(); + for (const customTz of customTimezones) { + // Only add if not already included + const exists = this.selectedTimezones.find(tz => tz.iana === customTz.iana); + if (!exists) { + this.selectedTimezones.push(customTz); + } + } + + // Update the modal with the new date and recalculated timezones + this.modal = new TimezoneModal((timezone: TimeZone) => this.addTimezone(timezone), this.selectedDate); + this.renderTimeline(); } @@ -969,7 +992,7 @@ export class TimelineManager { // Create timeline rows for selected timezones // For initial load, preserve the centered order from getTimezonesForTimeline // For manually added timezones, sort by offset for logical progression - const userTz = getUserTimezone(); + const userTz = getUserTimezone(this.selectedDate); const userIndex = this.selectedTimezones.findIndex(tz => tz.iana === userTz.iana); let timezonesToRender: TimeZone[]; @@ -985,7 +1008,7 @@ export class TimelineManager { const rowElement = document.createElement('div'); rowElement.className = 'timeline-row'; - const userTz = getUserTimezone(); + const userTz = getUserTimezone(this.selectedDate); if (timezone.iana === userTz.iana) { rowElement.classList.add('user-timezone'); } @@ -1108,13 +1131,14 @@ interface WindowWithTimeline extends Window { /** * Get all supported timezones that the browser knows about, * ordered starting with the user's current timezone and going around the world + * @param date Optional date to calculate timezone offsets for (defaults to current date) * @returns Array of timezone objects ordered from user's timezone around the globe */ -export function getAllTimezonesOrdered(): TimeZone[] { +export function getAllTimezonesOrdered(date?: Date): TimeZone[] { // Get user's timezone using Temporal (polyfill ensures availability) const userTimezone = Temporal.Now.timeZoneId(); - const now = new Date(); + const now = date || new Date(); // Get all supported timezones (comprehensive list) const allTimezones = Intl.supportedValuesOf('timeZone'); @@ -1146,12 +1170,12 @@ export function getAllTimezonesOrdered(): TimeZone[] { const displayName = displayFormatter.formatToParts(now).find(part => part.type === 'timeZoneName')?.value || iana; return { - name: createTimezoneDisplayName(iana, offset), + name: createTimezoneDisplayName(iana, offset, now), offset, displayName, iana, cityName: extractCityName(iana), - abbreviation: getTimezoneAbbreviation(displayName, iana), + abbreviation: getTimezoneAbbreviation(displayName, iana, now), }; }); @@ -1182,6 +1206,180 @@ export function getAllTimezonesOrdered(): TimeZone[] { return sortedTimezones; } +/** + * Get timezone variations for a given IANA identifier using fixed dates + * Returns both summer (June 1st) and winter (December 31st) variations + */ +function getTimezoneVariations(iana: string, year: number = new Date().getFullYear()): TimeZone[] { + const variations: TimeZone[] = []; + + // Use June 1st for summer time and December 31st for winter time + const summerDate = new Date(year, 5, 1); // June 1st + const winterDate = new Date(year, 11, 31); // December 31st + + for (const date of [summerDate, winterDate]) { + const formatter = new Intl.DateTimeFormat('en', { + timeZone: iana, + timeZoneName: 'longOffset', + }); + + const offsetStr = formatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || '+00:00'; + + // Parse offset string like "GMT+05:30" or "GMT-08:00" + const offsetMatch = offsetStr.match(/GMT([+-])(\d{2}):(\d{2})/); + let offset = 0; + if (offsetMatch && offsetMatch[2] && offsetMatch[3]) { + const sign = offsetMatch[1] === '+' ? 1 : -1; + const hours = parseInt(offsetMatch[2], 10); + const minutes = parseInt(offsetMatch[3], 10); + offset = sign * (hours + minutes / 60); + } + + // Get display name + const displayFormatter = new Intl.DateTimeFormat('en', { + timeZone: iana, + timeZoneName: 'long', + }); + const displayName = displayFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || iana; + + const timezone: TimeZone = { + name: createTimezoneDisplayName(iana, offset, date), + offset, + displayName, + iana, + cityName: extractCityName(iana), + abbreviation: getTimezoneAbbreviation(displayName, iana, date), + }; + + // Only add if we don't already have this variation (same offset) + const exists = variations.find(v => v.offset === timezone.offset); + if (!exists) { + variations.push(timezone); + } + } + + return variations; +} + +/** + * Extract base location from IANA timezone identifier for grouping + * Example: "America/Los_Angeles" -> "Los Angeles" + */ +function extractBaseLocation(iana: string): string { + const parts = iana.split('/'); + if (parts.length >= 2) { + // Handle cases like "America/New_York" or "Europe/London" + const lastPart = parts[parts.length - 1]; + return lastPart ? lastPart.replace(/_/g, ' ') : iana; + } + return iana; +} + +/** + * Extract country/region from IANA timezone identifier + * Example: "America/Los_Angeles" -> "America" + */ +function extractRegion(iana: string): string { + const parts = iana.split('/'); + return parts[0] || ''; +} + +/** + * Get grouped timezones with DST/Standard variations, prioritized by selected date + */ +export function getGroupedTimezones(selectedDate?: Date): GroupedTimezone[] { + const date = selectedDate || new Date(); + const allTimezones = Intl.supportedValuesOf('timeZone'); + const locationGroups = new Map(); + + // Group timezones by location + for (const iana of allTimezones) { + const baseLocation = extractBaseLocation(iana); + const region = extractRegion(iana); + const variations = getTimezoneVariations(iana); + + if (variations.length === 0) continue; + + // Find the current timezone for the selected date + const currentTimezone = getAllTimezonesOrdered(date).find(tz => tz.iana === iana); + if (!currentTimezone) continue; + + // Find alternate timezone (different offset) + const alternateTimezone = variations.find(v => v.offset !== currentTimezone.offset); + + const groupKey = `${region}/${baseLocation}`; + + if (!locationGroups.has(groupKey)) { + const group: GroupedTimezone = { + location: baseLocation, + country: region, + current: currentTimezone, + variations: variations, + }; + if (alternateTimezone) { + group.alternate = alternateTimezone; + } + locationGroups.set(groupKey, group); + } else { + // If we already have this location, check if this timezone is more representative + const existing = locationGroups.get(groupKey); + if (!existing) continue; + + // Prefer main city names over specific districts/areas + const currentCityParts = currentTimezone.cityName.split(/[/\-_]/); + const existingCityParts = existing.current.cityName.split(/[/\-_]/); + + if (currentCityParts.length < existingCityParts.length) { + // This timezone has a simpler name, prefer it + existing.current = currentTimezone; + if (alternateTimezone) { + existing.alternate = alternateTimezone; + } + existing.variations = [...existing.variations, ...variations].filter( + (v, i, arr) => arr.findIndex(v2 => v2.offset === v.offset) === i, + ); + } + } + } + + // Convert to array and sort + const grouped = Array.from(locationGroups.values()); + + // Get user's timezone for sorting + const userTimezone = Temporal.Now.timeZoneId(); + const userTimezoneData = getAllTimezonesOrdered(date).find(tz => tz.iana === userTimezone); + const userOffset = userTimezoneData?.offset || 0; + + // Sort by proximity to user's timezone, then by location name + return grouped.sort((a, b) => { + const getDistance = (offset: number): number => { + let distance = offset - userOffset; + if (distance < -12) distance += 24; + if (distance > 12) distance -= 24; + return Math.abs(distance); + }; + + const distanceA = getDistance(a.current.offset); + const distanceB = getDistance(b.current.offset); + + if (distanceA !== distanceB) { + return distanceA - distanceB; + } + return a.location.localeCompare(b.location); + }); +} + +/** + * Grouped timezone information for a location showing DST and Standard time variants + */ +export interface GroupedTimezone { + location: string; // Base location name (e.g., "Los Angeles", "New York") + country?: string; // Country name if available + current: TimeZone; // Current timezone for the selected date + alternate?: TimeZone; // Alternate timezone (DST/Standard variant) if different + variations: TimeZone[]; // All timezone variations for this location +} + /** * Cache of valid timezone offsets from existing IANA timezones */ @@ -1314,13 +1512,17 @@ export class TimezoneModal { private upButton: HTMLElement; private downButton: HTMLElement; private timezones: TimeZone[]; + private groupedTimezones: GroupedTimezone[]; private filteredTimezones: TimeZone[]; + private filteredGroups: GroupedTimezone[]; private selectedIndex = 0; private currentUserTimezone: string; private onTimezoneSelectedCallback: ((timezone: TimeZone) => void) | undefined; private userSearchQuery = ''; // Store user's search query separately + private selectedDate: Date; // Date to use for timezone calculations - constructor(onTimezoneSelected?: (timezone: TimeZone) => void) { + constructor(onTimezoneSelected?: (timezone: TimeZone) => void, selectedDate?: Date) { + this.selectedDate = selectedDate || new Date(); this.modal = document.getElementById('timezone-modal') as HTMLElement; this.overlay = document.getElementById('timezone-modal-overlay') as HTMLElement; this.input = document.getElementById('timezone-input') as HTMLInputElement; @@ -1334,12 +1536,16 @@ export class TimezoneModal { // Get user's timezone using Temporal (polyfill ensures availability) this.currentUserTimezone = Temporal.Now.timeZoneId(); - // Combine standard and custom timezones - const standardTimezones = getAllTimezonesOrdered(); + // Get both grouped and flat timezone lists + this.groupedTimezones = getGroupedTimezones(this.selectedDate); + + // Combine standard and custom timezones using the selected date + const standardTimezones = getAllTimezonesOrdered(this.selectedDate); const customTimezones = CustomTimezoneManager.getCustomTimezones(); this.timezones = [...standardTimezones, ...customTimezones]; this.filteredTimezones = [...this.timezones]; + this.filteredGroups = [...this.groupedTimezones]; this.onTimezoneSelectedCallback = onTimezoneSelected; this.init(); @@ -1377,9 +1583,13 @@ export class TimezoneModal { this.userSearchQuery = this.input.value.toLowerCase().trim(); if (this.userSearchQuery === '') { + // No search - show grouped view this.filteredTimezones = [...this.timezones]; + this.filteredGroups = [...this.groupedTimezones]; } else { + // Search - show flat filtered results this.filteredTimezones = this.searchTimezones(this.userSearchQuery); + this.filteredGroups = []; // Empty groups when searching // Check if query is a valid offset pattern with no matches const offsetMatch = parseOffsetQuery(this.userSearchQuery); @@ -1537,35 +1747,45 @@ export class TimezoneModal { } private navigateUp(): void { - this.selectedIndex = (this.selectedIndex - 1 + this.filteredTimezones.length) % this.filteredTimezones.length; + const hasSearch = this.userSearchQuery.trim() !== ''; + if (hasSearch) { + // Navigate in flat filtered timezones + this.selectedIndex = (this.selectedIndex - 1 + this.filteredTimezones.length) % this.filteredTimezones.length; + } else { + // Navigate in grouped timezones + this.selectedIndex = (this.selectedIndex - 1 + this.filteredGroups.length) % this.filteredGroups.length; + } this.renderWheel(); } private navigateDown(): void { - this.selectedIndex = (this.selectedIndex + 1) % this.filteredTimezones.length; + const hasSearch = this.userSearchQuery.trim() !== ''; + if (hasSearch) { + // Navigate in flat filtered timezones + this.selectedIndex = (this.selectedIndex + 1) % this.filteredTimezones.length; + } else { + // Navigate in grouped timezones + this.selectedIndex = (this.selectedIndex + 1) % this.filteredGroups.length; + } this.renderWheel(); } - private updateInputValue(): void { - const selectedTimezone = this.filteredTimezones[this.selectedIndex]; - if (selectedTimezone) { - // Format offset - const offsetStr = formatOffset(selectedTimezone.offset); + private renderWheel(): void { + this.wheel.innerHTML = ''; - // Check if abbreviation already contains offset information to avoid duplication - const hasOffsetInAbbreviation = /[+-]\d/.test(selectedTimezone.abbreviation); - const displayText = hasOffsetInAbbreviation - ? `${selectedTimezone.cityName} (${selectedTimezone.abbreviation})` - : `${selectedTimezone.cityName} (${selectedTimezone.abbreviation} ${offsetStr})`; + // Determine what to show based on search and mode + const hasSearch = this.userSearchQuery.trim() !== ''; - // Show city name with abbreviated timezone and offset: "Tokyo (JST +09:00)" or "Casey (GMT+8)" - this.input.value = displayText; + if (hasSearch) { + // Show flat filtered results when searching + this.renderFlatWheel(); + } else { + // Show grouped results when not searching + this.renderGroupedWheel(); } } - private renderWheel(): void { - this.wheel.innerHTML = ''; - + private renderFlatWheel(): void { if (this.filteredTimezones.length === 0) { const noResults = document.createElement('div'); noResults.className = 'wheel-timezone-item center'; @@ -1604,6 +1824,135 @@ export class TimezoneModal { } } + private renderGroupedWheel(): void { + if (this.filteredGroups.length === 0) { + const noResults = document.createElement('div'); + noResults.className = 'wheel-timezone-item center'; + noResults.innerHTML = '
No timezone groups found
'; + this.wheel.appendChild(noResults); + return; + } + + // Show 5 items: 2 above, current (center), 2 below + const itemsToShow = Math.min(5, this.filteredGroups.length); + const centerIndex = Math.floor(itemsToShow / 2); + + // Map selectedIndex to the main timezone for the group + + if (this.filteredGroups.length <= itemsToShow) { + // Render each group only once + for (let i = 0; i < this.filteredGroups.length; i++) { + const group = this.filteredGroups[i]; + if (!group) continue; + + const isCenter = i === Math.min(this.selectedIndex, this.filteredGroups.length - 1); + const isAdjacent = Math.abs(i - Math.min(this.selectedIndex, this.filteredGroups.length - 1)) === 1; + this.renderGroupedTimezoneItem(group, isCenter, isAdjacent, i, this.selectedIndex); + } + } else { + // Circular rendering for many groups + for (let i = 0; i < itemsToShow; i++) { + const groupIndex = + (Math.min(this.selectedIndex, this.filteredGroups.length - 1) - + centerIndex + + i + + this.filteredGroups.length) % + this.filteredGroups.length; + const group = this.filteredGroups[groupIndex]; + + if (!group) continue; + + const isCenter = i === centerIndex; + this.renderGroupedTimezoneItem(group, isCenter, i === centerIndex - 1 || i === centerIndex + 1, i, centerIndex); + } + } + } + + private renderGroupedTimezoneItem( + group: GroupedTimezone, + isCenter: boolean, + isAdjacent: boolean, + position: number, + centerIndex: number, + ): void { + const item = document.createElement('div'); + item.className = 'wheel-timezone-item grouped'; + + // Add position classes + if (isCenter) { + item.classList.add('center'); + } else if (isAdjacent) { + item.classList.add('adjacent'); + } else { + item.classList.add('distant'); + } + + // Add current user timezone class if it matches + if (group.current.iana === this.currentUserTimezone) { + item.classList.add('current'); + } + + // Format offset for display + const offsetStr = formatOffset(group.current.offset); + + // Check if abbreviation already contains offset information + const hasOffsetInAbbreviation = /[+-]\d/.test(group.current.abbreviation); + const displayText = hasOffsetInAbbreviation + ? `${group.location} (${group.current.abbreviation})` + : `${group.location} (${group.current.abbreviation} ${offsetStr})`; + + // Show alternate timezone info if available + const alternateInfo = group.alternate + ? ` โ€ข ${group.alternate.abbreviation} ${formatOffset(group.alternate.offset)}` + : ''; + + item.innerHTML = ` +
+ ${displayText} + ${group.alternate ? '+' : ''} +
+
${group.current.displayName}${alternateInfo}
+ `; + + // Handle plus button clicks + const plusBtn = item.querySelector('.timezone-plus-btn'); + if (plusBtn && group.alternate) { + plusBtn.addEventListener('click', e => { + e.stopPropagation(); + // Select the alternate timezone directly + if (this.onTimezoneSelectedCallback && group.alternate) { + this.onTimezoneSelectedCallback(group.alternate); + } + this.close(); + }); + } + + // Click handler for main item + item.addEventListener('click', () => { + if (!isCenter) { + // Navigate to this group + const steps = position - centerIndex; + if (steps > 0) { + for (let j = 0; j < steps; j++) { + this.navigateDown(); + } + } else { + for (let j = 0; j < Math.abs(steps); j++) { + this.navigateUp(); + } + } + } else { + // Center item was clicked - select the current timezone for this group + if (this.onTimezoneSelectedCallback) { + this.onTimezoneSelectedCallback(group.current); + } + this.close(); + } + }); + + this.wheel.appendChild(item); + } + private renderTimezoneItem( timezone: TimeZone, isCenter: boolean, diff --git a/src/styles/styles.css b/src/styles/styles.css index 4e3131b..d6f7192 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -878,6 +878,56 @@ p { overflow: hidden; text-overflow: ellipsis; width: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +/* Plus button for alternate timezone selection */ +.timezone-plus-btn { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + background: var(--color-secondary); + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + font-size: var(--text-xs); + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + z-index: 10; +} + +.timezone-plus-btn:hover { + background: var(--color-primary); + transform: translateY(-50%) scale(1.1); + box-shadow: var(--shadow-sm); +} + +/* Grouped timezone item styles */ +.wheel-timezone-item.grouped .wheel-timezone-display { + font-size: var(--text-xs); + opacity: 0.8; + line-height: 1.2; + white-space: normal; + text-align: center; + width: 100%; +} + +.wheel-timezone-item.grouped { + min-height: 60px; +} + +/* Ensure center grouped items have proper spacing for the plus button */ +.wheel-timezone-item.grouped.center .wheel-timezone-name { + padding-right: 25px; } .wheel-timezone-display { diff --git a/test/dst-transitions.test.ts b/test/dst-transitions.test.ts new file mode 100644 index 0000000..0e02154 --- /dev/null +++ b/test/dst-transitions.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for DST transition handling + * Validates that timezone display updates correctly when date selector changes across DST boundaries + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getUserTimezone, getTimezonesForTimeline } from '../src/scripts/index.js'; + +describe('DST Transition Handling', () => { + beforeEach(() => { + // Mock current date to August 7, 2025 (DST period) + vi.setSystemTime(new Date('2025-08-07T18:00:00.000Z')); // 6 PM UTC on Aug 7, 2025 + }); + + describe('Los Angeles DST Transitions', () => { + beforeEach(() => { + // Mock Temporal API + vi.stubGlobal('Temporal', { + Now: { + timeZoneId: () => 'America/Los_Angeles', + }, + }); + }); + + it('should show Pacific Daylight Time (PDT -7) during summer', () => { + const summerDate = new Date('2025-08-07T18:00:00.000Z'); // August 7, 2025 + + // Mock Intl to return PDT during summer + global.Intl = { + ...Intl, + DateTimeFormat: vi.fn().mockImplementation(() => ({ + resolvedOptions: () => ({ timeZone: 'America/Los_Angeles' }), + formatToParts: (date) => [ + { type: 'timeZoneName', value: 'GMT-07:00' } + ], + })), + supportedValuesOf: vi.fn().mockReturnValue(['America/Los_Angeles']), + } as any; + + const userTz = getUserTimezone(summerDate); + + expect(userTz.offset).toBe(-7); + expect(userTz.iana).toBe('America/Los_Angeles'); + expect(userTz.cityName).toBe('Los Angeles'); + }); + + it('should show Pacific Standard Time (PST -8) during winter', () => { + const winterDate = new Date('2025-12-31T18:00:00.000Z'); // December 31, 2025 + + // Mock Intl to return PST during winter + global.Intl = { + ...Intl, + DateTimeFormat: vi.fn().mockImplementation(() => ({ + resolvedOptions: () => ({ timeZone: 'America/Los_Angeles' }), + formatToParts: (date) => [ + { type: 'timeZoneName', value: 'GMT-08:00' } + ], + })), + supportedValuesOf: vi.fn().mockReturnValue(['America/Los_Angeles']), + } as any; + + const userTz = getUserTimezone(winterDate); + + expect(userTz.offset).toBe(-8); + expect(userTz.iana).toBe('America/Los_Angeles'); + expect(userTz.cityName).toBe('Los Angeles'); + }); + + it('should correctly handle DST transition in timeline generation', () => { + // Mock Intl to simulate realistic DST behavior + global.Intl = { + ...Intl, + DateTimeFormat: vi.fn().mockImplementation(() => { + // Create a mock that simulates different offsets based on the date passed to formatToParts + return { + resolvedOptions: () => ({ timeZone: 'America/Los_Angeles' }), + formatToParts: (date) => { + // Simulate DST: summer months (March-November) use PDT (-7), winter uses PST (-8) + const month = (date || new Date()).getMonth(); + const isDST = month >= 2 && month <= 10; // March (2) through November (10) + return [ + { type: 'timeZoneName', value: isDST ? 'GMT-07:00' : 'GMT-08:00' } + ]; + }, + }; + }), + supportedValuesOf: vi.fn().mockReturnValue(['America/Los_Angeles']), + } as any; + + // Test summer date + const summerDate = new Date('2025-08-07T18:00:00.000Z'); // August 7, 2025 + const summerUserTz = getUserTimezone(summerDate); + expect(summerUserTz.offset).toBe(-7); // PDT + + // Test winter date + const winterDate = new Date('2025-12-31T18:00:00.000Z'); // December 31, 2025 + const winterUserTz = getUserTimezone(winterDate); + expect(winterUserTz.offset).toBe(-8); // PST + + // Test that timeline respects the date-specific DST calculation + const summerTimezones = getTimezonesForTimeline(3, summerDate); + const winterTimezones = getTimezonesForTimeline(3, winterDate); + + const summerLA = summerTimezones.find(tz => tz.iana === 'America/Los_Angeles'); + const winterLA = winterTimezones.find(tz => tz.iana === 'America/Los_Angeles'); + + expect(summerLA?.offset).toBe(-7); // PDT in summer + expect(winterLA?.offset).toBe(-8); // PST in winter + }); + }); + + describe('New York DST Transitions', () => { + beforeEach(() => { + // Mock Temporal API for New York + vi.stubGlobal('Temporal', { + Now: { + timeZoneId: () => 'America/New_York', + }, + }); + }); + + it('should handle Eastern timezone DST transitions', () => { + // Mock Intl to simulate EDT/EST behavior + global.Intl = { + ...Intl, + DateTimeFormat: vi.fn().mockImplementation(() => ({ + resolvedOptions: () => ({ timeZone: 'America/New_York' }), + formatToParts: (date) => { + const month = (date || new Date()).getMonth(); + const isDST = month >= 2 && month <= 10; // March through November + return [ + { type: 'timeZoneName', value: isDST ? 'GMT-04:00' : 'GMT-05:00' } + ]; + }, + })), + supportedValuesOf: vi.fn().mockReturnValue(['America/New_York']), + } as any; + + // Test summer (EDT) + const summerDate = new Date('2025-08-07T18:00:00.000Z'); + const userTzSummer = getUserTimezone(summerDate); + expect(userTzSummer.offset).toBe(-4); // EDT + + // Test winter (EST) + const winterDate = new Date('2025-12-31T18:00:00.000Z'); + const userTzWinter = getUserTimezone(winterDate); + expect(userTzWinter.offset).toBe(-5); // EST + }); + }); + + describe('Enhanced API Functionality', () => { + it('should use current date when no date parameter is provided', () => { + global.Intl = { + ...Intl, + DateTimeFormat: vi.fn().mockImplementation(() => ({ + resolvedOptions: () => ({ timeZone: 'America/Los_Angeles' }), + formatToParts: () => [{ type: 'timeZoneName', value: 'GMT-07:00' }], + })), + supportedValuesOf: vi.fn().mockReturnValue(['America/Los_Angeles']), + } as any; + + const userTz = getUserTimezone(); // No date parameter + expect(userTz.iana).toBe('America/Los_Angeles'); + expect(userTz.offset).toBe(-7); + }); + + it('should return different timezone lists for different dates', () => { + // Mock setup for testing + global.Intl = { + ...Intl, + DateTimeFormat: vi.fn().mockImplementation(() => ({ + resolvedOptions: () => ({ timeZone: 'America/Los_Angeles' }), + formatToParts: (date) => { + const month = (date || new Date()).getMonth(); + const isDST = month >= 2 && month <= 10; + return [ + { type: 'timeZoneName', value: isDST ? 'GMT-07:00' : 'GMT-08:00' } + ]; + }, + })), + supportedValuesOf: vi.fn().mockReturnValue(['America/Los_Angeles']), + } as any; + + const summerTimezones = getTimezonesForTimeline(3, new Date('2025-08-07')); + const winterTimezones = getTimezonesForTimeline(3, new Date('2025-12-31')); + + // Should get some timezones back + expect(summerTimezones.length).toBeGreaterThanOrEqual(1); + expect(winterTimezones.length).toBeGreaterThanOrEqual(1); + + // The user timezone should have different offsets + const summerUserTz = summerTimezones.find(tz => tz.iana === 'America/Los_Angeles'); + const winterUserTz = winterTimezones.find(tz => tz.iana === 'America/Los_Angeles'); + + expect(summerUserTz?.offset).toBe(-7); // PDT + expect(winterUserTz?.offset).toBe(-8); // PST + }); + }); +}); \ No newline at end of file From f18611ccccb01bbe24d8cb7202c81f18e7c76a09 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:50:24 -0700 Subject: [PATCH 2/3] fix: separate version injection for main and develop builds in deploy-develop workflow (#155) --- .github/workflows/deploy-develop.yml | 8 +- test/deploy-version.test.ts | 119 +++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 test/deploy-version.test.ts diff --git a/.github/workflows/deploy-develop.yml b/.github/workflows/deploy-develop.yml index 68a8c0d..b5118d4 100644 --- a/.github/workflows/deploy-develop.yml +++ b/.github/workflows/deploy-develop.yml @@ -91,7 +91,9 @@ jobs: # Checkout main branch git checkout main - # Build main branch content + # Build main branch content (without develop GitVersion env vars) + # This allows the inject-version script to use git-based version calculation + # which will correctly determine the main branch version npm run build # Save main build @@ -105,10 +107,6 @@ jobs: else echo "No stash to pop" fi - env: - GITVERSION_SEMVER: ${{ steps.gitversion.outputs.semVer }} - GITVERSION_FULLSEMVER: ${{ steps.gitversion.outputs.fullSemVer }} - GITVERSION_INFORMATIONALVERSION: ${{ steps.gitversion.outputs.informationalVersion }} # Second: Build develop branch content for the subdirectory - name: Build develop branch for sub-directory diff --git a/test/deploy-version.test.ts b/test/deploy-version.test.ts new file mode 100644 index 0000000..d567eff --- /dev/null +++ b/test/deploy-version.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +describe('Deploy Version Behavior', () => { + it('should use different versions for main and develop builds', () => { + // This test validates that when GitVersion env vars are not set, + // the inject-version script falls back to git-based version calculation + // which will produce different versions for different branches + + // Store original values + const originalSemVer = process.env.GITVERSION_SEMVER; + const originalFullSemVer = process.env.GITVERSION_FULLSEMVER; + const originalInformationalVersion = process.env.GITVERSION_INFORMATIONALVERSION; + + try { + // Clear GitVersion environment variables to simulate main branch build + delete process.env.GITVERSION_SEMVER; + delete process.env.GITVERSION_FULLSEMVER; + delete process.env.GITVERSION_INFORMATIONALVERSION; + + // Build without GitVersion env vars (simulating main branch build scenario) + execSync('npm run build', { cwd: process.cwd(), stdio: 'pipe' }); + + // Check that version was injected + const distIndexPath = join(process.cwd(), 'dist', 'index.html'); + expect(existsSync(distIndexPath)).toBe(true); + + const htmlContent = readFileSync(distIndexPath, 'utf-8'); + + // Should have version meta tag + expect(htmlContent).toMatch(/v[^<]+<\/span>/); + + // Extract the version from the footer + const versionMatch = htmlContent.match(/v([^<]+)<\/span>/); + expect(versionMatch).toBeTruthy(); + + const injectedVersion = versionMatch![1]; + const currentBranch = execSync('git branch --show-current', { encoding: 'utf-8', cwd: process.cwd() }).trim(); + + console.log(`Injected version: ${injectedVersion}`); + console.log(`Current branch: ${currentBranch}`); + + // Should be a valid semver-like version + expect(injectedVersion).toMatch(/^\d+\.\d+\.\d+/); + + // Since we're not on main branch and have no GitVersion env vars, + // it should use git fallback which produces alpha versions for non-main branches + if (currentBranch !== 'main') { + expect(injectedVersion).toMatch(/alpha/); + } + + } finally { + // Restore environment variables + if (originalSemVer !== undefined) { + process.env.GITVERSION_SEMVER = originalSemVer; + } else { + delete process.env.GITVERSION_SEMVER; + } + if (originalFullSemVer !== undefined) { + process.env.GITVERSION_FULLSEMVER = originalFullSemVer; + } else { + delete process.env.GITVERSION_FULLSEMVER; + } + if (originalInformationalVersion !== undefined) { + process.env.GITVERSION_INFORMATIONALVERSION = originalInformationalVersion; + } else { + delete process.env.GITVERSION_INFORMATIONALVERSION; + } + } + }); + + it('should use GitVersion env vars when available', () => { + // Store original values + const originalSemVer = process.env.GITVERSION_SEMVER; + const originalFullSemVer = process.env.GITVERSION_FULLSEMVER; + const originalInformationalVersion = process.env.GITVERSION_INFORMATIONALVERSION; + + try { + // Set GitVersion environment variables (simulating develop branch build with CI) + process.env.GITVERSION_SEMVER = '1.2.3-beta.4'; + process.env.GITVERSION_FULLSEMVER = '1.2.3-beta.4+5'; + process.env.GITVERSION_INFORMATIONALVERSION = '1.2.3-beta.4+Branch.develop.Sha.abcdef'; + + // Build with GitVersion env vars + execSync('npm run build', { cwd: process.cwd(), stdio: 'pipe' }); + + // Check that the specific version was injected + const distIndexPath = join(process.cwd(), 'dist', 'index.html'); + const htmlContent = readFileSync(distIndexPath, 'utf-8'); + + // Should use the GitVersion env var + expect(htmlContent).toContain('v1.2.3-beta.4'); + expect(htmlContent).toContain(' Date: Fri, 8 Aug 2025 00:54:11 -0700 Subject: [PATCH 3/3] feat: enhance sunrise/sunset visualizations with distinct styling across all themes, implement dynamic DST handling in date selector, implement comprehensive WCAG AAA accessibility testing suite (#123) --- package-lock.json | 182 ++++++++ package.json | 6 +- src/index.html | 26 +- src/scripts/index.ts | 235 +++++++++- src/styles/styles.css | 492 +++++++++++++++++++-- test/accessibility-comprehensive.test.ts | 528 +++++++++++++++++++++++ test/setup.ts | 15 +- tsconfig.json | 2 + vitest.config.ts | 13 + 9 files changed, 1417 insertions(+), 82 deletions(-) create mode 100644 test/accessibility-comprehensive.test.ts diff --git a/package-lock.json b/package-lock.json index 0ec6f7a..efd1fa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "suncalc": "^1.9.0" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@eslint/js": "^9.32.0", @@ -21,12 +22,15 @@ "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.37.0", "@vitest/ui": "^3.2.4", + "axe-core": "^4.10.3", + "axe-playwright": "^2.1.0", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.5.4", "happy-dom": "^18.0.1", "husky": "^9.1.7", "jsdom": "^26.1.0", + "playwright": "^1.54.2", "prettier": "^3.6.2", "typescript": "^5.9.2", "vitest": "^3.2.4" @@ -46,6 +50,19 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@axe-core/playwright": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz", + "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.10.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1559,6 +1576,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/junit-report-builder": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/junit-report-builder/-/junit-report-builder-3.0.2.tgz", + "integrity": "sha512-R5M+SYhMbwBeQcNXYWNCZkl09vkVfAtcPIaCGdzIkkbeaTrVbGQ7HVgi4s+EmM/M1K4ZuWQH0jGcvMvNePfxYA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz", @@ -2054,6 +2078,49 @@ "node": ">=12" } }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axe-html-reporter": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/axe-html-reporter/-/axe-html-reporter-2.2.11.tgz", + "integrity": "sha512-WlF+xlNVgNVWiM6IdVrsh+N0Cw7qupe5HT9N6Uyi+aN7f6SSi92RDomiP1noW8OWIV85V6x404m5oKMeqRV3tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mustache": "^4.0.1" + }, + "engines": { + "node": ">=8.9.0" + }, + "peerDependencies": { + "axe-core": ">=3" + } + }, + "node_modules/axe-playwright": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axe-playwright/-/axe-playwright-2.1.0.tgz", + "integrity": "sha512-tY48SX56XaAp16oHPyD4DXpybz8Jxdz9P7exTjF/4AV70EGUavk+1fUPWirM0OYBR+YyDx6hUeDvuHVA6fB9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/junit-report-builder": "^3.0.2", + "axe-core": "^4.10.1", + "axe-html-reporter": "2.2.11", + "junit-report-builder": "^5.1.1", + "picocolors": "^1.1.1" + }, + "peerDependencies": { + "playwright": ">1.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3588,6 +3655,21 @@ "node": "*" } }, + "node_modules/junit-report-builder": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/junit-report-builder/-/junit-report-builder-5.1.1.tgz", + "integrity": "sha512-ZNOIIGMzqCGcHQEA2Q4rIQQ3Df6gSIfne+X9Rly9Bc2y55KxAZu8iGv+n2pP0bLf0XAOctJZgeloC54hWzCahQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "make-dir": "^3.1.0", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3635,6 +3717,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -3722,6 +3811,32 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -3802,6 +3917,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3987,6 +4112,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", + "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", + "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5032,6 +5204,16 @@ "node": ">=18" } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 1f5f61a..bce0f3d 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint:fix": "eslint src/**/*.ts --fix", "format": "prettier --write src/**/*.{ts,js,json,html,css,md}", "format:check": "prettier --check src/**/*.{ts,js,json,html,css,md}", - "test": "npm run lint && npm run format:check && npm run type-check && vitest run", + "test": "npm run build && npm run lint && npm run format:check && npm run type-check && vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", @@ -31,6 +31,7 @@ "author": "Taylor Marvin", "license": "MIT", "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@eslint/js": "^9.32.0", @@ -38,12 +39,15 @@ "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.37.0", "@vitest/ui": "^3.2.4", + "axe-core": "^4.10.3", + "axe-playwright": "^2.1.0", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.5.4", "happy-dom": "^18.0.1", "husky": "^9.1.7", "jsdom": "^26.1.0", + "playwright": "^1.54.2", "prettier": "^3.6.2", "typescript": "^5.9.2", "vitest": "^3.2.4" diff --git a/src/index.html b/src/index.html index ec7bdc5..0e41652 100644 --- a/src/index.html +++ b/src/index.html @@ -73,12 +73,14 @@