@jjlmoya/utils-sports 1.36.0 → 1.41.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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +8 -0
  4. package/src/tests/locale_completeness.test.ts +24 -3
  5. package/src/tests/tool_validation.test.ts +2 -2
  6. package/src/tool/runningPacePredictor/bibliography.astro +6 -0
  7. package/src/tool/runningPacePredictor/bibliography.ts +14 -0
  8. package/src/tool/runningPacePredictor/component.astro +301 -0
  9. package/src/tool/runningPacePredictor/entry.ts +30 -0
  10. package/src/tool/runningPacePredictor/i18n/de.ts +248 -0
  11. package/src/tool/runningPacePredictor/i18n/en.ts +257 -0
  12. package/src/tool/runningPacePredictor/i18n/es.ts +257 -0
  13. package/src/tool/runningPacePredictor/i18n/fr.ts +248 -0
  14. package/src/tool/runningPacePredictor/i18n/id.ts +248 -0
  15. package/src/tool/runningPacePredictor/i18n/it.ts +248 -0
  16. package/src/tool/runningPacePredictor/i18n/ja.ts +248 -0
  17. package/src/tool/runningPacePredictor/i18n/ko.ts +248 -0
  18. package/src/tool/runningPacePredictor/i18n/nl.ts +248 -0
  19. package/src/tool/runningPacePredictor/i18n/pl.ts +248 -0
  20. package/src/tool/runningPacePredictor/i18n/pt.ts +248 -0
  21. package/src/tool/runningPacePredictor/i18n/ru.ts +248 -0
  22. package/src/tool/runningPacePredictor/i18n/sv.ts +248 -0
  23. package/src/tool/runningPacePredictor/i18n/tr.ts +248 -0
  24. package/src/tool/runningPacePredictor/i18n/zh.ts +248 -0
  25. package/src/tool/runningPacePredictor/index.ts +9 -0
  26. package/src/tool/runningPacePredictor/logic.test.ts +70 -0
  27. package/src/tool/runningPacePredictor/logic.ts +161 -0
  28. package/src/tool/runningPacePredictor/running-pace-predictor.css +330 -0
  29. package/src/tool/runningPacePredictor/seo.astro +224 -0
  30. package/src/tool/runningPacePredictor/ui.ts +79 -0
  31. package/src/tool/swimCssCalculator/bibliography.astro +6 -0
  32. package/src/tool/swimCssCalculator/bibliography.ts +14 -0
  33. package/src/tool/swimCssCalculator/component.astro +350 -0
  34. package/src/tool/swimCssCalculator/entry.ts +29 -0
  35. package/src/tool/swimCssCalculator/i18n/de.ts +233 -0
  36. package/src/tool/swimCssCalculator/i18n/en.ts +241 -0
  37. package/src/tool/swimCssCalculator/i18n/es.ts +233 -0
  38. package/src/tool/swimCssCalculator/i18n/fr.ts +233 -0
  39. package/src/tool/swimCssCalculator/i18n/id.ts +233 -0
  40. package/src/tool/swimCssCalculator/i18n/it.ts +233 -0
  41. package/src/tool/swimCssCalculator/i18n/ja.ts +233 -0
  42. package/src/tool/swimCssCalculator/i18n/ko.ts +233 -0
  43. package/src/tool/swimCssCalculator/i18n/nl.ts +233 -0
  44. package/src/tool/swimCssCalculator/i18n/pl.ts +233 -0
  45. package/src/tool/swimCssCalculator/i18n/pt.ts +233 -0
  46. package/src/tool/swimCssCalculator/i18n/ru.ts +233 -0
  47. package/src/tool/swimCssCalculator/i18n/sv.ts +233 -0
  48. package/src/tool/swimCssCalculator/i18n/tr.ts +233 -0
  49. package/src/tool/swimCssCalculator/i18n/zh.ts +233 -0
  50. package/src/tool/swimCssCalculator/index.ts +9 -0
  51. package/src/tool/swimCssCalculator/logic.test.ts +78 -0
  52. package/src/tool/swimCssCalculator/logic.ts +137 -0
  53. package/src/tool/swimCssCalculator/seo.astro +224 -0
  54. package/src/tool/swimCssCalculator/swim-css-calculator.css +437 -0
  55. package/src/tool/swimCssCalculator/ui.ts +85 -0
  56. package/src/tools.ts +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.36.0",
