@ihabdevteam/core 0.344.0 → 0.346.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/components.cjs.js +182 -182
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +5 -5
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +1 -30
  8. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +14 -3
  9. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +17 -5
  10. package/dist/packages/core/src/components/Word/WordSearchResultBlock.css +8 -0
  11. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +22 -10
  12. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +2 -0
  13. package/dist/packages/core/src/i18n/locales/en.json +2 -1
  14. package/dist/packages/core/src/i18n/locales/ko.json +2 -1
  15. package/dist/packages/core/src/types/wordSearch.d.ts +0 -8
  16. package/dist/packages/core/src/utils/dailySelectionUtils.js +4 -2
  17. package/dist/packages/core/src/utils/date.d.ts +2 -0
  18. package/dist/packages/core/src/utils/date.js +18 -1
  19. package/dist/packages/core/src/utils/imageConfig.d.ts +1 -0
  20. package/dist/packages/core/src/utils/imageConfig.js +27 -1
  21. package/dist/packages/core/src/utils/phonemePairSearch.js +1 -4
  22. package/dist/packages/core/src/utils/trainingItemResults.d.ts +0 -17
  23. package/dist/packages/core/src/utils/trainingItemResults.js +2 -1
  24. package/dist/packages/core/src/utils/wordPairIndex.js +5 -7
  25. package/dist/packages/core/src/utils/wordTrainingPool.js +11 -2
  26. package/dist/packages/core/src/utils/wordTrainingRuntime.js +20 -11
  27. package/dist/packages/core/src/version.d.ts +1 -1
  28. package/dist/packages/core/src/version.js +1 -1
  29. package/dist/utils.cjs.js +4 -4
  30. package/package.json +1 -1
@@ -1754,7 +1754,7 @@ export declare function copyText(text: string): Promise<boolean>;
1754
1754
  *
1755
1755
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1756
1756
  */
1757
- export declare const CORE_VERSION = "0.344.0";
1757
+ export declare const CORE_VERSION = "0.346.0";
1758
1758
 
1759
1759
  /** @public */
1760
1760
  declare const coreDefault: Record<string, unknown>;
