@jjlmoya/utils-language 1.1.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 (109) hide show
  1. package/.github/workflows/npm-publish.yml +40 -0
  2. package/.gitignore +6 -0
  3. package/.stylelintrc.json +98 -0
  4. package/astro.config.mjs +19 -0
  5. package/eslint.config.js +201 -0
  6. package/package.json +79 -0
  7. package/prompts/create_tool.md +98 -0
  8. package/prompts/i18n/de.md +16 -0
  9. package/prompts/i18n/en.md +16 -0
  10. package/prompts/i18n/es.md +16 -0
  11. package/prompts/i18n/fr.md +16 -0
  12. package/prompts/i18n/id.md +16 -0
  13. package/prompts/i18n/it.md +16 -0
  14. package/prompts/i18n/ja.md +16 -0
  15. package/prompts/i18n/ko.md +16 -0
  16. package/prompts/i18n/nl.md +16 -0
  17. package/prompts/i18n/pl.md +16 -0
  18. package/prompts/i18n/pt.md +16 -0
  19. package/prompts/i18n/ru.md +16 -0
  20. package/prompts/i18n/sv.md +16 -0
  21. package/prompts/i18n/tr.md +16 -0
  22. package/prompts/i18n/zh.md +16 -0
  23. package/prompts/seo.md +58 -0
  24. package/prompts/translations/french.md +33 -0
  25. package/scripts/postinstall.mjs +27 -0
  26. package/src/category/LanguageCategorySEO.astro +9 -0
  27. package/src/category/i18n/de.ts +15 -0
  28. package/src/category/i18n/en.ts +23 -0
  29. package/src/category/i18n/es.ts +15 -0
  30. package/src/category/i18n/fr.ts +15 -0
  31. package/src/category/i18n/id.ts +15 -0
  32. package/src/category/i18n/it.ts +15 -0
  33. package/src/category/i18n/ja.ts +15 -0
  34. package/src/category/i18n/ko.ts +15 -0
  35. package/src/category/i18n/nl.ts +15 -0
  36. package/src/category/i18n/pl.ts +15 -0
  37. package/src/category/i18n/pt.ts +15 -0
  38. package/src/category/i18n/ru.ts +15 -0
  39. package/src/category/i18n/shared.ts +11 -0
  40. package/src/category/i18n/sv.ts +15 -0
  41. package/src/category/i18n/tr.ts +15 -0
  42. package/src/category/i18n/zh.ts +15 -0
  43. package/src/category/index.ts +24 -0
  44. package/src/components/PreviewNavSidebar.astro +116 -0
  45. package/src/components/PreviewToolbar.astro +143 -0
  46. package/src/data.ts +10 -0
  47. package/src/entries.ts +3 -0
  48. package/src/env.d.ts +5 -0
  49. package/src/index.ts +23 -0
  50. package/src/layouts/PreviewLayout.astro +118 -0
  51. package/src/pages/[locale]/[slug].astro +163 -0
  52. package/src/pages/[locale].astro +251 -0
  53. package/src/pages/index.astro +3 -0
  54. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  55. package/src/tests/diacritics_density.test.ts +118 -0
  56. package/src/tests/faq_count.test.ts +18 -0
  57. package/src/tests/i18n_coverage.test.ts +34 -0
  58. package/src/tests/inverted_punctuation.test.ts +84 -0
  59. package/src/tests/locale_completeness.test.ts +23 -0
  60. package/src/tests/mocks/astro_mock.js +2 -0
  61. package/src/tests/no_em_dash.test.ts +47 -0
  62. package/src/tests/no_en_dash.test.ts +70 -0
  63. package/src/tests/no_h1_in_components.test.ts +48 -0
  64. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  65. package/src/tests/schemas_fulfillment.test.ts +23 -0
  66. package/src/tests/script_density.test.ts +94 -0
  67. package/src/tests/seo_length.test.ts +23 -0
  68. package/src/tests/seo_parity.test.ts +60 -0
  69. package/src/tests/seo_translation_completeness.test.ts +69 -0
  70. package/src/tests/seo_wellformed_export.test.ts +65 -0
  71. package/src/tests/shared-test-helpers.ts +56 -0
  72. package/src/tests/slug_language_code_format.test.ts +23 -0
  73. package/src/tests/slug_uniqueness.test.ts +81 -0
  74. package/src/tests/spanish_leakage.test.ts +175 -0
  75. package/src/tests/title_quality.test.ts +55 -0
  76. package/src/tests/tool_exports.test.ts +34 -0
  77. package/src/tests/tool_validation.test.ts +16 -0
  78. package/src/tests/translation_copy.test.ts +122 -0
  79. package/src/tool/language-learning-study-plan-planner/bibliography.astro +6 -0
  80. package/src/tool/language-learning-study-plan-planner/bibliography.ts +6 -0
  81. package/src/tool/language-learning-study-plan-planner/component.astro +274 -0
  82. package/src/tool/language-learning-study-plan-planner/entry.ts +34 -0
  83. package/src/tool/language-learning-study-plan-planner/i18n/de.ts +22 -0
  84. package/src/tool/language-learning-study-plan-planner/i18n/en.ts +103 -0
  85. package/src/tool/language-learning-study-plan-planner/i18n/es.ts +21 -0
  86. package/src/tool/language-learning-study-plan-planner/i18n/fr.ts +21 -0
  87. package/src/tool/language-learning-study-plan-planner/i18n/id.ts +21 -0
  88. package/src/tool/language-learning-study-plan-planner/i18n/it.ts +21 -0
  89. package/src/tool/language-learning-study-plan-planner/i18n/ja.ts +21 -0
  90. package/src/tool/language-learning-study-plan-planner/i18n/ko.ts +21 -0
  91. package/src/tool/language-learning-study-plan-planner/i18n/nl.ts +21 -0
  92. package/src/tool/language-learning-study-plan-planner/i18n/pl.ts +21 -0
  93. package/src/tool/language-learning-study-plan-planner/i18n/pt.ts +21 -0
  94. package/src/tool/language-learning-study-plan-planner/i18n/ru.ts +21 -0
  95. package/src/tool/language-learning-study-plan-planner/i18n/shared.ts +70 -0
  96. package/src/tool/language-learning-study-plan-planner/i18n/sv.ts +21 -0
  97. package/src/tool/language-learning-study-plan-planner/i18n/tr.ts +21 -0
  98. package/src/tool/language-learning-study-plan-planner/i18n/zh.ts +21 -0
  99. package/src/tool/language-learning-study-plan-planner/index.ts +4 -0
  100. package/src/tool/language-learning-study-plan-planner/language-learning-study-plan-planner.css +403 -0
  101. package/src/tool/language-learning-study-plan-planner/logic.test.ts +59 -0
  102. package/src/tool/language-learning-study-plan-planner/logic.ts +157 -0
  103. package/src/tool/language-learning-study-plan-planner/seo.astro +9 -0
  104. package/src/tool/language-learning-study-plan-planner/types.ts +42 -0
  105. package/src/tool/language-learning-study-plan-planner/ui.ts +46 -0
  106. package/src/tools.ts +6 -0
  107. package/src/types.ts +68 -0
  108. package/tsconfig.json +15 -0
  109. package/vitest.config.ts +20 -0
