@jjlmoya/utils-language 1.8.0 → 1.9.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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -1
- package/src/index.ts +5 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/cefr-language-skill-profile-planner/bibliography.astro +6 -0
- package/src/tool/cefr-language-skill-profile-planner/bibliography.ts +6 -0
- package/src/tool/cefr-language-skill-profile-planner/cefr-language-skill-profile-planner.css +565 -0
- package/src/tool/cefr-language-skill-profile-planner/component.astro +100 -0
- package/src/tool/cefr-language-skill-profile-planner/controller.ts +158 -0
- package/src/tool/cefr-language-skill-profile-planner/dom-views.ts +154 -0
- package/src/tool/cefr-language-skill-profile-planner/entry.ts +34 -0
- package/src/tool/cefr-language-skill-profile-planner/evaluator.ts +12 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/de.ts +26 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/en.ts +59 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/es.ts +15 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/fr.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/id.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/it.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/ja.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/ko.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/nl.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/pl.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/pt.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/ru.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/sv.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/tr.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/i18n/zh.ts +11 -0
- package/src/tool/cefr-language-skill-profile-planner/index.ts +14 -0
- package/src/tool/cefr-language-skill-profile-planner/logic.test.ts +38 -0
- package/src/tool/cefr-language-skill-profile-planner/logic.ts +131 -0
- package/src/tool/cefr-language-skill-profile-planner/seo.astro +9 -0
- package/src/tool/cefr-language-skill-profile-planner/storage.ts +24 -0
- package/src/tool/cefr-language-skill-profile-planner/types.ts +45 -0
- package/src/tool/cefr-language-skill-profile-planner/ui.ts +43 -0
- package/src/tools.ts +2 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './cefr-language-skill-profile-planner.css';
|
|
3
|
+
import { CEFR_LEVELS, SKILL_KEYS } from './logic';
|
|
4
|
+
import type { CefrSkillProfileUI } from './ui';
|
|
5
|
+
import type { SkillKey } from './types';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
ui: CefrSkillProfileUI;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { ui } = Astro.props as Props;
|
|
12
|
+
const skillLabels: Record<SkillKey, string> = {
|
|
13
|
+
listening: ui.skillListening,
|
|
14
|
+
reading: ui.skillReading,
|
|
15
|
+
spokenInteraction: ui.skillSpokenInteraction,
|
|
16
|
+
spokenProduction: ui.skillSpokenProduction,
|
|
17
|
+
writing: ui.skillWriting,
|
|
18
|
+
};
|
|
19
|
+
const skillDefaults: Record<SkillKey, [string, string]> = {
|
|
20
|
+
listening: ['A1', 'B1'], reading: ['A1', 'B1'], spokenInteraction: ['A1', 'B1'], spokenProduction: ['A1', 'B1'], writing: ['A1', 'B1'],
|
|
21
|
+
};
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<div class="cefr-profile" data-cefr-profile data-ui={JSON.stringify(ui)}>
|
|
25
|
+
<form class="profile-form" data-profile-form>
|
|
26
|
+
<div class="profile-settings">
|
|
27
|
+
<label class="date-field">
|
|
28
|
+
<span>{ui.targetDate}</span>
|
|
29
|
+
<input type="date" name="targetDate" required />
|
|
30
|
+
</label>
|
|
31
|
+
<label>
|
|
32
|
+
<span>{ui.weeklyHours}</span>
|
|
33
|
+
<input type="number" name="weeklyHours" min="0.5" max="40" step="0.5" value="5" required />
|
|
34
|
+
</label>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="profile-table" role="group" aria-label={ui.skillProfile}>
|
|
38
|
+
<div class="profile-table-head">
|
|
39
|
+
<span>{ui.skillProfile}</span>
|
|
40
|
+
<span>{ui.currentLevel}</span>
|
|
41
|
+
<span>{ui.targetLevel}</span>
|
|
42
|
+
</div>
|
|
43
|
+
{SKILL_KEYS.map((key) => {
|
|
44
|
+
const [current, target] = skillDefaults[key];
|
|
45
|
+
return (
|
|
46
|
+
<div class="skill-input-row">
|
|
47
|
+
<span class="skill-input-label">{skillLabels[key]}</span>
|
|
48
|
+
<div class="choice" data-choice>
|
|
49
|
+
<input type="hidden" name={`${key}Current`} value={current} />
|
|
50
|
+
<button type="button" class="choice-trigger" aria-label={`${skillLabels[key]} ${ui.currentLevel}`} aria-haspopup="listbox" aria-expanded="false">{current}</button>
|
|
51
|
+
<div class="choice-menu" role="listbox" hidden>
|
|
52
|
+
{CEFR_LEVELS.map((level) => <button type="button" role="option" data-choice-value={level} aria-selected={level === current}>{level}</button>)}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="choice" data-choice>
|
|
56
|
+
<input type="hidden" name={`${key}Target`} value={target} />
|
|
57
|
+
<button type="button" class="choice-trigger" aria-label={`${skillLabels[key]} ${ui.targetLevel}`} aria-haspopup="listbox" aria-expanded="false">{target}</button>
|
|
58
|
+
<div class="choice-menu" role="listbox" hidden>
|
|
59
|
+
{CEFR_LEVELS.map((level) => <button type="button" role="option" data-choice-value={level} aria-selected={level === target}>{level}</button>)}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<p class="profile-note">{ui.plannerNote} {ui.hoursEstimateNote}</p>
|
|
68
|
+
<div class="preset-strip" aria-label={ui.presetLabel}>
|
|
69
|
+
<span>{ui.presetLabel}</span>
|
|
70
|
+
<button type="button" data-profile-preset="gentle">{ui.gentlePreset}</button>
|
|
71
|
+
<button type="button" data-profile-preset="steady">{ui.steadyPreset}</button>
|
|
72
|
+
<button type="button" data-profile-preset="focused">{ui.focusedPreset}</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="profile-actions">
|
|
75
|
+
<button class="profile-submit" type="submit">{ui.planButton}</button>
|
|
76
|
+
<button class="profile-reset" type="reset">{ui.resetButton}</button>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
|
|
80
|
+
<section class="profile-result" aria-live="polite" aria-atomic="true">
|
|
81
|
+
<div class="result-title-row">
|
|
82
|
+
<h2>{ui.resultTitle}</h2>
|
|
83
|
+
<span class="result-routes" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
|
|
84
|
+
</div>
|
|
85
|
+
<div data-profile-results>
|
|
86
|
+
<div class="profile-empty">
|
|
87
|
+
<span class="empty-atlas" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
|
|
88
|
+
<span>{ui.emptyResult}</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
import { mountCefrProfile } from './controller';
|
|
96
|
+
import type { CefrSkillProfileUI } from './ui';
|
|
97
|
+
|
|
98
|
+
const root = document.querySelector<HTMLElement>('[data-cefr-profile]');
|
|
99
|
+
if (root) mountCefrProfile(root, JSON.parse(root.dataset.ui ?? '{}') as CefrSkillProfileUI);
|
|
100
|
+
</script>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { calculateCefrProfile, SKILL_KEYS } from './logic';
|
|
2
|
+
import { renderEmpty, renderError, renderProfile } from './dom-views';
|
|
3
|
+
import { clearSavedInputs, readSavedInputs, saveInputs } from './storage';
|
|
4
|
+
import type { CefrPlannerInputs, CefrLevel } from './types';
|
|
5
|
+
import type { CefrSkillProfileUI } from './ui';
|
|
6
|
+
|
|
7
|
+
export function mountCefrProfile(root: HTMLElement, ui: CefrSkillProfileUI): void {
|
|
8
|
+
const form = root.querySelector<HTMLFormElement>('[data-profile-form]');
|
|
9
|
+
if (!form) return;
|
|
10
|
+
applySavedInputs(form, readSavedInputs());
|
|
11
|
+
setDefaultDate(form);
|
|
12
|
+
setupChoices(root, form);
|
|
13
|
+
form.addEventListener('submit', (event) => submitProfile(event, root, form, ui));
|
|
14
|
+
form.addEventListener('reset', () => resetProfile(root, form, ui));
|
|
15
|
+
root.querySelectorAll<HTMLButtonElement>('[data-profile-preset]').forEach((button) => button.addEventListener('click', () => applyPreset(button.dataset.profilePreset, form)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function submitProfile(event: SubmitEvent, root: HTMLElement, form: HTMLFormElement, ui: CefrSkillProfileUI): void {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
const result = calculateCefrProfile(readInputs(form));
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
renderError(root, translateError(result.error, ui), ui);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
saveInputs(result.plan.inputs);
|
|
26
|
+
renderProfile(root, result.plan, ui);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resetProfile(root: HTMLElement, form: HTMLFormElement, ui: CefrSkillProfileUI): void {
|
|
30
|
+
window.setTimeout(() => {
|
|
31
|
+
clearSavedInputs();
|
|
32
|
+
setDefaultDate(form);
|
|
33
|
+
syncChoices(form);
|
|
34
|
+
renderEmpty(root, ui);
|
|
35
|
+
}, 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setupChoices(root: HTMLElement, form: HTMLFormElement): void {
|
|
39
|
+
root.querySelectorAll<HTMLElement>('[data-choice]').forEach((choice) => {
|
|
40
|
+
const trigger = choice.querySelector<HTMLButtonElement>('.choice-trigger');
|
|
41
|
+
const menu = choice.querySelector<HTMLElement>('.choice-menu');
|
|
42
|
+
if (!trigger || !menu) return;
|
|
43
|
+
trigger.addEventListener('click', () => toggleChoice(trigger, menu));
|
|
44
|
+
choice.querySelectorAll<HTMLButtonElement>('[data-choice-value]').forEach((option) => option.addEventListener('click', () => chooseValue(form, choice, option)));
|
|
45
|
+
trigger.addEventListener('keydown', (event) => closeOnEscape(event, trigger, menu));
|
|
46
|
+
document.addEventListener('click', (event) => closeOutside(event, choice, trigger, menu));
|
|
47
|
+
});
|
|
48
|
+
syncChoices(form);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toggleChoice(trigger: HTMLButtonElement, menu: HTMLElement): void {
|
|
52
|
+
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
|
|
53
|
+
closeMenus();
|
|
54
|
+
trigger.setAttribute('aria-expanded', String(!isOpen));
|
|
55
|
+
menu.hidden = isOpen;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function chooseValue(form: HTMLFormElement, choice: HTMLElement, option: HTMLButtonElement): void {
|
|
59
|
+
const hidden = choice.querySelector<HTMLInputElement>('input[type="hidden"]');
|
|
60
|
+
const trigger = choice.querySelector<HTMLButtonElement>('.choice-trigger');
|
|
61
|
+
const menu = choice.querySelector<HTMLElement>('.choice-menu');
|
|
62
|
+
if (!hidden || !trigger || !menu) return;
|
|
63
|
+
hidden.value = option.dataset.choiceValue ?? hidden.value;
|
|
64
|
+
trigger.textContent = hidden.value;
|
|
65
|
+
choice.querySelectorAll<HTMLButtonElement>('[data-choice-value]').forEach((item) => item.setAttribute('aria-selected', String(item === option)));
|
|
66
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
67
|
+
menu.hidden = true;
|
|
68
|
+
form.dispatchEvent(new Event('change', { bubbles: true }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function syncChoices(form: HTMLFormElement): void {
|
|
72
|
+
form.querySelectorAll<HTMLElement>('[data-choice]').forEach((choice) => {
|
|
73
|
+
const hidden = choice.querySelector<HTMLInputElement>('input[type="hidden"]');
|
|
74
|
+
const trigger = choice.querySelector<HTMLButtonElement>('.choice-trigger');
|
|
75
|
+
if (!hidden || !trigger) return;
|
|
76
|
+
trigger.textContent = hidden.value;
|
|
77
|
+
choice.querySelectorAll<HTMLButtonElement>('[data-choice-value]').forEach((option) => option.setAttribute('aria-selected', String(option.dataset.choiceValue === hidden.value)));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function closeOnEscape(event: KeyboardEvent, trigger: HTMLButtonElement, menu: HTMLElement): void {
|
|
82
|
+
if (event.key !== 'Escape') return;
|
|
83
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
84
|
+
menu.hidden = true;
|
|
85
|
+
trigger.focus();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function closeOutside(event: MouseEvent, choice: HTMLElement, trigger: HTMLButtonElement, menu: HTMLElement): void {
|
|
89
|
+
if (choice.contains(event.target as Node)) return;
|
|
90
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
91
|
+
menu.hidden = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function closeMenus(): void {
|
|
95
|
+
document.querySelectorAll<HTMLElement>('[data-choice]').forEach((choice) => {
|
|
96
|
+
choice.querySelector<HTMLButtonElement>('.choice-trigger')?.setAttribute('aria-expanded', 'false');
|
|
97
|
+
const menu = choice.querySelector<HTMLElement>('.choice-menu');
|
|
98
|
+
if (menu) menu.hidden = true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readInputs(form: HTMLFormElement): CefrPlannerInputs {
|
|
103
|
+
const skills = Object.fromEntries(SKILL_KEYS.map((key) => [key, { current: value(form, `${key}Current`) as CefrLevel, target: value(form, `${key}Target`) as CefrLevel }])) as CefrPlannerInputs['skills'];
|
|
104
|
+
return { targetDate: value(form, 'targetDate'), weeklyHours: Number(value(form, 'weeklyHours')), skills };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applySavedInputs(form: HTMLFormElement, saved: Partial<CefrPlannerInputs> | null): void {
|
|
108
|
+
if (!saved) return;
|
|
109
|
+
setValue(form, 'targetDate', saved.targetDate);
|
|
110
|
+
setValue(form, 'weeklyHours', saved.weeklyHours);
|
|
111
|
+
if (!saved.skills) return;
|
|
112
|
+
SKILL_KEYS.forEach((key) => {
|
|
113
|
+
setValue(form, `${key}Current`, saved.skills?.[key]?.current);
|
|
114
|
+
setValue(form, `${key}Target`, saved.skills?.[key]?.target);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function applyPreset(preset: string | undefined, form: HTMLFormElement): void {
|
|
119
|
+
setValue(form, 'weeklyHours', getPresetHours(preset));
|
|
120
|
+
form.requestSubmit();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getPresetHours(preset: string | undefined): number {
|
|
124
|
+
if (preset === 'gentle') return 3;
|
|
125
|
+
if (preset === 'focused') return 10;
|
|
126
|
+
return 5;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function setDefaultDate(form: HTMLFormElement): void {
|
|
130
|
+
const input = form.elements.namedItem('targetDate') as HTMLInputElement | null;
|
|
131
|
+
if (!input) return;
|
|
132
|
+
input.min = new Date().toISOString().slice(0, 10);
|
|
133
|
+
if (input.value) return;
|
|
134
|
+
const date = new Date();
|
|
135
|
+
date.setUTCDate(date.getUTCDate() + 180);
|
|
136
|
+
input.value = date.toISOString().slice(0, 10);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function translateError(error: string, ui: CefrSkillProfileUI): string {
|
|
140
|
+
if (error.includes('future')) return ui.futureDateError;
|
|
141
|
+
if (error.includes('valid target date')) return ui.dateFormatError;
|
|
142
|
+
if (error.includes('Weekly')) return ui.hoursError;
|
|
143
|
+
if (error.includes('below')) return ui.targetBelowCurrentError;
|
|
144
|
+
if (error.includes('at least one')) return ui.noProgressError;
|
|
145
|
+
return error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function value(form: HTMLFormElement, name: string): string {
|
|
149
|
+
return String((form.elements.namedItem(name) as HTMLInputElement | HTMLSelectElement).value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function setValue(form: HTMLFormElement, name: string, valueToSet: unknown): void {
|
|
153
|
+
if (valueToSet === undefined) return;
|
|
154
|
+
const field = form.elements.namedItem(name) as HTMLInputElement | HTMLSelectElement | null;
|
|
155
|
+
if (!field) return;
|
|
156
|
+
field.value = String(valueToSet);
|
|
157
|
+
if (field.closest('[data-choice]')) syncChoices(form);
|
|
158
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { CEFR_LEVELS } from './logic';
|
|
2
|
+
import type { CefrProfilePlan, SkillKey, SkillMilestone, SkillPlan } from './types';
|
|
3
|
+
import type { CefrSkillProfileUI } from './ui';
|
|
4
|
+
|
|
5
|
+
const skillLabelKeys: Record<SkillKey, keyof CefrSkillProfileUI> = {
|
|
6
|
+
listening: 'skillListening', reading: 'skillReading', spokenInteraction: 'skillSpokenInteraction', spokenProduction: 'skillSpokenProduction', writing: 'skillWriting',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function renderProfile(root: HTMLElement, plan: CefrProfilePlan, ui: CefrSkillProfileUI): void {
|
|
10
|
+
const results = root.querySelector<HTMLElement>('[data-profile-results]');
|
|
11
|
+
if (!results) return;
|
|
12
|
+
results.replaceChildren(createStatus(plan, ui), createOverview(plan, ui), createSkillScene(plan, ui), createMilestoneList(plan, ui));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderEmpty(root: HTMLElement, ui: CefrSkillProfileUI): void {
|
|
16
|
+
const results = root.querySelector<HTMLElement>('[data-profile-results]');
|
|
17
|
+
if (!results) return;
|
|
18
|
+
const empty = document.createElement('div');
|
|
19
|
+
empty.className = 'profile-empty';
|
|
20
|
+
empty.textContent = ui.emptyResult;
|
|
21
|
+
results.replaceChildren(empty);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderError(root: HTMLElement, message: string, ui: CefrSkillProfileUI): void {
|
|
25
|
+
const results = root.querySelector<HTMLElement>('[data-profile-results]');
|
|
26
|
+
if (!results) return;
|
|
27
|
+
const error = document.createElement('div');
|
|
28
|
+
error.className = 'profile-error';
|
|
29
|
+
error.append(createText('profile-error-title', ui.checkInputs), createText('profile-error-copy', message));
|
|
30
|
+
results.replaceChildren(error);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createStatus(plan: CefrProfilePlan, ui: CefrSkillProfileUI): HTMLElement {
|
|
34
|
+
const status = document.createElement('div');
|
|
35
|
+
status.className = `profile-status ${plan.status}`;
|
|
36
|
+
status.append(createText('profile-status-label', getStatusLabel(plan.status, ui)), createText('profile-status-detail', `${formatNumber(plan.availableHours)} ${ui.hours}`));
|
|
37
|
+
return status;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getStatusLabel(status: CefrProfilePlan['status'], ui: CefrSkillProfileUI): string {
|
|
41
|
+
if (status === 'on-track') return ui.statusOnTrack;
|
|
42
|
+
if (status === 'tight') return ui.statusTight;
|
|
43
|
+
return ui.statusInsufficient;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createOverview(plan: CefrProfilePlan, ui: CefrSkillProfileUI): HTMLElement {
|
|
47
|
+
const overview = document.createElement('div');
|
|
48
|
+
overview.className = 'profile-overview';
|
|
49
|
+
overview.append(metric(ui.totalHours, `${formatNumber(plan.totalHoursLow)} to ${formatNumber(plan.totalHoursHigh)} ${ui.hours}`), metric(ui.availableHours, `${formatNumber(plan.availableHours)} ${ui.hours}`), metric(ui.weeksAvailable, `${plan.weeksAvailable} ${ui.week}`));
|
|
50
|
+
return overview;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createSkillScene(plan: CefrProfilePlan, ui: CefrSkillProfileUI): HTMLElement {
|
|
54
|
+
const section = document.createElement('section');
|
|
55
|
+
section.className = 'profile-scene';
|
|
56
|
+
section.append(createHeading(ui.skillProfile));
|
|
57
|
+
section.append(createAtlasScene(plan, ui), createSkillReadout(plan, ui));
|
|
58
|
+
return section;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createAtlasScene(plan: CefrProfilePlan, ui: CefrSkillProfileUI): SVGSVGElement {
|
|
62
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
63
|
+
svg.classList.add('atlas-scene');
|
|
64
|
+
svg.setAttribute('viewBox', '0 0 760 280');
|
|
65
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
66
|
+
plan.skills.forEach((skill, index) => appendAtlasRoute(svg, skill, ui, index));
|
|
67
|
+
return svg;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function appendAtlasRoute(svg: SVGSVGElement, skill: SkillPlan, ui: CefrSkillProfileUI, index: number): void {
|
|
71
|
+
const y = 30 + index * 52;
|
|
72
|
+
const start = 150 + CEFR_LEVELS.indexOf(skill.current) * 82;
|
|
73
|
+
const end = 150 + CEFR_LEVELS.indexOf(skill.target) * 82;
|
|
74
|
+
const route = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
75
|
+
route.classList.add('atlas-route');
|
|
76
|
+
route.dataset.skill = skill.key;
|
|
77
|
+
route.setAttribute('d', `M ${start} ${y} C ${(start + end) / 2} ${y - 25}, ${(start + end) / 2} ${y + 25}, ${end} ${y}`);
|
|
78
|
+
const current = createSvgText(18, y + 5, skill.current, 'atlas-level atlas-current');
|
|
79
|
+
const target = createSvgText(675, y + 5, `${ui[skillLabelKeys[skill.key]] ?? ''} ${skill.target}`, 'atlas-level atlas-target');
|
|
80
|
+
const startDot = createSvgCircle(start, y, 'atlas-dot atlas-start');
|
|
81
|
+
const endDot = createSvgCircle(end, y, 'atlas-dot atlas-end');
|
|
82
|
+
svg.append(route, startDot, endDot, current, target);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createSkillReadout(plan: CefrProfilePlan, ui: CefrSkillProfileUI): HTMLElement {
|
|
86
|
+
const readout = document.createElement('div');
|
|
87
|
+
readout.className = 'skill-readout';
|
|
88
|
+
plan.skills.forEach((skill) => {
|
|
89
|
+
const row = document.createElement('div');
|
|
90
|
+
row.className = 'skill-readout-row';
|
|
91
|
+
row.dataset.skill = skill.key;
|
|
92
|
+
row.append(createText('skill-name', ui[skillLabelKeys[skill.key]] ?? ''), createText('skill-route', `${skill.current} to ${skill.target}`), createText('skill-allocation', skill.gapLevels ? `${skill.allocation}%` : ui.noGap));
|
|
93
|
+
readout.append(row);
|
|
94
|
+
});
|
|
95
|
+
return readout;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createSvgText(x: number, y: number, text: string, className: string): SVGTextElement {
|
|
99
|
+
const element = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
100
|
+
element.classList.add(...className.split(' '));
|
|
101
|
+
element.setAttribute('x', String(x));
|
|
102
|
+
element.setAttribute('y', String(y));
|
|
103
|
+
element.textContent = text;
|
|
104
|
+
return element;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createSvgCircle(cx: number, cy: number, className: string): SVGCircleElement {
|
|
108
|
+
const element = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
109
|
+
element.classList.add(...className.split(' '));
|
|
110
|
+
element.setAttribute('cx', String(cx));
|
|
111
|
+
element.setAttribute('cy', String(cy));
|
|
112
|
+
element.setAttribute('r', '7');
|
|
113
|
+
return element;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createMilestoneList(plan: CefrProfilePlan, ui: CefrSkillProfileUI): HTMLElement {
|
|
117
|
+
const section = document.createElement('section');
|
|
118
|
+
section.className = 'profile-milestones';
|
|
119
|
+
section.append(createHeading(ui.milestoneMap));
|
|
120
|
+
const list = document.createElement('div');
|
|
121
|
+
list.className = 'milestone-list';
|
|
122
|
+
plan.milestones.forEach((milestone) => list.append(createMilestone(milestone, ui)));
|
|
123
|
+
section.append(list);
|
|
124
|
+
return section;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createMilestone(milestone: SkillMilestone, ui: CefrSkillProfileUI): HTMLElement {
|
|
128
|
+
const item = document.createElement('div');
|
|
129
|
+
item.className = 'milestone-item';
|
|
130
|
+
item.append(createText('milestone-skill', ui[skillLabelKeys[milestone.key]] ?? ''), createText('milestone-level', milestone.level), createText('milestone-date', `${ui.week} ${milestone.week} - ${milestone.date}`));
|
|
131
|
+
return item;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createHeading(text: string): HTMLElement {
|
|
135
|
+
return createText('section-heading', text);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function metric(label: string, value: string): HTMLElement {
|
|
139
|
+
const item = document.createElement('div');
|
|
140
|
+
item.className = 'profile-metric';
|
|
141
|
+
item.append(createText('metric-label', label), createText('metric-value', value));
|
|
142
|
+
return item;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createText(className: string, text: string): HTMLElement {
|
|
146
|
+
const element = document.createElement('span');
|
|
147
|
+
element.className = className;
|
|
148
|
+
element.textContent = text;
|
|
149
|
+
return element;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatNumber(value: number): string {
|
|
153
|
+
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value);
|
|
154
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { LanguageToolEntry, ToolDefinition, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { CefrSkillProfileUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { CefrSkillProfileUI } from './ui';
|
|
5
|
+
export type CefrLanguageSkillProfilePlannerLocaleContent = ToolLocaleContent<CefrSkillProfileUI>;
|
|
6
|
+
|
|
7
|
+
export const cefrLanguageSkillProfilePlanner: LanguageToolEntry<CefrSkillProfileUI> = {
|
|
8
|
+
id: 'cefr-language-skill-profile-planner',
|
|
9
|
+
icons: { bg: 'mdi:chart-timeline-variant-shimmer', fg: 'mdi:translate' },
|
|
10
|
+
i18n: {
|
|
11
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
12
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
13
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
15
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
16
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const CEFR_LANGUAGE_SKILL_PROFILE_PLANNER_TOOL: ToolDefinition = {
|
|
30
|
+
entry: cefrLanguageSkillProfilePlanner,
|
|
31
|
+
Component: () => import('./component.astro'),
|
|
32
|
+
SEOComponent: () => import('./seo.astro'),
|
|
33
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
34
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CefrProfilePlan, ProfileStatus } from './types';
|
|
2
|
+
|
|
3
|
+
export interface StatusCopy {
|
|
4
|
+
status: ProfileStatus;
|
|
5
|
+
messageKey: 'statusOnTrack' | 'statusTight' | 'statusInsufficient';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function evaluateStatus(plan: CefrProfilePlan): StatusCopy {
|
|
9
|
+
if (plan.status === 'on-track') return { status: plan.status, messageKey: 'statusOnTrack' };
|
|
10
|
+
if (plan.status === 'tight') return { status: plan.status, messageKey: 'statusTight' };
|
|
11
|
+
return { status: plan.status, messageKey: 'statusInsufficient' };
|
|
12
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { CefrSkillProfileUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
const ui: CefrSkillProfileUI = {
|
|
6
|
+
targetDate: 'Zieldatum', weeklyHours: 'Lernzeit pro Woche', listening: 'Hörverstehen', reading: 'Leseverstehen', spokenInteraction: 'Mündliche Interaktion', spokenProduction: 'Mündlicher Ausdruck', writing: 'Schreiben', currentLevel: 'Aktuelles Niveau', targetLevel: 'Zielniveau', planButton: 'Mein Profil abbilden', resetButton: 'Zurücksetzen', presetLabel: 'Mit einem Tempo starten', gentlePreset: '3 Stunden', steadyPreset: '5 Stunden', focusedPreset: '10 Stunden', resultTitle: 'Dein Kompetenzprofil', emptyResult: 'Lege deine fünf aktuellen Niveaus, Ziele, dein Datum und deine Wochenzeit fest, um die Profilkarte zu sehen.', statusOnTrack: 'Dein Kalender bietet genug Spielraum für die Schätzung.', statusTight: 'Dein Kalender erreicht die untere Schätzung.', statusInsufficient: 'Dein Kalender ist kürzer als die untere Schätzung.', totalHours: 'Geschätzte angeleitete Lernstunden', availableHours: 'Verfügbare Stunden', weeksAvailable: 'Verfügbare Wochen', skillProfile: 'Fünf Kompetenzpfade', milestoneMap: 'Meilensteinkarte', hours: 'Stunden', week: 'Woche', noGap: 'Auf Zielniveau', checkInputs: 'Eingaben prüfen', futureDateError: 'Wähle ein Zieldatum in der Zukunft.', dateFormatError: 'Wähle ein gültiges Zieldatum.', hoursError: 'Die wöchentliche Lernzeit muss zwischen 0,5 und 40 Stunden liegen.', targetBelowCurrentError: 'Ein Zielniveau darf nicht unter dem aktuellen Niveau liegen.', noProgressError: 'Erhöhe mindestens ein Zielniveau, um einen Lernplan zu erstellen.', plannerNote: 'Nutze die GER-Niveaus als Ausgangspunkt für eine Selbsteinschätzung, nicht als Prüfungsergebnis.', hoursEstimateNote: 'Die Stundenangabe ist ein Planungsbereich und verändert sich je nach sprachlicher Distanz, Kontakt und Lernqualität.', skillListening: 'Hörverstehen', skillReading: 'Leseverstehen', skillSpokenInteraction: 'Mündliche Interaktion', skillSpokenProduction: 'Mündlicher Ausdruck', skillWriting: 'Schreiben',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const faq = [
|
|
10
|
+
{ question: 'Was berechnet der Planer für das GER-Kompetenzprofil?', answer: 'Er vergleicht das aktuelle und das angestrebte GER-Niveau für Hörverstehen, Leseverstehen, mündliche Interaktion, mündlichen Ausdruck und Schreiben. Danach schätzt er einen Bereich angeleiteter Lernstunden, vergleicht ihn mit deinen verfügbaren Wochen und verteilt den Aufwand auf die Kompetenzen mit einer Lücke.' },
|
|
11
|
+
{ question: 'Kann ich das als Sprachtest verwenden?', answer: 'Nein. Der Planer ordnet eine Selbsteinschätzung und einen Lernaufwand. Er prüft keine Leistung, bestätigt kein Zertifikat und garantiert nicht, dass du bis zu einem bestimmten Datum ein Niveau erreichst.' },
|
|
12
|
+
{ question: 'Warum werden die Stunden als Bereich angezeigt?', answer: 'Der Fortschritt hängt von sprachlicher Distanz, Vorerfahrung, Kontakt, Unterrichtsqualität, Übungsmöglichkeiten und der Art der verwendeten Nachweise ab. Der Bereich ist deshalb bewusst eine Planungshilfe und kein Versprechen.' },
|
|
13
|
+
{ question: 'Wie verteilt der Planer die Wochenzeit?', answer: 'Kompetenzen mit größeren Niveaulücken erhalten einen größeren Anteil der Wochenzeit. Kompetenzen, die bereits ihr Ziel erreicht haben, werden als auf Zielniveau angezeigt und erhalten keinen geplanten Anteil.' },
|
|
14
|
+
{ question: 'Was soll ich bei einem unzureichenden Status tun?', answer: 'Erhöhe die realistische Wochenzeit, verschiebe das Zieldatum oder senke eines oder mehrere Zielniveaus. Prüfe das Profil nach einem festen Lernabschnitt erneut, statt versäumte Stunden auf einmal nachzuholen.' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const appSchema: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Planer für GER Sprachkompetenzprofile', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', isAccessibleForFree: true, url: 'https://gamebob.dev/de/cefr-sprachkompetenz-profil-planer' };
|
|
18
|
+
const howToSchema: HowTo = { '@type': 'HowTo', name: 'Einen Lernplan für ein GER Kompetenzprofil erstellen', step: [{ '@type': 'HowToStep', name: 'Datum festlegen', text: 'Wähle das Datum, an dem du dein Zielprofil überprüfen möchtest.' }, { '@type': 'HowToStep', name: 'Kompetenzen einschätzen', text: 'Wähle für alle fünf Kompetenzen ein aktuelles und ein angestrebtes GER Niveau.' }, { '@type': 'HowToStep', name: 'Wochenzeit schützen', text: 'Gib die Lernzeit ein, die du jede Woche nachhaltig aufbringen kannst.' }, { '@type': 'HowToStep', name: 'Karte lesen', text: 'Nutze Status, Kompetenzpfade und Meilensteine, um den Plan anzupassen.' }] };
|
|
19
|
+
const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
20
|
+
const withContext = (schema: SoftwareApplication | HowTo | FAQPage): Record<string, unknown> => ({ '@context': 'https://schema.org', ...schema });
|
|
21
|
+
|
|
22
|
+
export const content: ToolLocaleContent<CefrSkillProfileUI> = {
|
|
23
|
+
slug: 'cefr-sprachkompetenz-profil-planer', title: 'Planer für GER Sprachkompetenzprofile', description: 'Ordne aktuelle und angestrebte GER Niveaus für fünf Sprachkompetenzen, schätze angeleitete Lernstunden und setze realistische Meilensteine.', ui,
|
|
24
|
+
seo: [{ type: 'title', text: 'Ein Sprachziel über fünf Kompetenzen abbilden', level: 2 }, { type: 'paragraph', html: 'Ein einziges Sprachniveau kann ein ungleichmäßiges Profil verbergen. Vielleicht liest du auf B1, verstehst Gespräche aber erst auf A2 und brauchst beim Sprechen noch mehr Übung. Dieser Planer macht die Unterschiede sichtbar und verwandelt sie in einen Arbeits- und Meilensteinplan.' }, { type: 'title', text: 'So funktioniert die Profilschätzung', level: 2 }, { type: 'paragraph', html: 'Die Berechnung ordnet jedem GER Schritt einen breiten Bereich angeleiteter Lernstunden zu und addiert die Bereiche für jede Kompetenz mit einer Lücke. Die Wochenzeit folgt dem Mittelwert jedes Bereichs, sodass eine Lücke von zwei Niveaus mehr Zeit erhält als eine Lücke von einem Niveau. Das Ergebnis ist ein Planungsmodell, keine Leistungsmessung.' }, { type: 'title', text: 'Den Kalenderstatus lesen', level: 2 }, { type: 'table', headers: ['Status', 'Bedeutung', 'Nächster sinnvoller Schritt'], rows: [['Spielraum für die Schätzung', 'Die verfügbaren Stunden reichen bis zum oberen Ende des kombinierten Bereichs.', 'Behalte die Routine bei und nutze die Meilensteine als Prüfzeitpunkte.'], ['Untere Schätzung erreicht', 'Die verfügbaren Stunden reichen bis zum unteren, aber nicht bis zum oberen Ende.', 'Halte das Ziel flexibel und reserviere Zeit für Feedback und Wiederholung.'], ['Kürzer als die Schätzung', 'Die verfügbaren Stunden erreichen nicht das untere Ende des kombinierten Bereichs.', 'Verschiebe das Datum, reduziere ein Ziel oder erhöhe die nachhaltige Lernzeit.']] }, { type: 'title', text: 'Meilensteine mit Nachweisen verbinden', level: 2 }, { type: 'paragraph', html: 'Nutze jeden Meilenstein als Anlass, Nachweise zu sammeln, nicht als automatische Beförderung. Halte eine Hörprobe, ein kurzes Gespräch, eine Leseaufgabe und einen Text fest, die zum angestrebten Beschreibungsniveau passen. Wenn eine Kompetenz stagniert, ändere ihre Übung statt die Lücke in einem Gesamtdurchschnitt zu verstecken.' }, { type: 'list', items: ['Wähle Niveaus aus Aufgaben, die du konkret beschreiben kannst.', 'Halte die Wochenzeit über den gesamten Kalender nachhaltig.', 'Prüfe das Profil nach einem festen Lernblock und ändere jeweils nur eine Variable.', 'Nutze Lehrkraftfeedback oder eine offizielle Prüfung, wenn das Niveau für Studium, Arbeit oder Einwanderung zählt.'] }, { type: 'tip', title: 'Grenzen der Schätzung', html: 'Der GER beschreibt kommunikative Fähigkeiten anhand von Deskriptoren und schreibt keine universelle Stundenzahl vor. Sprachdistanz, Erfahrung, Kontakt, Feedback und Lernbedingungen können den tatsächlichen Aufwand deutlich verändern. Behandle diesen Plan nicht als Zertifikat oder Garantie.' }],
|
|
25
|
+
faq, bibliography: [{ name: 'Europarat: Gemeinsamer europäischer Referenzrahmen für Sprachen', url: 'https://rm.coe.int/marco-comun-europeo-de-referencia-para-las-lenguas-aprendizaje-ensenan/1680a52d53' }, { name: 'Cambridge English: Guided learning hours', url: 'https://support.cambridgeenglish.org/hc/en-gb/articles/202838506-Guided-learning-hours' }], howTo: [{ name: 'Datum festlegen', text: 'Wähle das Datum, an dem du dein Zielprofil überprüfen möchtest.' }, { name: 'Kompetenzen einschätzen', text: 'Wähle aktuelle und angestrebte GER Niveaus für alle fünf Kompetenzen.' }, { name: 'Wochenzeit schützen', text: 'Gib eine Lernzeit ein, die du jede Woche nachhaltig einhalten kannst, und bilde dein Profil ab.' }, { name: 'Lesen und anpassen', text: 'Nutze Status, Pfade und Meilensteine, um den Plan nach echter Übung zu überarbeiten.' }], schemas: [withContext(appSchema), withContext(howToSchema), withContext(faqSchema)],
|
|
26
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { CefrSkillProfileUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
const ui: CefrSkillProfileUI = {
|
|
6
|
+
targetDate: 'Target date', weeklyHours: 'Study time per week', listening: 'Listening', reading: 'Reading', spokenInteraction: 'Spoken interaction', spokenProduction: 'Spoken production', writing: 'Writing', currentLevel: 'Current level', targetLevel: 'Target level', planButton: 'Map my profile', resetButton: 'Reset', presetLabel: 'Start with a pace', gentlePreset: '3 hours', steadyPreset: '5 hours', focusedPreset: '10 hours', resultTitle: 'Your skill profile', emptyResult: 'Set your five current levels, target levels, date, and weekly time to reveal the profile map.', statusOnTrack: 'Your calendar has room for the estimate.', statusTight: 'Your calendar reaches the lower estimate.', statusInsufficient: 'Your calendar is shorter than the lower estimate.', totalHours: 'Estimated guided hours', availableHours: 'Hours available', weeksAvailable: 'Weeks available', skillProfile: 'Five skill paths', milestoneMap: 'Milestone map', hours: 'hours', week: 'week', noGap: 'At level', checkInputs: 'Check your inputs', futureDateError: 'Choose a target date in the future.', dateFormatError: 'Choose a valid target date.', hoursError: 'Weekly study time must be between 0.5 and 40 hours.', targetBelowCurrentError: 'A target level cannot be below the current level.', noProgressError: 'Raise at least one target level to make a profile plan.', plannerNote: 'Use the CEFR levels as a self assessment starting point, not as a certification result.', hoursEstimateNote: 'The hour range is a planning band and changes with language distance, exposure, and study quality.', skillListening: 'Listening', skillReading: 'Reading', skillSpokenInteraction: 'Spoken interaction', skillSpokenProduction: 'Spoken production', skillWriting: 'Writing',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const faq = [
|
|
10
|
+
{ question: 'What does the CEFR skill profile planner calculate?', answer: 'It compares a current and target CEFR level for listening, reading, spoken interaction, spoken production, and writing. It estimates a guided hour range, compares it with your available weeks, and divides the effort across the skills that have a gap.' },
|
|
11
|
+
{ question: 'Can I use this as a language test?', answer: 'No. The planner organizes a self assessment and a study workload. It does not test performance, verify a certificate, or predict that you will reach a level by a particular date.' },
|
|
12
|
+
{ question: 'Why are the hours shown as a range?', answer: 'Progress varies with language distance, previous exposure, teaching quality, practice opportunities, and the kind of evidence used to judge a level. The range is deliberately a planning band rather than a promise.' },
|
|
13
|
+
{ question: 'How does the planner divide weekly time?', answer: 'Skills with larger level gaps receive a larger share of the weekly time. Skills already at their target are shown as at level and receive no planned share.' },
|
|
14
|
+
{ question: 'What should I do when the status is insufficient?', answer: 'Add realistic weekly time, move the target date, or lower one or more target levels. Recheck the profile after a study checkpoint instead of trying to make up every missed hour at once.' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const appSchema: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'CEFR Language Skill Profile Planner', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', isAccessibleForFree: true, url: 'https://gamebob.dev/en/cefr-language-skill-profile-planner' };
|
|
18
|
+
const howToSchema: HowTo = { '@type': 'HowTo', name: 'Build a CEFR skill profile plan', step: [
|
|
19
|
+
{ '@type': 'HowToStep', name: 'Set your date', text: 'Choose the date by which you want to review the target profile.' },
|
|
20
|
+
{ '@type': 'HowToStep', name: 'Rate each skill', text: 'Choose a current and target CEFR level for all five skills.' },
|
|
21
|
+
{ '@type': 'HowToStep', name: 'Protect weekly time', text: 'Enter the study time you can sustain every week.' },
|
|
22
|
+
{ '@type': 'HowToStep', name: 'Read the map', text: 'Use the status, skill paths, and milestone dates to adjust the plan.' },
|
|
23
|
+
] };
|
|
24
|
+
const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
25
|
+
|
|
26
|
+
function withContext(schema: SoftwareApplication | HowTo | FAQPage): Record<string, unknown> {
|
|
27
|
+
return { '@context': 'https://schema.org', ...schema };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const content: ToolLocaleContent<CefrSkillProfileUI> = {
|
|
31
|
+
slug: 'cefr-language-skill-profile-planner',
|
|
32
|
+
title: 'CEFR Language Skill Profile Planner',
|
|
33
|
+
description: 'Map current and target CEFR levels across five language skills, estimate guided hours, and set milestones for a realistic study plan.',
|
|
34
|
+
ui,
|
|
35
|
+
seo: [
|
|
36
|
+
{ type: 'title', text: 'Map a Language Goal Across Five Skills', level: 2 },
|
|
37
|
+
{ type: 'paragraph', html: 'A single language level can hide an uneven profile. You may read at B1, listen at A2, and still need more practice in spoken interaction. This planner makes those differences visible by asking for a current and target CEFR level for each skill, then turning the gaps into a workload and milestone map.' },
|
|
38
|
+
{ type: 'title', text: 'How the Profile Estimate Works', level: 2 },
|
|
39
|
+
{ type: 'paragraph', html: 'The calculation assigns a broad guided hour band to each CEFR step and adds the bands for every skill with a gap. The weekly allocation follows the midpoint of each skill range, so a two level listening gap receives more of the available time than a one level writing gap. The result is a planning model, not a measurement of ability.' },
|
|
40
|
+
{ type: 'title', text: 'Read the Calendar Status', level: 2 },
|
|
41
|
+
{ type: 'table', headers: ['Status', 'Meaning', 'Useful next move'], rows: [['Room for the estimate', 'Available hours reach the upper end of the combined range.', 'Keep the routine and use the milestone dates as review points.'], ['Lower estimate reached', 'Available hours reach the lower end but not the upper end.', 'Keep the goal flexible and protect time for feedback and review.'], ['Shorter than the estimate', 'Available hours do not reach the lower end of the combined range.', 'Move the date, reduce a target, or add sustainable time.']] },
|
|
42
|
+
{ type: 'title', text: 'Turn Milestones Into Evidence', level: 2 },
|
|
43
|
+
{ type: 'paragraph', html: 'Use each milestone as a reason to collect evidence, not as an automatic promotion. Record a listening sample, a short conversation, a reading task, and a piece of writing that match the descriptor level you are targeting. If one skill stalls, change its practice rather than hiding the gap inside an overall average.' },
|
|
44
|
+
{ type: 'list', items: ['Choose levels from recent tasks you can actually describe.', 'Keep the weekly time sustainable for the whole calendar.', 'Review the profile after a fixed study block and adjust one variable.', 'Use teacher feedback or an official assessment when the level matters for admission, work, or immigration.'] },
|
|
45
|
+
{ type: 'tip', title: 'Limits of the estimate', html: 'The CEFR describes communicative ability through descriptors; it does not prescribe one universal number of hours. Language distance, previous experience, exposure, feedback, and study conditions can move the real workload substantially. Do not treat this plan as a certificate, a guarantee, or medical or legal advice.' },
|
|
46
|
+
],
|
|
47
|
+
faq,
|
|
48
|
+
bibliography: [
|
|
49
|
+
{ name: 'Council of Europe: Marco común europeo de referencia para las lenguas, volumen complementario', url: 'https://rm.coe.int/marco-comun-europeo-de-referencia-para-las-lenguas-aprendizaje-ensenan/1680a52d53' },
|
|
50
|
+
{ name: 'Cambridge English: Guided learning hours', url: 'https://support.cambridgeenglish.org/hc/en-gb/articles/202838506-Guided-learning-hours' },
|
|
51
|
+
],
|
|
52
|
+
howTo: [
|
|
53
|
+
{ name: 'Set your date', text: 'Choose the date by which you want to review the target profile.' },
|
|
54
|
+
{ name: 'Rate each skill', text: 'Choose a current and target CEFR level for listening, reading, spoken interaction, spoken production, and writing.' },
|
|
55
|
+
{ name: 'Protect weekly time', text: 'Enter the study time you can sustain every week, then map the profile.' },
|
|
56
|
+
{ name: 'Read and revise', text: 'Use the status, skill paths, and milestone dates to adjust the plan after real practice.' },
|
|
57
|
+
],
|
|
58
|
+
schemas: [withContext(appSchema), withContext(howToSchema), withContext(faqSchema)],
|
|
59
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { CefrSkillProfileUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
const ui: CefrSkillProfileUI = {
|
|
6
|
+
targetDate: 'Fecha objetivo', weeklyHours: 'Tiempo de estudio semanal', listening: 'Comprensión auditiva', reading: 'Comprensión lectora', spokenInteraction: 'Interacción oral', spokenProduction: 'Producción oral', writing: 'Expresión escrita', currentLevel: 'Nivel actual', targetLevel: 'Nivel objetivo', planButton: 'Mapear mi perfil', resetButton: 'Restablecer', presetLabel: 'Empieza con un ritmo', gentlePreset: '3 horas', steadyPreset: '5 horas', focusedPreset: '10 horas', resultTitle: 'Tu perfil de habilidades', emptyResult: 'Indica tus cinco niveles actuales, objetivos, fecha y tiempo semanal para ver el mapa del perfil.', statusOnTrack: 'Tu calendario tiene margen para la estimación.', statusTight: 'Tu calendario alcanza la estimación inferior.', statusInsufficient: 'Tu calendario es más corto que la estimación inferior.', totalHours: 'Horas guiadas estimadas', availableHours: 'Horas disponibles', weeksAvailable: 'Semanas disponibles', skillProfile: 'Cinco rutas de habilidad', milestoneMap: 'Mapa de hitos', hours: 'horas', week: 'semana', noGap: 'En el objetivo', checkInputs: 'Revisa tus datos', futureDateError: 'Elige una fecha objetivo futura.', dateFormatError: 'Elige una fecha objetivo válida.', hoursError: 'El tiempo de estudio semanal debe estar entre 0,5 y 40 horas.', targetBelowCurrentError: 'El nivel objetivo no puede ser inferior al nivel actual.', noProgressError: 'Sube al menos un nivel objetivo para crear un plan.', plannerNote: 'Usa los niveles del MCER como punto de partida para una autoevaluación, no como resultado de certificación.', hoursEstimateNote: 'El intervalo de horas es una banda de planificación y cambia según la distancia lingüística, la exposición y la calidad del estudio.', skillListening: 'Comprensión auditiva', skillReading: 'Comprensión lectora', skillSpokenInteraction: 'Interacción oral', skillSpokenProduction: 'Producción oral', skillWriting: 'Expresión escrita',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const faq = [{ question: '¿Qué calcula el planificador de perfil de habilidades del MCER?', answer: 'Compara el nivel actual y el objetivo de comprensión auditiva, comprensión lectora, interacción oral, producción oral y expresión escrita. Estima un intervalo de horas guiadas, lo compara con tus semanas disponibles y reparte el esfuerzo entre las habilidades que tienen una brecha.' }, { question: '¿Puedo usarlo como una prueba de idioma?', answer: 'No. El planificador organiza una autoevaluación y una carga de estudio. No examina tu rendimiento, no verifica certificados ni predice que alcanzarás un nivel en una fecha concreta.' }, { question: '¿Por qué las horas aparecen como un intervalo?', answer: 'El progreso depende de la distancia entre lenguas, la experiencia previa, la exposición, la calidad de la enseñanza, las oportunidades de práctica y las pruebas usadas para valorar el nivel. Por eso el intervalo es una banda de planificación, no una promesa.' }, { question: '¿Cómo reparte el tiempo semanal?', answer: 'Las habilidades con brechas de nivel mayores reciben una parte mayor del tiempo semanal. Las que ya están en su objetivo aparecen como en el objetivo y no reciben una parte planificada.' }, { question: '¿Qué hago si el estado es insuficiente?', answer: 'Añade tiempo semanal realista, mueve la fecha objetivo o reduce uno o varios niveles objetivo. Revisa el perfil después de un bloque de estudio en vez de intentar recuperar todas las horas de golpe.' }];
|
|
10
|
+
const appSchema: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Planificador de perfil de habilidades lingüísticas del MCER', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', isAccessibleForFree: true, url: 'https://gamebob.dev/es/planificador-perfil-habilidades-idiomas-cefr' };
|
|
11
|
+
const howToSchema: HowTo = { '@type': 'HowTo', name: 'Crear un plan de perfil de habilidades del MCER', step: [{ '@type': 'HowToStep', name: 'Fija la fecha', text: 'Elige la fecha en la que quieres revisar el perfil objetivo.' }, { '@type': 'HowToStep', name: 'Valora cada habilidad', text: 'Elige un nivel actual y uno objetivo del MCER para las cinco habilidades.' }, { '@type': 'HowToStep', name: 'Protege el tiempo semanal', text: 'Indica el tiempo de estudio que puedes mantener cada semana.' }, { '@type': 'HowToStep', name: 'Lee el mapa', text: 'Usa el estado, las rutas y las fechas de los hitos para ajustar el plan.' }] };
|
|
12
|
+
const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
13
|
+
const withContext = (schema: SoftwareApplication | HowTo | FAQPage): Record<string, unknown> => ({ '@context': 'https://schema.org', ...schema });
|
|
14
|
+
|
|
15
|
+
export const content: ToolLocaleContent<CefrSkillProfileUI> = { slug: 'planificador-perfil-habilidades-idiomas-cefr', title: 'Planificador de perfil de habilidades lingüísticas del MCER', description: 'Mapea niveles actuales y objetivos del MCER en cinco habilidades, estima horas guiadas y fija hitos para un plan de estudio realista.', ui, seo: [{ type: 'title', text: 'Mapea un objetivo lingüístico en cinco habilidades', level: 2 }, { type: 'paragraph', html: 'Un único nivel de idioma puede ocultar un perfil desigual. Quizá leas en B1, escuches en A2 y todavía necesites practicar la interacción oral. Este planificador hace visibles esas diferencias y convierte las brechas en una carga de trabajo y un mapa de hitos.' }, { type: 'title', text: 'Cómo funciona la estimación del perfil', level: 2 }, { type: 'paragraph', html: 'El cálculo asigna una banda amplia de horas guiadas a cada paso del MCER y suma las bandas de cada habilidad con una brecha. El reparto semanal sigue el punto medio de cada intervalo, así que una brecha de dos niveles recibe más tiempo que una de un nivel. Es un modelo de planificación, no una medición de capacidad.' }, { type: 'title', text: 'Interpreta el estado del calendario', level: 2 }, { type: 'table', headers: ['Estado', 'Significado', 'Siguiente paso útil'], rows: [['Margen para la estimación', 'Las horas disponibles llegan al extremo superior del intervalo combinado.', 'Mantén la rutina y usa los hitos como puntos de revisión.'], ['Estimación inferior alcanzada', 'Las horas disponibles llegan al extremo inferior, pero no al superior.', 'Mantén flexible el objetivo y protege tiempo para feedback y repaso.'], ['Por debajo de la estimación', 'Las horas disponibles no llegan al extremo inferior del intervalo combinado.', 'Mueve la fecha, reduce un objetivo o añade tiempo sostenible.']] }, { type: 'title', text: 'Convierte los hitos en evidencias', level: 2 }, { type: 'paragraph', html: 'Usa cada hito como motivo para reunir evidencias, no como una promoción automática. Guarda una muestra auditiva, una conversación breve, una tarea de lectura y un texto que correspondan al nivel descriptor que buscas. Si una habilidad se estanca, cambia su práctica en lugar de esconder la brecha dentro de una media general.' }, { type: 'list', items: ['Elige niveles a partir de tareas recientes que puedas describir.', 'Mantén un tiempo semanal sostenible durante todo el calendario.', 'Revisa el perfil después de un bloque fijo y cambia una sola variable.', 'Usa feedback docente o una evaluación oficial cuando el nivel importe para estudiar, trabajar o emigrar.'] }, { type: 'tip', title: 'Límites de la estimación', html: 'El MCER describe la capacidad comunicativa mediante descriptores y no prescribe un número universal de horas. La distancia lingüística, la experiencia, la exposición, el feedback y las condiciones de estudio pueden cambiar mucho el esfuerzo real. No trates este plan como certificado ni garantía.' }], faq, bibliography: [{ name: 'Consejo de Europa: Marco común europeo de referencia para las lenguas, volumen complementario', url: 'https://rm.coe.int/marco-comun-europeo-de-referencia-para-las-lenguas-aprendizaje-ensenan/1680a52d53' }, { name: 'Cambridge English: Guided learning hours', url: 'https://support.cambridgeenglish.org/hc/en-gb/articles/202838506-Guided-learning-hours' }], howTo: [{ name: 'Fija la fecha', text: 'Elige la fecha en la que quieres revisar el perfil objetivo.' }, { name: 'Valora cada habilidad', text: 'Elige niveles actuales y objetivos del MCER para las cinco habilidades.' }, { name: 'Protege el tiempo semanal', text: 'Indica el tiempo de estudio que puedes mantener cada semana y mapea el perfil.' }, { name: 'Lee y revisa', text: 'Usa el estado, las rutas y las fechas de los hitos para ajustar el plan después de practicar.' }], schemas: [withContext(appSchema), withContext(howToSchema), withContext(faqSchema)] };
|