@jjlmoya/utils-chrono 1.11.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +6 -0
  3. package/src/entries.ts +10 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tool/gmt-world-timer/bibliography.astro +11 -0
  7. package/src/tool/gmt-world-timer/bibliography.ts +7 -0
  8. package/src/tool/gmt-world-timer/client.ts +250 -0
  9. package/src/tool/gmt-world-timer/component.astro +13 -0
  10. package/src/tool/gmt-world-timer/components/GmtPanel.astro +18 -0
  11. package/src/tool/gmt-world-timer/entry.ts +34 -0
  12. package/src/tool/gmt-world-timer/gmt-world-timer.css +239 -0
  13. package/src/tool/gmt-world-timer/helpers.ts +28 -0
  14. package/src/tool/gmt-world-timer/i18n/de.ts +72 -0
  15. package/src/tool/gmt-world-timer/i18n/en.ts +72 -0
  16. package/src/tool/gmt-world-timer/i18n/es.ts +72 -0
  17. package/src/tool/gmt-world-timer/i18n/fr.ts +72 -0
  18. package/src/tool/gmt-world-timer/i18n/id.ts +72 -0
  19. package/src/tool/gmt-world-timer/i18n/it.ts +72 -0
  20. package/src/tool/gmt-world-timer/i18n/ja.ts +72 -0
  21. package/src/tool/gmt-world-timer/i18n/ko.ts +72 -0
  22. package/src/tool/gmt-world-timer/i18n/nl.ts +72 -0
  23. package/src/tool/gmt-world-timer/i18n/pl.ts +72 -0
  24. package/src/tool/gmt-world-timer/i18n/pt.ts +72 -0
  25. package/src/tool/gmt-world-timer/i18n/ru.ts +72 -0
  26. package/src/tool/gmt-world-timer/i18n/sv.ts +72 -0
  27. package/src/tool/gmt-world-timer/i18n/tr.ts +72 -0
  28. package/src/tool/gmt-world-timer/i18n/zh.ts +72 -0
  29. package/src/tool/gmt-world-timer/index.ts +11 -0
  30. package/src/tool/gmt-world-timer/seo.astro +11 -0
  31. package/src/tool/perpetual-calendar/bibliography.astro +16 -0
  32. package/src/tool/perpetual-calendar/bibliography.ts +16 -0
  33. package/src/tool/perpetual-calendar/calendar.ts +24 -0
  34. package/src/tool/perpetual-calendar/client.ts +98 -0
  35. package/src/tool/perpetual-calendar/component.astro +17 -0
  36. package/src/tool/perpetual-calendar/components/CalendarPanel.astro +49 -0
  37. package/src/tool/perpetual-calendar/dial.ts +176 -0
  38. package/src/tool/perpetual-calendar/entry.ts +48 -0
  39. package/src/tool/perpetual-calendar/helpers.ts +49 -0
  40. package/src/tool/perpetual-calendar/i18n/de.ts +85 -0
  41. package/src/tool/perpetual-calendar/i18n/en.ts +102 -0
  42. package/src/tool/perpetual-calendar/i18n/es.ts +85 -0
  43. package/src/tool/perpetual-calendar/i18n/fr.ts +85 -0
  44. package/src/tool/perpetual-calendar/i18n/id.ts +85 -0
  45. package/src/tool/perpetual-calendar/i18n/it.ts +85 -0
  46. package/src/tool/perpetual-calendar/i18n/ja.ts +85 -0
  47. package/src/tool/perpetual-calendar/i18n/ko.ts +85 -0
  48. package/src/tool/perpetual-calendar/i18n/nl.ts +85 -0
  49. package/src/tool/perpetual-calendar/i18n/pl.ts +85 -0
  50. package/src/tool/perpetual-calendar/i18n/pt.ts +85 -0
  51. package/src/tool/perpetual-calendar/i18n/ru.ts +85 -0
  52. package/src/tool/perpetual-calendar/i18n/sv.ts +85 -0
  53. package/src/tool/perpetual-calendar/i18n/tr.ts +85 -0
  54. package/src/tool/perpetual-calendar/i18n/zh.ts +85 -0
  55. package/src/tool/perpetual-calendar/index.ts +11 -0
  56. package/src/tool/perpetual-calendar/perpetual-calendar.css +181 -0
  57. package/src/tool/perpetual-calendar/seo.astro +16 -0
  58. package/src/tool/perpetual-calendar/state.ts +26 -0
  59. package/src/tool/tourbillon-visualizer/bibliography.astro +11 -0
  60. package/src/tool/tourbillon-visualizer/bibliography.ts +7 -0
  61. package/src/tool/tourbillon-visualizer/client.ts +122 -0
  62. package/src/tool/tourbillon-visualizer/component.astro +126 -0
  63. package/src/tool/tourbillon-visualizer/components/TourbillonPanel.astro +66 -0
  64. package/src/tool/tourbillon-visualizer/entry.ts +51 -0
  65. package/src/tool/tourbillon-visualizer/helpers.ts +35 -0
  66. package/src/tool/tourbillon-visualizer/i18n/de.ts +96 -0
  67. package/src/tool/tourbillon-visualizer/i18n/en.ts +96 -0
  68. package/src/tool/tourbillon-visualizer/i18n/es.ts +96 -0
  69. package/src/tool/tourbillon-visualizer/i18n/fr.ts +96 -0
  70. package/src/tool/tourbillon-visualizer/i18n/id.ts +96 -0
  71. package/src/tool/tourbillon-visualizer/i18n/it.ts +96 -0
  72. package/src/tool/tourbillon-visualizer/i18n/ja.ts +96 -0
  73. package/src/tool/tourbillon-visualizer/i18n/ko.ts +96 -0
  74. package/src/tool/tourbillon-visualizer/i18n/nl.ts +96 -0
  75. package/src/tool/tourbillon-visualizer/i18n/pl.ts +96 -0
  76. package/src/tool/tourbillon-visualizer/i18n/pt.ts +96 -0
  77. package/src/tool/tourbillon-visualizer/i18n/ru.ts +96 -0
  78. package/src/tool/tourbillon-visualizer/i18n/sv.ts +96 -0
  79. package/src/tool/tourbillon-visualizer/i18n/tr.ts +96 -0
  80. package/src/tool/tourbillon-visualizer/i18n/zh.ts +96 -0
  81. package/src/tool/tourbillon-visualizer/index.ts +11 -0
  82. package/src/tool/tourbillon-visualizer/renderer/base.ts +78 -0
  83. package/src/tool/tourbillon-visualizer/renderer/cage.ts +115 -0
  84. package/src/tool/tourbillon-visualizer/renderer/esc.ts +160 -0
  85. package/src/tool/tourbillon-visualizer/seo.astro +11 -0
  86. package/src/tool/tourbillon-visualizer/state.ts +21 -0
  87. package/src/tool/tourbillon-visualizer/tourbillon.ts +9 -0
  88. package/src/tools.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-chrono",
