@jjlmoya/utils-chrono 1.11.0 → 1.17.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 (99) 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/gear-train-explorer/i18n/de.ts +1 -1
  7. package/src/tool/gear-train-explorer/i18n/es.ts +1 -1
  8. package/src/tool/gear-train-explorer/i18n/fr.ts +1 -1
  9. package/src/tool/gear-train-explorer/i18n/id.ts +1 -1
  10. package/src/tool/gear-train-explorer/i18n/it.ts +1 -1
  11. package/src/tool/gear-train-explorer/i18n/nl.ts +1 -1
  12. package/src/tool/gear-train-explorer/i18n/pl.ts +1 -1
  13. package/src/tool/gear-train-explorer/i18n/pt.ts +1 -1
  14. package/src/tool/gear-train-explorer/i18n/ru.ts +1 -1
  15. package/src/tool/gear-train-explorer/i18n/sv.ts +1 -1
  16. package/src/tool/gear-train-explorer/i18n/tr.ts +1 -1
  17. package/src/tool/gmt-world-timer/bibliography.astro +11 -0
  18. package/src/tool/gmt-world-timer/bibliography.ts +7 -0
  19. package/src/tool/gmt-world-timer/client.ts +250 -0
  20. package/src/tool/gmt-world-timer/component.astro +13 -0
  21. package/src/tool/gmt-world-timer/components/GmtPanel.astro +18 -0
  22. package/src/tool/gmt-world-timer/entry.ts +34 -0
  23. package/src/tool/gmt-world-timer/gmt-world-timer.css +239 -0
  24. package/src/tool/gmt-world-timer/helpers.ts +28 -0
  25. package/src/tool/gmt-world-timer/i18n/de.ts +72 -0
  26. package/src/tool/gmt-world-timer/i18n/en.ts +72 -0
  27. package/src/tool/gmt-world-timer/i18n/es.ts +72 -0
  28. package/src/tool/gmt-world-timer/i18n/fr.ts +72 -0
  29. package/src/tool/gmt-world-timer/i18n/id.ts +72 -0
  30. package/src/tool/gmt-world-timer/i18n/it.ts +72 -0
  31. package/src/tool/gmt-world-timer/i18n/ja.ts +72 -0
  32. package/src/tool/gmt-world-timer/i18n/ko.ts +72 -0
  33. package/src/tool/gmt-world-timer/i18n/nl.ts +72 -0
  34. package/src/tool/gmt-world-timer/i18n/pl.ts +72 -0
  35. package/src/tool/gmt-world-timer/i18n/pt.ts +72 -0
  36. package/src/tool/gmt-world-timer/i18n/ru.ts +72 -0
  37. package/src/tool/gmt-world-timer/i18n/sv.ts +72 -0
  38. package/src/tool/gmt-world-timer/i18n/tr.ts +72 -0
  39. package/src/tool/gmt-world-timer/i18n/zh.ts +72 -0
  40. package/src/tool/gmt-world-timer/index.ts +11 -0
  41. package/src/tool/gmt-world-timer/seo.astro +11 -0
  42. package/src/tool/perpetual-calendar/bibliography.astro +16 -0
  43. package/src/tool/perpetual-calendar/bibliography.ts +16 -0
  44. package/src/tool/perpetual-calendar/calendar.ts +24 -0
  45. package/src/tool/perpetual-calendar/client.ts +98 -0
  46. package/src/tool/perpetual-calendar/component.astro +17 -0
  47. package/src/tool/perpetual-calendar/components/CalendarPanel.astro +49 -0
  48. package/src/tool/perpetual-calendar/dial.ts +176 -0
  49. package/src/tool/perpetual-calendar/entry.ts +48 -0
  50. package/src/tool/perpetual-calendar/helpers.ts +49 -0
  51. package/src/tool/perpetual-calendar/i18n/de.ts +85 -0
  52. package/src/tool/perpetual-calendar/i18n/en.ts +102 -0
  53. package/src/tool/perpetual-calendar/i18n/es.ts +85 -0
  54. package/src/tool/perpetual-calendar/i18n/fr.ts +85 -0
  55. package/src/tool/perpetual-calendar/i18n/id.ts +85 -0
  56. package/src/tool/perpetual-calendar/i18n/it.ts +85 -0
  57. package/src/tool/perpetual-calendar/i18n/ja.ts +85 -0
  58. package/src/tool/perpetual-calendar/i18n/ko.ts +85 -0
  59. package/src/tool/perpetual-calendar/i18n/nl.ts +85 -0
  60. package/src/tool/perpetual-calendar/i18n/pl.ts +85 -0
  61. package/src/tool/perpetual-calendar/i18n/pt.ts +85 -0
  62. package/src/tool/perpetual-calendar/i18n/ru.ts +85 -0
  63. package/src/tool/perpetual-calendar/i18n/sv.ts +85 -0
  64. package/src/tool/perpetual-calendar/i18n/tr.ts +85 -0
  65. package/src/tool/perpetual-calendar/i18n/zh.ts +85 -0
  66. package/src/tool/perpetual-calendar/index.ts +11 -0
  67. package/src/tool/perpetual-calendar/perpetual-calendar.css +181 -0
  68. package/src/tool/perpetual-calendar/seo.astro +16 -0
  69. package/src/tool/perpetual-calendar/state.ts +26 -0
  70. package/src/tool/tourbillon-visualizer/bibliography.astro +11 -0
  71. package/src/tool/tourbillon-visualizer/bibliography.ts +7 -0
  72. package/src/tool/tourbillon-visualizer/client.ts +122 -0
  73. package/src/tool/tourbillon-visualizer/component.astro +126 -0
  74. package/src/tool/tourbillon-visualizer/components/TourbillonPanel.astro +66 -0
  75. package/src/tool/tourbillon-visualizer/entry.ts +51 -0
  76. package/src/tool/tourbillon-visualizer/helpers.ts +35 -0
  77. package/src/tool/tourbillon-visualizer/i18n/de.ts +96 -0
  78. package/src/tool/tourbillon-visualizer/i18n/en.ts +96 -0
  79. package/src/tool/tourbillon-visualizer/i18n/es.ts +96 -0
  80. package/src/tool/tourbillon-visualizer/i18n/fr.ts +96 -0
  81. package/src/tool/tourbillon-visualizer/i18n/id.ts +96 -0
  82. package/src/tool/tourbillon-visualizer/i18n/it.ts +96 -0
  83. package/src/tool/tourbillon-visualizer/i18n/ja.ts +96 -0
  84. package/src/tool/tourbillon-visualizer/i18n/ko.ts +96 -0
  85. package/src/tool/tourbillon-visualizer/i18n/nl.ts +96 -0
  86. package/src/tool/tourbillon-visualizer/i18n/pl.ts +96 -0
  87. package/src/tool/tourbillon-visualizer/i18n/pt.ts +96 -0
  88. package/src/tool/tourbillon-visualizer/i18n/ru.ts +96 -0
  89. package/src/tool/tourbillon-visualizer/i18n/sv.ts +96 -0
  90. package/src/tool/tourbillon-visualizer/i18n/tr.ts +96 -0
  91. package/src/tool/tourbillon-visualizer/i18n/zh.ts +96 -0
  92. package/src/tool/tourbillon-visualizer/index.ts +11 -0
  93. package/src/tool/tourbillon-visualizer/renderer/base.ts +78 -0
  94. package/src/tool/tourbillon-visualizer/renderer/cage.ts +115 -0
  95. package/src/tool/tourbillon-visualizer/renderer/esc.ts +160 -0
  96. package/src/tool/tourbillon-visualizer/seo.astro +11 -0
  97. package/src/tool/tourbillon-visualizer/state.ts +21 -0
  98. package/src/tool/tourbillon-visualizer/tourbillon.ts +9 -0
  99. 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.17.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
 
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Räderwerk Erkunder: Interaktives Uhrmacherei Diagramm';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'getriebeerkunder',
39
+ slug: 'getriebe-erkunder',
40
40
  title,
