@jjlmoya/utils-language 1.9.0 → 1.10.1

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_exports.test.ts +8 -0
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/language-shadowing-session-planner/bibliography.astro +6 -0
  8. package/src/tool/language-shadowing-session-planner/bibliography.ts +6 -0
  9. package/src/tool/language-shadowing-session-planner/component.astro +143 -0
  10. package/src/tool/language-shadowing-session-planner/controller.ts +152 -0
  11. package/src/tool/language-shadowing-session-planner/dom-views.ts +82 -0
  12. package/src/tool/language-shadowing-session-planner/entry.ts +34 -0
  13. package/src/tool/language-shadowing-session-planner/evaluator.ts +17 -0
  14. package/src/tool/language-shadowing-session-planner/i18n/de.ts +54 -0
  15. package/src/tool/language-shadowing-session-planner/i18n/en.ts +128 -0
  16. package/src/tool/language-shadowing-session-planner/i18n/es.ts +45 -0
  17. package/src/tool/language-shadowing-session-planner/i18n/fr.ts +45 -0
  18. package/src/tool/language-shadowing-session-planner/i18n/id.ts +40 -0
  19. package/src/tool/language-shadowing-session-planner/i18n/it.ts +40 -0
  20. package/src/tool/language-shadowing-session-planner/i18n/ja.ts +40 -0
  21. package/src/tool/language-shadowing-session-planner/i18n/ko.ts +40 -0
  22. package/src/tool/language-shadowing-session-planner/i18n/nl.ts +40 -0
  23. package/src/tool/language-shadowing-session-planner/i18n/pl.ts +40 -0
  24. package/src/tool/language-shadowing-session-planner/i18n/pt.ts +40 -0
  25. package/src/tool/language-shadowing-session-planner/i18n/ru.ts +40 -0
  26. package/src/tool/language-shadowing-session-planner/i18n/sv.ts +40 -0
  27. package/src/tool/language-shadowing-session-planner/i18n/tr.ts +40 -0
  28. package/src/tool/language-shadowing-session-planner/i18n/zh.ts +40 -0
  29. package/src/tool/language-shadowing-session-planner/index.ts +14 -0
  30. package/src/tool/language-shadowing-session-planner/language-shadowing-session-planner.css +561 -0
  31. package/src/tool/language-shadowing-session-planner/logic.test.ts +53 -0
  32. package/src/tool/language-shadowing-session-planner/logic.ts +94 -0
  33. package/src/tool/language-shadowing-session-planner/seo.astro +9 -0
  34. package/src/tool/language-shadowing-session-planner/storage.ts +18 -0
  35. package/src/tool/language-shadowing-session-planner/timer-view.ts +55 -0
  36. package/src/tool/language-shadowing-session-planner/timer.test.ts +50 -0
  37. package/src/tool/language-shadowing-session-planner/timer.ts +158 -0
  38. package/src/tool/language-shadowing-session-planner/types.ts +32 -0
  39. package/src/tool/language-shadowing-session-planner/ui.ts +55 -0
  40. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-language",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,10 +1,11 @@
1
1
  import { cefrLanguageSkillProfilePlanner } from '../tool/cefr-language-skill-profile-planner/entry';
2
+ import { languageShadowingSessionPlanner } from '../tool/language-shadowing-session-planner/entry';
2
3
  import { languageLearningStudyPlanPlanner } from '../tool/language-learning-study-plan-planner/entry';
3
4
  import type { CategoryLocaleContent, KnownLocale } from '../types';
4
5
 
5
6
  export const languageCategory = {
6
7
  icon: 'mdi:translate-variant',
7
- tools: [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner],
8
+ tools: [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner, languageShadowingSessionPlanner],
8
9
  i18n: {
9
10
  de: () => import('./i18n/de').then((module) => module.content),
10
11
  en: () => import('./i18n/en').then((module) => module.content),
package/src/entries.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cefrLanguageSkillProfilePlanner } from './tool/cefr-language-skill-profile-planner/entry';
2
+ import { languageShadowingSessionPlanner } from './tool/language-shadowing-session-planner/entry';
2
3
  import { languageLearningStudyPlanPlanner } from './tool/language-learning-study-plan-planner/entry';
3
4
 
4
- export const ALL_ENTRIES = [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner];
5
+ export const ALL_ENTRIES = [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner, languageShadowingSessionPlanner];
@@ -18,6 +18,6 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
 
20
20
  it('all tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(2);
21
+ expect(ALL_TOOLS.length).toBe(3);
22
22
  });
23
23
  });
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { ALL_TOOLS } from '../tools';
3
5
  import { validateToolExports } from './shared-test-helpers';
4
6
 
@@ -20,6 +22,12 @@ describe('Tool Exports Pattern Validation', () => {
20
22
  expect(tool.BibliographyComponent).toBeInstanceOf(Function);
21
23
  });
22
24
  });
25
+
26
+ it.each(ALL_TOOLS)('$entry.id: Component should load its tool stylesheet', (tool) => {
27
+ const componentPath = join(process.cwd(), 'src', 'tool', tool.entry.id, 'component.astro');
28
+ const source = readFileSync(componentPath, 'utf8');
29
+ expect(source).toContain(`import './${tool.entry.id}.css';`);
30
+ });
23
31
  });