3
- "version": "1.11.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -16,6 +16,9 @@ import { strapLengthCalculator } from '../tool/strap-length-calculator/entry';
16
16
  import { telemeterCalculator } from '../tool/telemeter-calculator/entry';
17
17
  import { siderealTimeTracker } from '../tool/sidereal-time-tracker/entry';
18
18
  import { gearTrainExplorer } from '../tool/gear-train-explorer/entry';
19
+ import { perpetualCalendar } from '../tool/perpetual-calendar/entry';
20
+ import { tourbillonVisualizer } from '../tool/tourbillon-visualizer/entry';
21
+ import { gmtWorldTimer } from '../tool/gmt-world-timer/entry';
19
22
 
20
23
  export const chronoCategory: ChronoCategoryEntry = {
21
24
  icon: 'mdi:clock-outline',
@@ -37,6 +40,9 @@ export const chronoCategory: ChronoCategoryEntry = {
37
40
  telemeterCalculator,
38
41
  siderealTimeTracker,
39
42
  gearTrainExplorer,
43
+ perpetualCalendar,
44
+ tourbillonVisualizer,
45
+ gmtWorldTimer,
40
46
  ],
41
47
  i18n: {
42
48
  de: () => import('./i18n/de').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -34,6 +34,12 @@ export { siderealTimeTracker } from './tool/sidereal-time-tracker/entry';
34
34
  export type { SiderealTimeTrackerUI, SiderealTimeTrackerLocaleContent } from './tool/sidereal-time-tracker/entry';
35
35
  export { gearTrainExplorer } from './tool/gear-train-explorer/entry';
36
36
  export type { GearTrainExplorerUI, GearTrainExplorerLocaleContent } from './tool/gear-train-explorer/entry';
37
+ export { perpetualCalendar } from './tool/perpetual-calendar/entry';
38
+ export type { PerpetualCalendarUI, PerpetualCalendarLocaleContent } from './tool/perpetual-calendar/entry';
39
+ export { tourbillonVisualizer } from './tool/tourbillon-visualizer/entry';
40
+ export type { TourbillonUI, TourbillonLocaleContent } from './tool/tourbillon-visualizer/entry';
41
+ export { gmtWorldTimer } from './tool/gmt-world-timer/entry';
42
+ export type { GMTWorldTimerUI, GMTWorldTimerLocaleContent } from './tool/gmt-world-timer/entry';
37
43
  export { chronoCategory } from './category';
38
44
 
39
45
  import { watchAccuracyTracker } from './tool/watch-accuracy-tracker/entry';
@@ -54,6 +60,9 @@ import { strapLengthCalculator } from './tool/strap-length-calculator/entry';
54
60
  import { telemeterCalculator } from './tool/telemeter-calculator/entry';
55
61
  import { siderealTimeTracker } from './tool/sidereal-time-tracker/entry';
56
62
  import { gearTrainExplorer } from './tool/gear-train-explorer/entry';
63
+ import { perpetualCalendar } from './tool/perpetual-calendar/entry';
64
+ import { tourbillonVisualizer } from './tool/tourbillon-visualizer/entry';
65
+ import { gmtWorldTimer } from './tool/gmt-world-timer/entry';
57
66
 
58
- export const ALL_ENTRIES = [watchAccuracyTracker, wristPresenceCalculator, demagnetizingTimer, watchSavingsPlanner, crownReferenceGuide, powerReserveEstimator, beatRateConverter, waterResistanceConverter, strapTaperCalculator, watchSizeComparator, lumeColorSimulator, moonPhaseVisualizer, tachymeterCalculator, serviceIntervalTracker, strapLengthCalculator, telemeterCalculator, siderealTimeTracker, gearTrainExplorer];
67
+ export const ALL_ENTRIES = [watchAccuracyTracker, wristPresenceCalculator, demagnetizingTimer, watchSavingsPlanner, crownReferenceGuide, powerReserveEstimator, beatRateConverter, waterResistanceConverter, strapTaperCalculator, watchSizeComparator, lumeColorSimulator, moonPhaseVisualizer, tachymeterCalculator, serviceIntervalTracker, strapLengthCalculator, telemeterCalculator, siderealTimeTracker, gearTrainExplorer, perpetualCalendar, tourbillonVisualizer, gmtWorldTimer];
59
68
 
@@ -22,7 +22,7 @@ describe('Locale Completeness Validation', () => {
22
22
  });
23
23
 
24
24
  it('all tools registered', () => {
25
- expect(ALL_TOOLS.length).toBe(18);
25
+ expect(ALL_TOOLS.length).toBe(21);
26
26
  });
27
27
 
28
28
  });