@@ -2693,10 +2693,6 @@ declare interface FooterProps {
2693
2693
  */
2694
2694
  export declare function formatCount(n: number | null | undefined): string;
2695
2695
 
2696
- /**
2697
- * 날짜 포맷 유틸 — 산재한 Date 파싱 로직을 공유.
2698
- * 각 화면의 표시 형식 차이는 옵션으로 흡수.
2699
- */
2700
2696
  export declare function formatDateKo(iso: string): string;
2701
2697
 
2702
2698
  /**
@@ -8955,23 +8951,6 @@ export declare interface TrainingItem {
8955
8951
  [k: string]: unknown;
8956
8952
  }
8957
8953
 
8958
- /**
8959
- * 훈련 한 판의 **문항 하나하나**를 기록할 수 있는 모양으로 추린다.
8960
- *
8961
- * 08-26 회의에서 치료사가 "음소별 오류를 보고 싶다"고 했다. 그러려면 어느 낱말을
8962
- * 틀렸는지가 남아야 하는데, 지금은 안 남는다 — `customer_training_result` 의
8963
- * `item_id`·`item_title` 이 전부 null 이다.
8964
- *
8965
- * 정보가 없어서가 아니다. 훈련이 끝나는 순간 화면은 문항마다
8966
- * `{ question, choices, selected, selectedText, correct, answer }` 를 들고 있다
8967
- * (trainingPopupUtils 의 build*Results). 그걸 `correct/total` 두 숫자로 접어서 버리고
8968
- * 있었다. 접기 전에 한 번 추려 둔다.
8969
- *
8970
- * **음소를 여기서 뽑지 않는다.** 낱말을 남기는 것과 그 낱말에서 음소를 세는 것은 다른
8971
- * 일이고, 뒤엣것은 아직 규칙이 정해지지 않았다(어두/어중/어말 위치 모델도 바뀌는 중이다).
8972
- * 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
8973
- * 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
8974
- */
8975
8954
  /** @public 훈련 한 문항의 결과 — 서버에 남길 만큼만 추린 것 */
8976
8955
  export declare interface TrainingItemResult {
8977
8956
  /** 몇 번째 문항인가(0부터) — 같은 낱말이 두 번 나와도 구별된다 */
@@ -11147,14 +11126,6 @@ export declare interface WordSearchResultGroup {
11147
11126
  export declare interface WordSearchResultItem {
11148
11127
  id: string;
11149
11128
  words: WordSearchResultWord[];
11150
- /**
11151
- * 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
11152
- *
11153
- * @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
11154
- * 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
11155
- * 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
11156
- */
11157
- diffSection?: string;
11158
11129
  /** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
11159
11130
  diffSectionCode?: PhonemeSectionCode;
11160
11131
  diffHasEmpty?: boolean;
@@ -248,11 +248,22 @@
248
248
  box-shadow: 0 0 6px rgba(0,0,0,0.4);
249
249
  }
250
250
 
251
- /* Show spinner only during loading — hide the textual label.
252
- !important 불필요 — .audio-player__loading-text의 display를 지정하는 규칙이 이것뿐이라 경합 대상이 없음. */
251
+ /* **눈에서 감추되 낭독기에는 남긴다**(사장님 결정 (다), 2026-10-19 — (305)).
252
+ 전에는 `display: none` 이었다. 글자는 안 보였지만 **접근성 나무에서도 빠져** 기다리는
253
+ 동안 아무 말도 안 읽혔다 — 위의 `role="status"`·`aria-live` 가 헛돌았다.
254
+ 저장소 처음부터 그랬다(`speechear 복사본`).
255
+
256
+ 이 저장소가 쓰는 감추는 규약(`Table.css`·`Board.css`·`WordTable.css` 의 `clip-path: inset(50%)`)
257
+ 을 그대로 쓴다 — 자리를 안 차지하고 보이지 않지만 **읽힌다.**
258
+ `display: none` 이나 `visibility: hidden` 으로 바꾸면 읽히는 것이 다시 사라진다. */
253
259
  .audio-player__loading .audio-player__loading-text,
254
260
  .audio-player__panel.is-loading .audio-player__loading-text {
255
- display: none;
261
+ position: absolute;
262
+ width: 1px;
263
+ height: 1px;
264
+ overflow: hidden;
265
+ clip-path: inset(50%);
266
+ white-space: nowrap;
256
267
  }
257
268
 
258
269
  .audio-player__loading-spinner {
@@ -18,7 +18,9 @@ import Toast from '../Toast';
18
18
  import Button from '../Button';
19
19
  import Icon from '../Icon';
20
20
  import InfoTooltip from '../Tooltip/InfoTooltip';
21
- import { STATUS_MESSAGES, ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
21
+ /* `STATUS_MESSAGES` 를 더는 안 들여온다 — 걸음마다 다른 글을 화면에 쓰지 않기 때문이다
22
+ (사장님 결정 (다), 2026-10-19). 그 표는 **지우지 않았다**: 개발 판의 자취와 색이 쓴다. */
23
+ import { ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
22
24
  import { wasKeyboardPress } from '../../hooks/inputModality';
23
25
  import { measureAudioDuration, waitForCanPlay } from './audioWaits';
24
26
  import { useWaveReady } from './useWaveReady';
@@ -1418,10 +1420,20 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1418
1420
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1419
1421
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1420
1422
  return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (
1421
- /* **알릴 값이 있을 때만 읽히게 한다**(사장님 결정 (나), 2026-10-05).
1422
- `준비 완료` 는 일이 끝난 뒤의 알림이라, 소리 한 번에 말 한 마디가 거저 얹혔다.
1423
- 보이는 글은 그대로다 — 읽히는 것만 준다(`ANNOUNCED_STATUSES`). */
1424
- _jsxs("div", { className: "audio-player__loading", role: ANNOUNCED_STATUSES[fetchDebugStatus] ? 'status' : undefined, "aria-live": ANNOUNCED_STATUSES[fetchDebugStatus] ? 'polite' : 'off', children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
1423
+ /* **기다리는 동안 글자를 안 띄운다**(사장님 결정 (다), 2026-10-19 — (305)).
1424
+ 전에는 걸음마다 글이 바뀌었다: `소리를 만들고 있습니다…` 가 **2ms 뒤에**
1425
+ `리소스 매핑 중…` 으로 덮이고 `서명 URL 조회…` · `다운로드 중…` 까지 늘어섰다
1426
+ (말귀 실측 2026-10-19: 캐시가 다 찬 가장 짧은 판에서도 94ms · 746ms).
1427
+ 사장님 규칙은 **개발자의 말을 화면에 쓰지 않는다**다.
1428
+
1429
+ 골라 둔 길 (가)(사람 말 한 마디로 묶기)가 아니라 **글을 아예 없애는** 쪽으로
1430
+ 정하셨다 — 깜박이는 글은 한 마디로 묶어도 읽을 틈이 없다. 눈에는 동그라미만,
1431
+ **낭독기에는 한 마디**를 남긴다(그쪽은 깜박임이 없다).
1432
+
1433
+ `ANNOUNCED_STATUSES` 는 그대로 쓴다(사장님 결정 (나), 2026-10-05) — `준비 완료`
1434
+ 처럼 일이 끝난 뒤의 알림은 읽히지 않는다. 걸음 이름과 그 글(`STATUS_MESSAGES`)은
1435
+ **지우지 않는다**: 개발 판의 자취와 색이 그것을 쓴다. */
1436
+ _jsxs("div", { className: "audio-player__loading", role: ANNOUNCED_STATUSES[fetchDebugStatus] ? 'status' : undefined, "aria-live": ANNOUNCED_STATUSES[fetchDebugStatus] ? 'polite' : 'off', children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t('training.audioStatus.preparing', { defaultValue: '소리를 준비하고 있습니다…' }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
1425
1437
  /* 막힌 단계(아직 차례가 아닌 듣기·합성 주소를 받는 중)는 CSS pointer-events 로만 막아, Tab 으로 와서 Enter 를
1426
1438
  누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
1427
1439
  disabled: isDisabled,
@@ -135,6 +135,14 @@
135
135
  gap: 10px;
136
136
  }
137
137
 
138
+ /* 묶음 이름 머리(말귀 청 (308)) — 앱이 `초성 대비`·`모음 대비` 처럼 넘긴다.
139
+ **글자 크기는 여기서 안 정한다** — 이 저장소는 `styles.css` 의 공용 클래스(`caption-1b`)로
140
+ 준다(`date-picker__dow caption-1` 따위가 그 꼴이다). 세우다 `--font-label-m` 이라는
141
+ **없는 토큰**을 적었는데, 없는 변수는 조용히 아무 꼴도 안 내서 깨진 줄 몰랐다. */
142
+ .word-search-result-block__group-label {
143
+ color: var(--color-gray);
144
+ }
145
+
138
146
  .word-search-result-block__group + .word-search-result-block__group {
139
147
  margin-top: var(--spacing-l);
140
148
  padding-top: var(--spacing-l);
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { cx } from '../../utils/cx';
4
4
  import { compareKorean } from '../../utils/textSort';
5
5
  import { useEscToClose } from '../../hooks/useEscToClose';
6
- import { useState, useCallback, useMemo, useEffect } from 'react';
6
+ import { useState, useCallback, useMemo, useEffect, useId } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
  import './WordSearchResultBlock.css';
9
9
  import Button from '../Button';
@@ -126,6 +126,9 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
126
126
  const pageClamped = Math.min(page, totalPages);
127
127
  const pagedItems = useMemo(() => sortedItems.slice((pageClamped - 1) * pageSize, pageClamped * pageSize), [sortedItems, pageClamped, pageSize]);
128
128
  // 현재 페이지의 그룹 항목을 그룹 순서대로 재그룹핑한다(그룹 헤더 유지; 페이지 경계에서 한 그룹이 나뉠 수 있음).
129
+ /* 묶음 이름표의 id 뿌리 — 한 쪽에 이 묶음이 둘 이상 설 수 있어 `useId()` 로 가른다.
130
+ 이름 자체를 꼬리로 붙인다(한 쪽 안에서 `Map` 열쇠라 겹치지 않는다). */
131
+ const groupLabelBaseId = useId();
129
132
  const pagedGroups = useMemo(() => {
130
133
  if (!groups)
131
134
  return null;
@@ -180,15 +183,24 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
180
183
  과 아주 같은 h4 로 맞췄는데, 그러니 결과 덩이의 머리가 그 위 판 제목들(`검색 옵션` ·
181
184
  `단어 검색 설정`)과 같은 급이 되어 **무엇이 무엇의 머리인지 안 보였다.** 거르개 판은
182
185
  결과를 고르는 도구고 이 덩이는 그 결과라, 나란한 것이 아니라 아래 딸린 것이다. */
183
- _jsxs(Section, { type: sectionType, className: cx('word-search-result-block', className), children: [_jsxs("div", { className: "word-search-result-block__inner", children: [(titleLabel || toolbar || discNode) && (_jsxs("div", { className: "word-search-result-block__head", children: [_jsxs("div", { className: "word-search-result-block__head-main", children: [titleIcon && _jsx(Icon, { icon: titleIcon, className: "word-search-result-block__head-icon" }), titleLabel && _jsx("span", { className: "word-search-result-block__title h3", children: titleLabel })] }), toolbar && _jsx("div", { className: "word-search-result-block__head-actions", children: toolbar }), discNode && _jsx("div", { className: "word-search-result-block__head-disc body-3", children: discNode })] })), (groups ? groups.length === 0 : items.length === 0) && (_jsxs("div", { className: "word-search-result-block__empty-wrap", children: [_jsx("p", { className: "word-search-result-block__empty body-3", children: t('wordSearch.emptyResult', '검색 결과가 없습니다.') }), emptyHint && _jsx("p", { className: "word-search-result-block__empty-hint body-3", children: emptyHint })] })), groups ? ((pagedGroups ?? []).map((group) => (_jsx("div", { className: "word-search-result-block__group", children: _jsx("div", { className: "word-search-result-block__items", children: group.items.map((item) => {
184
- const isSelected = selected.includes(item.id);
185
- return (_jsx("div", { onMouseEnter: e => {
186
- openHover(e.currentTarget);
187
- setHoverWords(item.words ?? []);
188
- }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: (item.words ?? []).map(w => w.label), subLabel: (item.words ?? []).some(w => w.subLabel)
189
- ? (item.words ?? []).map(w => w.subLabel)
190
- : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
191
- }) }) }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
186
+ _jsxs(Section, { type: sectionType, className: cx('word-search-result-block', className), children: [_jsxs("div", { className: "word-search-result-block__inner", children: [(titleLabel || toolbar || discNode) && (_jsxs("div", { className: "word-search-result-block__head", children: [_jsxs("div", { className: "word-search-result-block__head-main", children: [titleIcon && _jsx(Icon, { icon: titleIcon, className: "word-search-result-block__head-icon" }), titleLabel && _jsx("span", { className: "word-search-result-block__title h3", children: titleLabel })] }), toolbar && _jsx("div", { className: "word-search-result-block__head-actions", children: toolbar }), discNode && _jsx("div", { className: "word-search-result-block__head-disc body-3", children: discNode })] })), (groups ? groups.length === 0 : items.length === 0) && (_jsxs("div", { className: "word-search-result-block__empty-wrap", children: [_jsx("p", { className: "word-search-result-block__empty body-3", children: t('wordSearch.emptyResult', '검색 결과가 없습니다.') }), emptyHint && _jsx("p", { className: "word-search-result-block__empty-hint body-3", children: emptyHint })] })), groups ? ((pagedGroups ?? []).map((group) => (
187
+ /* **묶음 이름을 머리로 그린다**(말귀 청 (308), 2026-10-19).
188
+ `pagedGroups` 가 `label` 을 들고 왔는데 **그리는 자리에서 안 썼다** — 받은 이름이
189
+ `key` 로만 쓰이고 사라져, 보는 사람은 어느 자리가 갈린 쌍인지 모른 채 한 덩어리로
190
+ 봤다(말귀 실측: 묶음 네 개를 넘겼는데 `__group` 이 1 개, 이름 글자 0 건, 1,159쌍).
191
+ `pagedGroups` 가 쪽마다 다시 묶으므로 **쪽 경계에서 나뉘면 나뉜 쪽에도 다시 선다.**
192
+
193
+ 이름을 `aria-labelledby` 로 가리킨다 — `IconGridMenu` 가 쓰는 규약이다.
194
+ `role="group"` 은 **이름이 없으면 낭독기가 묶음을 안 알린다**(`ColorPicker` 의 주석). */
195
+ _jsxs("div", { className: "word-search-result-block__group", role: "group", "aria-labelledby": `${groupLabelBaseId}-${group.label}`, children: [_jsx("div", { className: "word-search-result-block__group-label caption-1b", id: `${groupLabelBaseId}-${group.label}`, children: group.label }), _jsx("div", { className: "word-search-result-block__items", children: group.items.map((item) => {
196
+ const isSelected = selected.includes(item.id);
197
+ return (_jsx("div", { onMouseEnter: e => {
198
+ openHover(e.currentTarget);
199
+ setHoverWords(item.words ?? []);
200
+ }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: (item.words ?? []).map(w => w.label), subLabel: (item.words ?? []).some(w => w.subLabel)
201
+ ? (item.words ?? []).map(w => w.subLabel)
202
+ : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
203
+ }) })] }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
192
204
  const isSelected = selected.includes(item.id);
193
205
  return (_jsx("div", { onMouseEnter: e => {
194
206
  openHover(e.currentTarget);
@@ -2,6 +2,7 @@
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { getViableComposition, countUsableWords } from '../utils/wordTrainingRuntime';
5
+ import { IMAGE_EXISTENCE_CACHE_UPDATED } from '../utils/imageConfig';
5
6
  import { FILTER_ALL_VALUE } from '../types/filterPanel';
6
7
  import { useWindowEvent } from './useWindowEvent';
7
8
  import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection, buildWordTypeSection } from './useWordAttributeFilter';
@@ -236,6 +237,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
236
237
  const [poolVersion, setPoolVersion] = useState(0);
237
238
  // 단어 풀 갱신 이벤트 구독
238
239
  useWindowEvent('wordpool:update', () => setPoolVersion(v => v + 1));
240
+ useWindowEvent(IMAGE_EXISTENCE_CACHE_UPDATED, () => setPoolVersion(v => v + 1));
239
241
  // 언어 전환 시에도 poolVersion을 올려 재생성을 트리거한다.
240
242
  // useWordTrainingDataGeneration의 batch 생성 effect는 (다른 렌더에서 매번 새 배열이 되는)
241
243
  // types를 의존성에서 의도적으로 제외하므로, types의 label/subTitle이 바뀌는 유일한 외부
@@ -470,7 +470,8 @@
470
470
  "measuring_duration": "Measuring length…",
471
471
  "ready": "Ready",
472
472
  "error": "Error",
473
- "synthesizing": "Preparing the sound…"
473
+ "synthesizing": "Preparing the sound…",
474
+ "preparing": "Preparing sound…"
474
475
  }
475
476
  },
476
477
  "trainingSettings": {
@@ -462,7 +462,8 @@
462
462
  "measuring_duration": "오디오 길이 측정…",
463
463
  "ready": "준비 완료",
464
464
  "error": "오류 발생",
465
- "synthesizing": "소리를 만들고 있습니다…"
465
+ "synthesizing": "소리를 만들고 있습니다…",
466
+ "preparing": "소리를 준비하고 있습니다…"
466
467
  }
467
468
  },
468
469
  "trainingSettings": {
@@ -19,14 +19,6 @@ export interface WordSearchResultWord {
19
19
  export interface WordSearchResultItem {
20
20
  id: string;
21
21
  words: WordSearchResultWord[];
22
- /**
23
- * 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
24
- *
25
- * @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
26
- * 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
27
- * 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
28
- */
29
- diffSection?: string;
30
22
  /** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
31
23
  diffSectionCode?: import('../utils/phonemeSectionCodes').PhonemeSectionCode;
32
24
  diffHasEmpty?: boolean;
@@ -83,8 +83,10 @@ const DEFAULT_TRAINING_COUNTS = {
83
83
  // localStorage 캐시 없이 매번 결정적으로 재계산한다(저장 없음). 이웃한 두 날의 세트는 후보가 충분하면 겹치지 않는다(유형세트).
84
84
  export function resolveSelection(_forceRefresh = false, counts = DEFAULT_TRAINING_COUNTS, userId) {
85
85
  // 서버 완료집계와 동일하게 KST 기준 날짜. userId 없어도 날짜로 결정적(Math.random 비결정성 제거)
86
- const today = toKstYmd(Date.now());
87
- const yesterday = toKstYmd(Date.now() - 86400000);
86
+ // 한 순간에서 둘 다 계산한다. 두 번 시계를 읽는 사이 자정이 오면 오늘=어제가 된다.
87
+ const now = Date.now();
88
+ const today = toKstYmd(now);
89
+ const yesterday = toKstYmd(now - 86400000);
88
90
  const u = userId ?? '';
89
91
  const hasSource = NOISE_DATAS.length > 0 || SHORT_STORIES.length > 0 ||
90
92
  LONG_STORIES.length > 0 || ORDER_SETS.length > 0;
@@ -2,6 +2,8 @@
2
2
  * 날짜 포맷 유틸 — 산재한 Date 파싱 로직을 공유.
3
3
  * 각 화면의 표시 형식 차이는 옵션으로 흡수.
4
4
  */
5
+ /** @internal 저장할 시각 문자열도 화면 표시와 같은 달력 검증을 사용한다. */
6
+ export declare function isValidDateValue(when: string | number | Date): boolean;
5
7
  export declare function formatDateKo(iso: string): string;
6
8
  /** "2024년 1월 15일 오후 03:30" (long month + time) */
7
9
  export declare function formatDateLongKo(iso: string): string;
@@ -34,6 +34,14 @@ function toInstant(when) {
34
34
  d = when;
35
35
  else if (typeof when === 'string') {
36
36
  const s = when.trim();
37
+ // Date.parse는 없는 달력날도 다음 달로 굴린다(2월 30일 → 3월 2일).
38
+ // 표시 함수가 잘못된 원본을 실제 날짜처럼 보여 주지 않도록 날짜 부분을 먼저 확인한다.
39
+ const datePart = /^(\d{4}-\d{2}-\d{2})(?:$|[T ])/.exec(s)?.[1];
40
+ if (datePart) {
41
+ const calendarDate = new Date(`${datePart}T00:00:00Z`);
42
+ if (Number.isNaN(calendarDate.getTime()) || calendarDate.toISOString().slice(0, 10) !== datePart)
43
+ return null;
44
+ }
37
45
  const m = WALL_CLOCK.exec(s);
38
46
  /* 소수 초는 세 자리까지만 — 여섯 자리(Postgres)는 엔진마다 읽기가 갈린다 */
39
47
  d = new Date(m ? `${m[1]}T${m[2]}:${m[3] ?? '00'}${m[4] ? `.${m[4].slice(0, 3).padEnd(3, '0')}` : ''}+09:00` : s);
@@ -42,6 +50,10 @@ function toInstant(when) {
42
50
  d = new Date(when);
43
51
  return Number.isNaN(d.getTime()) ? null : d;
44
52
  }
53
+ /** @internal 저장할 시각 문자열도 화면 표시와 같은 달력 검증을 사용한다. */
54
+ export function isValidDateValue(when) {
55
+ return toInstant(when) !== null;
56
+ }
45
57
  /* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
46
58
  (같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
47
59
  const parsed = (iso) => toInstant(iso);
@@ -164,7 +176,12 @@ export function getTodayKst() {
164
176
  * 공개 export 아님(utils.ts 가 다시 내보내지 않는다).
165
177
  */
166
178
  export function todayYmdOr(today) {
167
- return today && /^\d{4}-\d{2}-\d{2}$/.test(today) ? today : getTodayKst();
179
+ if (today && /^\d{4}-\d{2}-\d{2}$/.test(today)) {
180
+ const date = toInstant(today);
181
+ if (date?.toISOString().slice(0, 10) === today)
182
+ return today;
183
+ }
184
+ return getTodayKst();
168
185
  }
169
186
  /**
170
187
  * KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
@@ -5,6 +5,7 @@ declare global {
5
5
  export declare function configureImageResolver(resolver: (word: string, homonum?: number) => string): void;
6
6
  /** @public */
7
7
  export declare function getImageResolver(): ((word: string, homonum?: number) => string) | null;
8
+ export declare const IMAGE_EXISTENCE_CACHE_UPDATED = "ihab:image-existence-updated";
8
9
  /**
9
10
  * @public
10
11
  * 단어 이미지 존재 여부 확인 — localStorage 캐시 우선, 없으면 HEAD 요청 후 캐시 저장.
@@ -10,6 +10,21 @@ export function getImageResolver() {
10
10
  }
11
11
  // ─── 로컬 캐싱 (localStorage + 메모리) ───────────────────────────────────────
12
12
  const LS_KEY = 'word_image_existence_map_v1';
13
+ export const IMAGE_EXISTENCE_CACHE_UPDATED = 'ihab:image-existence-updated';
14
+ let updatePending = null;
15
+ function notifyCacheUpdated() {
16
+ if (updatePending !== null) {
17
+ clearTimeout(updatePending);
18
+ updatePending = null;
19
+ }
20
+ if (typeof window !== 'undefined')
21
+ window.dispatchEvent(new Event(IMAGE_EXISTENCE_CACHE_UPDATED));
22
+ }
23
+ function scheduleCacheUpdated() {
24
+ if (updatePending !== null)
25
+ return;
26
+ updatePending = setTimeout(notifyCacheUpdated, 200);
27
+ }
13
28
  let memMap = null;
14
29
  /* 캐시가 바뀔 때마다 오르는 판 번호 — 통계·'있음' 낱말 집합을 판마다 한 번만 센다(단어훈련 칩 한 번에 유형 수만큼
15
30
  2.3만 칸을 훑었다). 캐시를 바꾸는 곳(확인 결과 담기·일괄 주입·비우기)은 모두 판을 올린다. */
@@ -111,7 +126,12 @@ async function probeImageExists(word, homonum, force = false) {
111
126
  * 한 번 확인된 단어는 영구 캐싱되어 다음 접속 시에도 즉시 반환.
112
127
  */
113
128
  export async function checkImageExists(word, homonum) {
114
- return probeImageExists(word, homonum);
129
+ const before = cacheVersion;
130
+ const exists = await probeImageExists(word, homonum);
131
+ // 여러 단어를 동시에 확인하는 표에서도 이벤트를 한 번으로 모은다.
132
+ if (cacheVersion !== before)
133
+ scheduleCacheUpdated();
134
+ return exists;
115
135
  }
116
136
  /** @public 단어 이미지 존재 여부 동기 조회 (캐시에 있을 때만). 없으면 undefined. */
117
137
  export function getImageExistsCached(word, homonum) {
@@ -129,6 +149,7 @@ export function getImageExistsCached(word, homonum) {
129
149
  export async function prewarmImageCache(words, options = {}) {
130
150
  const { force = false, concurrency = 16, onProgress } = options;
131
151
  const map = ensureLoaded();
152
+ const before = cacheVersion;
132
153
  const todo = force ? Array.from(new Set(words)) : Array.from(new Set(words)).filter(w => !(keyOf(w) in map));
133
154
  let done = 0;
134
155
  if (todo.length > 0) {
@@ -161,6 +182,9 @@ export async function prewarmImageCache(words, options = {}) {
161
182
  }
162
183
  }
163
184
  catch { }
185
+ // 병렬 HEAD마다 알리면 대량 사전 확인 중 필터가 수천 번 재계산된다.
186
+ if (cacheVersion !== before)
187
+ notifyCacheUpdated();
164
188
  return { checked: todo.length, cached: Object.keys(map).length, total: words.length };
165
189
  }
166
190
  /**
@@ -208,6 +232,7 @@ export function bulkSetImageExistence(entries) {
208
232
  }
209
233
  }
210
234
  catch { }
235
+ notifyCacheUpdated();
211
236
  }
212
237
  /** @public 캐시 초기화 (이미지 데이터 변경 시 호출) */
213
238
  export function clearImageExistenceCache() {
@@ -223,4 +248,5 @@ export function clearImageExistenceCache() {
223
248
  localStorage.removeItem(LS_KEY);
224
249
  }
225
250
  catch { }
251
+ notifyCacheUpdated();
226
252
  }
@@ -16,7 +16,7 @@ function decompose(char) {
16
16
  /* 음절 자리 코드는 **아래 모듈**에 있다 — `phonemeConvert` 도 함께 쓰는데, 여기 두면
17
17
  `phonemePairSearch ↔ phonemeConvert` 돌림 import 가 된다(한 번 그렇게 해 시험 338 꾸러미가
18
18
  무너졌다, 2026-10-13). */
19
- import { toPhonemeCodes, toLegacyPhonemeKey, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
19
+ import { toPhonemeCodes, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
20
20
  export { toPhonemeCodes, PHONEME_NONE } from './phonemeSectionCodes';
21
21
  const SECTION_KEYS = PHONEME_SECTION_CODES;
22
22
  const NONE = PHONEME_NONE;
@@ -444,8 +444,6 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
444
444
  for (const sec of senses.get(second) ?? [second]) {
445
445
  results.push({
446
446
  id: `${wordKey(f)}|${wordKey(sec)}`,
447
- /* 옛 이름을 내보내고 코드를 나란히 — 소비자가 옛 값으로 묶음을 가른다 */
448
- diffSection: toLegacyPhonemeKey(sDiffSection),
449
447
  diffSectionCode: sDiffSection,
450
448
  diffHasEmpty: sDiffHasEmpty,
451
449
  words: [wordCells(f), wordCells(sec)],
@@ -689,7 +687,6 @@ preferAsTarget) {
689
687
  const kt = wordKey(t), kc = wordKey(c);
690
688
  results.push({
691
689
  id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
692
- diffSection: toLegacyPhonemeKey(item.diffSection),
693
690
  diffSectionCode: item.diffSection,
694
691
  diffHasEmpty: item.diffHasEmpty,
695
692
  words: [wordCells(t), wordCells(c)],
@@ -1,20 +1,3 @@
1
- /**
2
- * 훈련 한 판의 **문항 하나하나**를 기록할 수 있는 모양으로 추린다.
3
- *
4
- * 08-26 회의에서 치료사가 "음소별 오류를 보고 싶다"고 했다. 그러려면 어느 낱말을
5
- * 틀렸는지가 남아야 하는데, 지금은 안 남는다 — `customer_training_result` 의
6
- * `item_id`·`item_title` 이 전부 null 이다.
7
- *
8
- * 정보가 없어서가 아니다. 훈련이 끝나는 순간 화면은 문항마다
9
- * `{ question, choices, selected, selectedText, correct, answer }` 를 들고 있다
10
- * (trainingPopupUtils 의 build*Results). 그걸 `correct/total` 두 숫자로 접어서 버리고
11
- * 있었다. 접기 전에 한 번 추려 둔다.
12
- *
13
- * **음소를 여기서 뽑지 않는다.** 낱말을 남기는 것과 그 낱말에서 음소를 세는 것은 다른
14
- * 일이고, 뒤엣것은 아직 규칙이 정해지지 않았다(어두/어중/어말 위치 모델도 바뀌는 중이다).
15
- * 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
16
- * 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
17
- */
18
1
  /** @public 훈련 한 문항의 결과 — 서버에 남길 만큼만 추린 것 */
19
2
  export interface TrainingItemResult {
20
3
  /** 몇 번째 문항인가(0부터) — 같은 낱말이 두 번 나와도 구별된다 */
@@ -15,6 +15,7 @@
15
15
  * 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
16
16
  * 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
17
17
  */
18
+ import { isValidDateValue } from './date';
18
19
  /**
19
20
  * 때 하나 — ISO 8601 글자로 남긴다. 못 읽는 값은 `null`(없는 때를 지어내지 않는다).
20
21
  *
@@ -29,7 +30,7 @@ function timeOf(v) {
29
30
  const s = v.trim();
30
31
  if (!s)
31
32
  return null;
32
- return Number.isNaN(new Date(s).getTime()) ? null : s;
33
+ return isValidDateValue(s) ? s : null;
33
34
  }
34
35
  if (v instanceof Date)
35
36
  return Number.isNaN(v.getTime()) ? null : v.toISOString();
@@ -57,16 +57,14 @@ function saveToLocalStorage(index, sig) {
57
57
  }
58
58
  catch { }
59
59
  }
60
- /** 입력의 signature 계산 (단어+발음 해시) — 캐시 무효화에 사용 */
60
+ /** 입력의 signature 계산 (단어+발음 해시) — 첫 유효 중복 행을 쓰므로 입력 순서도 포함한다. */
61
61
  function computeSig(items) {
62
- const sorted = items.map(it => `${it.word}|${it.pron ?? ''}`).sort();
62
+ const serialized = JSON.stringify(items.map(it => [it.word, it.pron ?? '']));
63
63
  let h = 0;
64
- for (const s of sorted) {
65
- for (let i = 0; i < s.length; i++) {
66
- h = ((h << 5) - h + s.charCodeAt(i)) | 0;
67
- }
64
+ for (let i = 0; i < serialized.length; i++) {
65
+ h = ((h << 5) - h + serialized.charCodeAt(i)) | 0;
68
66
  }
69
- return `${sorted.length}-${h}`;
67
+ return `${items.length}-${h}`;
70
68
  }
71
69
  async function buildIndexAsync(items, onProgress) {
72
70
  // 단어(철자) 기준 중복 제거하되, 대립 비교는 표준발음을 분해해서 수행한다.
@@ -158,13 +158,18 @@ export function filterByCachedImage(words) {
158
158
  const _poolCache = new Map();
159
159
  let _cachePoolRef = null;
160
160
  let _cacheIdxRef = null;
161
+ let _cacheImageOkRef = null;
161
162
  function _jamoKey(v) {
162
163
  if (v == null)
163
164
  return '';
164
165
  return Array.isArray(v) ? v.slice().sort().join(',') : v;
165
166
  }
166
167
  export function _poolCacheKey(opts) {
167
- const chStr = _jamoKey(opts.choseong);
168
+ // choseong=[] 은 후보를 전부 제외한다. 미지정(무제약)과 같은 키로 묶으면
169
+ // 먼저 계산한 쪽의 결과가 다른 조건에 재사용된다.
170
+ const chStr = Array.isArray(opts.choseong) && opts.choseong.length === 0
171
+ ? '[]'
172
+ : _jamoKey(opts.choseong);
168
173
  const c = opts.composition;
169
174
  const sylKey = c?.syllable == null ? '' : (Array.isArray(c.syllable) ? c.syllable.slice().sort().join(',') : String(c.syllable));
170
175
  const compStr = c ? `${_jamoKey(c.cho)}/${_jamoKey(c.jung)}/${_jamoKey(c.jong)}/${c.jongNone ? 'N' : ''}/${sylKey}` : '';
@@ -201,11 +206,15 @@ export async function buildUsablePool(opts) {
201
206
  // wordPool/pairIndex가 교체되면 캐시 전체 무효화
202
207
  const pool = getWordPool();
203
208
  const idx = getPairIndex();
204
- if (pool !== _cachePoolRef || idx !== _cacheIdxRef) {
209
+ // 이미지 필터의 결과는 풀 참조를 바꾸지 않고도 prewarm/일괄 주입 중 달라진다.
210
+ // getImageOkWordSet은 캐시 판이 바뀔 때 새 Set을 돌려주므로 이를 무효화 기준으로 쓴다.
211
+ const imageOkRef = opts.excludeNoImage ? getImageOkWordSet() : null;
212
+ if (pool !== _cachePoolRef || idx !== _cacheIdxRef || (opts.excludeNoImage && imageOkRef !== _cacheImageOkRef)) {
205
213
  _poolCache.clear();
206
214
  _posContrastCache.clear();
207
215
  _cachePoolRef = pool;
208
216
  _cacheIdxRef = idx;
217
+ _cacheImageOkRef = imageOkRef;
209
218
  }
210
219
  const key = _poolCacheKey(opts);
211
220
  if (!_poolCache.has(key))
@@ -10,6 +10,7 @@
10
10
  * @public
11
11
  */
12
12
  import { getWordPool } from './wordPool';
13
+ import { getImageOkWordSet } from './imageConfig';
13
14
  import { shuffle } from './array';
14
15
  import { getPairIndex } from './wordPairIndex';
15
16
  import { wordContainsPhoneme, syllableLength, parseSyllable, CHOSEONG, JUNGSEONG, JONGSEONG, JONG_DECOMPOSE } from './koreanSyllable';
@@ -165,7 +166,7 @@ async function genStartsWithSame(opts) {
165
166
  }
166
167
  // sourceWord 와 correct 가 모두 필요하므로 같은 첫음절 단어 ≥2개인 그룹만 사용
167
168
  const viableFirsts = Object.entries(sameFirstPool)
168
- .filter(([, ws]) => ws.length >= 2)
169
+ .filter(([, ws]) => new Set(ws.map(w => wordPron.get(w) || w)).size >= 2)
169
170
  .map(([f]) => f);
170
171
  if (viableFirsts.length === 0)
171
172
  return [];
@@ -176,15 +177,13 @@ async function genStartsWithSame(opts) {
176
177
  const group = sameFirstPool[targetSyllable];
177
178
  if (group.length < 2)
178
179
  continue;
179
- // sourceWord 와 correct 는 같은 그룹에서 서로 다른 단어
180
+ // sourceWord 와 correct 는 같은 그룹에서 소리도 달라야 한다. 앞의 두 낱말이
181
+ // 동음어여도 유효한 다른 짝이 있으면 그 짝을 선택한다.
180
182
  const picked = shuffle([...group]);
181
183
  const sourceWord = picked[0];
182
- const correct = picked[1];
183
- // 명시적 동일 단어 방지. 표기가 달라도 발음이 같으면(깜박/깜빡 → 둘 다 '깜빡') 정답 카드가
184
- // 문제 소리와 완전히 똑같이 들려 변별 과제가 성립하지 않으므로 발음 동일도 배제한다.
185
- if (!sourceWord || !correct || sourceWord === correct)
186
- continue;
187
- if ((wordPron.get(sourceWord) || sourceWord) === (wordPron.get(correct) || correct))
184
+ const sourcePron = wordPron.get(sourceWord) || sourceWord;
185
+ const correct = picked.find(w => w !== sourceWord && (wordPron.get(w) || w) !== sourcePron);
186
+ if (!sourceWord || !correct)
188
187
  continue;
189
188
  // 오답: 첫 음절(발음)이 다르고, 정답(correct)과 음절수(글자수)가 같으며, 발음이 다른 단어 (sourceWord, correct 모두 제외)
190
189
  // 철자 첫 글자로 거르면 '학교(학꾜)'가 targetSyllable '하'인 문제의 오답으로 뽑혀
@@ -579,20 +578,28 @@ function genRemoveConsonant(opts) {
579
578
  const _resultCache = new Map();
580
579
  let _resultCachePoolRef = null;
581
580
  let _resultCacheIdxRef = null;
581
+ let _resultCacheImageOkRef = null;
582
+ let _resultCacheEpoch = 0;
582
583
  /** @public 훈련 유형별 문제 item 생성 */
583
584
  export async function generateRuntimeItems(typeKey, opts = {}) {
584
585
  const pool = getWordPool();
585
586
  const idx = getPairIndex();
586
- if (pool !== _resultCachePoolRef || idx !== _resultCacheIdxRef) {
587
+ const imageOkRef = opts.excludeNoImage ? getImageOkWordSet() : null;
588
+ if (pool !== _resultCachePoolRef || idx !== _resultCacheIdxRef || (opts.excludeNoImage && imageOkRef !== _resultCacheImageOkRef)) {
587
589
  _resultCache.clear();
590
+ _resultCacheEpoch++;
588
591
  _resultCachePoolRef = pool;
589
592
  _resultCacheIdxRef = idx;
593
+ _resultCacheImageOkRef = imageOkRef;
590
594
  }
591
595
  const cacheKey = `${typeKey}:${_poolCacheKey(opts)}`;
592
596
  if (_resultCache.has(cacheKey))
593
597
  return _resultCache.get(cacheKey);
598
+ const epoch = _resultCacheEpoch;
594
599
  const items = await _generateRuntimeItems(typeKey, opts);
595
- _resultCache.set(cacheKey, items);
600
+ // 계산 중 풀이나 이미지 캐시가 교체됐다면 늦게 끝난 옛 문항을 새 캐시에 다시 넣지 않는다.
601
+ if (epoch === _resultCacheEpoch)
602
+ _resultCache.set(cacheKey, items);
596
603
  return items;
597
604
  }
598
605
  const GENERATOR_MAP = {
@@ -656,7 +663,9 @@ export async function precomputeCommonCombinations(excludeNoImage) {
656
663
  }
657
664
  /** @public 이미지/난이도 조건이 적용된 초성 set — pool 단어 어디든 해당 자모가 있으면 viable */
658
665
  export async function getViableChoseongs(opts = {}) {
659
- const pool = await buildUsablePool({ difficulty: opts.difficulty ?? null, excludeNoImage: opts.excludeNoImage });
666
+ // 선택 가능 여부는 전체 후보 기준이어야 한다. 생성용 샘플 캡(기본 1500)을 쓰면
667
+ // 샘플 밖의 드문 초성이 실제로 가능해도 버튼에서 사라진다.
668
+ const pool = await buildUsablePool({ difficulty: opts.difficulty ?? null, excludeNoImage: opts.excludeNoImage, fullCount: true });
660
669
  const set = new Set();
661
670
  // 모든 19 초성 자모를 단어와 매칭해서 viable 검사
662
671
  const allJamos = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.344.0";
49
+ export declare const CORE_VERSION = "0.346.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.344.0';
49
+ export const CORE_VERSION = '0.346.0';