41
41
  description: 'Erkunden Sie das mechanische Herz einer Uhr mit einer animierten Räderwerk-Visualisierung. Sehen Sie Federhauswalze, Minutenrad, Kleinbodenrad, Sekundenrad, Hemmungsrad, Anker und Unruh in Bewegung.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Explorador de Tren de Engranajes: Diagrama Interactivo de Relojería';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'exploradortrenengranajes',
39
+ slug: 'explorador-tren-engranajes',
40
40
  title,
41
41
  description: 'Explore el corazón mecánico de un reloj con una visualización animada del tren de engranajes. Vea el barril del muelle real, la rueda central, la tercera rueda, la cuarta rueda, la rueda de escape, la horquilla del áncora y el volante en movimiento.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Explorateur de Train d\'Engrenages: Diagramme Horloger Interactif';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'explorateurtrengrenages',
39
+ slug: 'explorateur-train-engrenages',
40
40
  title,
41
41
  description: 'Explorez le cœur mécanique d\'une montre avec une visualisation animée du train d\'engrenages. Voyez le barillet, la roue des minutes, la roue des heures, la roue des secondes, la roue d\'échappement, l\'ancre et le balancier en mouvement.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Penjelajah Roda Gigi: Diagram Horologi Interaktif';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'penjelajahrodagigi',