@@ -5,7 +5,7 @@ import { chronoCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(18);
8
+ expect(ALL_TOOLS.length).toBe(21);
9
9
  });
10
10
 
11
11
 
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { gmtWorldTimer } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+ interface Props { locale?: KnownLocale; }
6
+ const { locale = 'en' } = Astro.props as Props;
7
+ const loader = gmtWorldTimer.i18n[locale] || gmtWorldTimer.i18n.en;
8
+ const content = await loader?.();
9
+ if (!content) return null;
10
+ ---
11
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,7 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'Time Zone Map - TimeAndDate', url: 'https://www.timeanddate.com/time/map/' },
5
+ { name: 'IANA Time Zone Database', url: 'https://www.iana.org/time-zones' },
6
+ { name: 'UTC - Coordinated Universal Time', url: 'https://www.timeanddate.com/time/utc.html' },
7
+ ];
@@ -0,0 +1,250 @@
1
+ const STORAGE_KEY = 'gmt-world-timer-zones';
2
+
3
+ interface ZoneItem {
4
+ id: string;
5
+ label: string;
6
+ }
7
+
8
+ const DEFAULT_ZONES: ZoneItem[] = [
9
+ { id: 'America/New_York', label: 'New York' },
10
+ { id: 'Europe/London', label: 'London' },
11
+ { id: 'Europe/Paris', label: 'Paris' },
12
+ { id: 'Asia/Tokyo', label: 'Tokyo' },
13
+ ];
14
+
15
+ const ALL_ZONES: ZoneItem[] = [
16
+ { id: 'Pacific/Midway', label: 'Midway' },
17
+ { id: 'Pacific/Honolulu', label: 'Honolulu' },
18
+ { id: 'America/Anchorage', label: 'Anchorage' },
19
+ { id: 'America/Los_Angeles', label: 'Los Angeles' },
20
+ { id: 'America/Denver', label: 'Denver' },
21
+ { id: 'America/Chicago', label: 'Chicago' },
22
+ { id: 'America/New_York', label: 'New York' },
23
+ { id: 'America/Halifax', label: 'Halifax' },
24
+ { id: 'America/Argentina/Buenos_Aires', label: 'Buenos Aires' },
25
+ { id: 'Atlantic/Azores', label: 'Azores' },
26
+ { id: 'Europe/London', label: 'London' },
27
+ { id: 'Europe/Paris', label: 'Paris' },
28
+ { id: 'Europe/Berlin', label: 'Berlin' },
29
+ { id: 'Europe/Madrid', label: 'Madrid' },
30
+ { id: 'Europe/Rome', label: 'Rome' },
31
+ { id: 'Europe/Athens', label: 'Athens' },
32
+ { id: 'Europe/Moscow', label: 'Moscow' },
33
+ { id: 'Asia/Dubai', label: 'Dubai' },
34
+ { id: 'Asia/Karachi', label: 'Karachi' },
35
+ { id: 'Asia/Kolkata', label: 'Kolkata' },
36
+ { id: 'Asia/Dhaka', label: 'Dhaka' },
37
+ { id: 'Asia/Bangkok', label: 'Bangkok' },
38
+ { id: 'Asia/Shanghai', label: 'Shanghai' },
39
+ { id: 'Asia/Singapore', label: 'Singapore' },
40
+ { id: 'Asia/Tokyo', label: 'Tokyo' },
41
+ { id: 'Asia/Seoul', label: 'Seoul' },
42
+ { id: 'Australia/Sydney', label: 'Sydney' },
43
+ { id: 'Pacific/Noumea', label: 'Noumea' },
44
+ { id: 'Pacific/Auckland', label: 'Auckland' },
45
+ ];
46
+
47
+ let zones: ZoneItem[] = [];
48
+
49
+ function loadZones(): void {
50
+ try {
51
+ const raw = localStorage.getItem(STORAGE_KEY);
52
+ if (raw) {
53
+ const parsed: ZoneItem[] = JSON.parse(raw);
54
+ zones = parsed.filter((z) => ALL_ZONES.some((a) => a.id === z.id));
55
+ }
56
+ } catch {
57
+ zones = [];
58
+ }
59
+ if (zones.length === 0) zones = [...DEFAULT_ZONES];
60
+ }
61
+
62
+ function saveZones(): void {
63
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(zones));
64
+ }
65
+
66
+ function getOffsetLabel(tz: string): string {
67
+ try {
68
+ const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts();
69
+ const off = parts.find((p) => p.type === 'timeZoneName');
70
+ return off ? off.value : '';
71
+ } catch {
72
+ return '';
73
+ }
74
+ }
75
+
76
+ function getZoneDate(tz: string): Date {
77
+ const now = new Date();
78
+ const localMs = now.getMilliseconds();
79
+ const localOff = now.getTimezoneOffset();
80
+ const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(now);
81
+ const offPart = parts.find((p) => p.type === 'timeZoneName');
82
+ if (!offPart || !offPart.value) return now;
83
+ const m = offPart.value.match(/GMT([+-]\d+)(?::(\d+))?/);
84
+ if (!m) return now;
85
+ const sign = m[1].startsWith('+') ? 1 : -1;
86
+ const h = parseInt(m[1].slice(1));
87
+ const min = m[2] ? parseInt(m[2]) : 0;
88
+ const tzOff = sign * (h * 60 + min);
89
+ const utcMs = now.getTime() + localOff * 60000;
90
+ return new Date(utcMs + tzOff * 60000 + localMs * 0);
91
+ }
92
+
93
+ function makeClockSVG(tz: string): string {
94
+ const d = getZoneDate(tz);
95
+ const h = d.getHours() % 12;
96
+ const m = d.getMinutes();
97
+ const s = d.getSeconds();
98
+ const ms = d.getMilliseconds();
99
+ const hA = (h / 12) * 360 + (m / 60) * 30;
100
+ const mA = (m / 60) * 360 + (s / 60) * 6;
101
+ const sA = (s / 60) * 360 + (ms / 1000) * 6;
102
+ const tick = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((a) => {
103
+ const rad = (a - 90) * (Math.PI / 180);
104
+ const r1 = a % 90 === 0 ? 34 : 37;
105
+ const x1 = 50 + r1 * Math.cos(rad);
106
+ const y1 = 50 + r1 * Math.sin(rad);
107
+ const x2 = 50 + 43 * Math.cos(rad);
108
+ const y2 = 50 + 43 * Math.sin(rad);
109
+ const w = a % 90 === 0 ? '1.8' : '1';
110
+ return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="var(--clock-marks)" stroke-width="${w}" stroke-linecap="round"/>`;
111
+ }).join('');
112
+ return `<svg class="gmt-card-clock" viewBox="0 0 100 100">
113
+ <circle cx="50" cy="50" r="46" fill="var(--clock-face)" stroke="var(--clock-bezel)" stroke-width="1.6"/>
114
+ ${tick}
115
+ <line x1="50" y1="50" x2="50" y2="27" stroke="var(--clock-hour)" stroke-width="3" stroke-linecap="round" transform="rotate(${hA.toFixed(1)},50,50)"/>
116
+ <line x1="50" y1="50" x2="50" y2="14" stroke="var(--clock-min)" stroke-width="1.8" stroke-linecap="round" transform="rotate(${mA.toFixed(1)},50,50)"/>
117
+ <line x1="50" y1="55" x2="50" y2="8" stroke="var(--clock-sec)" stroke-width="1" stroke-linecap="round" transform="rotate(${sA.toFixed(1)},50,50)"/>
118
+ <circle cx="50" cy="50" r="3" fill="var(--clock-hour)"/>
119
+ </svg>`;
120
+ }
121
+
122
+ function getTimeStr(tz: string): string {
123
+ try {
124
+ return new Intl.DateTimeFormat('en', {
125
+ timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
126
+ }).format(new Date());
127
+ } catch {
128
+ return '--:--:--';
129
+ }
130
+ }
131
+
132
+ function renderCard(z: ZoneItem): string {
133
+ const id = z.id.replace(/\//g, '-');
134
+ return `<div class="gmt-card" data-zone="${z.id}" id="card-${id}">
135
+ <button class="gmt-card-remove" data-remove="${z.id}" aria-label="Remove">&#x2715;</button>
136
+ <div class="gmt-card-clock-w" id="clock-${id}">${makeClockSVG(z.id)}</div>
137
+ <div class="gmt-card-info">
138
+ <div class="gmt-card-city">${z.label}</div>
139
+ <div class="gmt-card-time" id="time-${id}">${getTimeStr(z.id)}</div>
140
+ <div class="gmt-card-offset" id="off-${id}">${getOffsetLabel(z.id)}</div>
141
+ </div>
142
+ </div>`;
143
+ }
144
+
145
+ function renderGrid(): void {
146
+ const grid = document.getElementById('gmt-grid')!;
147
+ grid.innerHTML = zones.map(renderCard).join('');
148
+ grid.querySelectorAll('[data-remove]').forEach((btn) => {
149
+ btn.addEventListener('click', () => {
150
+ const id = (btn as HTMLElement).dataset.remove!;
151
+ removeZone(id);
152
+ });
153
+ });
154
+ }
155
+
156
+ function updateCards(): void {
157
+ for (const z of zones) {
158
+ const id = z.id.replace(/\//g, '-');
159
+ const clockEl = document.getElementById(`clock-${id}`);
160
+ const timeEl = document.getElementById(`time-${id}`);
161
+ if (clockEl) clockEl.innerHTML = makeClockSVG(z.id);
162
+ if (timeEl) timeEl.textContent = getTimeStr(z.id);
163
+ }
164
+ requestAnimationFrame(updateCards);
165
+ }
166
+
167
+ function addZone(z: ZoneItem): void {
168
+ if (zones.some((existing) => existing.id === z.id)) return;
169
+ zones.push(z);
170
+ saveZones();
171
+ renderGrid();
172
+ }
173
+
174
+ function removeZone(id: string): void {
175
+ zones = zones.filter((z) => z.id !== id);
176
+ saveZones();
177
+ renderGrid();
178
+ if (zones.length === 0) {
179
+ zones = [...DEFAULT_ZONES];
180
+ saveZones();
181
+ renderGrid();
182
+ }
183
+ }
184
+
185
+ function filterZones(q: string): ZoneItem[] {
186
+ return ALL_ZONES.filter((z) => {
187
+ const l = z.label.toLowerCase();
188
+ const i = z.id.toLowerCase();
189
+ return l.includes(q) || i.includes(q);
190
+ }).slice(0, 10);
191
+ }
192
+
193
+ function renderDropdownItems(dd: HTMLElement, matches: ZoneItem[]): void {
194
+ dd.innerHTML = matches
195
+ .map((z) => {
196
+ const taken = zones.some((existing) => existing.id === z.id);
197
+ return `<div class="gmt-dd-item ${taken ? 'taken' : ''}" data-add="${z.id}">
198
+ <span>${z.label}</span>
199
+ <span class="gmt-dd-offset">${getOffsetLabel(z.id)}</span>
200
+ </div>`;
201
+ })
202
+ .join('');
203
+ dd.querySelectorAll('[data-add]').forEach((el) => {
204
+ el.addEventListener('click', () => {
205
+ const zid = (el as HTMLElement).dataset.add!;
206
+ const zone = ALL_ZONES.find((z) => z.id === zid);
207
+ if (zone) addZone(zone);
208
+ (document.getElementById('gmt-search') as HTMLInputElement).value = '';
209
+ dd.classList.remove('open');
210
+ });
211
+ });
212
+ dd.classList.add('open');
213
+ }
214
+
215
+ function renderDropdown(query: string): void {
216
+ const dd = document.getElementById('gmt-dropdown')!;
217
+ const q = query.toLowerCase().trim();
218
+ if (!q) { dd.classList.remove('open'); return; }
219
+ const matches = filterZones(q);
220
+ if (matches.length === 0) {
221
+ dd.innerHTML = '<div class="gmt-dd-empty">No cities found</div>';
222
+ dd.classList.add('open');
223
+ return;
224
+ }
225
+ renderDropdownItems(dd, matches);
226
+ }
227
+
228
+ function init(): void {
229
+ loadZones();
230
+ renderGrid();
231
+ requestAnimationFrame(updateCards);
232
+
233
+ const search = document.getElementById('gmt-search') as HTMLInputElement;
234
+ const dd = document.getElementById('gmt-dropdown')!;
235
+
236
+ search.addEventListener('input', () => renderDropdown(search.value));
237
+ search.addEventListener('focus', () => {
238
+ if (search.value.trim()) renderDropdown(search.value);
239
+ });
240
+ document.addEventListener('click', (e) => {
241
+ if (!(e.target as HTMLElement).closest('.gmt-search-w')) {
242
+ dd.classList.remove('open');
243
+ }
244
+ });
245
+ search.addEventListener('keydown', (e) => {
246
+ if (e.key === 'Escape') dd.classList.remove('open');
247
+ });
248
+ }
249
+
250
+ init();
@@ -0,0 +1,13 @@
1
+ ---
2
+ import GmtPanel from './components/GmtPanel.astro';
3
+ interface Props { ui: Record<string, string>; }
4
+ const { ui } = Astro.props;
5
+ ---
6
+
7
+ <link href="./gmt-world-timer.css" rel="stylesheet" />
8
+
9
+ <div class="tool-main-card" data-ui={JSON.stringify(ui)}>
10
+ <GmtPanel labels={ui} />
11
+ </div>
12
+
13
+ <script src="./client.ts"></script>
@@ -0,0 +1,18 @@
1
+ ---
2
+ interface Props { labels: Record<string, string>; }
3
+ const { labels } = Astro.props;
4
+ ---
5
+
6
+ <div class="gmt-tool">
7
+ <div class="gmt-search-w">
8
+ <input
9
+ type="text"
10
+ class="gmt-search"
11
+ id="gmt-search"
12
+ placeholder={labels.searchPlaceholder || 'Search city or time zone...'}
13
+ autocomplete="off"
14
+ />
15
+ <div class="gmt-dropdown" id="gmt-dropdown"></div>
16
+ </div>
17
+ <div class="gmt-grid" id="gmt-grid"></div>
18
+ </div>
@@ -0,0 +1,34 @@
1
+ import type { ChronoToolEntry, ToolLocaleContent } from '../../types';
2
+
3
+ export type GMTWorldTimerUI = {
4
+ title: string;
5
+ searchPlaceholder: string;
6
+ addLabel: string;
7
+ removeLabel: string;
8
+ noResults: string;
9
+ yourZones: string;
10
+ };
11
+
12
+ export type GMTWorldTimerLocaleContent = ToolLocaleContent<GMTWorldTimerUI>;
13
+
14
+ export const gmtWorldTimer: ChronoToolEntry<GMTWorldTimerUI> = {
15
+ id: 'gmt-world-timer',
16
+ icons: { bg: 'mdi:web', fg: 'mdi:clock-time-eight' },
17
+ i18n: {
18
+ de: () => import('./i18n/de').then((m) => m.content),
19
+ en: () => import('./i18n/en').then((m) => m.content),
20
+ es: () => import('./i18n/es').then((m) => m.content),
21
+ fr: () => import('./i18n/fr').then((m) => m.content),
22
+ id: () => import('./i18n/id').then((m) => m.content),
23
+ it: () => import('./i18n/it').then((m) => m.content),
24
+ ja: () => import('./i18n/ja').then((m) => m.content),
25
+ ko: () => import('./i18n/ko').then((m) => m.content),
26
+ nl: () => import('./i18n/nl').then((m) => m.content),
27
+ pl: () => import('./i18n/pl').then((m) => m.content),
28
+ pt: () => import('./i18n/pt').then((m) => m.content),
29
+ ru: () => import('./i18n/ru').then((m) => m.content),
30
+ sv: () => import('./i18n/sv').then((m) => m.content),
31
+ tr: () => import('./i18n/tr').then((m) => m.content),
32
+ zh: () => import('./i18n/zh').then((m) => m.content),
33
+ },
34
+ };
@@ -0,0 +1,239 @@
1
+ .theme-dark .gmt-tool {
2
+ --clock-face: color-mix(in srgb, var(--bg-surface) 60%, #000);
3
+ }
4
+
5
+ .gmt-tool {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: 1.5rem;
9
+
10
+ --clock-face: var(--bg-surface);
11
+ --clock-bezel: color-mix(in srgb, var(--accent) 35%, transparent);
12
+ --clock-marks: var(--text-muted);
13
+ --clock-hour: var(--accent);
14
+ --clock-min: var(--text-base);
15
+ --clock-sec: var(--color-error);
16
+ }
17
+
18
+ .gmt-search-w {
19
+ position: relative;
20
+ max-width: 420px;
21
+ }
22
+
23
+ .gmt-search {
24
+ width: 100%;
25
+ padding: 0.8rem 1.1rem;
26
+ border: 1.5px solid var(--border-base);
27
+ border-radius: 0.85rem;
28
+ background: var(--bg-surface);
29
+ color: var(--text-base);
30
+ font-size: 0.9rem;
31
+ outline: none;
32
+ transition: border-color 0.15s ease;
33
+ box-sizing: border-box;
34
+ }
35
+
36
+ .gmt-search:focus {
37
+ border-color: var(--accent);
38
+ }
39
+
40
+ .gmt-search::placeholder {
41
+ color: var(--text-base);
42
+ opacity: 0.3;
43
+ }
44
+
45
+ .gmt-dropdown {
46
+ position: absolute;
47
+ top: 100%;
48
+ left: 0;
49
+ right: 0;
50
+ z-index: 50;
51
+ margin-top: 0.3rem;
52
+ border: 1px solid var(--border-base);
53
+ border-radius: 0.85rem;
54
+ background: var(--bg-surface);
55
+ max-height: 280px;
56
+ overflow-y: auto;
57
+ display: none;
58
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
59
+ }
60
+
61
+ .gmt-dropdown.open {
62
+ display: block;
63
+ }
64
+
65
+ .gmt-dd-item {
66
+ padding: 0.65rem 0.9rem;
67
+ cursor: pointer;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: space-between;
71
+ font-size: 0.85rem;
72
+ color: var(--text-base);
73
+ transition: background 0.1s ease;
74
+ }
75
+
76
+ .gmt-dd-item:first-child {
77
+ border-radius: 0.85rem 0.85rem 0 0;
78
+ }
79
+
80
+ .gmt-dd-item:last-child {
81
+ border-radius: 0 0 0.85rem 0.85rem;
82
+ }
83
+
84
+ .gmt-dd-item:hover {
85
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
86
+ }
87
+
88
+ .gmt-dd-item.taken {
89
+ opacity: 0.3;
90
+ pointer-events: none;
91
+ }
92
+
93
+ .gmt-dd-offset {
94
+ font-size: 0.75rem;
95
+ opacity: 0.5;
96
+ }
97
+
98
+ .gmt-dd-empty {
99
+ padding: 1rem;
100
+ text-align: center;
101
+ font-size: 0.8rem;
102
+ color: var(--text-base);
103
+ opacity: 0.4;
104
+ }
105
+
106
+ .gmt-grid {
107
+ display: grid;
108
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
109
+ gap: 1rem;
110
+ }
111
+
112
+ .gmt-card {
113
+ position: relative;
114
+ display: flex;
115
+ flex-direction: column;
116
+ align-items: center;
117
+ gap: 0.75rem;
118
+ padding: 1.75rem 1rem 1.15rem;
119
+ background: var(--bg-surface);
120
+ border: 1px solid var(--border-base);
121
+ border-radius: 1.25rem;
122
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
123
+ min-height: 300px;
124
+ }
125
+
126
+ .gmt-card:hover {
127
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border-base));
128
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
129
+ }
130
+
131
+ .gmt-card-remove {
132
+ position: absolute;
133
+ top: 0.6rem;
134
+ right: 0.6rem;
135
+ width: 1.6rem;
136
+ height: 1.6rem;
137
+ border: none;
138
+ border-radius: 50%;
139
+ background: color-mix(in srgb, var(--text-base) 8%, transparent);
140
+ color: var(--text-base);
141
+ font-size: 0.85rem;
142
+ line-height: 1;
143
+ cursor: pointer;
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ opacity: 0;
148
+ transition: opacity 0.15s ease;
149
+ }
150
+
151
+ .gmt-card:hover .gmt-card-remove {
152
+ opacity: 0.4;
153
+ }
154
+
155
+ .gmt-card-remove:hover {
156
+ opacity: 1;
157
+ background: color-mix(in srgb, var(--accent) 20%, transparent);
158
+ color: var(--accent);
159
+ }
160
+
161
+ .gmt-card-clock-w {
162
+ width: 160px;
163
+ height: 160px;
164
+ display: flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ }
168
+
169
+ .gmt-card-clock {
170
+ width: 100%;
171
+ height: 100%;
172
+ display: block;
173
+ }
174
+
175
+ .gmt-card-info {
176
+ display: flex;
177
+ flex-direction: column;
178
+ align-items: center;
179
+ gap: 0.2rem;
180
+ }
181
+
182
+ .gmt-card-city {
183
+ font-size: 0.8rem;
184
+ font-weight: 600;
185
+ text-transform: uppercase;
186
+ letter-spacing: 0.1em;
187
+ color: var(--text-base);
188
+ opacity: 0.5;
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ white-space: nowrap;
192
+ max-width: 180px;
193
+ }
194
+
195
+ .gmt-card-time {
196
+ font-size: 1rem;
197
+ font-weight: 500;
198
+ line-height: 1.4;
199
+ color: var(--text-base);
200
+ letter-spacing: 0.05em;
201
+ font-variant-numeric: tabular-nums;
202
+ opacity: 0.8;
203
+ }
204
+
205
+ .gmt-card-offset {
206
+ font-size: 0.7rem;
207
+ font-weight: 500;
208
+ color: var(--text-base);
209
+ opacity: 0.35;
210
+ letter-spacing: 0.03em;
211
+ }
212
+
213
+ @media (max-width: 640px) {
214
+ .gmt-grid {
215
+ grid-template-columns: repeat(2, 1fr);
216
+ gap: 0.6rem;
217
+ }
218
+
219
+ .gmt-card {
220
+ padding: 1.2rem 0.6rem 0.85rem;
221
+ min-height: 220px;
222
+ }
223
+
224
+ .gmt-card-clock-w {
225
+ width: 120px;
226
+ height: 120px;
227
+ }
228
+
229
+ .gmt-card-time {
230
+ font-size: 0.85rem;
231
+ }
232
+ }
233
+
234
+ @media (max-width: 400px) {
235
+ .gmt-grid {
236
+ grid-template-columns: 1fr;
237
+ gap: 0.6rem;
238
+ }
239
+ }
@@ -0,0 +1,28 @@
1
+ import type { WithContext, Thing } from 'schema-dts';
2
+ import type { FAQItem, HowToStep } from '../../types';
3
+
4
+ function buildFAQ(faq: FAQItem[]): WithContext<Thing> {
5
+ return {
6
+ '@context': 'https://schema.org', '@type': 'FAQPage',
7
+ 'mainEntity': faq.map((f) => ({ '@type': 'Question', 'name': f.question, 'acceptedAnswer': { '@type': 'Answer', 'text': f.answer } })),
8
+ } as unknown as WithContext<Thing>;
9
+ }
10
+
11
+ function buildApp(title: string): WithContext<Thing> {
12
+ return {
13
+ '@context': 'https://schema.org', '@type': 'SoftwareApplication', 'name': title,
14
+ 'operatingSystem': 'All', 'applicationCategory': 'UtilitiesApplication',
15
+ 'browserRequirements': 'Requires HTML5. Requires JavaScript.',
16
+ } as unknown as WithContext<Thing>;
17
+ }
18
+
19
+ function buildHowTo(title: string, howTo: HowToStep[]): WithContext<Thing> {
20
+ return {
21
+ '@context': 'https://schema.org', '@type': 'HowTo', 'name': title,
22
+ 'step': howTo.map((h) => ({ '@type': 'HowToStep', 'name': h.name, 'text': h.text })),
23
+ } as unknown as WithContext<Thing>;
24
+ }
25
+
26
+ export function buildSchemas(title: string, faq: FAQItem[], howTo: HowToStep[]): WithContext<Thing>[] {
27
+ return [buildFAQ(faq), buildApp(title), buildHowTo(title, howTo)];
28
+ }