@jjlmoya/utils-travel 1.26.0 → 1.28.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 (47) hide show
  1. package/package.json +2 -2
  2. package/scripts/validate-icons.mjs +57 -0
  3. package/src/entries.ts +2 -1
  4. package/src/tests/locale_completeness.test.ts +2 -2
  5. package/src/tests/tool_validation.test.ts +2 -2
  6. package/src/tool/jet-lag-recovery-planner/bibliography.astro +6 -0
  7. package/src/tool/jet-lag-recovery-planner/bibliography.ts +7 -0
  8. package/src/tool/jet-lag-recovery-planner/component.astro +177 -0
  9. package/src/tool/jet-lag-recovery-planner/entry.ts +69 -0
  10. package/src/tool/jet-lag-recovery-planner/i18n/content.ts +155 -0
  11. package/src/tool/jet-lag-recovery-planner/i18n/de.ts +2 -0
  12. package/src/tool/jet-lag-recovery-planner/i18n/en.ts +2 -0
  13. package/src/tool/jet-lag-recovery-planner/i18n/es.ts +2 -0
  14. package/src/tool/jet-lag-recovery-planner/i18n/fr.ts +2 -0
  15. package/src/tool/jet-lag-recovery-planner/i18n/id.ts +2 -0
  16. package/src/tool/jet-lag-recovery-planner/i18n/it.ts +2 -0
  17. package/src/tool/jet-lag-recovery-planner/i18n/ja.ts +2 -0
  18. package/src/tool/jet-lag-recovery-planner/i18n/ko.ts +2 -0
  19. package/src/tool/jet-lag-recovery-planner/i18n/nl.ts +2 -0
  20. package/src/tool/jet-lag-recovery-planner/i18n/pl.ts +2 -0
  21. package/src/tool/jet-lag-recovery-planner/i18n/pt.ts +2 -0
  22. package/src/tool/jet-lag-recovery-planner/i18n/ru.ts +2 -0
  23. package/src/tool/jet-lag-recovery-planner/i18n/sv.ts +2 -0
  24. package/src/tool/jet-lag-recovery-planner/i18n/tr.ts +2 -0
  25. package/src/tool/jet-lag-recovery-planner/i18n/zh.ts +2 -0
  26. package/src/tool/jet-lag-recovery-planner/index.ts +12 -0
  27. package/src/tool/jet-lag-recovery-planner/jet-lag-recovery-planner.css +505 -0
  28. package/src/tool/jet-lag-recovery-planner/logic.test.ts +49 -0
  29. package/src/tool/jet-lag-recovery-planner/logic.ts +102 -0
  30. package/src/tool/jet-lag-recovery-planner/seo.astro +14 -0
  31. package/src/tool/mini-adventures/component.astro +1 -2
  32. package/src/tool/mini-adventures/i18n/de.ts +1 -1
  33. package/src/tool/mini-adventures/i18n/en.ts +1 -1
  34. package/src/tool/mini-adventures/i18n/es.ts +1 -1
  35. package/src/tool/mini-adventures/i18n/fr.ts +1 -1
  36. package/src/tool/mini-adventures/i18n/id.ts +1 -1
  37. package/src/tool/mini-adventures/i18n/it.ts +1 -1
  38. package/src/tool/mini-adventures/i18n/ja.ts +1 -1
  39. package/src/tool/mini-adventures/i18n/ko.ts +1 -1
  40. package/src/tool/mini-adventures/i18n/nl.ts +1 -1
  41. package/src/tool/mini-adventures/i18n/pl.ts +1 -1
  42. package/src/tool/mini-adventures/i18n/pt.ts +1 -1
  43. package/src/tool/mini-adventures/i18n/ru.ts +1 -1
  44. package/src/tool/mini-adventures/i18n/sv.ts +1 -1
  45. package/src/tool/mini-adventures/i18n/tr.ts +1 -1
  46. package/src/tool/mini-adventures/i18n/zh.ts +1 -1
  47. package/src/tools.ts +4 -0
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('nl');
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('pl');
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('pt');
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('ru');
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('sv');
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('tr');
@@ -0,0 +1,2 @@
1
+ import { createContent } from './content';
2
+ export const content = createContent('zh');
@@ -0,0 +1,12 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { jetLagRecoveryPlanner } from './entry';
3
+
4
+ export * from './entry';
5
+ export * from './logic';
6
+
7
+ export const JET_LAG_RECOVERY_PLANNER_TOOL: ToolDefinition = {
8
+ entry: jetLagRecoveryPlanner,
9
+ Component: () => import('./component.astro'),
10
+ SEOComponent: () => import('./seo.astro'),
11
+ BibliographyComponent: () => import('./bibliography.astro'),
12
+ };
@@ -0,0 +1,505 @@
1
+ .jet-lag-planner {
2
+ --jl-ink: #17324d;
3
+ --jl-muted: #587086;
4
+ --jl-paper: #fffdf7;
5
+ --jl-line: #d8e1e4;
6
+ --jl-blue: #197c9c;
7
+ --jl-blue-soft: #e5f3f4;
8
+ --jl-sun: #e8a52e;
9
+ --jl-night: #294e78;
10
+
11
+ color: var(--jl-ink);
12
+ }
13
+
14
+ .theme-dark .jet-lag-planner {
15
+ --jl-ink: #edf7f6;
16
+ --jl-muted: #abc0c9;
17
+ --jl-paper: #172b3d;
18
+ --jl-line: #355066;
19
+ --jl-blue: #6ed1d2;
20
+ --jl-blue-soft: #1d4858;
21
+ --jl-sun: #f5bd51;
22
+ --jl-night: #9cc4e8;
23
+ }
24
+
25
+ .jl-card {
26
+ background: var(--jl-paper);
27
+ border: 1px solid var(--jl-line);
28
+ border-radius: 1.5rem;
29
+ box-shadow: 0 1rem 2.5rem rgba(18, 46, 69, 0.08);
30
+ }
31
+
32
+ .theme-dark .jl-card {
33
+ box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.22);
34
+ }
35
+
36
+ .jl-workspace {
37
+ display: grid;
38
+ grid-template-columns: 1.1fr 0.9fr;
39
+ grid-template-areas:
40
+ "hero-copy journey"
41
+ "form result";
42
+ gap: 2rem;
43
+ align-items: center;
44
+ margin-top: 0;
45
+ padding: clamp(1.5rem, 4vw, 3rem);
46
+ overflow: hidden;
47
+ background:
48
+ radial-gradient(circle at 85% 25%, rgba(232, 165, 46, 0.16), transparent 30%),
49
+ radial-gradient(circle at 8% 90%, rgba(25, 124, 156, 0.14), transparent 35%),
50
+ var(--jl-paper);
51
+ }
52
+
53
+ .jl-hero-copy {
54
+ grid-area: hero-copy;
55
+ position: relative;
56
+ z-index: 1;
57
+ }
58
+
59
+ .jl-kicker {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 0.45rem;
63
+ color: var(--jl-blue);
64
+ font-size: 0.78rem;
65
+ font-weight: 800;
66
+ letter-spacing: 0.11em;
67
+ text-transform: uppercase;
68
+ }
69
+
70
+ .jl-kicker svg {
71
+ width: 1.15rem;
72
+ height: 1.15rem;
73
+ }
74
+
75
+ .jl-hero-copy p {
76
+ max-width: 58ch;
77
+ margin: 0;
78
+ color: var(--jl-muted);
79
+ font-size: 1.05rem;
80
+ line-height: 1.65;
81
+ }
82
+
83
+ .jl-journey {
84
+ grid-area: journey;
85
+ position: relative;
86
+ min-height: 13rem;
87
+ display: grid;
88
+ grid-template-columns: 1fr auto 1fr;
89
+ gap: 0.75rem;
90
+ align-items: center;
91
+ padding: 1rem;
92
+ border: 1px dashed var(--jl-line);
93
+ border-radius: 45% 55% 50% 40%;
94
+ transform: rotate(-2deg);
95
+ }
96
+
97
+ .jl-journey::before {
98
+ position: absolute;
99
+ inset: 0.6rem;
100
+ border: 2px solid color-mix(in srgb, var(--jl-blue) 35%, transparent);
101
+ border-radius: 50%;
102
+ content: "";
103
+ transform: rotate(8deg);
104
+ }
105
+
106
+ .jl-journey-node,
107
+ .jl-journey-line {
108
+ position: relative;
109
+ z-index: 1;
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ }
114
+
115
+ .jl-journey-node {
116
+ flex-direction: column;
117
+ gap: 0.6rem;
118
+ font-size: 0.76rem;
119
+ font-weight: 800;
120
+ text-align: center;
121
+ text-transform: uppercase;
122
+ }
123
+
124
+ .jl-journey-node svg {
125
+ width: 4rem;
126
+ height: 4rem;
127
+ padding: 0.8rem;
128
+ border-radius: 50%;
129
+ }
130
+
131
+ .jl-night {
132
+ color: var(--jl-night);
133
+ }
134
+
135
+ .jl-night svg {
136
+ background: color-mix(in srgb, var(--jl-night) 16%, transparent);
137
+ }
138
+
139
+ .jl-day {
140
+ color: var(--jl-sun);
141
+ }
142
+
143
+ .jl-day svg {
144
+ background: color-mix(in srgb, var(--jl-sun) 18%, transparent);
145
+ }
146
+
147
+ .jl-journey-line {
148
+ color: var(--jl-blue);
149
+ }
150
+
151
+ .jl-journey-line svg {
152
+ width: 2rem;
153
+ height: 2rem;
154
+ }
155
+
156
+ .jl-form {
157
+ grid-area: form;
158
+ padding-right: 2rem;
159
+ border-right: 1px solid var(--jl-line);
160
+ }
161
+
162
+ .jl-form-heading,
163
+ .jl-result-top {
164
+ display: flex;
165
+ align-items: flex-start;
166
+ gap: 0.8rem;
167
+ }
168
+
169
+ .jl-result {
170
+ grid-area: result;
171
+ }
172
+
173
+ .jl-form-heading {
174
+ margin-bottom: 1.5rem;
175
+ }
176
+
177
+ .jl-step {
178
+ display: inline-grid;
179
+ width: 2rem;
180
+ height: 2rem;
181
+ flex: 0 0 auto;
182
+ place-items: center;
183
+ border-radius: 0.55rem;
184
+ background: var(--jl-blue-soft);
185
+ color: var(--jl-blue);
186
+ font-size: 0.76rem;
187
+ font-weight: 900;
188
+ }
189
+
190
+ .jl-form h3,
191
+ .jl-result h3 {
192
+ margin: 0;
193
+ font-size: 1.3rem;
194
+ }
195
+
196
+ .jl-form-heading p {
197
+ margin: 0.3rem 0 0;
198
+ color: var(--jl-muted);
199
+ font-size: 0.85rem;
200
+ }
201
+
202
+ .jl-form-grid {
203
+ display: grid;
204
+ grid-template-columns: 1fr 1fr;
205
+ gap: 1rem;
206
+ }
207
+
208
+ .jl-field {
209
+ display: flex;
210
+ flex-direction: column;
211
+ gap: 0.45rem;
212
+ }
213
+
214
+ .jl-field span {
215
+ font-size: 0.82rem;
216
+ font-weight: 800;
217
+ }
218
+
219
+ .jl-field small {
220
+ color: var(--jl-muted);
221
+ font-size: 0.72rem;
222
+ line-height: 1.3;
223
+ }
224
+
225
+ .jl-field input,
226
+ .jl-field select {
227
+ width: 100%;
228
+ box-sizing: border-box;
229
+ min-height: 2.9rem;
230
+ padding: 0.7rem 0.8rem;
231
+ border: 1px solid var(--jl-line);
232
+ border-radius: 0.7rem;
233
+ background: color-mix(in srgb, var(--jl-paper) 88%, var(--jl-blue-soft));
234
+ color: var(--jl-ink);
235
+ font: inherit;
236
+ }
237
+
238
+ .jl-field input:focus,
239
+ .jl-field select:focus,
240
+ .jl-actions button:focus-visible {
241
+ outline: 3px solid color-mix(in srgb, var(--jl-blue) 30%, transparent);
242
+ outline-offset: 2px;
243
+ border-color: var(--jl-blue);
244
+ }
245
+
246
+ .jl-days-field {
247
+ max-width: 9rem;
248
+ }
249
+
250
+ .jl-actions {
251
+ display: flex;
252
+ flex-wrap: wrap;
253
+ gap: 0.75rem;
254
+ margin-top: 1.5rem;
255
+ }
256
+
257
+ .jl-actions button {
258
+ display: inline-flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ gap: 0.5rem;
262
+ min-height: 2.9rem;
263
+ padding: 0.7rem 1rem;
264
+ border: 1px solid transparent;
265
+ border-radius: 0.7rem;
266
+ font: inherit;
267
+ font-size: 0.87rem;
268
+ font-weight: 800;
269
+ cursor: pointer;
270
+ }
271
+
272
+ .jl-actions svg {
273
+ width: 1.15rem;
274
+ height: 1.15rem;
275
+ }
276
+
277
+ .jl-primary {
278
+ background: var(--jl-blue);
279
+ color: #fff;
280
+ }
281
+
282
+ .theme-dark .jl-primary {
283
+ color: #082433;
284
+ }
285
+
286
+ .jl-secondary {
287
+ border-color: var(--jl-line);
288
+ background: transparent;
289
+ color: var(--jl-ink);
290
+ }
291
+
292
+ .jl-result-top {
293
+ justify-content: space-between;
294
+ }
295
+
296
+ .jl-result-top > div {
297
+ display: flex;
298
+ gap: 0.8rem;
299
+ align-items: flex-start;
300
+ }
301
+
302
+ .jl-direction {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ min-height: 2rem;
306
+ padding: 0.35rem 0.65rem;
307
+ border-radius: 2rem;
308
+ background: var(--jl-blue-soft);
309
+ color: var(--jl-blue);
310
+ font-size: 0.76rem;
311
+ font-weight: 900;
312
+ text-align: right;
313
+ }
314
+
315
+ .jl-direction[data-direction="west"] {
316
+ background: color-mix(in srgb, var(--jl-sun) 18%, transparent);
317
+ color: #a36d11;
318
+ }
319
+
320
+ .theme-dark .jl-direction[data-direction="west"] {
321
+ color: #ffd27c;
322
+ }
323
+
324
+ .jl-result-intro {
325
+ margin: 1rem 0;
326
+ color: var(--jl-muted);
327
+ line-height: 1.55;
328
+ }
329
+
330
+ .jl-metrics {
331
+ display: grid;
332
+ grid-template-columns: repeat(3, 1fr);
333
+ gap: 0.65rem;
334
+ }
335
+
336
+ .jl-metrics > div {
337
+ min-width: 0;
338
+ padding: 0.8rem;
339
+ border-radius: 0.8rem;
340
+ background: var(--jl-blue-soft);
341
+ }
342
+
343
+ .jl-metrics span,
344
+ .jl-metrics strong {
345
+ display: block;
346
+ }
347
+
348
+ .jl-metrics span {
349
+ color: var(--jl-muted);
350
+ font-size: 0.68rem;
351
+ line-height: 1.2;
352
+ }
353
+
354
+ .jl-metrics strong {
355
+ margin-top: 0.35rem;
356
+ font-size: 1.05rem;
357
+ }
358
+
359
+ .jl-status {
360
+ margin: 1rem 0;
361
+ padding: 0.7rem 0.9rem;
362
+ border-left: 3px solid var(--jl-sun);
363
+ background: color-mix(in srgb, var(--jl-sun) 12%, transparent);
364
+ color: var(--jl-ink);
365
+ font-size: 0.85rem;
366
+ font-weight: 800;
367
+ }
368
+
369
+ .jl-status[data-complete="true"] {
370
+ border-left-color: var(--jl-blue);
371
+ background: var(--jl-blue-soft);
372
+ }
373
+
374
+ .jl-days-list {
375
+ display: grid;
376
+ gap: 0.7rem;
377
+ max-height: 27rem;
378
+ padding-right: 0.25rem;
379
+ overflow-y: auto;
380
+ }
381
+
382
+ .jl-day-card {
383
+ padding: 0.9rem;
384
+ border: 1px solid var(--jl-line);
385
+ border-radius: 0.85rem;
386
+ }
387
+
388
+ .jl-day-title {
389
+ display: flex;
390
+ justify-content: space-between;
391
+ gap: 0.75rem;
392
+ margin-bottom: 0.7rem;
393
+ font-size: 0.8rem;
394
+ }
395
+
396
+ .jl-day-title strong {
397
+ color: var(--jl-blue);
398
+ }
399
+
400
+ .jl-schedule {
401
+ display: grid;
402
+ grid-template-columns: repeat(5, 1fr);
403
+ gap: 0.4rem;
404
+ }
405
+
406
+ .jl-schedule > div {
407
+ display: flex;
408
+ flex-direction: column;
409
+ gap: 0.15rem;
410
+ }
411
+
412
+ .jl-schedule span {
413
+ color: var(--jl-blue);
414
+ font-size: 1.1rem;
415
+ }
416
+
417
+ .jl-schedule small {
418
+ color: var(--jl-muted);
419
+ font-size: 0.62rem;
420
+ line-height: 1.1;
421
+ }
422
+
423
+ .jl-schedule b {
424
+ font-size: 0.83rem;
425
+ }
426
+
427
+ .jl-light,
428
+ .jl-note,
429
+ .jl-safety {
430
+ display: flex;
431
+ align-items: flex-start;
432
+ gap: 0.45rem;
433
+ margin: 0.75rem 0 0;
434
+ color: var(--jl-muted);
435
+ font-size: 0.78rem;
436
+ line-height: 1.45;
437
+ }
438
+
439
+ .jl-light {
440
+ color: var(--jl-ink);
441
+ font-weight: 700;
442
+ }
443
+
444
+ .jl-light-dot {
445
+ width: 0.65rem;
446
+ height: 0.65rem;
447
+ flex: 0 0 auto;
448
+ margin-top: 0.2rem;
449
+ border-radius: 50%;
450
+ background: var(--jl-sun);
451
+ }
452
+
453
+ .jl-note {
454
+ margin-top: 1.2rem;
455
+ }
456
+
457
+ .jl-note svg,
458
+ .jl-safety svg {
459
+ width: 1rem;
460
+ height: 1rem;
461
+ flex: 0 0 auto;
462
+ color: var(--jl-blue);
463
+ }
464
+
465
+ .jl-safety {
466
+ color: var(--jl-ink);
467
+ font-weight: 700;
468
+ }
469
+
470
+ @media (max-width: 850px) {
471
+ .jl-workspace {
472
+ grid-template-columns: 1fr;
473
+ grid-template-areas:
474
+ "hero-copy"
475
+ "journey"
476
+ "form"
477
+ "result";
478
+ }
479
+
480
+ .jl-form {
481
+ padding-right: 0;
482
+ padding-bottom: 1.5rem;
483
+ border-right: 0;
484
+ border-bottom: 1px solid var(--jl-line);
485
+ }
486
+ }
487
+
488
+ @media (max-width: 520px) {
489
+ .jl-form-grid {
490
+ grid-template-columns: 1fr;
491
+ }
492
+
493
+ .jl-days-field {
494
+ max-width: none;
495
+ }
496
+
497
+ .jl-schedule {
498
+ grid-template-columns: repeat(3, 1fr);
499
+ row-gap: 0.7rem;
500
+ }
501
+
502
+ .jl-metrics {
503
+ grid-template-columns: 1fr;
504
+ }
505
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateJetLagPlan, formatTime } from './logic';
3
+
4
+ const base = {
5
+ originOffset: 1,
6
+ destinationOffset: 9,
7
+ departure: '2026-09-05T10:00',
8
+ arrival: '2026-09-05T20:00',
9
+ sleepTime: '23:00',
10
+ wakeTime: '07:00',
11
+ days: 8,
12
+ };
13
+
14
+ describe('jet lag recovery planner', () => {
15
+ it('wraps local times and advances an eastward schedule', () => {
16
+ const plan = calculateJetLagPlan(base);
17
+ expect(plan.direction).toBe('east');
18
+ expect(plan.offsetDifference).toBe(8);
19
+ expect(plan.days[0]?.sleep).toBe('06:00');
20
+ expect(plan.days.at(-1)?.sleep).toBe('23:00');
21
+ expect(plan.targetReached).toBe(true);
22
+ });
23
+
24
+ it('delays a westward schedule', () => {
25
+ const plan = calculateJetLagPlan({ ...base, originOffset: 9, destinationOffset: 1, days: 4 });
26
+ expect(plan.direction).toBe('west');
27
+ expect(plan.days[0]?.sleep).toBe('16:00');
28
+ expect(plan.days.at(-1)?.sleep).toBe('19:00');
29
+ expect(plan.remainingHours).toBe(4);
30
+ });
31
+
32
+ it('shows no shift for the same offset', () => {
33
+ const plan = calculateJetLagPlan({ ...base, destinationOffset: 1, days: 5 });
34
+ expect(plan.direction).toBe('same');
35
+ expect(plan.days[0]?.sleep).toBe('23:00');
36
+ expect(plan.days[0]?.light).toBe('none');
37
+ });
38
+
39
+ it('rejects negative travel duration without breaking the plan', () => {
40
+ const plan = calculateJetLagPlan({ ...base, arrival: '2026-09-04T20:00' });
41
+ expect(plan.travelHours).toBeNull();
42
+ expect(plan.days).toHaveLength(8);
43
+ });
44
+
45
+ it('formats wrapped times consistently', () => {
46
+ expect(formatTime(-60)).toBe('23:00');
47
+ expect(formatTime(24 * 60 + 15)).toBe('00:15');
48
+ });
49
+ });
@@ -0,0 +1,102 @@
1
+ export type TravelDirection = 'east' | 'west' | 'same';
2
+
3
+ export interface JetLagInputs {
4
+ originOffset: number;
5
+ destinationOffset: number;
6
+ departure: string;
7
+ arrival: string;
8
+ sleepTime: string;
9
+ wakeTime: string;
10
+ days: number;
11
+ }
12
+
13
+ export interface JetLagDay {
14
+ day: number;
15
+ sleep: string;
16
+ wake: string;
17
+ breakfast: string;
18
+ lunch: string;
19
+ dinner: string;
20
+ light: 'morning' | 'afternoon' | 'none';
21
+ shiftHours: number;
22
+ }
23
+
24
+ export interface JetLagPlan {
25
+ direction: TravelDirection;
26
+ offsetDifference: number;
27
+ absoluteDifference: number;
28
+ travelHours: number | null;
29
+ dailyShift: number;
30
+ targetReached: boolean;
31
+ remainingHours: number;
32
+ days: JetLagDay[];
33
+ }
34
+
35
+ const MINUTES_IN_DAY = 24 * 60;
36
+
37
+ const toMinutes = (time: string): number => {
38
+ const [hours, minutes] = time.split(':').map(Number);
39
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return 0;
40
+ return Math.max(0, Math.min(MINUTES_IN_DAY - 1, hours * 60 + minutes));
41
+ };
42
+
43
+ const wrapMinutes = (minutes: number): number => ((minutes % MINUTES_IN_DAY) + MINUTES_IN_DAY) % MINUTES_IN_DAY;
44
+
45
+ export const formatTime = (minutes: number): string => {
46
+ const wrapped = wrapMinutes(Math.round(minutes));
47
+ return `${String(Math.floor(wrapped / 60)).padStart(2, '0')}:${String(wrapped % 60).padStart(2, '0')}`;
48
+ };
49
+
50
+ const hoursBetween = (start: string, end: string): number | null => {
51
+ const startDate = new Date(start);
52
+ const endDate = new Date(end);
53
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null;
54
+ const difference = (endDate.getTime() - startDate.getTime()) / 3_600_000;
55
+ return difference >= 0 ? Math.round(difference * 10) / 10 : null;
56
+ };
57
+
58
+ const resolveDirection = (offsetDifference: number): TravelDirection => {
59
+ if (offsetDifference === 0) return 'same';
60
+ return offsetDifference > 0 ? 'east' : 'west';
61
+ };
62
+
63
+ const buildDay = (inputs: JetLagInputs, context: { offsetDifference: number; direction: TravelDirection; dailyShift: number; day: number }): JetLagDay => {
64
+ const progress = Math.min(Math.abs(context.offsetDifference), context.dailyShift * (context.day + 1));
65
+ const adjustment = context.direction === 'east' ? -progress : progress;
66
+ const sleep = toMinutes(inputs.sleepTime) + context.offsetDifference * 60 + adjustment * 60;
67
+ const wake = toMinutes(inputs.wakeTime) + context.offsetDifference * 60 + adjustment * 60;
68
+ let light: JetLagDay['light'] = 'none';
69
+ if (context.direction === 'east') light = 'morning';
70
+ if (context.direction === 'west') light = 'afternoon';
71
+ return {
72
+ day: context.day + 1,
73
+ sleep: formatTime(sleep),
74
+ wake: formatTime(wake),
75
+ breakfast: formatTime(wake + 60),
76
+ lunch: formatTime(wake + 5 * 60),
77
+ dinner: formatTime(wake + 10 * 60),
78
+ light,
79
+ shiftHours: Math.round(progress * 10) / 10,
80
+ };
81
+ };
82
+
83
+ export const calculateJetLagPlan = (inputs: JetLagInputs): JetLagPlan => {
84
+ const offsetDifference = inputs.destinationOffset - inputs.originOffset;
85
+ const absoluteDifference = Math.abs(offsetDifference);
86
+ const direction = resolveDirection(offsetDifference);
87
+ const daysCount = Math.max(1, Math.min(14, Math.round(inputs.days)));
88
+ const dailyShift = absoluteDifference === 0 ? 0 : Math.min(1, absoluteDifference / daysCount);
89
+ const travelHours = hoursBetween(inputs.departure, inputs.arrival);
90
+ const plan = Array.from({ length: daysCount }, (_, day) => buildDay(inputs, { offsetDifference, direction, dailyShift, day }));
91
+ const shifted = plan.at(-1)?.shiftHours ?? 0;
92
+ return {
93
+ direction,
94
+ offsetDifference,
95
+ absoluteDifference,
96
+ travelHours,
97
+ dailyShift: Math.round(dailyShift * 10) / 10,
98
+ targetReached: shifted >= absoluteDifference,
99
+ remainingHours: Math.max(0, Math.round((absoluteDifference - shifted) * 10) / 10),
100
+ days: plan,
101
+ };
102
+ };
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { jetLagRecoveryPlanner } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await jetLagRecoveryPlanner.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}