39
+ slug: 'penjelajah-roda-gigi',
40
40
  title,
41
41
  description: 'Jelajahi jantung mekanis jam tangan dengan visualisasi roda gigi animasi. Lihat barre l mainspring, roda tengah, roda ketiga, roda keempat, roda escapement, garpu palet, dan roda keseimbangan bergerak.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Esploratore del Treno di Ingranaggi: Diagramma Interattivo di Orologeria';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'esploratoretreningranaggi',
39
+ slug: 'esploratore-treni-ingranaggi',
40
40
  title,
41
41
  description: 'Esplora il cuore meccanico di un orologio con una visualizzazione animata del treno di ingranaggi. Vedi il bariletto, la ruota centrale, la terza ruota, la quarta ruota, la ruota di scappamento, l\'ancora e il bilanciere in movimento.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Tandwieltrein Verkenner: Interactief Horlogerie Diagram';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'tandwieltreinverkenner',
39
+ slug: 'tandwiel-trein-verkenner',
40
40
  title,
41
41
  description: 'Verken het mechanische hart van een horloge met een geanimeerde tandwieltreinvisualisatie. Zie het veerhuis, het centrale wiel, het derde wiel, het vierde wiel, het echappement, de anker en het balanswiel in beweging.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Odkrywca Kół Zębatych: Interaktywny Diagram Zegarmistrzowski';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'odkrywcakolebate',
39
+ slug: 'odkrywca-kola-zebate',
40
40
  title,
41
41
  description: 'Poznaj mechaniczne serce zegarka dzięki animowanej wizualizacji mechanizmu zębatego. Zobacz bęben sprężyny, koło środkowe, trzecie koło, czwarte koło, koło wychwytowe, widełki kotwicowe i balans w ruchu.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Explorador do Trem de Engrenagens: Diagrama Interativo de Relojoaria';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'exploradortremengrenagens',
39
+ slug: 'explorador-trem-engrenagens',
40
40
  title,
41
41
  description: 'Explore o coração mecânico de um relógio com uma visualização animada do trem de engrenagens. Veja a barrica, a roda central, a terceira roda, a quarta roda, a roda de escape, a âncora e o volante em movimento.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Исследователь зубчатой передачи: интерактивная схема часового механизма';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'issledovatelyzubchatoyperedachi',
39
+ slug: 'issledovatel-zubchatoy-peredachi',
40
40
  title,
41
41
  description: 'Исследуйте механическое сердце часов с помощью анимированной визуализации зубчатой передачи. Увидьте барабан, центральное колесо, третье колесо, четвертое колесо, спусковое колесо, анкерную вилку и баланс в движении.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Växeltrain Utforskare: Interaktivt Urmakeridiagram';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'vaxeltrainutforskare',
39
+ slug: 'vaxeltrain-utforskare',
40
40
  title,
41
41
  description: 'Utforska det mekaniska hjärtat av en klocka med en animerad visualisering av kugghjulsväxeln. Se fjäderhuset, centrumhjulet, tredje hjulet, fjärde hjulet, gånghjulet, ankaret och balanshjulet i rörelse.',
42
42
  ui: {
@@ -36,7 +36,7 @@ const howTo = [
36
36
  const title = 'Dişli Takımı Kaşifi: Etkileşimli Saatçilik Diyagramı';
37
37
 
38
38
  export const content: ToolLocaleContent<GearTrainExplorerUI> = {
39
- slug: 'dislitrenikesfi',
39
+ slug: 'disli-treni-kesfi',
40
40
  title,
41
41
  description: 'Animasyonlu bir dişli takımı görselleştirmesiyle bir saatin mekanik kalbini keşfedin. Ana yay kutusu, merkez tekerlek, üçüncü tekerlek, dördüncü tekerlek, eşapman tekerleği, palet çatalı ve balans tekerleğini hareket halinde görün.',
42
42
  ui: {
@@ -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
+ };