24
32
 
25
33
  describe('Dynamic Import Validation', () => {
@@ -5,7 +5,7 @@ import { languageCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(2);
8
+ expect(ALL_TOOLS.length).toBe(3);
9
9
  });
10
10
 
11
11
  it('languageCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <Bibliography links={bibliography} />
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: '日本語聴解学習におけるシャドーイングの効果', url: 'https://www.jstage.jst.go.jp/article/jlem/29/1/29_26/_article/-char/ja' },
5
+ { name: 'British Council: Teaching English pronunciation online: Practical tips and benefits of shadowing', url: 'https://americas.britishcouncil.org/new-ways-of-teaching/events/teaching-english-pronunciation-online' },
6
+ ];
@@ -0,0 +1,143 @@
1
+ ---
2
+ import './language-shadowing-session-planner.css';
3
+ import { calculateShadowingPlan, DEFAULT_SHADOWING_INPUTS } from './logic';
4
+ import type { ShadowingSessionUI } from './ui';
5
+
6
+ interface Props {
7
+ ui: ShadowingSessionUI;
8
+ }
9
+
10
+ const { ui } = Astro.props as Props;
11
+ const initialPlan = calculateShadowingPlan(DEFAULT_SHADOWING_INPUTS);
12
+ const serializedUI = JSON.stringify(ui).replace(/</g, '\\u003c');
13
+ const presetData = {
14
+ quick: { totalMinutes: 5, clipSeconds: 15, repetitions: 8, pauseSeconds: 5 },
15
+ focused: { totalMinutes: 12, clipSeconds: 30, repetitions: 10, pauseSeconds: 10 },
16
+ long: { totalMinutes: 25, clipSeconds: 45, repetitions: 14, pauseSeconds: 15 },
17
+ };
18
+ const formatSeconds = (seconds: number): string => {
19
+ const minutes = Math.floor(seconds / 60);
20
+ const remainder = seconds % 60;
21
+ return remainder === 0 ? `${minutes} min` : `${minutes} min ${remainder} s`;
22
+ };
23
+ const formatClock = (seconds: number): string => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
24
+ const initialBlockLabel = (kind: string, index: number | undefined): string => {
25
+ if (kind === 'shadow') return String(index);
26
+ if (kind === 'pause') return ui.pauseBlock;
27
+ return ui.bufferBlock;
28
+ };
29
+ ---
30
+
31
+ <div class="shadowing-tool" data-shadowing-app>
32
+ <form class="shadowing-controls" data-role="controls">
33
+ <div class="quick-starts">
34
+ <span class="control-label">{ui.quickStarts}</span>
35
+ <div class="quick-start-grid">
36
+ <button type="button" class="quick-start" data-preset={JSON.stringify(presetData.quick)}>{ui.quickShort}</button>
37
+ <button type="button" class="quick-start" data-preset={JSON.stringify(presetData.focused)} aria-pressed="false">{ui.quickFocused}</button>
38
+ <button type="button" class="quick-start" data-preset={JSON.stringify(presetData.long)}>{ui.quickLong}</button>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="field-grid">
43
+ <label class="shadowing-field">
44
+ <span class="control-label">{ui.totalMinutes}</span>
45
+ <span class="field-value"><output data-output="totalMinutes">{DEFAULT_SHADOWING_INPUTS.totalMinutes}</output><span>{ui.minutesUnit}</span></span>
46
+ <input type="range" min="1" max="180" step="1" value={DEFAULT_SHADOWING_INPUTS.totalMinutes} data-range="totalMinutes" aria-label={ui.totalMinutes} />
47
+ <input type="number" min="1" max="180" step="1" value={DEFAULT_SHADOWING_INPUTS.totalMinutes} data-number="totalMinutes" aria-describedby="shadowing-input-help" />
48
+ </label>
49
+ <label class="shadowing-field">
50
+ <span class="control-label">{ui.clipSeconds}</span>
51
+ <span class="field-value"><output data-output="clipSeconds">{DEFAULT_SHADOWING_INPUTS.clipSeconds}</output><span>{ui.secondsUnit}</span></span>
52
+ <input type="range" min="3" max="300" step="1" value={DEFAULT_SHADOWING_INPUTS.clipSeconds} data-range="clipSeconds" aria-label={ui.clipSeconds} />
53
+ <input type="number" min="3" max="300" step="1" value={DEFAULT_SHADOWING_INPUTS.clipSeconds} data-number="clipSeconds" aria-describedby="shadowing-input-help" />
54
+ </label>
55
+ <label class="shadowing-field">
56
+ <span class="control-label">{ui.repetitions}</span>
57
+ <span class="field-value"><output data-output="repetitions">{DEFAULT_SHADOWING_INPUTS.repetitions}</output><span>{ui.passesUnit}</span></span>
58
+ <input type="range" min="1" max="30" step="1" value={DEFAULT_SHADOWING_INPUTS.repetitions} data-range="repetitions" aria-label={ui.repetitions} />
59
+ <input type="number" min="1" max="30" step="1" value={DEFAULT_SHADOWING_INPUTS.repetitions} data-number="repetitions" aria-describedby="shadowing-input-help" />
60
+ </label>
61
+ <label class="shadowing-field">
62
+ <span class="control-label">{ui.pauseSeconds}</span>
63
+ <span class="field-value"><output data-output="pauseSeconds">{DEFAULT_SHADOWING_INPUTS.pauseSeconds}</output><span>{ui.secondsUnit}</span></span>
64
+ <input type="range" min="0" max="120" step="1" value={DEFAULT_SHADOWING_INPUTS.pauseSeconds} data-range="pauseSeconds" aria-label={ui.pauseSeconds} />
65
+ <input type="number" min="0" max="120" step="1" value={DEFAULT_SHADOWING_INPUTS.pauseSeconds} data-number="pauseSeconds" aria-describedby="shadowing-input-help" />
66
+ </label>
67
+ </div>
68
+
69
+ <p class="input-help" id="shadowing-input-help">{ui.inputHelp}</p>
70
+ <button type="button" class="reset-button" data-reset>{ui.resetLabel}</button>
71
+ </form>
72
+
73
+ <section class="shadowing-result" aria-live="polite">
74
+ <div class="result-topline">
75
+ <span class="result-kicker">{ui.scheduledShadowing}</span>
76
+ <span class="status-pill" data-role="status" data-tone="good">{ui.statusBuffer}</span>
77
+ </div>
78
+ <div class="result-headline">
79
+ <div class="result-stat"><strong data-role="passes">{initialPlan.plannedRepetitions}/{initialPlan.requestedRepetitions}</strong><span>{ui.passesLabel}</span></div>
80
+ <div class="result-stat"><strong data-role="minutes">{formatSeconds(initialPlan.scheduledSeconds)}</strong><span>{ui.minutesScheduled}</span></div>
81
+ </div>
82
+ <p class="result-detail" data-role="detail">{ui.bufferDetail.replace('{remaining}', formatSeconds(initialPlan.remainingSeconds))}</p>
83
+
84
+ <div class="timer-dock">
85
+ <div class="timer-readout">
86
+ <span class="timer-title">{ui.timerTitle}</span>
87
+ <strong data-role="timer-clock">{formatClock(initialPlan.scheduledSeconds)}</strong>
88
+ <span class="timer-status" data-role="timer-status">{ui.timerIdle}</span>
89
+ <span class="timer-block" data-role="timer-block">{ui.timerStartHint}</span>
90
+ </div>
91
+ <div class="timer-actions">
92
+ <button type="button" class="timer-toggle" data-role="timer-toggle">{ui.startTimer}</button>
93
+ <button type="button" class="timer-reset" data-role="timer-reset" disabled>{ui.resetTimer}</button>
94
+ <button type="button" class="timer-sound" data-role="timer-sound" aria-pressed="true">{ui.timerSoundOn}</button>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="timeline-card">
99
+ <div class="timeline-heading">
100
+ <span>{ui.timelineLabel}</span>
101
+ <span data-role="budget-note">{ui.budgetNote.replace('{budget}', formatSeconds(initialPlan.budgetSeconds))}</span>
102
+ </div>
103
+ <div class="timeline" data-role="timeline" role="list" aria-label={ui.timelineLabel}>
104
+ {initialPlan.blocks.map((block) => (
105
+ <div class="shadowing-timeline-block" data-kind={block.kind} style={`--n-block-width: ${((block.endSeconds - block.startSeconds) / initialPlan.budgetSeconds) * 100}%`} role="listitem">
106
+ <span class="shadowing-block-label">{initialBlockLabel(block.kind, block.index)}</span>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ <div class="timeline-legend">
111
+ <span><i data-kind="shadow"></i>{ui.legendShadow}</span>
112
+ <span><i data-kind="pause"></i>{ui.legendPause}</span>
113
+ <span><i data-kind="buffer"></i>{ui.legendBuffer}</span>
114
+ </div>
115
+ </div>
116
+
117
+ <div class="metric-strip">
118
+ <div><span>{ui.activeSpeaking}</span><strong data-role="active">{formatSeconds(initialPlan.activeSeconds)}</strong></div>
119
+ <div><span>{ui.pauseTime}</span><strong data-role="pause">{formatSeconds(initialPlan.pauseTotalSeconds)}</strong></div>
120
+ <div><span>{ui.flexibleBuffer}</span><strong data-role="buffer">{formatSeconds(initialPlan.remainingSeconds)}</strong></div>
121
+ </div>
122
+ <p class="budget-note">{ui.useBuffer}</p>
123
+ <div class="rehearsal-cues">
124
+ <span class="cue-title">{ui.cueTitle}</span>
125
+ <div class="cue-grid">
126
+ <p><strong>01</strong>{ui.cuePlay}</p>
127
+ <p><strong>02</strong>{ui.cueSpeak}</p>
128
+ <p><strong>03</strong>{ui.cueNotice}</p>
129
+ </div>
130
+ </div>
131
+ </section>
132
+ </div>
133
+
134
+ <script is:inline type="application/json" id="language-shadowing-session-ui" set:html={serializedUI}></script>
135
+ <script>
136
+ import { mountShadowingSession } from './controller';
137
+
138
+ const shadowingUI = JSON.parse(document.getElementById('language-shadowing-session-ui')?.textContent ?? '{}');
139
+ const shadowingRoot = document.querySelector<HTMLElement>('[data-shadowing-app]');
140
+ if (shadowingRoot && shadowingUI) {
141
+ mountShadowingSession(shadowingRoot, shadowingUI);
142
+ }
143
+ </script>
@@ -0,0 +1,152 @@
1
+ import { calculateShadowingPlan, DEFAULT_SHADOWING_INPUTS, normalizeShadowingInputs } from './logic';
2
+ import { formatInputValue, renderShadowingPlan } from './dom-views';
3
+ import { readShadowingInputs, saveShadowingInputs } from './storage';
4
+ import { ShadowingTimer } from './timer';
5
+ import { renderTimerState, type TimerViewElements } from './timer-view';
6
+ import type { ShadowingPlanInputs } from './types';
7
+ import type { ShadowingSessionUI } from './ui';
8
+
9
+ type InputKey = keyof ShadowingPlanInputs;
10
+
11
+ interface InputPair {
12
+ range: HTMLInputElement;
13
+ number: HTMLInputElement;
14
+ }
15
+
16
+ function getInputPair(root: HTMLElement, key: InputKey): InputPair {
17
+ const range = root.querySelector<HTMLInputElement>(`[data-range="${key}"]`);
18
+ const number = root.querySelector<HTMLInputElement>(`[data-number="${key}"]`);
19
+ if (!range || !number) throw new Error(`Missing input pair for ${key}`);
20
+ return { range, number };
21
+ }
22
+
23
+ function readInputs(root: HTMLElement): ShadowingPlanInputs {
24
+ const keys: InputKey[] = ['totalMinutes', 'clipSeconds', 'repetitions', 'pauseSeconds'];
25
+ const values = Object.fromEntries(keys.map((key) => [key, Number(getInputPair(root, key).number.value)])) as unknown as ShadowingPlanInputs;
26
+ return normalizeShadowingInputs(values);
27
+ }
28
+
29
+ function writeInput(root: HTMLElement, key: InputKey, value: number, locale: string): void {
30
+ const pair = getInputPair(root, key);
31
+ pair.range.value = String(value);
32
+ pair.number.value = String(value);
33
+ const output = root.querySelector<HTMLElement>(`[data-output="${key}"]`);
34
+ if (output) output.textContent = formatInputValue(value, locale);
35
+ }
36
+
37
+ function writeInputs(root: HTMLElement, inputs: ShadowingPlanInputs, locale: string): void {
38
+ (Object.keys(inputs) as InputKey[]).forEach((key) => writeInput(root, key, inputs[key], locale));
39
+ }
40
+
41
+ function inputsMatch(left: ShadowingPlanInputs, right: ShadowingPlanInputs): boolean {
42
+ return (Object.keys(left) as InputKey[]).every((key) => left[key] === right[key]);
43
+ }
44
+
45
+ function syncPresetState(root: HTMLElement, inputs: ShadowingPlanInputs): void {
46
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
47
+ const preset = JSON.parse(button.dataset.preset ?? '{}') as ShadowingPlanInputs;
48
+ const isActive = inputsMatch(inputs, preset);
49
+ button.classList.toggle('is-active', isActive);
50
+ button.setAttribute('aria-pressed', String(isActive));
51
+ });
52
+ }
53
+
54
+ function bindInput(root: HTMLElement, key: InputKey, locale: string, update: () => void): void {
55
+ const pair = getInputPair(root, key);
56
+ pair.range.addEventListener('input', () => {
57
+ pair.number.value = pair.range.value;
58
+ const value = Number(pair.range.value);
59
+ const output = root.querySelector<HTMLElement>(`[data-output="${key}"]`);
60
+ if (output) output.textContent = formatInputValue(value, locale);
61
+ update();
62
+ });
63
+ pair.number.addEventListener('input', () => {
64
+ const value = Number(pair.number.value);
65
+ if (!Number.isFinite(value)) return;
66
+ pair.range.value = String(value);
67
+ const output = root.querySelector<HTMLElement>(`[data-output="${key}"]`);
68
+ if (output) output.textContent = formatInputValue(value, locale);
69
+ update();
70
+ });
71
+ }
72
+
73
+ function queryViewElements(root: HTMLElement) {
74
+ const get = (role: string): HTMLElement => {
75
+ const element = root.querySelector<HTMLElement>(`[data-role="${role}"]`);
76
+ if (!element) throw new Error(`Missing view element ${role}`);
77
+ return element;
78
+ };
79
+ return {
80
+ passes: get('passes'),
81
+ minutes: get('minutes'),
82
+ active: get('active'),
83
+ pause: get('pause'),
84
+ buffer: get('buffer'),
85
+ status: get('status'),
86
+ detail: get('detail'),
87
+ timeline: get('timeline'),
88
+ budgetNote: get('budget-note'),
89
+ };
90
+ }
91
+
92
+ function queryTimerElements(root: HTMLElement): TimerViewElements {
93
+ const get = <T extends HTMLElement>(role: string): T => {
94
+ const element = root.querySelector<T>(`[data-role="${role}"]`);
95
+ if (!element) throw new Error(`Missing timer element ${role}`);
96
+ return element;
97
+ };
98
+ return {
99
+ clock: get('timer-clock'),
100
+ status: get('timer-status'),
101
+ block: get('timer-block'),
102
+ toggle: get<HTMLButtonElement>('timer-toggle'),
103
+ reset: get<HTMLButtonElement>('timer-reset'),
104
+ sound: get<HTMLButtonElement>('timer-sound'),
105
+ };
106
+ }
107
+
108
+ interface PresetBinding {
109
+ root: HTMLElement;
110
+ button: HTMLButtonElement;
111
+ inputs: ShadowingPlanInputs;
112
+ locale: string;
113
+ update: () => void;
114
+ }
115
+
116
+ function bindPreset({ root, button, inputs, locale, update }: PresetBinding): void {
117
+ button.addEventListener('click', () => {
118
+ writeInputs(root, inputs, locale);
119
+ update();
120
+ });
121
+ }
122
+
123
+ export function mountShadowingSession(root: HTMLElement, ui: ShadowingSessionUI): void {
124
+ const stored = readShadowingInputs();
125
+ const initial = normalizeShadowingInputs({ ...DEFAULT_SHADOWING_INPUTS, ...stored });
126
+ const viewElements = queryViewElements(root);
127
+ const timerElements = queryTimerElements(root);
128
+ const timer = new ShadowingTimer({ onChange: (state) => renderTimerState(timerElements, state, ui) });
129
+ const update = () => {
130
+ const inputs = readInputs(root);
131
+ syncPresetState(root, inputs);
132
+ saveShadowingInputs(inputs);
133
+ const plan = calculateShadowingPlan(inputs);
134
+ renderShadowingPlan(viewElements, plan, ui);
135
+ timer.setPlan(plan);
136
+ };
137
+
138
+ writeInputs(root, initial, ui.numberLocale);
139
+ (Object.keys(initial) as InputKey[]).forEach((key) => bindInput(root, key, ui.numberLocale, update));
140
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
141
+ const inputs = JSON.parse(button.dataset.preset ?? '{}') as ShadowingPlanInputs;
142
+ bindPreset({ root, button, inputs, locale: ui.numberLocale, update });
143
+ });
144
+ root.querySelector<HTMLButtonElement>('[data-reset]')?.addEventListener('click', () => {
145
+ writeInputs(root, DEFAULT_SHADOWING_INPUTS, ui.numberLocale);
146
+ update();
147
+ });
148
+ timerElements.toggle.addEventListener('click', () => timer.toggle());
149
+ timerElements.reset.addEventListener('click', () => timer.reset());
150
+ timerElements.sound.addEventListener('click', () => timer.toggleSound());
151
+ update();
152
+ }
@@ -0,0 +1,82 @@
1
+ import type { ShadowingSessionUI } from './ui';
2
+ import type { ShadowingBlock, ShadowingPlanResult } from './types';
3
+ import { evaluateShadowingPlan } from './evaluator';
4
+
5
+ interface ViewElements {
6
+ passes: HTMLElement;
7
+ minutes: HTMLElement;
8
+ active: HTMLElement;
9
+ pause: HTMLElement;
10
+ buffer: HTMLElement;
11
+ status: HTMLElement;
12
+ detail: HTMLElement;
13
+ timeline: HTMLElement;
14
+ budgetNote: HTMLElement;
15
+ }
16
+
17
+ function formatSeconds(seconds: number, locale: string): string {
18
+ const numberFormat = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 });
19
+ const minutes = Math.floor(seconds / 60);
20
+ const remainder = seconds % 60;
21
+ if (minutes === 0) return `${numberFormat.format(remainder)} s`;
22
+ if (remainder === 0) return `${numberFormat.format(minutes)} min`;
23
+ return `${numberFormat.format(minutes)} min ${numberFormat.format(remainder)} s`;
24
+ }
25
+
26
+ function blockLabel(block: ShadowingBlock, ui: ShadowingSessionUI): string {
27
+ if (block.kind === 'shadow') return `${ui.shadowBlock} ${block.index ?? ''}`.trim();
28
+ if (block.kind === 'pause') return ui.pauseBlock;
29
+ return ui.bufferBlock;
30
+ }
31
+
32
+ function createBlock(block: ShadowingBlock, result: ShadowingPlanResult, ui: ShadowingSessionUI): HTMLElement {
33
+ const element = document.createElement('div');
34
+ const width = ((block.endSeconds - block.startSeconds) / Math.max(result.budgetSeconds, 1)) * 100;
35
+ element.className = 'shadowing-timeline-block';
36
+ element.dataset.kind = block.kind;
37
+ element.style.setProperty('--n-block-width', `${width}%`);
38
+ element.setAttribute('role', 'listitem');
39
+ element.setAttribute('aria-label', `${blockLabel(block, ui)}: ${formatSeconds(block.endSeconds - block.startSeconds, ui.numberLocale)}`);
40
+
41
+ const label = document.createElement('span');
42
+ label.className = 'shadowing-block-label';
43
+ label.textContent = block.kind === 'shadow' ? String(block.index) : blockLabel(block, ui);
44
+ element.append(label);
45
+ return element;
46
+ }
47
+
48
+ function renderTimeline(elements: ViewElements, result: ShadowingPlanResult, ui: ShadowingSessionUI): void {
49
+ elements.timeline.replaceChildren(...result.blocks.map((block) => createBlock(block, result, ui)));
50
+ elements.timeline.setAttribute('aria-label', ui.timelineLabel);
51
+ }
52
+
53
+ function statusLabel(messageKey: 'fits' | 'short' | 'buffer', ui: ShadowingSessionUI): string {
54
+ if (messageKey === 'fits') return ui.statusFits;
55
+ if (messageKey === 'short') return ui.statusShort;
56
+ return ui.statusBuffer;
57
+ }
58
+
59
+ export function renderShadowingPlan(elements: ViewElements, result: ShadowingPlanResult, ui: ShadowingSessionUI): void {
60
+ const locale = ui.numberLocale;
61
+ const evaluation = evaluateShadowingPlan(result);
62
+ const messages: Record<string, string> = {
63
+ fits: ui.fitsDetail,
64
+ short: ui.shortDetail.replace('{planned}', String(result.plannedRepetitions)).replace('{requested}', String(result.requestedRepetitions)),
65
+ buffer: ui.bufferDetail.replace('{remaining}', formatSeconds(result.remainingSeconds, locale)),
66
+ };
67
+
68
+ elements.passes.textContent = `${result.plannedRepetitions}/${result.requestedRepetitions}`;
69
+ elements.minutes.textContent = formatSeconds(result.scheduledSeconds, locale);
70
+ elements.active.textContent = formatSeconds(result.activeSeconds, locale);
71
+ elements.pause.textContent = formatSeconds(result.pauseTotalSeconds, locale);
72
+ elements.buffer.textContent = formatSeconds(result.remainingSeconds, locale);
73
+ elements.status.textContent = statusLabel(evaluation.messageKey, ui);
74
+ elements.status.dataset.tone = evaluation.tone;
75
+ elements.detail.textContent = messages[evaluation.messageKey] ?? ui.fitsDetail;
76
+ elements.budgetNote.textContent = ui.budgetNote.replace('{budget}', formatSeconds(result.budgetSeconds, locale));
77
+ renderTimeline(elements, result, ui);
78
+ }
79
+
80
+ export function formatInputValue(value: number, locale: string): string {
81
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
82
+ }
@@ -0,0 +1,34 @@
1
+ import type { LanguageToolEntry, ToolDefinition, ToolLocaleContent } from '../../types';
2
+ import type { ShadowingSessionUI } from './ui';
3
+
4
+ export type { ShadowingSessionUI } from './ui';
5
+ export type LanguageShadowingSessionPlannerLocaleContent = ToolLocaleContent<ShadowingSessionUI>;
6
+
7
+ export const languageShadowingSessionPlanner: LanguageToolEntry<ShadowingSessionUI> = {
8
+ id: 'language-shadowing-session-planner',
9
+ icons: { bg: 'mdi:waveform', fg: 'mdi:microphone-outline' },
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 LANGUAGE_SHADOWING_SESSION_PLANNER_TOOL: ToolDefinition = {
30
+ entry: languageShadowingSessionPlanner,
31
+ Component: () => import('./component.astro'),
32
+ SEOComponent: () => import('./seo.astro'),
33
+ BibliographyComponent: () => import('./bibliography.astro'),
34
+ };
@@ -0,0 +1,17 @@
1
+ import type { ShadowingPlanResult, ShadowingPlanStatus } from './types';
2
+
3
+ export interface ShadowingEvaluation {
4
+ status: ShadowingPlanStatus;
5
+ tone: 'good' | 'warn' | 'neutral';
6
+ messageKey: 'fits' | 'short' | 'buffer';
7
+ }
8
+
9
+ export function evaluateShadowingPlan(result: ShadowingPlanResult): ShadowingEvaluation {
10
+ if (result.status === 'short') {
11
+ return { status: 'short', tone: 'warn', messageKey: 'short' };
12
+ }
13
+ if (result.status === 'buffer') {
14
+ return { status: 'buffer', tone: 'neutral', messageKey: 'buffer' };
15
+ }
16
+ return { status: 'fits', tone: 'good', messageKey: 'fits' };
17
+ }
@@ -0,0 +1,54 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import type { ShadowingSessionUI } from '../ui';
4
+
5
+ const ui: ShadowingSessionUI = {
6
+ quickStarts: 'Schnellstarts', quickShort: '5 Min Schleife', quickFocused: '12 Min Fokus', quickLong: '25 Min intensiv',
7
+ totalMinutes: 'Sitzungsbudget', clipSeconds: 'Clip-Länge', repetitions: 'Shadowing-Durchläufe', pauseSeconds: 'Pause zwischen Durchläufen',
8
+ minutesUnit: 'Min', secondsUnit: 'Sek.', passesUnit: 'Durchläufe', resetLabel: 'Standardwerte zurücksetzen', scheduledShadowing: 'Geplantes Shadowing',
9
+ passesLabel: 'geplante Durchläufe', minutesScheduled: 'Übungszeit', activeSpeaking: 'Sprechzeit', pauseTime: 'Pausenzeit', flexibleBuffer: 'Flexibler Puffer',
10
+ timelineLabel: 'Sitzungsablauf', shadowBlock: 'Shadowing-Durchlauf', pauseBlock: 'Pause', bufferBlock: 'Wiederholung oder Notizen', statusFits: 'Passt genau', statusShort: 'Budget zu kurz', statusBuffer: 'Freiraum',
11
+ shortDetail: 'Nur {planned} von {requested} gewünschten Durchläufen passen als vollständige Clips. Kürze den Clip, verringere die Wiederholungen oder gib mehr Zeit.',
12
+ bufferDetail: 'Alle gewünschten Durchläufe passen. {remaining} bleiben für eine Wiederholung, eine Aufnahmeprüfung oder Notizen.', fitsDetail: 'Jeder gewünschte Durchlauf und jede Pause liegt innerhalb des Sitzungsbudgets.',
13
+ budgetNote: 'Budget: {budget}', useBuffer: 'Nutze den letzten Block für eine Wiederholung mit weniger Unterstützung, eine kurze eigene Aufnahme oder eine Notiz zu dem Laut, den du nachahmen möchtest.',
14
+ cueTitle: 'Hinweise für die Übung', cuePlay: 'Spiele den ganzen Clip ab, bevor du das Ziel änderst.', cueSpeak: 'Halte zuerst den Rhythmus, dann schärfe einen Laut.', cueNotice: 'Notiere eine Beobachtung, die deinen nächsten Durchlauf verändert.',
15
+ timerTitle: 'Übung starten', startTimer: 'Countdown starten', pauseTimer: 'Countdown pausieren', resumeTimer: 'Countdown fortsetzen', resetTimer: 'Timer zurücksetzen', timerSoundOn: 'Ton an', timerSoundOff: 'Ton aus',
16
+ timerIdle: 'Bereit', timerRunning: 'Läuft', timerPaused: 'Pausiert', timerComplete: 'Fertig', timerCompleteDetail: 'Sitzung abgeschlossen. Setze den Timer zurück, um sie erneut zu starten.', timerStartHint: 'Starte, sobald dein Clip bereit ist.', timerNoSchedule: 'Gib genug Zeit für einen vollständigen Durchlauf ein, um den Timer zu starten.',
17
+ legendShadow: 'Shadowing-Durchlauf', legendPause: 'Pause', legendBuffer: 'Flexible Zeit', inputHelp: 'Ein Durchlauf ist eine vollständige Wiedergabe, während du mitsprichst. Pausen gibt es nur zwischen vollständigen Durchläufen.', numberLocale: 'de-DE',
18
+ };
19
+
20
+ const faq = [
21
+ { question: 'Was berechnet dieser Shadowing-Planer?', answer: 'Er verwandelt Sitzungsbudget, Clip-Länge, Anzahl der Durchläufe und Pausenlänge in einen Ablauf. Es zählen nur vollständige Durchläufe, damit du genau siehst, wie viele passen und wie viel flexible Zeit bleibt.' },
22
+ { question: 'Was ist ein Shadowing-Durchlauf?', answer: 'Ein Durchlauf ist eine vollständige Wiedergabe des Clips, bei der du die Sprache so genau und unmittelbar wie möglich wiederholst. Der Planer verwendet dafür die eingegebene Clip-Länge.' },
23
+ { question: 'Warum endet der Planer vor einem unvollständigen Durchlauf?', answer: 'Ein unvollständiger Durchlauf ist in einem Übungsplan wenig nützlich, weil er den vorgesehenen Clip verändert. Die Warnung hilft dir, Budget, Clip-Länge, Wiederholungen oder Pausen vor dem Start anzupassen.' },
24
+ { question: 'Wie nutze ich den flexiblen Puffer?', answer: 'Nutze ihn für eine bewusste Wiederholung, eine kurze Aufnahme mit Vergleich oder Notizen zu Rhythmus, Verbindung, Betonung oder einem Laut. Mehr Wiederholungen bedeuten nicht automatisch eine bessere Aussprache.' },
25
+ { question: 'Misst das meine Aussprache oder garantiert es Fortschritt?', answer: 'Nein. Das Tool plant nur Zeit. Es hört deine Stimme nicht, bewertet keine Genauigkeit, schätzt kein Sprachniveau und garantiert keine Verbesserung. Wähle einen Clip, den du gut genug verstehst, und hole Feedback ein, wenn Genauigkeit wichtig ist.' },
26
+ ];
27
+
28
+ const howTo = [
29
+ { name: 'Sitzungsbudget festlegen', text: 'Gib die Gesamtzahl der Minuten ein, die du für diese Übung schützen kannst.' },
30
+ { name: 'Clip-Länge eingeben', text: 'Verwende die Dauer eines vollständigen Audio- oder Videoclips.' },
31
+ { name: 'Durchläufe und Pausen wählen', text: 'Lege die Zahl vollständiger Shadowing-Durchläufe und die nötige Pause dazwischen fest.' },
32
+ { name: 'Ablauf befolgen', text: 'Absolviere die nummerierten Durchläufe, nimm die markierten Pausen und nutze den letzten flexiblen Block für Wiederholung, Vergleich oder Notizen.' },
33
+ ];
34
+
35
+ const appSchema: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Planer für Language Shadowing Sitzungen', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', isAccessibleForFree: true, url: 'https://gamebob.dev/de/planer-language-shadowing-sitzung' };
36
+ const howToSchema: HowTo = { '@type': 'HowTo', name: 'Eine Language-Shadowing-Sitzung planen', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
37
+ const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
38
+
39
+ export const content: ToolLocaleContent<ShadowingSessionUI> = {
40
+ slug: 'planer-language-shadowing-sitzung', title: 'Planer für Language Shadowing Sitzungen', description: 'Erstelle eine zeitlich begrenzte Shadowing-Übung aus Clip-Länge, Wiederholungen, Pausen und verfügbarer Zeit.', ui,
41
+ seo: [
42
+ { type: 'title', text: 'Aus einem Shadowing Clip wird eine machbare Sitzung', level: 2 },
43
+ { type: 'paragraph', html: 'Shadowing wird leicht abgebrochen, wenn der Übungsblock keine Form hat. Dieser Planer macht aus einem Clip eine begrenzte Probe: vollständige Durchläufe sind nummeriert, Pausen sichtbar und das übrige Budget wird zu einem bewussten Fenster für Wiederholung oder Notizen. Du kannst den Ablauf vor dem Start anpassen, statt erst mittendrin zu merken, dass die Sitzung nicht passt.' },
44
+ { type: 'title', text: 'So funktioniert die Zeitrechnung', level: 2 },
45
+ { type: 'paragraph', html: 'Jeder Shadowing-Durchlauf entspricht der vollständigen Clip-Länge. Die Pause wird nur zwischen Durchläufen addiert, niemals nach dem letzten. Ein 30 Sekunden langer Clip mit vier Durchläufen und 10 Sekunden Pause braucht zum Beispiel 2 Minuten und 10 Sekunden: 120 Sekunden Sprechzeit plus 30 Sekunden Pausen. Ist das Budget kürzer als der nächste vollständige Durchlauf, wird er nicht eingeplant.' },
46
+ { type: 'title', text: 'Die Übungsleiste lesen', level: 2 },
47
+ { type: 'table', headers: ['Markierung', 'Bedeutung', 'Sinnvolle Aktion'], rows: [['Nummerierte korallfarbene Blöcke', 'Vollständige Shadowing-Durchläufe, die ins Budget passen.', 'Sprich mit dem Clip und nutze die Zahl als Endpunkt.'], ['Blaue Blöcke', 'Die Pause zwischen zwei vollständigen Durchläufen.', 'Atme, richte deine Aufmerksamkeit neu aus und wähle den nächsten Laut.'], ['Goldener Block', 'Zeit, die nach allen gewünschten Durchläufen übrig bleibt.', 'Nutze eine Wiederholung, einen Aufnahmevergleich oder eine kurze Notiz statt automatisch mehr Wiederholungen einzubauen.']] },
48
+ { type: 'title', text: 'Jeden Durchlauf sinnvoller machen', level: 2 },
49
+ { type: 'paragraph', html: 'Wähle einen Clip, den du wiederholen kannst, ohne den Sprecher zu verlieren. Konzentriere dich am Anfang auf den Rhythmus und höre später auf Betonung, Reduktionen, Verbindungen oder einen Konsonanten, den du beschreiben kannst. Wenn der Clip noch zu schwer ist, verlangsame ihn oder wähle einen kürzeren Ausschnitt, bevor du weitere Wiederholungen hinzufügst.' },
50
+ { type: 'list', items: ['Wähle Audio, das du wiederholen kannst, ohne das nächste Segment zu suchen.', 'Gib die echte Clip-Länge einschließlich des vollständigen Endes ein.', 'Nutze Pausen, um ein Lautmerkmal zu benennen, statt Erklärungen zu durchblättern.', 'Verwende den letzten Puffer für einen Vergleich oder eine Notiz, die die nächste Sitzung verändert.', 'Hole dir bei wichtiger Genauigkeit Feedback von Lehrkräften oder Lernpartnern.'] },
51
+ { type: 'tip', title: 'Was der Planer nicht sagen kann', html: 'Die Zeitleiste ist Arithmetik, keine Aussprachebewertung. Sie hört deine Stimme nicht, beurteilt deine Nachahmung nicht und bestimmt kein Sprachniveau. Forschung zu Shadowing untersucht bestimmte Lernende, Aufgaben und Trainingsbedingungen. Verwende diesen Ablauf daher als praktischen Rahmen, nicht als Beweis für garantierten Fortschritt.' },
52
+ ], faq, bibliography: [{ name: '日本語聴解学習におけるシャドーイングの効果', url: 'https://www.jstage.jst.go.jp/article/jlem/29/1/29_26/_article/-char/ja' }, { name: 'British Council: Teaching English pronunciation online: Practical tips and benefits of shadowing', url: 'https://americas.britishcouncil.org/new-ways-of-teaching/events/teaching-english-pronunciation-online' }], howTo,
53
+ schemas: [{ '@context': 'https://schema.org', ...appSchema } as unknown as Record<string, unknown>, { '@context': 'https://schema.org', ...howToSchema } as unknown as Record<string, unknown>, { '@context': 'https://schema.org', ...faqSchema } as unknown as Record<string, unknown>],
54
+ };