@jjlmoya/utils-language 1.8.0 → 1.10.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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +5 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/cefr-language-skill-profile-planner/bibliography.astro +6 -0
  8. package/src/tool/cefr-language-skill-profile-planner/bibliography.ts +6 -0
  9. package/src/tool/cefr-language-skill-profile-planner/cefr-language-skill-profile-planner.css +565 -0
  10. package/src/tool/cefr-language-skill-profile-planner/component.astro +100 -0
  11. package/src/tool/cefr-language-skill-profile-planner/controller.ts +158 -0
  12. package/src/tool/cefr-language-skill-profile-planner/dom-views.ts +154 -0
  13. package/src/tool/cefr-language-skill-profile-planner/entry.ts +34 -0
  14. package/src/tool/cefr-language-skill-profile-planner/evaluator.ts +12 -0
  15. package/src/tool/cefr-language-skill-profile-planner/i18n/de.ts +26 -0
  16. package/src/tool/cefr-language-skill-profile-planner/i18n/en.ts +59 -0
  17. package/src/tool/cefr-language-skill-profile-planner/i18n/es.ts +15 -0
  18. package/src/tool/cefr-language-skill-profile-planner/i18n/fr.ts +11 -0
  19. package/src/tool/cefr-language-skill-profile-planner/i18n/id.ts +11 -0
  20. package/src/tool/cefr-language-skill-profile-planner/i18n/it.ts +11 -0
  21. package/src/tool/cefr-language-skill-profile-planner/i18n/ja.ts +11 -0
  22. package/src/tool/cefr-language-skill-profile-planner/i18n/ko.ts +11 -0
  23. package/src/tool/cefr-language-skill-profile-planner/i18n/nl.ts +11 -0
  24. package/src/tool/cefr-language-skill-profile-planner/i18n/pl.ts +11 -0
  25. package/src/tool/cefr-language-skill-profile-planner/i18n/pt.ts +11 -0
  26. package/src/tool/cefr-language-skill-profile-planner/i18n/ru.ts +11 -0
  27. package/src/tool/cefr-language-skill-profile-planner/i18n/sv.ts +11 -0
  28. package/src/tool/cefr-language-skill-profile-planner/i18n/tr.ts +11 -0
  29. package/src/tool/cefr-language-skill-profile-planner/i18n/zh.ts +11 -0
  30. package/src/tool/cefr-language-skill-profile-planner/index.ts +14 -0
  31. package/src/tool/cefr-language-skill-profile-planner/logic.test.ts +38 -0
  32. package/src/tool/cefr-language-skill-profile-planner/logic.ts +131 -0
  33. package/src/tool/cefr-language-skill-profile-planner/seo.astro +9 -0
  34. package/src/tool/cefr-language-skill-profile-planner/storage.ts +24 -0
  35. package/src/tool/cefr-language-skill-profile-planner/types.ts +45 -0
  36. package/src/tool/cefr-language-skill-profile-planner/ui.ts +43 -0
  37. package/src/tool/language-shadowing-session-planner/bibliography.astro +6 -0
  38. package/src/tool/language-shadowing-session-planner/bibliography.ts +6 -0
  39. package/src/tool/language-shadowing-session-planner/component.astro +142 -0
  40. package/src/tool/language-shadowing-session-planner/controller.ts +152 -0
  41. package/src/tool/language-shadowing-session-planner/dom-views.ts +82 -0
  42. package/src/tool/language-shadowing-session-planner/entry.ts +34 -0
  43. package/src/tool/language-shadowing-session-planner/evaluator.ts +17 -0
  44. package/src/tool/language-shadowing-session-planner/i18n/de.ts +54 -0
  45. package/src/tool/language-shadowing-session-planner/i18n/en.ts +128 -0
  46. package/src/tool/language-shadowing-session-planner/i18n/es.ts +45 -0
  47. package/src/tool/language-shadowing-session-planner/i18n/fr.ts +45 -0
  48. package/src/tool/language-shadowing-session-planner/i18n/id.ts +40 -0
  49. package/src/tool/language-shadowing-session-planner/i18n/it.ts +40 -0
  50. package/src/tool/language-shadowing-session-planner/i18n/ja.ts +40 -0
  51. package/src/tool/language-shadowing-session-planner/i18n/ko.ts +40 -0
  52. package/src/tool/language-shadowing-session-planner/i18n/nl.ts +40 -0
  53. package/src/tool/language-shadowing-session-planner/i18n/pl.ts +40 -0
  54. package/src/tool/language-shadowing-session-planner/i18n/pt.ts +40 -0
  55. package/src/tool/language-shadowing-session-planner/i18n/ru.ts +40 -0
  56. package/src/tool/language-shadowing-session-planner/i18n/sv.ts +40 -0
  57. package/src/tool/language-shadowing-session-planner/i18n/tr.ts +40 -0
  58. package/src/tool/language-shadowing-session-planner/i18n/zh.ts +40 -0
  59. package/src/tool/language-shadowing-session-planner/index.ts +14 -0
  60. package/src/tool/language-shadowing-session-planner/language-shadowing-session-planner.css +561 -0
  61. package/src/tool/language-shadowing-session-planner/logic.test.ts +53 -0
  62. package/src/tool/language-shadowing-session-planner/logic.ts +94 -0
  63. package/src/tool/language-shadowing-session-planner/seo.astro +9 -0
  64. package/src/tool/language-shadowing-session-planner/storage.ts +18 -0
  65. package/src/tool/language-shadowing-session-planner/timer-view.ts +55 -0
  66. package/src/tool/language-shadowing-session-planner/timer.test.ts +50 -0
  67. package/src/tool/language-shadowing-session-planner/timer.ts +158 -0
  68. package/src/tool/language-shadowing-session-planner/types.ts +32 -0
  69. package/src/tool/language-shadowing-session-planner/ui.ts +55 -0
  70. package/src/tools.ts +3 -1
