Skip to content

Commit 3be958f

Browse files
Improve timezone inference and documentation (#13)
1 parent 9df69f5 commit 3be958f

2 files changed

Lines changed: 284 additions & 7 deletions

File tree

what-time-is-it-for-me.docs.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,19 @@
1-
This tool lets you pick a date, time, and source timezone to generate a shareable link. Opening that link shows how the chosen moment translates into the visitor's current timezone, making it simple to coordinate across regions.
1+
# What time is it for me?
2+
3+
This tool helps compare a chosen moment across timezones and share it with others.
4+
5+
## Timezone inference
6+
- On load, the app first attempts to detect the visitor's timezone via their IP address using the public `worldtimeapi.org` service.
7+
- If the IP lookup fails, it falls back to the browser's reported timezone.
8+
- The detected value is shown near the top of the page as “Your inferred timezone: <zone>”.
9+
- The inferred timezone is used for the fixed comparison row and to preselect the timezone dropdown unless the visitor picks a different zone manually or via URL parameters.
10+
11+
## Comparison table
12+
- Selecting a date, time, and timezone fills a comparison table.
13+
- The table always includes the inferred local timezone plus any custom rows the visitor adds.
14+
- Custom rows can be added with the “Add timezone” button and removed individually.
15+
- Each row displays the converted time alongside its UTC offset.
16+
17+
## Sharing
18+
- The “Copy link with this moment” button builds a URL with query parameters for the selected date, time, and timezone.
19+
- The preview field shows the URL that will be copied so it can be shared manually if needed.

what-time-is-it-for-me.html

Lines changed: 265 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,72 @@
3939
flex-direction: column;
4040
}
4141