@@ -0,0 +1,403 @@
1
+ .language-planner {
2
+ --planner-surface: var(--bg-surface);
3
+ --planner-muted: var(--text-muted);
4
+ --planner-line: var(--border-base);
5
+ --planner-control: var(--bg-muted);
6
+ --planner-accent: var(--primary);
7
+ --planner-accent-soft: var(--primary-bg);
8
+ --planner-shadow: var(--shadow-base);
9
+ --planner-success: var(--color-success);
10
+ --planner-warning: var(--color-warning);
11
+ --planner-danger: var(--color-error);
12
+
13
+ display: grid;
14
+ gap: 1rem;
15
+ grid-template-columns: minmax(290px, 0.9fr) minmax(400px, 1.1fr);
16
+ padding: clamp(1.25rem, 3vw, 2.25rem);
17
+ background: var(--planner-surface);
18
+ border: 1px solid var(--planner-line);
19
+ border-radius: 1.25rem;
20
+ box-shadow: 0 1rem 2.5rem var(--planner-shadow);
21
+ color: var(--text-base);
22
+ }
23
+
24
+ .planner-editor,
25
+ .planner-output {
26
+ min-width: 0;
27
+ }
28
+
29
+ .planner-output {
30
+ padding-left: clamp(1rem, 3vw, 2rem);
31
+ border-left: 1px solid var(--planner-line);
32
+ }
33
+
34
+ .form-intro,
35
+ .result-heading,
36
+ .form-actions,
37
+ .preset-row,
38
+ .output-heading {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 1rem;
43
+ }
44
+
45
+ .form-intro {
46
+ align-items: flex-start;
47
+ margin-bottom: 1.4rem;
48
+ }
49
+
50
+ .eyebrow,
51
+ .section-title,
52
+ .metric-label,
53
+ .allocation-label,
54
+ .status-label,
55
+ .milestone-hours {
56
+ margin: 0;
57
+ color: var(--planner-accent);
58
+ font-size: 0.7rem;
59
+ font-weight: 900;
60
+ letter-spacing: 0.12em;
61
+ text-transform: uppercase;
62
+ }
63
+
64
+ .form-intro h2,
65
+ .empty-result h2,
66
+ .error-result h2,
67
+ .output-heading h2 {
68
+ margin: 0.45rem 0;
69
+ font-size: clamp(1.5rem, 3vw, 2.4rem);
70
+ letter-spacing: -0.05em;
71
+ line-height: 1;
72
+ }
73
+
74
+ .form-note,
75
+ .empty-result p,
76
+ .error-result p {
77
+ max-width: 27rem;
78
+ margin: 0;
79
+ color: var(--planner-muted);
80
+ font-size: 0.85rem;
81
+ line-height: 1.5;
82
+ }
83
+
84
+ .form-grid {
85
+ display: grid;
86
+ grid-template-columns: 1fr 1fr;
87
+ gap: 0 0.7rem;
88
+ }
89
+
90
+ .form-grid label {
91
+ display: grid;
92
+ gap: 0.35rem;
93
+ margin: 0.85rem 0 0.4rem;
94
+ font-size: 0.78rem;
95
+ font-weight: 800;
96
+ }
97
+
98
+ .wide-field {
99
+ grid-column: span 2;
100
+ }
101
+
102
+ select,
103
+ input {
104
+ width: 100%;
105
+ min-height: 2.85rem;
106
+ box-sizing: border-box;
107
+ padding: 0.7rem;
108
+ border: 1px solid var(--planner-line);
109
+ border-radius: 0.6rem;
110
+ background: var(--planner-control);
111
+ color: var(--text-base);
112
+ font: inherit;
113
+ }
114
+
115
+ select {
116
+ appearance: auto;
117
+ }
118
+
119
+ select:focus-visible,
120
+ input:focus-visible,
121
+ button:focus-visible {
122
+ outline: 3px solid var(--planner-accent-soft);
123
+ outline-offset: 2px;
124
+ }
125
+
126
+ button {
127
+ cursor: pointer;
128
+ border: 1px solid var(--planner-line);
129
+ border-radius: 999px;
130
+ background: var(--planner-control);
131
+ color: var(--text-base);
132
+ font: inherit;
133
+ font-size: 0.75rem;
134
+ font-weight: 800;
135
+ padding: 0.55rem 0.75rem;
136
+ }
137
+
138
+ button:hover {
139
+ border-color: var(--planner-accent);
140
+ }
141
+
142
+ .preset-row {
143
+ justify-content: flex-start;
144
+ flex-wrap: wrap;
145
+ margin-top: 1rem;
146
+ padding-top: 1rem;
147
+ border-top: 1px solid var(--planner-line);
148
+ }
149
+
150
+ .preset-row > span {
151
+ color: var(--planner-muted);
152
+ font-size: 0.78rem;
153
+ font-weight: 800;
154
+ }
155
+
156
+ .preset-row button,
157
+ .text-button {
158
+ border-color: transparent;
159
+ background: var(--planner-accent-soft);
160
+ color: var(--planner-accent);
161
+ }
162
+
163
+ .form-actions {
164
+ justify-content: flex-start;
165
+ margin-top: 1.25rem;
166
+ padding-top: 1.25rem;
167
+ border-top: 1px solid var(--planner-line);
168
+ }
169
+
170
+ .primary-button {
171
+ border-color: var(--planner-accent);
172
+ border-radius: 0.6rem;
173
+ background: var(--planner-accent);
174
+ color: var(--text-on-primary, #fff);
175
+ padding: 0.8rem 1.1rem;
176
+ }
177
+
178
+ .text-button {
179
+ padding: 0.45rem 0.7rem;
180
+ }
181
+
182
+ .output-heading {
183
+ align-items: flex-start;
184
+ margin-bottom: 1.4rem;
185
+ }
186
+
187
+ .live-badge {
188
+ padding: 0.4rem 0.6rem;
189
+ border-radius: 999px;
190
+ background: var(--planner-accent-soft);
191
+ color: var(--planner-accent);
192
+ font-size: 0.62rem;
193
+ font-weight: 900;
194
+ letter-spacing: 0.1em;
195
+ }
196
+
197
+ .empty-result,
198
+ .error-result {
199
+ display: flex;
200
+ min-height: 13rem;
201
+ flex-direction: column;
202
+ align-items: flex-start;
203
+ justify-content: center;
204
+ gap: 1rem;
205
+ }
206
+
207
+ .route-mark {
208
+ display: grid;
209
+ width: 3.2rem;
210
+ height: 3.2rem;
211
+ place-items: center;
212
+ border-radius: 50%;
213
+ background: var(--planner-accent);
214
+ color: var(--text-on-primary, #fff);
215
+ font-size: 1.8rem;
216
+ }
217
+
218
+ .error-result {
219
+ min-height: auto;
220
+ padding: 1rem;
221
+ border: 1px solid var(--planner-danger);
222
+ border-left-width: 4px;
223
+ border-radius: 0.75rem;
224
+ background: var(--planner-control);
225
+ }
226
+
227
+ .error-result h2 {
228
+ color: var(--planner-danger);
229
+ font-size: 1.2rem;
230
+ }
231
+
232
+ .plan-result {
233
+ display: grid;
234
+ gap: 1.5rem;
235
+ }
236
+
237
+ .status-banner {
238
+ display: grid;
239
+ gap: 0.35rem;
240
+ padding: 1rem;
241
+ border: 1px solid currentcolor;
242
+ border-left-width: 4px;
243
+ border-radius: 0.5rem;
244
+ background: var(--planner-control);
245
+ }
246
+
247
+ .status-banner.on-track {
248
+ color: var(--planner-success);
249
+ }
250
+
251
+ .status-banner.tight {
252
+ color: var(--planner-warning);
253
+ }
254
+
255
+ .status-banner.insufficient {
256
+ color: var(--planner-danger);
257
+ }
258
+
259
+ .status-copy {
260
+ color: var(--text-base);
261
+ line-height: 1.45;
262
+ }
263
+
264
+ .result-heading {
265
+ align-items: flex-end;
266
+ }
267
+
268
+ .result-level {
269
+ font-size: clamp(1.4rem, 3vw, 2.25rem);
270
+ font-weight: 850;
271
+ line-height: 1;
272
+ text-align: right;
273
+ }
274
+
275
+ .metric-grid,
276
+ .allocation-grid {
277
+ display: grid;
278
+ grid-template-columns: repeat(4, minmax(0, 1fr));
279
+ gap: 0.75rem;
280
+ }
281
+
282
+ .metric-card {
283
+ display: grid;
284
+ gap: 0.45rem;
285
+ padding: 0.8rem 0;
286
+ border-top: 3px solid var(--planner-accent);
287
+ }
288
+
289
+ .metric-label,
290
+ .allocation-label,
291
+ .section-title {
292
+ color: var(--planner-muted);
293
+ }
294
+
295
+ .metric-value {
296
+ font-size: clamp(1.2rem, 2.5vw, 1.8rem);
297
+ font-weight: 850;
298
+ }
299
+
300
+ .metric-detail,
301
+ .milestone-date {
302
+ color: var(--planner-muted);
303
+ font-size: 0.72rem;
304
+ }
305
+
306
+ .allocation-section,
307
+ .milestones-section {
308
+ display: grid;
309
+ gap: 0.75rem;
310
+ }
311
+
312
+ .allocation-item {
313
+ display: grid;
314
+ gap: 0.35rem;
315
+ padding: 0.75rem 0;
316
+ border-bottom: 3px solid var(--planner-accent);
317
+ }
318
+
319
+ .allocation-time {
320
+ font-size: 1.1rem;
321
+ font-weight: 800;
322
+ }
323
+
324
+ .milestone-list {
325
+ display: grid;
326
+ gap: 0.65rem;
327
+ margin: 0;
328
+ padding: 0;
329
+ list-style: none;
330
+ }
331
+
332
+ .milestone-list li {
333
+ display: grid;
334
+ grid-template-columns: 1fr auto auto;
335
+ align-items: center;
336
+ gap: 1rem;
337
+ padding: 0.8rem 0;
338
+ border-top: 1px solid var(--planner-line);
339
+ }
340
+
341
+ .milestone-level {
342
+ font-weight: 800;
343
+ }
344
+
345
+ @media (max-width: 820px) {
346
+ .language-planner {
347
+ grid-template-columns: 1fr;
348
+ }
349
+
350
+ .planner-output {
351
+ padding-top: 1.5rem;
352
+ padding-left: 0;
353
+ border-top: 1px solid var(--planner-line);
354
+ border-left: 0;
355
+ }
356
+ }
357
+
358
+ @media (max-width: 700px) {
359
+ .form-intro,
360
+ .result-heading {
361
+ display: grid;
362
+ }
363
+
364
+ .form-grid,
365
+ .metric-grid,
366
+ .allocation-grid {
367
+ grid-template-columns: 1fr 1fr;
368
+ }
369
+
370
+ .wide-field {
371
+ grid-column: span 2;
372
+ }
373
+
374
+ .milestone-list li {
375
+ grid-template-columns: 1fr auto;
376
+ }
377
+
378
+ .milestone-hours {
379
+ grid-column: 1 / -1;
380
+ }
381
+ }
382
+
383
+ @media (max-width: 460px) {
384
+ .form-grid,
385
+ .metric-grid,
386
+ .allocation-grid {
387
+ grid-template-columns: 1fr;
388
+ }
389
+
390
+ .wide-field {
391
+ grid-column: auto;
392
+ }
393
+
394
+ .form-actions {
395
+ align-items: stretch;
396
+ flex-direction: column;
397
+ }
398
+
399
+ .primary-button,
400
+ .text-button {
401
+ width: 100%;
402
+ }
403
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateStudyPlan, validateInputs } from './logic';
3
+ import type { LanguagePlannerInputs } from './types';
4
+
5
+ const TODAY = new Date('2026-08-29T12:00:00Z');
6
+
7
+ const baseInputs: LanguagePlannerInputs = {
8
+ sourceLanguage: 'English',
9
+ targetLanguage: 'Spanish',
10
+ currentLevel: 'A1',
11
+ targetLevel: 'B1',
12
+ targetDate: '2027-08-29',
13
+ weeklyHours: 5,
14
+ priority: 'balanced',
15
+ };
16
+
17
+ describe('calculateStudyPlan', () => {
18
+ it('calculates a range, available time, and level milestones', () => {
19
+ const result = calculateStudyPlan(baseInputs, TODAY);
20
+
21
+ expect(result.ok).toBe(true);
22
+ if (!result.ok) return;
23
+ expect(result.plan.hoursLow).toBe(260);
24
+ expect(result.plan.hoursHigh).toBe(300);
25
+ expect(result.plan.weeksRemaining).toBe(53);
26
+ expect(result.plan.availableHours).toBe(265);
27
+ expect(result.plan.status).toBe('tight');
28
+ expect(result.plan.milestones.map((milestone) => milestone.level)).toEqual(['A2', 'B1']);
29
+ });
30
+
31
+ it('handles the beginner boundary and a short deadline', () => {
32
+ const result = calculateStudyPlan({ ...baseInputs, currentLevel: 'A0', targetLevel: 'A1', targetDate: '2026-11-27', weeklyHours: 10 }, TODAY);
33
+
34
+ expect(result.ok).toBe(true);
35
+ if (!result.ok) return;
36
+ expect(result.plan.hoursLow).toBe(90);
37
+ expect(result.plan.hoursHigh).toBe(100);
38
+ expect(result.plan.status).toBe('on-track');
39
+ });
40
+
41
+ it('reports insufficient time without pretending the goal is guaranteed', () => {
42
+ const result = calculateStudyPlan({ ...baseInputs, targetLevel: 'C2', targetDate: '2026-09-12', weeklyHours: 1 }, TODAY);
43
+
44
+ expect(result.ok).toBe(true);
45
+ if (!result.ok) return;
46
+ expect(result.plan.status).toBe('insufficient');
47
+ expect(result.plan.recommendedWeeklyHoursHigh).toBe(550);
48
+ });
49
+ });
50
+
51
+ describe('validateInputs', () => {
52
+ it('rejects same languages, reversed levels, invalid dates, and impossible schedules', () => {
53
+ expect(validateInputs({ ...baseInputs, targetLanguage: 'English' }, TODAY)).toContain('different');
54
+ expect(validateInputs({ ...baseInputs, currentLevel: 'B2', targetLevel: 'A2' }, TODAY)).toContain('above');
55
+ expect(validateInputs({ ...baseInputs, targetDate: '2026-02-30' }, TODAY)).toContain('valid target date');
56
+ expect(validateInputs({ ...baseInputs, weeklyHours: 0.25 }, TODAY)).toContain('between 0.5 and 40');
57
+ expect(validateInputs({ ...baseInputs, targetDate: '2026-08-29' }, TODAY)).toContain('future');
58
+ });
59
+ });
@@ -0,0 +1,157 @@
1
+ import type {
2
+ LanguagePlannerInputs,
3
+ PlanStatus,
4
+ SkillAllocation,
5
+ StudyLevel,
6
+ StudyMilestone,
7
+ StudyPlan,
8
+ } from './types';
9
+
10
+ export const STUDY_LEVELS: StudyLevel[] = ['A0', 'A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
11
+
12
+ export const LEVEL_LABELS: Record<StudyLevel, string> = {
13
+ A0: 'Pre-A1',
14
+ A1: 'A1',
15
+ A2: 'A2',
16
+ B1: 'B1',
17
+ B2: 'B2',
18
+ C1: 'C1',
19
+ C2: 'C2',
20
+ };
21
+
22
+ export const GUIDED_HOURS_FROM_BEGINNER: Record<StudyLevel, readonly [number, number]> = {
23
+ A0: [0, 0],
24
+ A1: [90, 100],
25
+ A2: [180, 200],
26
+ B1: [350, 400],
27
+ B2: [500, 600],
28
+ C1: [700, 800],
29
+ C2: [1000, 1200],
30
+ };
31
+
32
+ export const PRIORITY_ALLOCATIONS: Record<'balanced' | 'comprehension' | 'production', SkillAllocation> = {
33
+ balanced: { listening: 25, speaking: 25, reading: 25, writing: 25 },
34
+ comprehension: { listening: 35, speaking: 15, reading: 35, writing: 15 },
35
+ production: { listening: 15, speaking: 35, reading: 15, writing: 35 },
36
+ };
37
+
38
+ export type StudyPlanResult = { ok: true; plan: StudyPlan } | { ok: false; error: string };
39
+
40
+ export function calculateStudyPlan(inputs: LanguagePlannerInputs, today = new Date()): StudyPlanResult {
41
+ const validationError = validateInputs(inputs, today);
42
+ if (validationError) return { ok: false, error: validationError };
43
+
44
+ const currentIndex = STUDY_LEVELS.indexOf(inputs.currentLevel);
45
+ const targetIndex = STUDY_LEVELS.indexOf(inputs.targetLevel);
46
+ const todayDate = startOfDay(today);
47
+ const hours = getHoursNeeded(inputs.currentLevel, inputs.targetLevel);
48
+ const schedule = getSchedule(inputs, todayDate);
49
+ const milestones = createMilestones(inputs, currentIndex, targetIndex, todayDate);
50
+
51
+ return {
52
+ ok: true,
53
+ plan: {
54
+ inputs,
55
+ weeksRemaining: schedule.weeksRemaining,
56
+ availableHours: schedule.availableHours,
57
+ hoursLow: hours.low,
58
+ hoursHigh: hours.high,
59
+ recommendedWeeklyHoursLow: round(hours.low / schedule.weeksRemaining, 1),
60
+ recommendedWeeklyHoursHigh: round(hours.high / schedule.weeksRemaining, 1),
61
+ allocation: PRIORITY_ALLOCATIONS[inputs.priority],
62
+ status: getStatus(schedule.availableHours, hours.low, hours.high),
63
+ milestones,
64
+ },
65
+ };
66
+ }
67
+
68
+ export function validateInputs(inputs: LanguagePlannerInputs, today = new Date()): string | null {
69
+ const languageError = validateLanguages(inputs);
70
+ if (languageError) return languageError;
71
+ const levelError = validateLevels(inputs);
72
+ if (levelError) return levelError;
73
+ const scheduleError = validateSchedule(inputs, today);
74
+ if (scheduleError) return scheduleError;
75
+ return null;
76
+ }
77
+
78
+ function validateLanguages(inputs: LanguagePlannerInputs): string | null {
79
+ if (!inputs.sourceLanguage || !inputs.targetLanguage) return 'Choose both a starting language and a target language.';
80
+ if (inputs.sourceLanguage === inputs.targetLanguage) return 'Choose different starting and target languages.';
81
+ return null;
82
+ }
83
+
84
+ function validateLevels(inputs: LanguagePlannerInputs): string | null {
85
+ if (!STUDY_LEVELS.includes(inputs.currentLevel) || !STUDY_LEVELS.includes(inputs.targetLevel)) return 'Choose valid CEFR levels.';
86
+ if (STUDY_LEVELS.indexOf(inputs.targetLevel) <= STUDY_LEVELS.indexOf(inputs.currentLevel)) return 'Your target level must be above your current level.';
87
+ return null;
88
+ }
89
+
90
+ function validateSchedule(inputs: LanguagePlannerInputs, today: Date): string | null {
91
+ if (!Number.isFinite(inputs.weeklyHours) || inputs.weeklyHours < 0.5 || inputs.weeklyHours > 40) return 'Weekly study time must be between 0.5 and 40 hours.';
92
+ const targetDate = parseDate(inputs.targetDate);
93
+ if (!targetDate) return 'Choose a valid target date.';
94
+ if (targetDate.getTime() <= startOfDay(today).getTime()) return 'Choose a target date in the future.';
95
+ return null;
96
+ }
97
+
98
+ function getHoursNeeded(currentLevel: StudyLevel, targetLevel: StudyLevel) {
99
+ const [currentLow, currentHigh] = GUIDED_HOURS_FROM_BEGINNER[currentLevel];
100
+ const [targetLow, targetHigh] = GUIDED_HOURS_FROM_BEGINNER[targetLevel];
101
+ return { low: targetLow - currentLow, high: targetHigh - currentHigh };
102
+ }
103
+
104
+ function getSchedule(inputs: LanguagePlannerInputs, today: Date) {
105
+ const targetDate = parseDate(inputs.targetDate)!;
106
+ const daysRemaining = Math.floor((targetDate.getTime() - today.getTime()) / 86_400_000);
107
+ const weeksRemaining = Math.max(1, Math.ceil(daysRemaining / 7));
108
+ return { weeksRemaining, availableHours: round(inputs.weeklyHours * weeksRemaining, 1) };
109
+ }
110
+
111
+ function createMilestones(inputs: LanguagePlannerInputs, currentIndex: number, targetIndex: number, today: Date): StudyMilestone[] {
112
+ return STUDY_LEVELS.slice(currentIndex + 1, targetIndex + 1).map((level, offset) => {
113
+ const previousLevel = STUDY_LEVELS[currentIndex + offset]!;
114
+ const [previousLow, previousHigh] = GUIDED_HOURS_FROM_BEGINNER[previousLevel];
115
+ const [levelLow, levelHigh] = GUIDED_HOURS_FROM_BEGINNER[level];
116
+ const midpointHours = ((levelLow - previousLow) + (levelHigh - previousHigh)) / 2;
117
+ const week = Math.max(1, Math.ceil(midpointHours / inputs.weeklyHours));
118
+ return {
119
+ level,
120
+ hoursLow: levelLow - previousLow,
121
+ hoursHigh: levelHigh - previousHigh,
122
+ week,
123
+ date: formatDate(addDays(today, week * 7)),
124
+ };
125
+ });
126
+ }
127
+
128
+ function getStatus(availableHours: number, hoursLow: number, hoursHigh: number): PlanStatus {
129
+ if (availableHours < hoursLow) return 'insufficient';
130
+ if (availableHours < hoursHigh) return 'tight';
131
+ return 'on-track';
132
+ }
133
+
134
+ function parseDate(value: string): Date | null {
135
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
136
+ const date = new Date(`${value}T00:00:00Z`);
137
+ return Number.isNaN(date.getTime()) || formatDate(date) !== value ? null : date;
138
+ }
139
+
140
+ function startOfDay(date: Date): Date {
141
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
142
+ }
143
+
144
+ function addDays(date: Date, days: number): Date {
145
+ const result = new Date(date);
146
+ result.setUTCDate(result.getUTCDate() + days);
147
+ return result;
148
+ }
149
+
150
+ function formatDate(date: Date): string {
151
+ return date.toISOString().slice(0, 10);
152
+ }
153
+
154
+ function round(value: number, decimals: number): number {
155
+ const factor = 10 ** decimals;
156
+ return Math.round(value * factor) / factor;
157
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { languageLearningStudyPlanPlanner } from './entry';
4
+
5
+ const locale = Astro.props.locale ?? 'en';
6
+ const content = await languageLearningStudyPlanPlanner.i18n.en?.();
7
+ ---
8
+
9
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,42 @@
1
+ export type StudyLevel = 'A0' | 'A1' | 'A2' | 'B1' | 'B2' | 'C1' | 'C2';
2
+ export type StudyPriority = 'balanced' | 'comprehension' | 'production';
3
+
4
+ export interface LanguagePlannerInputs {
5
+ sourceLanguage: string;
6
+ targetLanguage: string;
7
+ currentLevel: StudyLevel;
8
+ targetLevel: StudyLevel;
9
+ targetDate: string;
10
+ weeklyHours: number;
11
+ priority: StudyPriority;
12
+ }
13
+
14
+ export interface SkillAllocation {
15
+ listening: number;
16
+ speaking: number;
17
+ reading: number;
18
+ writing: number;
19
+ }
20
+
21
+ export interface StudyMilestone {
22
+ level: StudyLevel;
23
+ hoursLow: number;
24
+ hoursHigh: number;
25
+ week: number;
26
+ date: string;
27
+ }
28
+
29
+ export type PlanStatus = 'on-track' | 'tight' | 'insufficient';
30
+
31
+ export interface StudyPlan {
32
+ inputs: LanguagePlannerInputs;
33
+ weeksRemaining: number;
34
+ availableHours: number;
35
+ hoursLow: number;
36
+ hoursHigh: number;
37
+ recommendedWeeklyHoursLow: number;
38
+ recommendedWeeklyHoursHigh: number;
39
+ allocation: SkillAllocation;
40
+ status: PlanStatus;
41
+ milestones: StudyMilestone[];
42
+ }
@@ -0,0 +1,46 @@
1
+ export interface LanguagePlannerUI extends Record<string, string> {
2
+ plannerKicker: string;
3
+ plannerHeading: string;
4
+ sourceLanguage: string;
5
+ targetLanguage: string;
6
+ currentLevel: string;
7
+ targetLevel: string;
8
+ targetDate: string;
9
+ weeklyHours: string;
10
+ priority: string;
11
+ balanced: string;
12
+ comprehension: string;
13
+ production: string;
14
+ planButton: string;
15
+ resetButton: string;
16
+ presetsLabel: string;
17
+ casualPreset: string;
18
+ steadyPreset: string;
19
+ sprintPreset: string;
20
+ plannerNote: string;
21
+ resultTitle: string;
22
+ liveLabel: string;
23
+ hoursRange: string;
24
+ weeksAvailable: string;
25
+ hoursAvailable: string;
26
+ recommendedTime: string;
27
+ onTrack: string;
28
+ tight: string;
29
+ insufficient: string;
30
+ statusLabel: string;
31
+ weeklyAllocation: string;
32
+ milestones: string;
33
+ skillListening: string;
34
+ skillSpeaking: string;
35
+ skillReading: string;
36
+ skillWriting: string;
37
+ milestoneTarget: string;
38
+ milestoneWeek: string;
39
+ errorPrefix: string;
40
+ languageOptions: string;
41
+ emptyResultDescription: string;
42
+ guidedLearningDetail: string;
43
+ targetDateDetail: string;
44
+ weeklyPaceDetail: string;
45
+ recommendedRangeDetail: string;
46
+ }