@@ -0,0 +1,561 @@
1
+ :root {
2
+ --n-base-bg: #f5efe5;
3
+ --n-base-surface: #fffaf2;
4
+ --n-base-surface-raised: #fffdf8;
5
+ --n-base-ink: #2c2723;
6
+ --n-base-muted: #756b62;
7
+ --n-base-line: #d8cbbd;
8
+ --n-base-track: #eadfd2;
9
+ --n-base-accent: #d65a3d;
10
+ --n-base-accent-deep: #963b2c;
11
+ --n-base-action-bg: #b13d25;
12
+ --n-base-action-ink: #fffaf2;
13
+ --n-base-action-hover: #963b2c;
14
+ --n-base-disabled-ink: #5d5148;
15
+ --n-base-blue: #477a89;
16
+ --n-base-gold: #c7953d;
17
+ --n-base-good: #37745d;
18
+ --n-base-stage: #efe5d9;
19
+ --n-base-shadow: rgba(77, 52, 38, 0.12);
20
+ }
21
+
22
+ .theme-dark {
23
+ --n-base-bg: #17181b;
24
+ --n-base-surface: #202226;
25
+ --n-base-surface-raised: #282a2f;
26
+ --n-base-ink: #f6efe5;
27
+ --n-base-muted: #b7aaa0;
28
+ --n-base-line: #464148;
29
+ --n-base-track: #343238;
30
+ --n-base-accent: #f07a58;
31
+ --n-base-accent-deep: #f6efe5;
32
+ --n-base-action-bg: #f6efe5;
33
+ --n-base-action-ink: #17181b;
34
+ --n-base-action-hover: #fffaf2;
35
+ --n-base-disabled-ink: #d2c7bd;
36
+ --n-base-blue: #77b4c3;
37
+ --n-base-gold: #e1b25b;
38
+ --n-base-good: #82c5a4;
39
+ --n-base-stage: #2a2927;
40
+ --n-base-shadow: rgba(0, 0, 0, 0.28);
41
+ }
42
+
43
+ .shadowing-tool {
44
+ --n-bg: var(--n-base-bg);
45
+ --n-surface: var(--n-base-surface);
46
+ --n-surface-raised: var(--n-base-surface-raised);
47
+ --n-ink: var(--n-base-ink);
48
+ --n-muted: var(--n-base-muted);
49
+ --n-line: var(--n-base-line);
50
+ --n-track: var(--n-base-track);
51
+ --n-accent: var(--n-base-accent);
52
+ --n-accent-deep: var(--n-base-accent-deep);
53
+ --n-action-bg: var(--n-base-action-bg);
54
+ --n-action-ink: var(--n-base-action-ink);
55
+ --n-action-hover: var(--n-base-action-hover);
56
+ --n-disabled-ink: var(--n-base-disabled-ink);
57
+ --n-blue: var(--n-base-blue);
58
+ --n-gold: var(--n-base-gold);
59
+ --n-good: var(--n-base-good);
60
+ --n-stage: var(--n-base-stage);
61
+ --n-shadow: var(--n-base-shadow);
62
+
63
+ display: grid;
64
+ grid-template-columns: minmax(250px, 0.75fr) minmax(360px, 1.25fr);
65
+ overflow: hidden;
66
+ border: 2px solid var(--n-line);
67
+ border-radius: 1.5rem;
68
+ background: var(--n-bg);
69
+ color: var(--n-ink);
70
+ box-shadow: 0 0.35rem 0.8rem var(--n-shadow);
71
+ isolation: isolate;
72
+ }
73
+
74
+ .shadowing-controls,
75
+ .shadowing-result {
76
+ min-width: 0;
77
+ padding: clamp(1.25rem, 3vw, 2.5rem);
78
+ }
79
+
80
+ .shadowing-controls {
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: 1.5rem;
84
+ border-right: 1px solid var(--n-line);
85
+ background: var(--n-surface);
86
+ }
87
+
88
+ .quick-starts,
89
+ .field-grid {
90
+ display: grid;
91
+ gap: 0.7rem;
92
+ }
93
+
94
+ .control-label,
95
+ .result-kicker,
96
+ .timeline-heading,
97
+ .metric-strip span,
98
+ .input-help,
99
+ .budget-note,
100
+ .timeline-legend,
101
+ .field-value {
102
+ font-size: 0.72rem;
103
+ letter-spacing: 0.06em;
104
+ line-height: 1.35;
105
+ text-transform: uppercase;
106
+ }
107
+
108
+ .control-label,
109
+ .result-kicker,
110
+ .timeline-heading,
111
+ .metric-strip span,
112
+ .timeline-legend,
113
+ .field-value {
114
+ color: var(--n-muted);
115
+ }
116
+
117
+ .quick-start-grid {
118
+ display: grid;
119
+ grid-template-columns: repeat(3, 1fr);
120
+ gap: 0.45rem;
121
+ }
122
+
123
+ .quick-start,
124
+ .reset-button {
125
+ border: 1px solid var(--n-line);
126
+ border-radius: 999px;
127
+ background: var(--n-surface-raised);
128
+ color: var(--n-ink);
129
+ cursor: pointer;
130
+ font: inherit;
131
+ font-size: 0.78rem;
132
+ padding: 0.65rem 0.55rem;
133
+ }
134
+
135
+ .quick-start:hover,
136
+ .quick-start:focus-visible,
137
+ .quick-start.is-active {
138
+ border-color: var(--n-line);
139
+ background: var(--n-surface);
140
+ color: var(--n-ink);
141
+ }
142
+
143
+ .quick-start.is-active {
144
+ border-color: var(--n-action-bg);
145
+ background: var(--n-action-bg);
146
+ color: var(--n-action-ink);
147
+ }
148
+
149
+ .field-grid {
150
+ gap: 1rem;
151
+ }
152
+
153
+ .shadowing-field {
154
+ display: grid;
155
+ grid-template-columns: 1fr auto;
156
+ gap: 0.5rem 0.7rem;
157
+ align-items: center;
158
+ }
159
+
160
+ .field-value {
161
+ display: flex;
162
+ gap: 0.25rem;
163
+ align-items: baseline;
164
+ color: var(--n-accent-deep);
165
+ font-weight: 800;
166
+ }
167
+
168
+ .field-value output {
169
+ font-size: 1.15rem;
170
+ letter-spacing: normal;
171
+ }
172
+
173
+ .shadowing-field input[type='range'] {
174
+ grid-column: 1 / -1;
175
+ width: 100%;
176
+ accent-color: var(--n-accent);
177
+ cursor: pointer;
178
+ }
179
+
180
+ .shadowing-field input[type='number'] {
181
+ grid-column: 1 / -1;
182
+ width: 100%;
183
+ border: 1px solid var(--n-line);
184
+ border-radius: 0.65rem;
185
+ background: var(--n-surface-raised);
186
+ color: var(--n-ink);
187
+ font: inherit;
188
+ font-size: 1rem;
189
+ min-height: 2.75rem;
190
+ padding: 0.55rem 0.7rem;
191
+ }
192
+
193
+ .shadowing-field input:focus-visible,
194
+ .quick-start:focus-visible,
195
+ .reset-button:focus-visible {
196
+ outline: 3px solid var(--n-gold);
197
+ outline-offset: 2px;
198
+ }
199
+
200
+ .input-help,
201
+ .budget-note {
202
+ margin: 0;
203
+ color: var(--n-muted);
204
+ letter-spacing: normal;
205
+ text-transform: none;
206
+ }
207
+
208
+ .reset-button {
209
+ align-self: flex-start;
210
+ padding-inline: 1rem;
211
+ }
212
+
213
+ .shadowing-result {
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 1.25rem;
217
+ background: var(--n-bg);
218
+ }
219
+
220
+ .timeline-card {
221
+ display: grid;
222
+ gap: 0.9rem;
223
+ min-width: 0;
224
+ padding: 1rem;
225
+ border: 1px solid var(--n-line);
226
+ border-radius: 1rem;
227
+ background: var(--n-surface);
228
+ }
229
+
230
+ .result-topline,
231
+ .timeline-heading,
232
+ .metric-strip,
233
+ .timeline-legend {
234
+ display: flex;
235
+ gap: 0.75rem;
236
+ justify-content: space-between;
237
+ align-items: center;
238
+ }
239
+
240
+ .status-pill {
241
+ border: 1px solid var(--n-line);
242
+ border-radius: 999px;
243
+ color: var(--n-muted);
244
+ font-size: 0.75rem;
245
+ font-weight: 800;
246
+ padding: 0.45rem 0.7rem;
247
+ }
248
+
249
+ .status-pill[data-tone='good'] {
250
+ border-color: var(--n-good);
251
+ color: var(--n-good);
252
+ }
253
+
254
+ .status-pill[data-tone='warn'] {
255
+ border-color: var(--n-accent);
256
+ color: var(--n-accent-deep);
257
+ }
258
+
259
+ .result-headline {
260
+ display: grid;
261
+ grid-template-columns: repeat(2, minmax(0, 1fr));
262
+ gap: 1rem;
263
+ }
264
+
265
+ .result-stat {
266
+ min-width: 0;
267
+ }
268
+
269
+ .result-stat strong {
270
+ display: block;
271
+ overflow: visible;
272
+ color: var(--n-ink);
273
+ font-size: clamp(1.85rem, 4.5vw, 3.6rem);
274
+ line-height: 1;
275
+ white-space: nowrap;
276
+ }
277
+
278
+ .result-stat span {
279
+ display: block;
280
+ margin-top: 0.4rem;
281
+ color: var(--n-muted);
282
+ font-size: 0.82rem;
283
+ line-height: 1.2;
284
+ }
285
+
286
+ .result-detail {
287
+ margin: 0;
288
+ color: var(--n-ink);
289
+ line-height: 1.5;
290
+ max-width: 42rem;
291
+ }
292
+
293
+ .timer-dock {
294
+ display: grid;
295
+ grid-template-columns: minmax(0, 1fr) auto;
296
+ gap: 1rem;
297
+ align-items: center;
298
+ padding: 1rem;
299
+ border: 1px solid var(--n-line);
300
+ border-radius: 1rem;
301
+ background: var(--n-surface);
302
+ }
303
+
304
+ .timer-readout {
305
+ display: grid;
306
+ grid-template-columns: auto auto;
307
+ gap: 0.35rem 0.75rem;
308
+ align-items: baseline;
309
+ }
310
+
311
+ .timer-title,
312
+ .timer-status {
313
+ color: var(--n-muted);
314
+ font-size: 0.72rem;
315
+ letter-spacing: 0.06em;
316
+ text-transform: uppercase;
317
+ }
318
+
319
+ .timer-readout strong {
320
+ grid-column: 1;
321
+ color: var(--n-ink);
322
+ font-size: clamp(1.8rem, 4vw, 2.7rem);
323
+ line-height: 1;
324
+ letter-spacing: 0.04em;
325
+ }
326
+
327
+ .timer-status {
328
+ grid-column: 2;
329
+ color: var(--n-accent-deep);
330
+ }
331
+
332
+ .timer-block {
333
+ grid-column: 1 / -1;
334
+ color: var(--n-muted);
335
+ font-size: 0.8rem;
336
+ line-height: 1.4;
337
+ }
338
+
339
+ .timer-actions {
340
+ display: flex;
341
+ gap: 0.45rem;
342
+ flex-wrap: wrap;
343
+ justify-content: flex-end;
344
+ }
345
+
346
+ .timer-actions button {
347
+ border: 1px solid var(--n-line);
348
+ border-radius: 999px;
349
+ background: var(--n-surface-raised);
350
+ color: var(--n-ink);
351
+ cursor: pointer;
352
+ font: inherit;
353
+ font-size: 0.75rem;
354
+ font-weight: 800;
355
+ padding: 0.65rem 0.8rem;
356
+ }
357
+
358
+ .timer-actions .timer-toggle {
359
+ border-color: var(--n-action-bg);
360
+ background: var(--n-action-bg);
361
+ color: var(--n-action-ink);
362
+ }
363
+
364
+ .timer-actions button:hover:not(:disabled),
365
+ .timer-actions button:focus-visible {
366
+ border-color: var(--n-line);
367
+ background: var(--n-surface);
368
+ color: var(--n-ink);
369
+ }
370
+
371
+ .timer-actions .timer-toggle:hover:not(:disabled),
372
+ .timer-actions .timer-toggle:focus-visible {
373
+ border-color: var(--n-action-hover);
374
+ background: var(--n-action-hover);
375
+ color: var(--n-action-ink);
376
+ }
377
+
378
+ .timer-actions button:disabled {
379
+ border-color: var(--n-line);
380
+ background: var(--n-track);
381
+ color: var(--n-disabled-ink);
382
+ cursor: not-allowed;
383
+ }
384
+
385
+ .timeline-heading {
386
+ margin-top: 0.75rem;
387
+ }
388
+
389
+ .timeline {
390
+ display: flex;
391
+ min-height: 7.5rem;
392
+ gap: 0.2rem;
393
+ align-items: stretch;
394
+ min-width: 0;
395
+ overflow: hidden;
396
+ padding: 0.55rem;
397
+ border: 1px solid var(--n-line);
398
+ border-radius: 1rem;
399
+ background: var(--n-stage);
400
+ }
401
+
402
+ .shadowing-timeline-block {
403
+ flex: 0 1 calc(var(--n-block-width) - 0.2rem);
404
+ min-width: 0.35rem;
405
+ display: flex;
406
+ align-items: flex-end;
407
+ justify-content: center;
408
+ border-radius: 0.55rem;
409
+ color: var(--n-surface-raised);
410
+ padding: 0.6rem 0.25rem;
411
+ }
412
+
413
+ .shadowing-timeline-block[data-kind='shadow'] {
414
+ min-width: 1.35rem;
415
+ background: var(--n-accent);
416
+ }
417
+
418
+ .shadowing-timeline-block[data-kind='pause'] {
419
+ background: var(--n-blue);
420
+ }
421
+
422
+ .shadowing-timeline-block[data-kind='pause'] .shadowing-block-label {
423
+ display: none;
424
+ }
425
+
426
+ .shadowing-timeline-block[data-kind='buffer'] {
427
+ background: var(--n-gold);
428
+ color: var(--n-ink);
429
+ }
430
+
431
+ .shadowing-block-label {
432
+ overflow: hidden;
433
+ font-size: 0.68rem;
434
+ font-weight: 800;
435
+ text-overflow: ellipsis;
436
+ white-space: nowrap;
437
+ }
438
+
439
+ .timeline-legend {
440
+ justify-content: flex-start;
441
+ flex-wrap: wrap;
442
+ gap: 0.85rem;
443
+ font-size: 0.68rem;
444
+ }
445
+
446
+ .timeline-legend span {
447
+ display: inline-flex;
448
+ gap: 0.35rem;
449
+ align-items: center;
450
+ }
451
+
452
+ .timeline-legend i {
453
+ display: inline-block;
454
+ width: 0.7rem;
455
+ height: 0.7rem;
456
+ border-radius: 0.2rem;
457
+ }
458
+
459
+ .timeline-legend i[data-kind='shadow'] {
460
+ background: var(--n-accent);
461
+ }
462
+
463
+ .timeline-legend i[data-kind='pause'] {
464
+ background: var(--n-blue);
465
+ }
466
+
467
+ .timeline-legend i[data-kind='buffer'] {
468
+ background: var(--n-gold);
469
+ }
470
+
471
+ .metric-strip {
472
+ justify-content: stretch;
473
+ gap: 0.5rem;
474
+ }
475
+
476
+ .metric-strip div {
477
+ flex: 1;
478
+ display: grid;
479
+ gap: 0.35rem;
480
+ padding-top: 0.75rem;
481
+ border-top: 1px solid var(--n-line);
482
+ }
483
+
484
+ .metric-strip strong {
485
+ color: var(--n-ink);
486
+ font-size: 1.05rem;
487
+ }
488
+
489
+ .rehearsal-cues {
490
+ display: grid;
491
+ gap: 0.75rem;
492
+ margin-top: auto;
493
+ padding-top: 1.1rem;
494
+ border-top: 1px solid var(--n-line);
495
+ }
496
+
497
+ .cue-title {
498
+ color: var(--n-muted);
499
+ font-size: 0.72rem;
500
+ letter-spacing: 0.06em;
501
+ text-transform: uppercase;
502
+ }
503
+
504
+ .cue-grid {
505
+ display: grid;
506
+ grid-template-columns: repeat(3, minmax(0, 1fr));
507
+ gap: 0.75rem;
508
+ }
509
+
510
+ .cue-grid p {
511
+ display: grid;
512
+ gap: 0.4rem;
513
+ margin: 0;
514
+ color: var(--n-muted);
515
+ font-size: 0.78rem;
516
+ line-height: 1.4;
517
+ }
518
+
519
+ .cue-grid strong {
520
+ color: var(--n-accent-deep);
521
+ font-size: 0.72rem;
522
+ letter-spacing: 0.08em;
523
+ }
524
+
525
+ @media (max-width: 780px) {
526
+ .shadowing-tool {
527
+ grid-template-columns: 1fr;
528
+ }
529
+
530
+ .shadowing-controls {
531
+ border-right: 0;
532
+ border-bottom: 1px solid var(--n-line);
533
+ }
534
+ }
535
+
536
+ @media (max-width: 480px) {
537
+ .shadowing-controls,
538
+ .shadowing-result {
539
+ padding: 1.1rem;
540
+ }
541
+
542
+ .result-headline {
543
+ grid-template-columns: 1fr;
544
+ }
545
+
546
+ .timer-dock {
547
+ grid-template-columns: 1fr;
548
+ }
549
+
550
+ .timer-actions {
551
+ justify-content: flex-start;
552
+ }
553
+
554
+ .timeline {
555
+ min-height: 6rem;
556
+ }
557
+
558
+ .cue-grid {
559
+ grid-template-columns: 1fr;
560
+ }
561
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateShadowingPlan, DEFAULT_SHADOWING_INPUTS, normalizeShadowingInputs } from './logic';
3
+
4
+ describe('calculateShadowingPlan', () => {
5
+ it('fits every requested repetition and exposes the spare buffer', () => {
6
+ const result = calculateShadowingPlan({ totalMinutes: 2, clipSeconds: 20, repetitions: 4, pauseSeconds: 10 });
7
+
8
+ expect(result.plannedRepetitions).toBe(4);
9
+ expect(result.activeSeconds).toBe(80);
10
+ expect(result.pauseTotalSeconds).toBe(30);
11
+ expect(result.scheduledSeconds).toBe(110);
12
+ expect(result.remainingSeconds).toBe(10);
13
+ expect(result.status).toBe('buffer');
14
+ expect(result.blocks.at(-1)?.kind).toBe('buffer');
15
+ });
16
+
17
+ it('stops before an incomplete repetition when the budget is short', () => {
18
+ const result = calculateShadowingPlan({ totalMinutes: 1, clipSeconds: 25, repetitions: 4, pauseSeconds: 10 });
19
+
20
+ expect(result.plannedRepetitions).toBe(2);
21
+ expect(result.scheduledSeconds).toBe(60);
22
+ expect(result.remainingSeconds).toBe(0);
23
+ expect(result.status).toBe('short');
24
+ expect(result.blocks.map((block) => block.kind)).toEqual(['shadow', 'pause', 'shadow']);
25
+ });
26
+
27
+ it('fits exactly without adding a buffer block', () => {
28
+ const result = calculateShadowingPlan({ totalMinutes: 1, clipSeconds: 20, repetitions: 3, pauseSeconds: 0 });
29
+
30
+ expect(result.scheduledSeconds).toBe(60);
31
+ expect(result.remainingSeconds).toBe(0);
32
+ expect(result.status).toBe('fits');
33
+ expect(result.blocks).toHaveLength(3);
34
+ });
35
+
36
+ it('returns an empty active schedule when one complete pass cannot fit', () => {
37
+ const result = calculateShadowingPlan({ totalMinutes: 1, clipSeconds: 100, repetitions: 2, pauseSeconds: 20 });
38
+
39
+ expect(result.plannedRepetitions).toBe(0);
40
+ expect(result.activeSeconds).toBe(0);
41
+ expect(result.blocks).toEqual([{ kind: 'buffer', startSeconds: 0, endSeconds: 60 }]);
42
+ expect(result.status).toBe('short');
43
+ });
44
+
45
+ it('normalizes invalid and out of range values', () => {
46
+ expect(normalizeShadowingInputs({ totalMinutes: Number.NaN, clipSeconds: 900, repetitions: 0, pauseSeconds: -9 })).toEqual({
47
+ totalMinutes: DEFAULT_SHADOWING_INPUTS.totalMinutes,
48
+ clipSeconds: 300,
49
+ repetitions: 1,
50
+ pauseSeconds: 0,
51
+ });
52
+ });
53
+ });
@@ -0,0 +1,94 @@
1
+ import type { ShadowingBlock, ShadowingPlanInputs, ShadowingPlanResult } from './types';
2
+
3
+ export const DEFAULT_SHADOWING_INPUTS: ShadowingPlanInputs = {
4
+ totalMinutes: 8,
5
+ clipSeconds: 20,
6
+ repetitions: 12,
7
+ pauseSeconds: 10,
8
+ };
9
+
10
+ const MINUTES_MIN = 1;
11
+ const MINUTES_MAX = 180;
12
+ const CLIP_MIN = 3;
13
+ const CLIP_MAX = 300;
14
+ const REPETITIONS_MIN = 1;
15
+ const REPETITIONS_MAX = 30;
16
+ const PAUSE_MIN = 0;
17
+ const PAUSE_MAX = 120;
18
+
19
+ function clamp(value: number, minimum: number, maximum: number): number {
20
+ return Math.min(maximum, Math.max(minimum, value));
21
+ }
22
+
23
+ function safeInteger(value: number, fallback: number): number {
24
+ return Number.isFinite(value) ? Math.round(value) : fallback;
25
+ }
26
+
27
+ export function normalizeShadowingInputs(inputs: ShadowingPlanInputs): ShadowingPlanInputs {
28
+ return {
29
+ totalMinutes: clamp(safeInteger(inputs.totalMinutes, DEFAULT_SHADOWING_INPUTS.totalMinutes), MINUTES_MIN, MINUTES_MAX),
30
+ clipSeconds: clamp(safeInteger(inputs.clipSeconds, DEFAULT_SHADOWING_INPUTS.clipSeconds), CLIP_MIN, CLIP_MAX),
31
+ repetitions: clamp(safeInteger(inputs.repetitions, DEFAULT_SHADOWING_INPUTS.repetitions), REPETITIONS_MIN, REPETITIONS_MAX),
32
+ pauseSeconds: clamp(safeInteger(inputs.pauseSeconds, DEFAULT_SHADOWING_INPUTS.pauseSeconds), PAUSE_MIN, PAUSE_MAX),
33
+ };
34
+ }
35
+
36
+ function buildBlocks(inputs: ShadowingPlanInputs, plannedRepetitions: number, remainingSeconds: number): ShadowingBlock[] {
37
+ const blocks: ShadowingBlock[] = [];
38
+ let cursor = 0;
39
+
40
+ for (let index = 1; index <= plannedRepetitions; index += 1) {
41
+ blocks.push({ kind: 'shadow', index, startSeconds: cursor, endSeconds: cursor + inputs.clipSeconds });
42
+ cursor += inputs.clipSeconds;
43
+ if (index < plannedRepetitions && inputs.pauseSeconds > 0) {
44
+ blocks.push({ kind: 'pause', startSeconds: cursor, endSeconds: cursor + inputs.pauseSeconds });
45
+ cursor += inputs.pauseSeconds;
46
+ }
47
+ }
48
+
49
+ if (remainingSeconds > 0) {
50
+ blocks.push({ kind: 'buffer', startSeconds: cursor, endSeconds: cursor + remainingSeconds });
51
+ }
52
+
53
+ return blocks;
54
+ }
55
+
56
+ function plannedRepetitionsForBudget(inputs: ShadowingPlanInputs, budgetSeconds: number): number {
57
+ const cycleSeconds = inputs.clipSeconds + inputs.pauseSeconds;
58
+ const maximum = Math.floor((budgetSeconds + inputs.pauseSeconds) / cycleSeconds);
59
+ return Math.min(inputs.repetitions, Math.max(0, maximum));
60
+ }
61
+
62
+ function getStatus(plannedRepetitions: number, requestedRepetitions: number, remainingSeconds: number) {
63
+ if (plannedRepetitions < requestedRepetitions) return 'short' as const;
64
+ if (remainingSeconds > 0) return 'buffer' as const;
65
+ return 'fits' as const;
66
+ }
67
+
68
+ export function calculateShadowingPlan(rawInputs: ShadowingPlanInputs): ShadowingPlanResult {
69
+ const inputs = normalizeShadowingInputs(rawInputs);
70
+ const budgetSeconds = inputs.totalMinutes * 60;
71
+ const requestedSeconds = inputs.repetitions * inputs.clipSeconds + Math.max(0, inputs.repetitions - 1) * inputs.pauseSeconds;
72
+ const plannedRepetitions = plannedRepetitionsForBudget(inputs, budgetSeconds);
73
+ const activeSeconds = plannedRepetitions * inputs.clipSeconds;
74
+ const pauseTotalSeconds = Math.max(0, plannedRepetitions - 1) * inputs.pauseSeconds;
75
+ const scheduledSeconds = activeSeconds + pauseTotalSeconds;
76
+ const remainingSeconds = Math.max(0, budgetSeconds - scheduledSeconds);
77
+ const coveragePercent = budgetSeconds === 0 ? 0 : Math.round((scheduledSeconds / budgetSeconds) * 100);
78
+ const status = getStatus(plannedRepetitions, inputs.repetitions, remainingSeconds);
79
+
80
+ return {
81
+ inputs,
82
+ budgetSeconds,
83
+ requestedSeconds,
84
+ activeSeconds,
85
+ pauseTotalSeconds,
86
+ scheduledSeconds,
87
+ remainingSeconds,
88
+ requestedRepetitions: inputs.repetitions,
89
+ plannedRepetitions,
90
+ coveragePercent,
91
+ status,
92
+ blocks: buildBlocks(inputs, plannedRepetitions, remainingSeconds),
93
+ };
94
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { languageShadowingSessionPlanner } from './entry';
4
+
5
+ const locale = Astro.props.locale ?? 'en';
6
+ const content = await languageShadowingSessionPlanner.i18n[locale as keyof typeof languageShadowingSessionPlanner.i18n]?.();
7
+ ---
8
+
9
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}