42+
.comparison-table {
43+
margin-top: 1rem;
44+
}
45+
46+
.comparison-table[hidden] {
47+
display: none;
48+
}
49+
50+
.comparison-table table {
51+
width: 100%;
52+
border-collapse: collapse;
53+
}
54+
55+
.comparison-table th,
56+
.comparison-table td {
57+
padding: 0.75rem 0.5rem;
58+
border-bottom: 1px solid var(--surface-border, rgba(0, 0, 0, 0.08));
59+
vertical-align: top;
60+
}
61+
62+
.comparison-table th {
63+
text-align: left;
64+
}
65+
66+
.comparison-label {
67+
font-weight: 600;
68+
}
69+
70+
.comparison-zone {
71+
font-size: 0.9rem;
72+
color: var(--text-muted);
73+
margin-top: 0.25rem;
74+
word-break: break-word;
75+
}
76+
77+
.comparison-select-wrapper select {
78+
width: 100%;
79+
}
80+
81+
.comparison-controls {
82+
margin-top: 0.75rem;
83+
}
84+
85+
.comparison-actions-header {
86+
width: 1%;
87+
}
88+
89+
.comparison-actions-cell {
90+
text-align: right;
91+
}
92+
93+
.link-button {
94+
background: none;
95+
border: none;
96+
color: var(--link-color, var(--accent-color, #0051ff));
97+
cursor: pointer;
98+
padding: 0;
99+
font: inherit;
100+
text-decoration: underline;
101+
}
102+
103+
.link-button:hover,
104+
.link-button:focus {
105+
text-decoration: none;
106+
}
107+
42108
@media (max-width: 720px) {
43109
body {
44110
padding: 20px 16px 40px;
@@ -55,6 +121,9 @@ <h1>What time is it for me?</h1>
55121
</header>
56122

57123
<main>
124+
<section class="surface tool-card" aria-live="polite" aria-label="Inferred timezone">
125+
<p>Your inferred timezone: <span id="inferred-timezone-value">Detecting…</span></p>
126+
</section>
58127
<section class="surface tool-card" aria-labelledby="selection-heading">
59128
<h2 id="selection-heading">Pick a moment</h2>
60129
<form id="moment-form">
@@ -78,6 +147,21 @@ <h2 id="selection-heading">Pick a moment</h2>
78147
<h2 id="comparison-heading">Comparison</h2>
79148
<p id="comparison-output" class="lead">Choose a date, time, and timezone to see how it translates to your
80149
current timezone.</p>
150+
<div id="comparison-table" class="comparison-table" hidden>
151+
<table>
152+
<thead>
153+
<tr>
154+
<th scope="col">Timezone</th>
155+
<th scope="col">Local time</th>
156+
<th scope="col" class="comparison-actions-header" aria-label="Actions"></th>
157+
</tr>
158+
</thead>
159+
<tbody id="comparison-rows"></tbody>
160+
</table>
161+
<div class="comparison-controls">
162+
<button type="button" id="add-comparison-row">Add timezone</button>
163+
</div>
164+
</div>
81165
</section>
82166

83167
<section class="surface tool-card" aria-labelledby="share-heading">
@@ -99,12 +183,20 @@ <h2 id="share-heading">Shareable link</h2>
99183
const datetimeInput = document.getElementById('datetime-input');
100184
const timezoneSelect = document.getElementById('timezone-select');
101185
const comparisonOutput = document.getElementById('comparison-output');
186+
const comparisonTable = document.getElementById('comparison-table');
187+
const comparisonRowsContainer = document.getElementById('comparison-rows');
188+
const addComparisonRowButton = document.getElementById('add-comparison-row');
102189
const copyButton = document.getElementById('copy-link-button');
103190
const copyStatus = document.getElementById('copy-status');
104191
const shareableUrlInput = document.getElementById('shareable-url');
192+
const inferredTimezoneValue = document.getElementById('inferred-timezone-value');
105193

106194
const resolvedZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
107-
const localZone = resolvedZone || DateTime.local().zoneName || 'UTC';
195+
const fallbackZone = resolvedZone || DateTime.local().zoneName || 'UTC';
196+
let localZone = fallbackZone;
197+
let localRowEntry = null;
198+
let timezoneManuallySet = false;
199+
const comparisonRows = [];
108200

109201
function ensureOptionForZone(zone) {
110202
const exists = Array.from(timezoneSelect.options).some(option => option.value === zone);
@@ -144,6 +236,126 @@ <h2 id="share-heading">Shareable link</h2>
144236
ensureOptionForZone(localZone);
145237
}
146238

239+
function createTimezoneSelectElement() {
240+
const select = document.createElement('select');
241+
select.className = 'comparison-timezone-select';
242+
select.innerHTML = timezoneSelect.innerHTML;
243+
return select;
244+
}
245+
246+
function updateInferredTimezoneDisplay(zone) {
247+
inferredTimezoneValue.textContent = zone || 'Unavailable';
248+
}
249+
250+
function addLocalComparisonRow() {
251+
const row = document.createElement('tr');
252+
row.className = 'comparison-row comparison-row-fixed';
253+
row.dataset.rowType = 'local';
254+
255+
const labelCell = document.createElement('th');
256+
labelCell.scope = 'row';
257+
labelCell.innerHTML = `<div class="comparison-label">Your timezone</div><div class="comparison-zone">${localZone}</div>`;
258+
259+
const timeCell = document.createElement('td');
260+
timeCell.className = 'comparison-time';
261+
262+
const actionCell = document.createElement('td');
263+
actionCell.className = 'comparison-actions-cell';
264+
265+
row.append(labelCell, timeCell, actionCell);
266+
comparisonRowsContainer.append(row);
267+
268+
localRowEntry = {
269+
getZone: () => localZone,
270+
timeCell,
271+
updateLabel(zone) {
272+
labelCell.innerHTML = `<div class="comparison-label">Your timezone</div><div class="comparison-zone">${zone}</div>`;
273+
}
274+
};
275+
276+
comparisonRows.push(localRowEntry);
277+
}
278+
279+
function addCustomComparisonRow(initialZone) {
280+
ensureOptionForZone(initialZone);
281+
const row = document.createElement('tr');
282+
row.className = 'comparison-row comparison-row-custom';
283+
284+
const labelCell = document.createElement('th');
285+
labelCell.scope = 'row';
286+
const select = createTimezoneSelectElement();
287+
select.value = initialZone;
288+
if (initialZone && select.value !== initialZone) {
289+
const option = document.createElement('option');
290+
option.value = initialZone;
291+
option.textContent = initialZone;
292+
select.append(option);
293+
select.value = initialZone;
294+
}
295+
const selectWrapper = document.createElement('div');
296+
selectWrapper.className = 'comparison-select-wrapper';
297+
selectWrapper.append(select);
298+
labelCell.append(selectWrapper);
299+
300+
const timeCell = document.createElement('td');
301+
timeCell.className = 'comparison-time';
302+
303+
const actionCell = document.createElement('td');
304+
actionCell.className = 'comparison-actions-cell';
305+
const removeButton = document.createElement('button');
306+
removeButton.type = 'button';
307+
removeButton.className = 'link-button';
308+
removeButton.textContent = 'Remove';
309+
removeButton.addEventListener('click', () => {
310+
row.remove();
311+
const index = comparisonRows.findIndex(entry => entry.row === row);
312+
if (index >= 0) {
313+
comparisonRows.splice(index, 1);
314+
}
315+
updateComparison(true);
316+
});
317+
actionCell.append(removeButton);
318+
319+
row.append(labelCell, timeCell, actionCell);
320+
comparisonRowsContainer.append(row);
321+
322+
const entry = {
323+
row,
324+
getZone: () => select.value,
325+
timeCell
326+
};
327+
comparisonRows.push(entry);
328+
329+
select.addEventListener('change', () => updateComparison());
330+
return row;
331+
}
332+
333+
function updateLocalZone(zone, { updateSelect = true, updateComparison = true } = {}) {
334+
if (!zone) {
335+
updateInferredTimezoneDisplay('Unavailable');
336+
return;
337+
}
338+
339+
const previousZone = localZone;
340+
const zoneChanged = zone !== localZone;
341+
localZone = zone;
342+
343+
if (localRowEntry) {
344+
localRowEntry.updateLabel(zone);
345+
}
346+
347+
updateInferredTimezoneDisplay(zone);
348+
ensureOptionForZone(zone);
349+
350+
if (updateSelect && !timezoneManuallySet && (timezoneSelect.value === previousZone || !timezoneSelect.value)) {
351+
timezoneSelect.value = zone;
352+
}
353+
354+
if (updateComparison && (zoneChanged || !comparisonTable.hidden)) {
355+
updateComparison();
356+
}
357+
}
358+
147359
function formatOffset(minutes) {
148360
const sign = minutes >= 0 ? '+' : '-';
149361
const absolute = Math.abs(minutes);
@@ -189,6 +401,7 @@ <h2 id="share-heading">Shareable link</h2>
189401

190402
if (!datetimeValue || !selectedZone) {
191403
comparisonOutput.textContent = 'Choose a date, time, and timezone to see how it translates to your current timezone.';
404+
comparisonTable.hidden = true;
192405
shareableUrlInput.value = location.href;
193406
return;
194407
}
@@ -197,18 +410,32 @@ <h2 id="share-heading">Shareable link</h2>
197410

198411
if (!momentInSelectedZone.isValid) {
199412
comparisonOutput.textContent = 'The selected date or timezone could not be parsed. Please check your inputs.';
413+
comparisonTable.hidden = true;
200414
shareableUrlInput.value = location.href;
201415
return;
202416
}
203417

204-
const momentInLocalZone = momentInSelectedZone.setZone(localZone);
205418
const selectedOffset = formatOffset(momentInSelectedZone.offset);
206-
const localOffset = formatOffset(momentInLocalZone.offset);
207419

208420
const sourceText = `${formatDateTime(momentInSelectedZone)} in ${momentInSelectedZone.zoneName} (${selectedOffset})`;
209-
const localText = `${formatDateTime(momentInLocalZone)} in ${localZone} (${localOffset})`;
421+
comparisonOutput.textContent = `${sourceText}. Here's how that moment translates across the selected timezones:`;
422+
comparisonTable.hidden = false;
423+
424+
for (const entry of comparisonRows) {
425+
const zoneName = entry.getZone();
426+
if (!zoneName) {
427+
entry.timeCell.textContent = 'Pick a timezone to see the converted time.';
428+
continue;
429+
}
210430

211-
comparisonOutput.textContent = `${sourceText} will be ${localText}.`;
431+
const zoned = momentInSelectedZone.setZone(zoneName);
432+
if (!zoned.isValid) {
433+
entry.timeCell.textContent = 'Unable to resolve that timezone.';
434+
continue;
435+
}
436+
437+
entry.timeCell.textContent = `${formatDateTime(zoned)} (${formatOffset(zoned.offset)})`;
438+
}
212439

213440
const shareUrl = buildShareableUrl(momentInSelectedZone);
214441
shareableUrlInput.value = shareUrl;
@@ -275,24 +502,56 @@ <h2 id="share-heading">Shareable link</h2>
275502
if (timezoneParam) {
276503
ensureOptionForZone(timezoneParam);
277504
timezoneSelect.value = timezoneParam;
505+
timezoneManuallySet = true;
278506
}
279507

280508
if (datetimeInput.value && timezoneSelect.value) {
281509
updateComparison(true);
282510
}
283511
}
284512

513+
async function detectTimezoneFromIp() {
514+
let detectedZone = null;
515+
try {
516+
const response = await fetch('https://worldtimeapi.org/api/ip');
517+
if (response.ok) {
518+
const data = await response.json();
519+
if (data && typeof data.timezone === 'string' && data.timezone) {
520+
detectedZone = data.timezone;
521+
}
522+
}
523+
} catch (error) {
524+
// Ignore network or parsing issues and fall back to browser detection
525+
}
526+
527+
if (detectedZone) {
528+
updateLocalZone(detectedZone);
529+
} else {
530+
updateLocalZone(fallbackZone, { updateSelect: false, updateComparison: false });
531+
}
532+
}
533+
285534
populateTimezones();
535+
addLocalComparisonRow();
286536
timezoneSelect.value = localZone;
287537
datetimeInput.value = DateTime.now().toISO({ suppressSeconds: true, suppressMilliseconds: true }).slice(0, 16);
288538
updateComparison(true);
289539

290540
applyQueryParameters();
541+
detectTimezoneFromIp();
291542

292543
datetimeInput.addEventListener('input', () => updateComparison());
293544
datetimeInput.addEventListener('change', () => updateComparison());
294-
timezoneSelect.addEventListener('change', () => updateComparison());
545+
timezoneSelect.addEventListener('change', () => {
546+
timezoneManuallySet = true;
547+
updateComparison();
548+
});
295549
copyButton.addEventListener('click', copyLink);
550+
addComparisonRowButton.addEventListener('click', () => {
551+
const defaultZone = timezoneSelect.value || localZone;
552+
addCustomComparisonRow(defaultZone);
553+
updateComparison();
554+
});
296555
})();
297556
</script>
298557
</body>

0 commit comments

Comments
 (0)