@ihabdevteam/core 0.330.0 → 0.332.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.
@@ -1678,7 +1678,7 @@ export declare function copyText(text: string): Promise<boolean>;
1678
1678
  *
1679
1679
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1680
1680
  */
1681
- export declare const CORE_VERSION = "0.330.0";
1681
+ export declare const CORE_VERSION = "0.332.0";
1682
1682
 
1683
1683
  /** @public */
1684
1684
  declare const coreDefault: Record<string, unknown>;
@@ -3916,6 +3916,12 @@ export declare type MonthBarData = {
3916
3916
  daily: BarData[];
3917
3917
  };
3918
3918
 
3919
+ /** @public */
3920
+ export declare const monthKeyOf: (year: number, month1: number) => string;
3921
+
3922
+ /** @public */
3923
+ export declare const monthKeyOfDate: (d: Date) => string;
3924
+
3919
3925
  /** @public */
3920
3926
  export declare type NavItem = {
3921
3927
  key?: string;
@@ -4497,6 +4503,18 @@ declare type PanelSize = 'small' | 'medium' | 'large';
4497
4503
  */
4498
4504
  export declare function parseLocalYmd(ymd: string): Date;
4499
4505
 
4506
+ /**
4507
+ * `"2026-10"` → `{ year: 2026, month1: 10 }`. 꼴이 아니면 `null`.
4508
+ *
4509
+ * **두 자리만 받는다.** `"2026-9"` 를 받아 주면 짓는 쪽과 뜯는 쪽이 어긋나도 조용하고,
4510
+ * 그 어긋남이 바로 열쇠가 안 맞는 결함이다.
4511
+ */
4512
+ /** @public */
4513
+ export declare function parseMonthKey(key: string): {
4514
+ year: number;
4515
+ month1: number;
4516
+ } | null;
4517
+
4500
4518
  /**
4501
4519
  * 드롭다운 값을 정렬 상태로 되돌린다.
4502
4520
  *
@@ -10589,6 +10607,9 @@ export declare interface WeakItem {
10589
10607
  */
10590
10608
  export declare function weakItems(records: readonly ScoreRecord[]): WeakItem[];
10591
10609
 
10610
+ /** @public */
10611
+ export declare const weekValueOf: (monthKey: string, week: number) => string;
10612
+
10592
10613
  /** text 뒤에 문맥에 맞는 을/를을 붙여 반환한다. */
10593
10614
  export declare function withEulReul(text: string): string;
10594
10615
 
@@ -122,16 +122,20 @@
122
122
  .result-table__row:hover { background: var(--color-deepWhite); }
123
123
  .result-table__row:last-child { border-bottom: 0; }
124
124
 
125
+ /* **가운데로 맞춘다 — `baseline` 이 아니다**((307)①, 2026-10-14 사장님 지적).
126
+ `baseline` 이면 `display:flex` 인 단추의 **제 안 아이콘 바닥**이 기준선이 된다. 그래서
127
+ 32px 단추가 줄 높이를 정하고 26px 낱말이 **3px 위로** 밀렸다(말귀_1 실측:
128
+ 줄 mid 664 · 낱말 mid 661). 단추에만 `align-self: center` 를 걸던 앞 고침은 **안 움직였다**
129
+ — 단추가 이미 줄을 꽉 채우니 제 안에서 가운데로 가도 그대로다. 줄을 맞춰야 한다.
130
+ 단추 없는 줄은 26px 하나뿐이라 달라지는 것이 없다. */
125
131
  .trp-row__problem {
126
132
  display: flex;
127
- align-items: baseline;
133
+ align-items: center;
128
134
  gap: 10px;
129
135
  }
130
- /* **다시 듣기** — 문제 글 옆((303), 2026-10-14). 글이 길면 줄바꿈으로 밀리므로 안 줄인다.
131
- `align-self` 로 올려 붙인다 — 줄이 `baseline` 이라 그대로 두면 단추가 글자 밑줄에 걸린다. */
136
+ /* **다시 듣기** — 문제 글 옆((303), 2026-10-14). 글이 길면 줄바꿈으로 밀리므로 안 줄인다. */
132
137
  .trp-row__replay {
133
138
  flex-shrink: 0;
134
- align-self: center;
135
139
  }
136
140
  .trp-row__no {
137
141
  flex-shrink: 0;
@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next';
5
5
  import Button from '../Button';
6
6
  import Badge from '../Badge';
7
7
  import { playOneShot } from '../Training/oneShotSound';
8
+ import { questionSoundCandidates, canSpeak, playableUrl } from '../Training/questionSoundRoute';
9
+ import { speak, stopSpeaking } from '../../utils/speak';
8
10
  import Popup from './Popup';
9
11
  import "./TrainingResultPopup.css";
10
12
  function pickFirstString(...vals) {
@@ -46,10 +48,7 @@ function resolveProblem(it) {
46
48
  * **못 찾으면 `null` 이고, 그러면 단추를 안 그린다** — 안 넘기는 쪽은 지금과 똑같다.
47
49
  */
48
50
  function resolveSound(it) {
49
- const v = pickFirstString(it?.questionSound, it?.soundSrc, it?.audioSrc, it?.sound, it?.audio, it?.src);
50
- /* 글자이기만 하면 되는 게 아니다 — `pickFirstString` 은 아무 글이나 돌려준다.
51
- 소리 자리에 제목이 들어 있는 꼴을 틀려고 하면 조용히 아무 일도 안 난다. */
52
- return v && /^(https?:|blob:|data:audio|\/|\.\/)/.test(v) ? v : null;
51
+ return playableUrl(pickFirstString(it?.questionSound, it?.soundSrc, it?.audioSrc, it?.sound, it?.audio, it?.src));
53
52
  }
54
53
  function resolveDecision(it) {
55
54
  if (!it)
@@ -112,11 +111,66 @@ export default function TrainingResultPopup(props) {
112
111
  setPlayingRow(null);
113
112
  }, []);
114
113
  useEffect(() => stopPlaying, [stopPlaying]);
115
- const replay = useCallback((i, url) => {
114
+ /*
115
+ * **코어가 그 문항을 읽을 때 쓴 길을 그대로 탄다**((307)③, 2026-10-14).
116
+ *
117
+ * 0.328.0 은 부르는 쪽이 넘긴 주소 하나만 봤다. 그래서 **정식 음원이 없는 낱말에는 단추가
118
+ * 아예 안 섰다** — 말귀_1 실측으로 다섯 줄 중 둘이 그랬다. 그런데 **훈련 중에는 다섯 다
119
+ * 들렸다**: 코어가 리졸버와 합성으로 읽어 주고 있었다. 같은 길을 여기서도 탄다
120
+ * (차례는 `questionSoundRoute` 한 자리가 안다).
121
+ *
122
+ * **임자 셈으로 "못 틀었다" 와 "사람이 멈췄다" 를 가른다.** `done` 은 둘 다 `false` 다 —
123
+ * 그것만 보고 다음 주소로 넘어가면, 멈춤 단추를 누른 사람이 **다음 소리를 듣는다.**
124
+ * 그래서 누를 때마다 표를 하나 만들고, 표가 바뀌었으면 길을 끊는다(카드가 쓰는 꼴과 같다).
125
+ */
126
+ const turnRef = useRef(0);
127
+ const replay = useCallback((i, text, rawUrl) => {
116
128
  stopPlaying();
117
- const s = playOneShot(url, () => { playingRef.current = null; setPlayingRow(null); });
118
- playingRef.current = s.stop;
129
+ const turn = ++turnRef.current;
130
+ const mine = () => turnRef.current === turn;
119
131
  setPlayingRow(i);
132
+ /* 이 줄이 아직 제 차례인 동안만 상태를 되돌린다 — 늦게 온 끝이 남의 줄을 끈다 */
133
+ playingRef.current = () => { turnRef.current++; stopSpeaking(); };
134
+ /** 한 주소를 틀고 **끝까지 들었나**를 돌려준다. 멈추개도 그 소리로 바꿔 둔다. */
135
+ const tryUrl = (url) => {
136
+ const s = playOneShot(url, (played) => { if (played && mine()) {
137
+ playingRef.current = null;
138
+ setPlayingRow(null);
139
+ } });
140
+ playingRef.current = () => { turnRef.current++; s.stop(); stopSpeaking(); };
141
+ return s.done;
142
+ };
143
+ /* **날 주소는 누름 그 자리에서 튼다.**
144
+ 차례를 다 모으고 나서 틀도록 짰더니 `await` 가 하나 끼었고, 그러면 자동재생 막음이
145
+ `play()` 를 거절할 수 있다 — 누름의 표가 `await` 를 못 넘는 브라우저가 있다. 시험 셋이
146
+ 그 바뀜을 잡았다(누르자마자 안 울렸다). 있는 주소는 바로, 없을 때만 더듬는다. */
147
+ const raw = playableUrl(rawUrl);
148
+ const firstDone = raw ? tryUrl(raw) : Promise.resolve(false);
149
+ void (async () => {
150
+ if (await firstDone)
151
+ return;
152
+ if (!mine())
153
+ return;
154
+ /* 날 주소는 위에서 이미 썼으니 다시 넘기지 않는다 */
155
+ for (const url of await questionSoundCandidates(text, null)) {
156
+ if (!mine())
157
+ return;
158
+ if (await tryUrl(url))
159
+ return;
160
+ if (!mine())
161
+ return;
162
+ }
163
+ if (!mine())
164
+ return;
165
+ /* 주소로는 못 냈다 — 브라우저가 읽는다. 여기까지 와야 음원 없는 낱말도 들린다.
166
+ 멈추개를 **말소리용으로 바꿔 둔다** — 안 바꾸면 끝난 `<audio>` 의 `stop` 이 남아,
167
+ 멈춤 단추가 말을 끊는 것처럼 보이면서 실제로 끊는 것은 `stopSpeaking` 뿐이게 된다. */
168
+ playingRef.current = () => { turnRef.current++; stopSpeaking(); };
169
+ speak(text, { onEnd: () => { if (mine()) {
170
+ playingRef.current = null;
171
+ setPlayingRow(null);
172
+ } } });
173
+ })();
120
174
  }, [stopPlaying]);
121
175
  const rows = useMemo(() => list.map((it) => {
122
176
  const itr = it ?? {};
@@ -128,7 +182,10 @@ export default function TrainingResultPopup(props) {
128
182
  const selectedText = typeof itr.selectedText === 'string' ? itr.selectedText : null;
129
183
  const isCorrect = resolveDecision(itr) === true;
130
184
  const sound = resolveSound(itr);
131
- return { problem, variant, label, answer, selectedText, isCorrect, sound };
185
+ /* **단추는 길이 하나라도 있으면 선다** — 주소가 있거나, 코어가 글자를 읽을 수 있으면.
186
+ 주소만 보던 때는 음원 없는 낱말에서 단추가 통째로 사라졌다((307)③). */
187
+ const canHear = !!sound || canSpeak(problem);
188
+ return { problem, variant, label, answer, selectedText, isCorrect, sound, canHear };
132
189
  }), [list, getJudge, t]);
133
190
  const hasAnswerCols = useMemo(() => rows.some(r => r.answer !== null || r.selectedText !== null), [rows]);
134
191
  // 스크롤은 Popup의 .popup-content 하나만 담당한다 — 예전엔 결과 카드가 자체 스크롤
@@ -149,11 +206,15 @@ export default function TrainingResultPopup(props) {
149
206
  // 결과 계산 중에는 Esc 로도 완료(닫기)하지 않는다 — '완료' 단추와 같은 까닭
150
207
  beforeClose: blockWhilePending, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: reasonId, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
151
208
  /* 막되 초점은 남긴다(onDisabledClick 이 있으면 native disabled 대신 aria-disabled) — 키보드·낭독기가 까닭을 들을 수 있게 */
152
- disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem }), r.sound && (_jsx(Button, { htmlType: "button", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: playingRow === i ? 'square' : 'volume-2', className: "trp-row__replay",
209
+ disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem }), r.canHear && (_jsx(Button, { htmlType: "button", size: 32, type: "dot",
210
+ /* **변형을 주지 않는다 — 기본이다**((307)②, 사장님이 두 번 짚으셨다).
211
+ 앱은 이것을 못 바꾼다 — 코어가 넘기는 prop 이고, 앱 CSS 로 코어를
212
+ 겨누지 않는 것이 이 집 규약이다. 그래서 코어가 떼어 낸다. */
213
+ useBorder: false, icon: playingRow === i ? 'square' : 'volume-2', className: "trp-row__replay",
153
214
  /* **줄 번호를 이름에 넣는다** — 줄마다 같은 단추가 서므로, 낭독기로
154
215
  단추 목록을 훑으면 `다시 듣기` 가 문항 수만큼 서서 못 가린다
155
216
  (`TrainingCardGroup` 의 `접기` 와 같은 꼴). */
156
217
  ariaLabel: playingRow === i
157
218
  ? t('training.result.replaySoundStop', { no: i + 1, defaultValue: '{{no}}번 듣기 멈춤' })
158
- : t('training.result.replaySound', { no: i + 1, defaultValue: '{{no}}번 다시 듣기' }), onClick: () => (playingRow === i ? stopPlaying() : replay(i, r.sound)) }))] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
219
+ : t('training.result.replaySound', { no: i + 1, defaultValue: '{{no}}번 다시 듣기' }), onClick: () => (playingRow === i ? stopPlaying() : replay(i, r.problem, r.sound)) }))] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
159
220
  }
@@ -0,0 +1,11 @@
1
+ /** 틀 수 있는 주소면 그대로, 아니면 `null` */
2
+ export declare function playableUrl(v: unknown): string | null;
3
+ /** 코어가 글자를 소리로 바꿀 길을 하나라도 들고 있나 — 없으면 단추를 세울 뜻이 없다 */
4
+ export declare function canSpeak(text: string | null | undefined): boolean;
5
+ /**
6
+ * 더듬을 주소를 **차례대로** 모은다(①②③). ④ 는 주소가 아니므로 여기 없다 —
7
+ * 다 실패하면 부르는 쪽이 `speechSynthesis` 로 간다.
8
+ *
9
+ * 리졸버가 던지거나 빈 글자를 주면 그 자리만 건너뛴다 — 한 자리의 실패로 길이 끊기지 않는다.
10
+ */
11
+ export declare function questionSoundCandidates(text: string | null | undefined, rawUrl: unknown): Promise<string[]>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * **코어가 한 문항을 읽을 때 타는 길 — 그 차례를 아는 자리.**
3
+ *
4
+ * 차례는 넷이다.
5
+ * ① 부르는 쪽이 넘긴 날 주소(정식 음원)
6
+ * ② `configureTTS({ urlResolver })` — 저장소에 있는 정식 음원을 찾는다
7
+ * ③ `configureTTS({ fallbackUrlResolver })` — 없으면 그때 합성한다(앱의 런타임 TTS)
8
+ * ④ 브라우저 `speechSynthesis`
9
+ *
10
+ * **왜 모으나.** 이 차례가 `TrainingWordCard` 와 `TrainingStepAudioPlayer` 에 **두 벌**로
11
+ * 적혀 있었다. 0.328.0 의 결과 팝업 다시 듣기는 ①만 보고 — ②③④ 를 안 봐서 **정식 음원이
12
+ * 없는 낱말에는 단추가 아예 안 섰다.** 말귀_1 이 실측했다(2026-10-14): 다섯 줄 중 둘이
13
+ * 정식 400 · 자동 캐시 400 이라 단추가 없었는데, **훈련 중에는 다섯 다 들렸다** — 코어가
14
+ * ③④ 로 읽어 주고 있었다. 셋째 벌을 또 쓰지 않으려고 차례만 여기 둔다.
15
+ *
16
+ * **재생과 임자(자물쇠)는 여기서 다루지 않는다.** 그쪽은 부르는 쪽마다 쥐는 법이 다르고
17
+ * (카드는 `claimSound`, 팝업은 제 줄 하나), 그 다름이 각자의 내력에 적혀 있다. 여기서 하는
18
+ * 일은 **"어느 주소를 어느 차례로 더듬나"** 뿐이다.
19
+ */
20
+ import { getTTSUrlResolver, getTTSFallbackUrlResolver } from '../../utils/ttsConfig';
21
+ /**
22
+ * 틀 수 있는 주소의 꼴.
23
+ *
24
+ * 글자이기만 하면 되는 게 아니다 — 소리 자리에 제목이 들어 있는 꼴을 틀려고 하면
25
+ * 조용히 아무 일도 안 난다.
26
+ */
27
+ const PLAYABLE = /^(https?:|blob:|data:audio|\/|\.\/)/;
28
+ /** 틀 수 있는 주소면 그대로, 아니면 `null` */
29
+ export function playableUrl(v) {
30
+ return typeof v === 'string' && PLAYABLE.test(v) ? v : null;
31
+ }
32
+ /** 코어가 글자를 소리로 바꿀 길을 하나라도 들고 있나 — 없으면 단추를 세울 뜻이 없다 */
33
+ export function canSpeak(text) {
34
+ if (!text)
35
+ return false;
36
+ if (getTTSUrlResolver() || getTTSFallbackUrlResolver())
37
+ return true;
38
+ return typeof window !== 'undefined' && !!window.speechSynthesis;
39
+ }
40
+ /**
41
+ * 더듬을 주소를 **차례대로** 모은다(①②③). ④ 는 주소가 아니므로 여기 없다 —
42
+ * 다 실패하면 부르는 쪽이 `speechSynthesis` 로 간다.
43
+ *
44
+ * 리졸버가 던지거나 빈 글자를 주면 그 자리만 건너뛴다 — 한 자리의 실패로 길이 끊기지 않는다.
45
+ */
46
+ export async function questionSoundCandidates(text, rawUrl) {
47
+ const out = [];
48
+ const raw = playableUrl(rawUrl);
49
+ if (raw)
50
+ out.push(raw);
51
+ const t = (text ?? '').trim();
52
+ if (!t)
53
+ return out;
54
+ for (const resolver of [getTTSUrlResolver(), getTTSFallbackUrlResolver()]) {
55
+ if (!resolver)
56
+ continue;
57
+ try {
58
+ const url = playableUrl(await Promise.resolve(resolver(t)));
59
+ /* 같은 주소를 두 번 더듬지 않는다 — 리졸버 둘이 같은 저장소를 가리키는 일이 있다 */
60
+ if (url && !out.includes(url))
61
+ out.push(url);
62
+ }
63
+ catch { /* 이 자리만 건너뛴다 */ }
64
+ }
65
+ return out;
66
+ }
@@ -56,7 +56,6 @@ export declare function todayWeekValue(): {
56
56
  export declare function buildMonthChartData(points: ChartPoint[]): Record<string, MonthBarData>;
57
57
  export declare const MONTH_OPTIONS: {
58
58
  value: string;
59
- label: string;
60
59
  year: number;
61
60
  month: number;
62
61
  }[];
@@ -64,7 +63,6 @@ export declare const MONTH_OPTIONS: {
64
63
  * 옵션에서 빠졌다. 컨트롤 훅은 자료가 바뀔 때마다 이것으로 다시 만든다. */
65
64
  export declare function monthOptionsNow(): {
66
65
  value: string;
67
- label: string;
68
66
  year: number;
69
67
  month: number;
70
68
  }[];
@@ -166,12 +166,14 @@ function genMonthOptions() {
166
166
  const now = getTodayKstDate();
167
167
  const curY = now.getFullYear();
168
168
  const curM = now.getMonth() + 1;
169
+ /* **이름표를 여기서 짓지 않는다.** 한때 `label: v` 로 **열쇠를 그대로** 넣어 뒀다 —
170
+ 아무도 안 읽어 조용했지만, 읽는 날 화면에 자리표가 그대로 뜬다(0.325.0 이 그 꼴이었다).
171
+ 보이는 글은 말을 아는 쪽(`useBarChartControl`)이 `yearMonthLabel` 로 짓는다. */
169
172
  const opts = [];
170
173
  for (let y = 2024; y <= curY; y++) {
171
174
  const end = y === curY ? curM : 12;
172
175
  for (let m = 1; m <= end; m++) {
173
- const v = monthKeyOf(y, m);
174
- opts.push({ value: v, label: v, year: y, month: m });
176
+ opts.push({ value: monthKeyOf(y, m), year: y, month: m });
175
177
  }
176
178
  }
177
179
  return opts.reverse();
@@ -1,27 +1,51 @@
1
1
  /**
2
2
  * **막대판 달 열쇠의 꼴을 아는 유일한 자리.**
3
3
  *
4
- * 달 열쇠는 `"2026년 10월"` 이다. 그 꼴이 **열 자리에 흩어져 있었다** — 짓는 데 다섯,
5
- * 정규식으로 뜯는 데 다섯(`barChartData` · `barChartTypes` · `BarChart` · `useBarChartControl`).
6
- * 하나를 고치고 아홉을 놓치면 **열쇠가 안 맞아 달이 통째로 빈 칸**이 된다. 0.325.0 에서
7
- * 같은 결함을 밟았다 — 두 자리에 적힌 결정 하나를 글자로 바꾸다 한 자리가 조용히 안 바뀌었다.
8
- *
9
- * **이 꼴은 이름표가 아니라 자리표다.** 한국어처럼 생겼지만 화면에 그대로 내면 안 된다 —
10
- * 보이는 글은 `yearMonthLabel(t, lang, year, month)` 이 Intl 로 짓는다(`October 2026`).
11
- * `monthOptionsNow()` 의 `label` 도 `useBarChartControl` 이 그렇게 덮어쓴다.
12
- *
13
- * **꼴을 왜 안 바꾸나.** 로케일에 안 매인 `"2026-10"` 이 옳지만, 소비자(`speechear-v2`)의
14
- * 시험이 이 꼴을 열 군데서 못 박고 있다(`toContain('2026년 3월')`). 열쇠 꼴을 바꾸는 일은
15
- * 소비자를 함께 고쳐야 하므로 따로 한다. 지금 고치는 것은 **아는 자리를 하나로 모으는 것**이다.
4
+ * 달 열쇠는 `"2026-10"` 이다. **로케일에 안 매인 자리표다.**
5
+ *
6
+ * 0.329.0 까지는 `"2026년 10월"` 이었다. 그 꼴이 **열 자리에 흩어져 있었고**(짓는 데 다섯,
7
+ * 정규식으로 뜯는 데 다섯 — `barChartData` · `barChartTypes` · `BarChart` · `useBarChartControl`)
8
+ * 하나를 고치고 아홉을 놓치면 **달이 통째로 빈 칸**이 됐다. 0.325.0 에서 실제로 밟았다.
9
+ * 먼저 이 파일 하나로 모았고(0.329.0), 이제 꼴을 바꾼다.
10
+ *
11
+ * **왜 바꾸나.** 한국어처럼 생긴 자리표라 **이름표와 섞였다.** 0.325.0 의 결함이 바로 그
12
+ * 섞임이었다 — 달 드롭다운에 열쇠 글자가 그대로 떴고, 한국어 화면에서는 **그럴듯해 보여**
13
+ * 영어 화면에서만 티가 났다. `"2026-10"` 은 누가 봐도 이름표가 아니다.
14
+ *
15
+ * 보이는 글은 `yearMonthLabel(t, lang, year, month)` 이 Intl 로 짓는다
16
+ * (`2026년 10월` · `October 2026`). 그쪽은 이번에 **안 바뀐다.**
17
+ *
18
+ * **내보낸다.** `buildMonthChartData` 가 돌려주는 꾸러미를 소비자가 **열쇠로 찾아야** 하는데,
19
+ * 꼴을 알 길이 없으면 제 손으로 짓는다 — 실제로 2026-10-14 에 소비자 쪽에 `monthKeyShape.ts`
20
+ * 가 생길 뻔했다. 그러면 꼴을 아는 자리가 **저장소를 건너 둘**이 되고, 이 파일이 막으려던
21
+ * 바로 그 결함이 더 먼 자리에서 난다. 짓는 것도 뜯는 것도 여기 것을 쓰게 한다.
22
+ *
23
+ * **함께 한 일** — 소비자(`speechear-v2`)가 이 꼴을 글자로 박은 자리 **열일곱**(파일 일곱)을
24
+ * 말귀_1 이 먼저 느슨하게 했다. 담아 두는 자리는 양쪽 다 없어(`localStorage` 등) 옛 열쇠가
25
+ * 남아 빈 칸이 되는 길은 없다 — 훑어서 셌다.
16
26
  */
17
- /** `(2026, 10)` → `"2026년 10월"`. 달은 **1 부터** 센다. */
27
+ /**
28
+ * `(2026, 10)` → `"2026-10"`. 달은 **1 부터** 세고, **두 자리로 채운다**(`2026-01`).
29
+ *
30
+ * 채우는 까닭은 글자 차례가 달 차례와 같아지기 때문이다 — 안 채우면 `"2026-9"` 가
31
+ * `"2026-10"` 뒤에 선다(단어표 칸순이 10 을 9 앞에 세우던 것과 같은 꼴).
32
+ */
33
+ /** @public */
18
34
  export declare const monthKeyOf: (year: number, month1: number) => string;
19
35
  /** 날에서 바로 — `Date.getMonth()` 가 0 부터인 것을 여기서만 더한다 */
36
+ /** @public */
20
37
  export declare const monthKeyOfDate: (d: Date) => string;
21
- /** `"2026년 10월"` → `{ year: 2026, month1: 10 }`. 꼴이 아니면 `null`. */
38
+ /**
39
+ * `"2026-10"` → `{ year: 2026, month1: 10 }`. 꼴이 아니면 `null`.
40
+ *
41
+ * **두 자리만 받는다.** `"2026-9"` 를 받아 주면 짓는 쪽과 뜯는 쪽이 어긋나도 조용하고,
42
+ * 그 어긋남이 바로 열쇠가 안 맞는 결함이다.
43
+ */
44
+ /** @public */
22
45
  export declare function parseMonthKey(key: string): {
23
46
  year: number;
24
47
  month1: number;
25
48
  } | null;
26
- /** 주 값은 달 열쇠에 주 번호를 붙인 것 — `"2026년 10월#2"`. 주는 0 부터. */
49
+ /** 주 값은 달 열쇠에 주 번호를 붙인 것 — `"2026-10#2"`. 주는 0 부터. */
50
+ /** @public */
27
51
  export declare const weekValueOf: (monthKey: string, week: number) => string;
@@ -1,27 +1,55 @@
1
1
  /**
2
2
  * **막대판 달 열쇠의 꼴을 아는 유일한 자리.**
3
3
  *
4
- * 달 열쇠는 `"2026년 10월"` 이다. 그 꼴이 **열 자리에 흩어져 있었다** — 짓는 데 다섯,
5
- * 정규식으로 뜯는 데 다섯(`barChartData` · `barChartTypes` · `BarChart` · `useBarChartControl`).
6
- * 하나를 고치고 아홉을 놓치면 **열쇠가 안 맞아 달이 통째로 빈 칸**이 된다. 0.325.0 에서
7
- * 같은 결함을 밟았다 — 두 자리에 적힌 결정 하나를 글자로 바꾸다 한 자리가 조용히 안 바뀌었다.
8
- *
9
- * **이 꼴은 이름표가 아니라 자리표다.** 한국어처럼 생겼지만 화면에 그대로 내면 안 된다 —
10
- * 보이는 글은 `yearMonthLabel(t, lang, year, month)` 이 Intl 로 짓는다(`October 2026`).
11
- * `monthOptionsNow()` 의 `label` 도 `useBarChartControl` 이 그렇게 덮어쓴다.
12
- *
13
- * **꼴을 왜 안 바꾸나.** 로케일에 안 매인 `"2026-10"` 이 옳지만, 소비자(`speechear-v2`)의
14
- * 시험이 이 꼴을 열 군데서 못 박고 있다(`toContain('2026년 3월')`). 열쇠 꼴을 바꾸는 일은
15
- * 소비자를 함께 고쳐야 하므로 따로 한다. 지금 고치는 것은 **아는 자리를 하나로 모으는 것**이다.
4
+ * 달 열쇠는 `"2026-10"` 이다. **로케일에 안 매인 자리표다.**
5
+ *
6
+ * 0.329.0 까지는 `"2026년 10월"` 이었다. 그 꼴이 **열 자리에 흩어져 있었고**(짓는 데 다섯,
7
+ * 정규식으로 뜯는 데 다섯 — `barChartData` · `barChartTypes` · `BarChart` · `useBarChartControl`)
8
+ * 하나를 고치고 아홉을 놓치면 **달이 통째로 빈 칸**이 됐다. 0.325.0 에서 실제로 밟았다.
9
+ * 먼저 이 파일 하나로 모았고(0.329.0), 이제 꼴을 바꾼다.
10
+ *
11
+ * **왜 바꾸나.** 한국어처럼 생긴 자리표라 **이름표와 섞였다.** 0.325.0 의 결함이 바로 그
12
+ * 섞임이었다 — 달 드롭다운에 열쇠 글자가 그대로 떴고, 한국어 화면에서는 **그럴듯해 보여**
13
+ * 영어 화면에서만 티가 났다. `"2026-10"` 은 누가 봐도 이름표가 아니다.
14
+ *
15
+ * 보이는 글은 `yearMonthLabel(t, lang, year, month)` 이 Intl 로 짓는다
16
+ * (`2026년 10월` · `October 2026`). 그쪽은 이번에 **안 바뀐다.**
17
+ *
18
+ * **내보낸다.** `buildMonthChartData` 가 돌려주는 꾸러미를 소비자가 **열쇠로 찾아야** 하는데,
19
+ * 꼴을 알 길이 없으면 제 손으로 짓는다 — 실제로 2026-10-14 에 소비자 쪽에 `monthKeyShape.ts`
20
+ * 가 생길 뻔했다. 그러면 꼴을 아는 자리가 **저장소를 건너 둘**이 되고, 이 파일이 막으려던
21
+ * 바로 그 결함이 더 먼 자리에서 난다. 짓는 것도 뜯는 것도 여기 것을 쓰게 한다.
22
+ *
23
+ * **함께 한 일** — 소비자(`speechear-v2`)가 이 꼴을 글자로 박은 자리 **열일곱**(파일 일곱)을
24
+ * 말귀_1 이 먼저 느슨하게 했다. 담아 두는 자리는 양쪽 다 없어(`localStorage` 등) 옛 열쇠가
25
+ * 남아 빈 칸이 되는 길은 없다 — 훑어서 셌다.
16
26
  */
17
- /** `(2026, 10)` → `"2026년 10월"`. 달은 **1 부터** 센다. */
18
- export const monthKeyOf = (year, month1) => `${year}년 ${month1}월`;
27
+ /**
28
+ * `(2026, 10)` → `"2026-10"`. 달은 **1 부터** 세고, **두 자리로 채운다**(`2026-01`).
29
+ *
30
+ * 채우는 까닭은 글자 차례가 달 차례와 같아지기 때문이다 — 안 채우면 `"2026-9"` 가
31
+ * `"2026-10"` 뒤에 선다(단어표 칸순이 10 을 9 앞에 세우던 것과 같은 꼴).
32
+ */
33
+ /** @public */
34
+ export const monthKeyOf = (year, month1) => `${year}-${String(month1).padStart(2, '0')}`;
19
35
  /** 날에서 바로 — `Date.getMonth()` 가 0 부터인 것을 여기서만 더한다 */
36
+ /** @public */
20
37
  export const monthKeyOfDate = (d) => monthKeyOf(d.getFullYear(), d.getMonth() + 1);
21
- /** `"2026년 10월"` → `{ year: 2026, month1: 10 }`. 꼴이 아니면 `null`. */
38
+ /**
39
+ * `"2026-10"` → `{ year: 2026, month1: 10 }`. 꼴이 아니면 `null`.
40
+ *
41
+ * **두 자리만 받는다.** `"2026-9"` 를 받아 주면 짓는 쪽과 뜯는 쪽이 어긋나도 조용하고,
42
+ * 그 어긋남이 바로 열쇠가 안 맞는 결함이다.
43
+ */
44
+ /** @public */
22
45
  export function parseMonthKey(key) {
23
- const m = /^(\d+)년 (\d+)월$/.exec(key);
24
- return m ? { year: Number(m[1]), month1: Number(m[2]) } : null;
46
+ const m = /^(\d{4})-(\d{2})$/.exec(key);
47
+ if (!m)
48
+ return null;
49
+ const month1 = Number(m[2]);
50
+ /* `2026-13` 은 달이 아니다 — 꼴만 맞다고 받아 주면 엉뚱한 달을 집는다 */
51
+ return month1 >= 1 && month1 <= 12 ? { year: Number(m[1]), month1 } : null;
25
52
  }
26
- /** 주 값은 달 열쇠에 주 번호를 붙인 것 — `"2026년 10월#2"`. 주는 0 부터. */
53
+ /** 주 값은 달 열쇠에 주 번호를 붙인 것 — `"2026-10#2"`. 주는 0 부터. */
54
+ /** @public */
27
55
  export const weekValueOf = (monthKey, week) => `${monthKey}#${week}`;
@@ -145,6 +145,7 @@ export { default as LineChart, LINE_CHART_HOVER_DELAY_MS } from './components/Li
145
145
  export { default as GameIntro } from './components/GameIntro';
146
146
  export { useBarChartControl } from './components/useBarChartControl';
147
147
  export { buildMonthChartData } from './components/barChartData';
148
+ export { monthKeyOf, monthKeyOfDate, parseMonthKey, weekValueOf } from './components/barChartMonthKey';
148
149
  export { getControlDateRange } from './components/barChartTypes';
149
150
  export type { ControlDateRange } from './components/barChartTypes';
150
151
  export type { BarChartProps, BarChartControl, BarChartSize, BarData, MonthBarData, ChartPoint } from './components/BarChart';
@@ -95,6 +95,9 @@ export { default as LineChart, LINE_CHART_HOVER_DELAY_MS } from './components/Li
95
95
  export { default as GameIntro } from './components/GameIntro';
96
96
  export { useBarChartControl } from './components/useBarChartControl';
97
97
  export { buildMonthChartData } from './components/barChartData';
98
+ /* 달 열쇠의 꼴 — `buildMonthChartData` 의 꾸러미를 찾으려면 부르는 쪽도 이 꼴을 알아야 한다.
99
+ 안 내보내면 소비자가 제 손으로 짓고, 꼴을 아는 자리가 저장소를 건너 둘이 된다. */
100
+ export { monthKeyOf, monthKeyOfDate, parseMonthKey, weekValueOf } from './components/barChartMonthKey';
98
101
  export { getControlDateRange } from './components/barChartTypes';
99
102
  export { default as RingChart } from './components/RingChart';
100
103
  export { default as ChartSection, ChartTotal, ChartLegend } from './components/ChartSection';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.330.0";
49
+ export declare const CORE_VERSION = "0.332.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.330.0';
49
+ export const CORE_VERSION = '0.332.0';