Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 68 additions & 3 deletions src/scripts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,36 +805,97 @@ export class TimelineManager {
// Initialize datetime modal with callback
this.dateTimeModal = new DateTimeModal((dateTime: Date) => this.setSelectedDate(dateTime));

// Initialize with user's timezone and a few others
this.initializeDefaultTimezones();
// Initialize timezones from URL or defaults
this.initializeTimezones();

// Listen for settings changes to refresh timeline
window.addEventListener('settingsChanged', () => {
this.renderTimeline();
});
}

private initializeTimezones(): void {
// Try to parse timezones from URL first
const urlTimezones = this.parseTimezonesFromURL();

if (urlTimezones.length > 0) {
this.selectedTimezones = urlTimezones;
} else {
// Fallback to default timezones
this.initializeDefaultTimezones();
}

this.renderTimeline();
}

private initializeDefaultTimezones(): void {
// Get screen-appropriate number of timezone rows
const { numRows } = getTimelineDimensions();

// Get properly centered timezones with user timezone in the middle
this.selectedTimezones = getTimezonesForTimeline(numRows);
}

this.renderTimeline();
/**
* Parse timezones from URL parameters
*/
private parseTimezonesFromURL(): TimeZone[] {
const urlParams = new URLSearchParams(window.location.search);
const timezonesParam = urlParams.get('timezones');

if (!timezonesParam) {
return [];
}

// Split by comma and decode URI components
const timezoneIds = timezonesParam.split(',').map(id => decodeURIComponent(id.trim()));
const validTimezones: TimeZone[] = [];

// Get all available timezones for lookup
const allTimezones = getAllTimezonesOrdered();
const timezoneMap = new Map(allTimezones.map(tz => [tz.iana, tz]));

for (const id of timezoneIds) {
const timezone = timezoneMap.get(id);
if (timezone) {
validTimezones.push(timezone);
}
}

return validTimezones;
}

/**
* Update URL with current timezone selection
*/
private updateURL(): void {
const url = new URL(window.location.href);

if (this.selectedTimezones.length > 0) {
// Create timezone IDs and join with commas (don't double-encode)
const timezoneIds = this.selectedTimezones.map(tz => tz.iana).join(',');
url.searchParams.set('timezones', timezoneIds);
} else {
url.searchParams.delete('timezones');
}

// Update URL without page reload
window.history.replaceState({}, '', url.toString());
}

public addTimezone(timezone: TimeZone): void {
// Check if timezone already exists
const exists = this.selectedTimezones.find(tz => tz.iana === timezone.iana);
if (!exists) {
this.selectedTimezones.push(timezone);
this.updateURL();
this.renderTimeline();
}
}

public removeTimezone(timezone: TimeZone): void {
this.selectedTimezones = this.selectedTimezones.filter(tz => tz.iana !== timezone.iana);
this.updateURL();
this.renderTimeline();
}

Expand All @@ -860,6 +921,10 @@ export class TimelineManager {
this.dateTimeModal.open();
}

public getSelectedTimezones(): TimeZone[] {
return [...this.selectedTimezones];
}

private renderTimeline(): void {
const { numHours } = getTimelineDimensions();

Expand Down
217 changes: 217 additions & 0 deletions test/timezone-url-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* Timezone URL Persistence Tests
* Tests for URL parameter handling of selected timezones
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TimelineManager } from '../src/scripts/index.js';
import { loadActualHTML } from './setup.js';