3
+ "version": "1.41.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,6 +10,7 @@ import { pingPongScoreKeeper } from '../tool/pingPongScoreKeeper/entry';
10
10
  import { rugbyScoreKeeper } from '../tool/rugbyScoreKeeper/entry';
11
11
  import { baseballScoreKeeper } from '../tool/baseballScoreKeeper/entry';
12
12
  import { penaltyShootout } from '../tool/penaltyShootout/entry';
13
+ import { swimCssCalculator } from '../tool/swimCssCalculator/entry';
13
14
 
14
15
  export const sportsCategory: SportsCategoryEntry = {
15
16
  icon: 'mdi:soccer',
@@ -25,6 +26,7 @@ export const sportsCategory: SportsCategoryEntry = {
25
26
  rugbyScoreKeeper,
26
27
  baseballScoreKeeper,
27
28
  penaltyShootout,
29
+ swimCssCalculator,
28
30
  ] as unknown as SportsToolEntry<Record<string, string>>[],
29
31
  i18n: {
30
32
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -32,6 +32,10 @@ export { baseballScoreKeeper } from './tool/baseballScoreKeeper/entry';
32
32
  export type { BaseballScoreKeeperLocaleContent } from './tool/baseballScoreKeeper/entry';
33
33
  export { penaltyShootout } from './tool/penaltyShootout/entry';
34
34
  export type { PenaltyShootoutLocaleContent } from './tool/penaltyShootout/entry';
35
+ export { runningPacePredictor } from './tool/runningPacePredictor/entry';
36
+ export type { RunningPacePredictorLocaleContent } from './tool/runningPacePredictor/entry';
37
+ export { swimCssCalculator } from './tool/swimCssCalculator/entry';
38
+ export type { SwimCssCalculatorLocaleContent } from './tool/swimCssCalculator/entry';
35
39
 
36
40
  export { sportsCategory } from './category';
37
41
 
@@ -52,6 +56,8 @@ import { snookerScoreKeeper } from './tool/snookerScoreKeeper/entry';
52
56
  import { rugbyScoreKeeper } from './tool/rugbyScoreKeeper/entry';
53
57
  import { baseballScoreKeeper } from './tool/baseballScoreKeeper/entry';
54
58
  import { penaltyShootout } from './tool/penaltyShootout/entry';
59
+ import { runningPacePredictor } from './tool/runningPacePredictor/entry';
60
+ import { swimCssCalculator } from './tool/swimCssCalculator/entry';
55
61
 
56
62
  export const ALL_ENTRIES = [
57
63
  basketScoreKeeper,
@@ -71,4 +77,6 @@ export const ALL_ENTRIES = [
71
77
  rugbyScoreKeeper,
72
78
  baseballScoreKeeper,
73
79
  penaltyShootout,
80
+ runningPacePredictor,
81
+ swimCssCalculator,
74
82
  ];
@@ -1,8 +1,29 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
3
4
 
4
- describe('Locale Completeness Validation', () => {
5
- it('all tools registered', () => {
6
- expect(ALL_TOOLS.length).toBe(17);
5
+ describe('Locale Completeness and Slug Validation', () => {
6
+ it('all tools registered in ALL_TOOLS', () => {
7
+ expect(ALL_TOOLS.length).toBeGreaterThan(0);
8
+ });
9
+
10
+ ALL_TOOLS.forEach((tool) => {
11
+ describe(`Tool entry: ${tool.entry.id}`, () => {
12
+ it('every registered locale content must have a valid non-empty slug', async () => {
13
+ const locales = Object.keys(tool.entry.i18n);
14
+ expect(locales.length).toBeGreaterThan(0);
15
+
16
+ for (const locale of locales) {
17
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
18
+ expect(loader).toBeDefined();
19
+ const content = (await loader?.()) as ToolLocaleContent;
20
+ expect(content, `Locale "${locale}" for tool "${tool.entry.id}" failed to load content object`).toBeDefined();
21
+ expect(content.slug, `Locale "${locale}" for tool "${tool.entry.id}" has undefined or empty slug`).toBeDefined();
22
+ expect(typeof content.slug).toBe('string');
23
+ expect(content.slug.length).toBeGreaterThan(0);
24
+ expect(content.title, `Locale "${locale}" for tool "${tool.entry.id}" has undefined title`).toBeDefined();
25
+ }
26
+ });
27
+ });
7
28
  });
8
29
  });
@@ -4,8 +4,8 @@ import { sportsCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 17 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(17);
7
+ it('should have tools registered in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBeGreaterThan(0);
9
9
  });
10
10
 
11
11
  it('sportsCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,14 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'Peter Riegel Race Time Prediction Model',
6
+ url: 'https://en.wikipedia.org/wiki/Peter_Riegel',
7
+ },
8
+ {
9
+ name: 'World Athletics Official Competition Rules',
10
+ url: 'https://worldathletics.org',
11
+ },
12
+ ];
13
+
14
+ export const bibliography = bibliographyEntries;
@@ -0,0 +1,301 @@
1
+ ---
2
+ import type { RunningPacePredictorUI } from './ui';
3
+ import './running-pace-predictor.css';
4
+
5
+ interface Props {
6
+ ui: RunningPacePredictorUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div class="rpp-container">
13
+ <div class="rpp-unit-switch-container">
14
+ <button class="rpp-btn-unit active" data-unit="km">{ui.unitKm}</button>
15
+ <button class="rpp-btn-unit" data-unit="mi">{ui.unitMiles}</button>
16
+ </div>
17
+
18
+ <div class="rpp-calculator-card">
19
+ <div class="rpp-card-header">
20
+ <h2 class="rpp-card-title">{ui.paceCalculatorTitle}</h2>
21
+ </div>
22
+
23
+ <div class="rpp-presets-bar">
24
+ <span class="rpp-presets-title">{ui.presetsTitle}:</span>
25
+ <button class="rpp-btn-preset" data-dist="5">5K</button>
26
+ <button class="rpp-btn-preset active" data-dist="10">10K</button>
27
+ <button class="rpp-btn-preset" data-dist="21.0975">Half Marathon</button>
28
+ <button class="rpp-btn-preset" data-dist="42.195">Marathon</button>
29
+ </div>
30
+
31
+ <div class="rpp-inputs-grid">
32
+ <div class="rpp-custom-dist-container">
33
+ <div style="flex:1;">
34
+ <div class="rpp-stepper-title">{ui.distanceLabel}</div>
35
+ <input type="number" id="rpp-distance" class="rpp-custom-dist-input" value="10" step="0.01" min="0.1" aria-label={ui.distanceLabel} />
36
+ </div>
37
+ <span class="rpp-custom-dist-unit" id="rpp-dist-unit-label">km</span>
38
+ </div>
39
+
40
+ <div class="rpp-stepper-card">
41
+ <div class="rpp-stepper-header">
42
+ <span class="rpp-stepper-title">{ui.timeLabel}</span>
43
+ <span class="rpp-stepper-value" id="rpp-time-display">00:50:00</span>
44
+ </div>
45
+
46
+ <input type="hidden" id="rpp-total-seconds" value="3000" />
47
+
48
+ <div class="rpp-stepper-controls">
49
+ <button class="rpp-btn-step" data-step="-300">-5m</button>
50
+ <button class="rpp-btn-step" data-step="-60">-1m</button>
51
+ <button class="rpp-btn-step" data-step="60">+1m</button>
52
+ <button class="rpp-btn-step" data-step="300">+5m</button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="rpp-hero-pace-card">
58
+ <div class="rpp-hero-pace-label">{ui.paceLabel}</div>
59
+ <div class="rpp-hero-pace-value" id="rpp-hero-pace-display">5:00 min/km</div>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="rpp-predictions-section">
64
+ <h3 class="rpp-section-title">{ui.racePredictorTitle}</h3>
65
+ <p class="rpp-section-subtitle">{ui.riegelExplanation}</p>
66
+ <div class="rpp-pred-grid" id="rpp-predictions-cards"></div>
67
+ </div>
68
+
69
+ <div class="rpp-zones-section">
70
+ <h3 class="rpp-section-title">{ui.trainingZonesTitle}</h3>
71
+ <div class="rpp-zones-grid" id="rpp-zones-cards"></div>
72
+ </div>
73
+ </div>
74
+
75
+ <script>
76
+ import {
77
+ calculatePaceFromTimeAndDistance,
78
+ generateAllRacePredictions,
79
+ calculateTrainingZones,
80
+ formatPace,
81
+ formatTimeHMS,
82
+ } from './logic';
83
+
84
+ let currentUnit = 'km';
85
+
86
+ function renderPredictions(predictions: ReturnType<typeof generateAllRacePredictions>, isMiles: boolean, unitStr: string) {
87
+ const predCards = document.getElementById('rpp-predictions-cards');
88
+ if (!predCards) return;
89
+ const maxDist = 42.195;
90
+ predCards.innerHTML = predictions
91
+ .map((p) => {
92
+ const pDistStr = isMiles ? `${p.distanceMiles.toFixed(2)} mi` : `${p.distanceKm.toFixed(2)} km`;
93
+ const pPace = isMiles ? formatPace(p.paceSecPerMile) : formatPace(p.paceSecPerKm);
94
+ const percent = Math.min(100, Math.round((p.distanceKm / maxDist) * 100));
95
+ return `
96
+ <div class="rpp-pred-card">
97
+ <div class="rpp-pred-card-top">
98
+ <div>
99
+ <div class="rpp-pred-dist">${p.id.toUpperCase()} <span style="font-size:0.8rem; color:#94a3b8; font-weight:600;">(${pDistStr})</span></div>
100
+ <div style="font-size:0.85rem; color:#94a3b8; font-weight:700; margin-top:0.1rem;">${pPace} ${unitStr}</div>
101
+ </div>
102
+ <div class="rpp-pred-time">${formatTimeHMS(p.timeSeconds)}</div>
103
+ </div>
104
+ <div class="rpp-pred-bar-bg">
105
+ <div class="rpp-pred-bar-fill" style="width: ${percent}%;"></div>
106
+ </div>
107
+ </div>
108
+ `;
109
+ })
110
+ .join('');
111
+ }
112
+
113
+ function renderZones(fiveKPace: number, isMiles: boolean, unitStr: string) {
114
+ const zonesCards = document.getElementById('rpp-zones-cards');
115
+ if (!zonesCards) return;
116
+ const zones = calculateTrainingZones(fiveKPace);
117
+ zonesCards.innerHTML = zones
118
+ .map((z) => {
119
+ const minP = isMiles ? formatPace(z.minPaceSecPerMile) : formatPace(z.minPaceSecPerKm);
120
+ const maxP = isMiles ? formatPace(z.maxPaceSecPerMile) : formatPace(z.maxPaceSecPerKm);
121
+ return `
122
+ <div class="rpp-zone-card">
123
+ <div class="rpp-zone-header">
124
+ <span class="rpp-zone-pill rpp-zone-pill-${z.id}">${z.id.toUpperCase()}</span>
125
+ <span class="rpp-zone-range">${minP} - ${maxP} ${unitStr}</span>
126
+ </div>
127
+ <div class="rpp-zone-bar-bg">
128
+ <div class="rpp-zone-bar-${z.id}"></div>
129
+ </div>
130
+ </div>
131
+ `;
132
+ })
133
+ .join('');
134
+ }
135
+
136
+ function getRequiredElements() {
137
+ const distInput = document.getElementById('rpp-distance') as HTMLInputElement | null;
138
+ const secInput = document.getElementById('rpp-total-seconds') as HTMLInputElement | null;
139
+ const timeDisplay = document.getElementById('rpp-time-display');
140
+ const heroPaceDisplay = document.getElementById('rpp-hero-pace-display');
141
+ const distUnitLabel = document.getElementById('rpp-dist-unit-label');
142
+
143
+ const hasAll = Boolean(distInput && secInput && timeDisplay && heroPaceDisplay && distUnitLabel);
144
+ return hasAll ? { distInput: distInput!, secInput: secInput!, timeDisplay: timeDisplay!, heroPaceDisplay: heroPaceDisplay!, distUnitLabel: distUnitLabel! } : null;
145
+ }
146
+
147
+ function getCoreInputs() {
148
+ const els = getRequiredElements();
149
+ if (!els) return null;
150
+
151
+ const isMiles = currentUnit === 'mi';
152
+ const rawDist = parseFloat(els.distInput.value) || 0;
153
+ const totalSec = Math.max(0, parseInt(els.secInput.value) || 0);
154
+
155
+ return {
156
+ distKm: isMiles ? rawDist * 1.609344 : rawDist,
157
+ totalSec,
158
+ isMiles,
159
+ timeDisplay: els.timeDisplay,
160
+ heroPaceDisplay: els.heroPaceDisplay,
161
+ distUnitLabel: els.distUnitLabel,
162
+ };
163
+ }
164
+
165
+ function updateApp() {
166
+ const ctx = getCoreInputs();
167
+ if (!ctx) return;
168
+
169
+ ctx.distUnitLabel.textContent = ctx.isMiles ? 'miles' : 'km';
170
+ ctx.timeDisplay.textContent = formatTimeHMS(ctx.totalSec);
171
+
172
+ const pace = calculatePaceFromTimeAndDistance(ctx.totalSec, ctx.distKm);
173
+ const mainPaceFormatted = ctx.isMiles ? formatPace(pace.secPerMile) : formatPace(pace.secPerKm);
174
+ const unitStr = ctx.isMiles ? 'min/mile' : 'min/km';
175
+ ctx.heroPaceDisplay.textContent = `${mainPaceFormatted} ${unitStr}`;
176
+
177
+ const predictions = generateAllRacePredictions(ctx.distKm, ctx.totalSec);
178
+ renderPredictions(predictions, ctx.isMiles, unitStr);
179
+
180
+ const fiveKPred = predictions.find((p) => p.id === '5k');
181
+ renderZones(fiveKPred ? fiveKPred.paceSecPerKm : pace.secPerKm, ctx.isMiles, unitStr);
182
+ }
183
+
184
+ const STORAGE_KEY = 'rpp_user_state';
185
+
186
+ function saveState() {
187
+ try {
188
+ const distInput = document.getElementById('rpp-distance') as HTMLInputElement;
189
+ const secInput = document.getElementById('rpp-total-seconds') as HTMLInputElement;
190
+ const activePreset = document.querySelector('.rpp-btn-preset.active')?.getAttribute('data-dist') || null;
191
+
192
+ const state = {
193
+ unit: currentUnit,
194
+ distance: distInput ? distInput.value : '10',
195
+ totalSeconds: secInput ? secInput.value : '3000',
196
+ activePreset,
197
+ };
198
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
199
+ } catch {
200
+ return;
201
+ }
202
+ }
203
+
204
+ function applySavedInputs(state: { unit?: string; distance?: string; totalSeconds?: string; activePreset?: string }) {
205
+ if (state.unit) {
206
+ currentUnit = state.unit;
207
+ document.querySelectorAll('.rpp-btn-unit').forEach((b) => {
208
+ b.classList.toggle('active', b.getAttribute('data-unit') === currentUnit);
209
+ });
210
+ }
211
+ if (state.distance) {
212
+ const distIn = document.getElementById('rpp-distance') as HTMLInputElement;
213
+ if (distIn) distIn.value = state.distance;
214
+ }
215
+ if (state.totalSeconds) {
216
+ const secInput = document.getElementById('rpp-total-seconds') as HTMLInputElement;
217
+ if (secInput) secInput.value = state.totalSeconds;
218
+ }
219
+ if (state.activePreset) {
220
+ document.querySelectorAll('.rpp-btn-preset').forEach((b) => {
221
+ b.classList.toggle('active', b.getAttribute('data-dist') === state.activePreset);
222
+ });
223
+ }
224
+ }
225
+
226
+ function loadState() {
227
+ try {
228
+ const raw = localStorage.getItem(STORAGE_KEY);
229
+ if (raw) {
230
+ applySavedInputs(JSON.parse(raw));
231
+ }
232
+ } catch {
233
+ return;
234
+ }
235
+ }
236
+
237
+ function bindDistAndStepEvents(onDistChange: () => void) {
238
+ const distInput = document.getElementById('rpp-distance');
239
+ if (distInput) {
240
+ distInput.addEventListener('input', onDistChange);
241
+ distInput.addEventListener('change', onDistChange);
242
+ }
243
+ document.querySelectorAll('.rpp-btn-step').forEach((btn) => {
244
+ btn.addEventListener('click', (e) => {
245
+ const delta = parseInt((e.currentTarget as HTMLElement).getAttribute('data-step') || '0');
246
+ const secInput = document.getElementById('rpp-total-seconds') as HTMLInputElement;
247
+ if (secInput) {
248
+ secInput.value = Math.max(0, (parseInt(secInput.value) || 0) + delta).toString();
249
+ onDistChange();
250
+ }
251
+ });
252
+ });
253
+ }
254
+
255
+ function onUnitBtnClick(e: Event, onDistChange: () => void) {
256
+ const target = e.currentTarget as HTMLElement;
257
+ const selectedUnit = target.getAttribute('data-unit') || 'km';
258
+ if (selectedUnit === currentUnit) return;
259
+
260
+ document.querySelectorAll('.rpp-btn-unit').forEach((b) => b.classList.remove('active'));
261
+ target.classList.add('active');
262
+
263
+ const distIn = document.getElementById('rpp-distance') as HTMLInputElement;
264
+ if (distIn) {
265
+ const currentVal = parseFloat(distIn.value) || 0;
266
+ distIn.value = selectedUnit === 'mi' ? (currentVal / 1.609344).toFixed(2) : (currentVal * 1.609344).toFixed(2);
267
+ }
268
+
269
+ currentUnit = selectedUnit;
270
+ onDistChange();
271
+ }
272
+
273
+ function onPresetBtnClick(e: Event, onDistChange: () => void) {
274
+ const target = e.currentTarget as HTMLElement;
275
+ document.querySelectorAll('.rpp-btn-preset').forEach((b) => b.classList.remove('active'));
276
+ target.classList.add('active');
277
+
278
+ const distKm = parseFloat(target.getAttribute('data-dist') || '0');
279
+ const distIn = document.getElementById('rpp-distance') as HTMLInputElement;
280
+ if (distIn && distKm > 0) {
281
+ distIn.value = currentUnit === 'mi' ? (distKm / 1.609344).toFixed(2) : distKm.toString();
282
+ onDistChange();
283
+ }
284
+ }
285
+
286
+ function bindEventListeners() {
287
+ const onDistChange = () => {
288
+ updateApp();
289
+ saveState();
290
+ };
291
+ bindDistAndStepEvents(onDistChange);
292
+ document.querySelectorAll('.rpp-btn-unit').forEach((b) => b.addEventListener('click', (e) => onUnitBtnClick(e, onDistChange)));
293
+ document.querySelectorAll('.rpp-btn-preset').forEach((b) => b.addEventListener('click', (e) => onPresetBtnClick(e, onDistChange)));
294
+ }
295
+
296
+ document.addEventListener('DOMContentLoaded', () => {
297
+ loadState();
298
+ bindEventListeners();
299
+ updateApp();
300
+ });
301
+ </script>
@@ -0,0 +1,30 @@
1
+ import type { SportsToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { RunningPacePredictorUI } from './ui';
3
+
4
+ export type { RunningPacePredictorUI };
5
+ export type RunningPacePredictorLocaleContent = ToolLocaleContent<RunningPacePredictorUI>;
6
+
7
+ export const runningPacePredictor: SportsToolEntry<RunningPacePredictorUI> = {
8
+ id: 'running-pace-predictor',
9
+ icons: {
10
+ bg: 'mdi:run-fast',
11
+ fg: 'mdi:timer-outline',
12
+ },
13
+ i18n: {
14
+ es: () => import('./i18n/es').then((m) => m.content),
15
+ en: () => import('./i18n/en').then((m) => m.content),
16
+ fr: () => import('./i18n/fr').then((m) => m.content),
17
+ de: () => import('./i18n/de').then((m) => m.content),
18
+ id: () => import('./i18n/id').then((m) => m.content),
19
+ it: () => import('./i18n/it').then((m) => m.content),
20
+ ja: () => import('./i18n/ja').then((m) => m.content),
21
+ ko: () => import('./i18n/ko').then((m) => m.content),
22
+ nl: () => import('./i18n/nl').then((m) => m.content),
23
+ pl: () => import('./i18n/pl').then((m) => m.content),
24
+ pt: () => import('./i18n/pt').then((m) => m.content),
25
+ ru: () => import('./i18n/ru').then((m) => m.content),
26
+ sv: () => import('./i18n/sv').then((m) => m.content),
27
+ tr: () => import('./i18n/tr').then((m) => m.content),
28
+ zh: () => import('./i18n/zh').then((m) => m.content),
29
+ },
30
+ };