describe('Timezone URL Persistence', () => {
let timelineManager: TimelineManager;

beforeEach(() => {
// Load the actual HTML from the site
loadActualHTML();

// Reset URL state
window.location.search = '';

// Mock history.replaceState
vi.spyOn(history, 'replaceState').mockImplementation(() => {});
});

describe('URL Parameter Parsing', () => {
it('should initialize with default timezones when no URL parameters', () => {
timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

// Should have some default timezones
expect(timezones.length).toBeGreaterThan(0);

// Should include user's timezone
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const hasUserTz = timezones.some(tz => tz.iana === userTz);
expect(hasUserTz).toBe(true);
});

it('should parse single timezone from URL parameters', () => {
window.location.search = '?timezones=America/New_York';
timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

expect(timezones.length).toBe(1);
expect(timezones[0].iana).toBe('America/New_York');
});

it('should parse multiple timezones from URL parameters', () => {
window.location.search = '?timezones=America/New_York,Europe/London,Asia/Tokyo';
timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

expect(timezones.length).toBe(3);
expect(timezones.map(tz => tz.iana)).toEqual([
'America/New_York',
'Europe/London',
'Asia/Tokyo'
]);
});

it('should handle URL-encoded timezone parameters', () => {
window.location.search = '?timezones=America%2FNew_York%2CEurope%2FLondon';
timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

expect(timezones.length).toBe(2);
expect(timezones.map(tz => tz.iana)).toEqual([
'America/New_York',
'Europe/London'
]);
});

it('should ignore invalid timezone identifiers', () => {
window.location.search = '?timezones=America/New_York,Invalid/Timezone,Europe/London';
timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

// Should only include valid timezones
expect(timezones.length).toBe(2);
expect(timezones.map(tz => tz.iana)).toEqual([
'America/New_York',
'Europe/London'
]);
});

it('should fallback to default timezones when all URL timezones are invalid', () => {
window.location.search = '?timezones=Invalid/Timezone1,Invalid/Timezone2';
timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

// Should fallback to default behavior (multiple default timezones)
expect(timezones.length).toBeGreaterThan(0);
});
});

describe('URL Parameter Updates', () => {
beforeEach(() => {
timelineManager = new TimelineManager();
});

it('should update URL when timezone is added', () => {
const newTimezone = {
name: 'Tokyo',
offset: 9,
displayName: 'Japan Standard Time',
iana: 'Asia/Tokyo',
cityName: 'Tokyo',
abbreviation: 'JST'
};

timelineManager.addTimezone(newTimezone);

expect(history.replaceState).toHaveBeenCalled();

// Get the URL that was passed to replaceState
const calls = vi.mocked(history.replaceState).mock.calls;
const lastCall = calls[calls.length - 1];
const url = lastCall[2];

expect(url).toContain('timezones=');
expect(url).toContain('Asia%2FTokyo'); // URL-encoded version
});

it('should update URL when timezone is removed', () => {
// First add a timezone to have something to remove
const testTimezone = {
name: 'Tokyo',
offset: 9,
displayName: 'Japan Standard Time',
iana: 'Asia/Tokyo',
cityName: 'Tokyo',
abbreviation: 'JST'
};

timelineManager.addTimezone(testTimezone);
vi.clearAllMocks(); // Clear previous calls

timelineManager.removeTimezone(testTimezone);

expect(history.replaceState).toHaveBeenCalled();

// Get the URL that was passed to replaceState
const calls = vi.mocked(history.replaceState).mock.calls;
const lastCall = calls[calls.length - 1];
const url = lastCall[2];

// Asia/Tokyo should no longer be in the URL
expect(url).not.toContain('Asia/Tokyo');
});

it('should preserve other URL parameters when updating timezones', () => {
window.location.search = '?theme=forest-harmony&mode=light';
window.location.href = 'http://localhost:3000/?theme=forest-harmony&mode=light';

// Create new manager to pick up URL params
timelineManager = new TimelineManager();

const newTimezone = {
name: 'Sydney',
offset: 10,
displayName: 'Australian Eastern Standard Time',
iana: 'Australia/Sydney',
cityName: 'Sydney',
abbreviation: 'AEST'
};

timelineManager.addTimezone(newTimezone);

// Get the URL that was passed to replaceState
const calls = vi.mocked(history.replaceState).mock.calls;
const lastCall = calls[calls.length - 1];
const url = lastCall[2];

expect(url).toContain('theme=forest-harmony');
expect(url).toContain('mode=light');
expect(url).toContain('timezones=');
expect(url).toContain('Australia%2FSydney'); // URL-encoded version
});

it('should remove timezones parameter when no timezones are selected', () => {
// Start with a timezone in URL
window.location.search = '?timezones=Asia/Tokyo';
timelineManager = new TimelineManager();

// Remove all timezones
const timezones = timelineManager.getSelectedTimezones();
timezones.forEach(tz => timelineManager.removeTimezone(tz));

// Should have updated URL to remove timezones parameter
const calls = vi.mocked(history.replaceState).mock.calls;
const lastCall = calls[calls.length - 1];
const url = lastCall[2];

expect(url).not.toContain('timezones=');
});
});

describe('Integration with Settings', () => {
it('should work alongside existing settings URL parameters', () => {
window.location.search = '?theme=neon-cyber&mode=light&timeFormat=24h&timezones=America/New_York,Europe/London';

timelineManager = new TimelineManager();
const timezones = timelineManager.getSelectedTimezones();

expect(timezones.length).toBe(2);
expect(timezones.map(tz => tz.iana)).toEqual([
'America/New_York',
'Europe/London'
]);

// Settings should still work
const urlParams = new URLSearchParams(window.location.search);
expect(urlParams.get('theme')).toBe('neon-cyber');
expect(urlParams.get('mode')).toBe('light');
expect(urlParams.get('timeFormat')).toBe('24h');
});
});
});