@ihabdevteam/core 0.321.0 → 0.322.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 (56) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/components.cjs.js +3 -3
  3. package/dist/hooks.cjs.js +3 -3
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +5 -5
  6. package/dist/ihabdevteam-core.d.ts +64 -60
  7. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  8. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  9. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  10. package/dist/packages/core/src/components/PanelRow.js +2 -2
  11. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  12. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  13. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
  14. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  15. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
  16. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  17. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
  18. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -2
  19. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
  20. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
  21. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  22. package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
  23. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  24. package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
  25. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
  26. package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
  27. package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
  28. package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
  29. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  30. package/dist/packages/core/src/hooks/inputModality.js +1 -1
  31. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  32. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  33. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  34. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  35. package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
  36. package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
  37. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  38. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -5
  39. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  40. package/dist/packages/core/src/utils/devWarn.js +5 -5
  41. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  42. package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
  43. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  44. package/dist/packages/core/src/utils/navScroll.js +4 -4
  45. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  46. package/dist/packages/core/src/utils/pressable.js +3 -3
  47. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  48. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  49. package/dist/packages/core/src/utils/textSort.d.ts +15 -5
  50. package/dist/packages/core/src/utils/textSort.js +19 -9
  51. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  52. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +3 -3
  53. package/dist/packages/core/src/version.d.ts +1 -1
  54. package/dist/packages/core/src/version.js +1 -1
  55. package/dist/utils.cjs.js +1 -1
  56. package/package.json +1 -1
@@ -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.321.0";
1681
+ export declare const CORE_VERSION = "0.322.0";
1682
1682
 
1683
1683
  /** @public */
1684
1684
  declare const coreDefault: Record<string, unknown>;
@@ -2039,7 +2039,7 @@ export declare interface DeviceRow {
2039
2039
  worn: boolean;
2040
2040
  }
2041
2041
 
2042
- declare type DeviceSide = '좌' | '우';
2042
+ declare type DeviceSide = 'left' | 'right';
2043
2043
 
2044
2044
  export declare function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): JSX.Element;
2045
2045
 
@@ -2231,10 +2231,10 @@ export declare interface DropdownProps {
2231
2231
  * @public 소음이 **들리는 크기**(0~2, 원본 소음 파일 기준) = mainVolume × 비율. 켜짐 여부는 보지
2232
2232
  * 않는다. 결과 기록·파형이 쓴다. 소음 `<audio>` 에 거는 값은 `noiseElementVolume`.
2233
2233
  */
2234
- export declare function effectiveNoiseVolume(settings: 비율설정): number;
2234
+ export declare function effectiveNoiseVolume(settings: SpeechVolumeSettings): number;
2235
2235
 
2236
2236
  /** @public 문제 소리 볼륨(0~1) = mainVolume. 비율과 상관없다. */
2237
- export declare function effectiveSpeechVolume(settings: 비율설정): number;
2237
+ export declare function effectiveSpeechVolume(settings: SpeechVolumeSettings): number;
2238
2238
 
2239
2239
  /** @public */
2240
2240
  export declare function EffectPartyPopper({ active, count, duration, strength, // multiplier for speed/spread
@@ -2927,6 +2927,16 @@ export declare function getTTSUrlResolver(): ((text: string) => string | Promise
2927
2927
  /** @public 단어 풀 조회 */
2928
2928
  export declare function getWordPool(): WordPool | null;
2929
2929
 
2930
+ /**
2931
+ * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
2932
+ *
2933
+ * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
2934
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
2935
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
2936
+ * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
2937
+ */
2938
+ export declare function gradeDescriptions(t: TFunction): string[];
2939
+
2930
2940
  /** @public 이 타입에 '문제(정답) 단어' 구조 제약이 있는가(편집기 카운트/후보 좁히기 판단용). */
2931
2941
  export declare function hasQuestionWordConstraint(typeKey: WordTrainingTypeKey): boolean;
2932
2942
 
@@ -3179,20 +3189,20 @@ declare interface IsSelectionCorrectParams {
3179
3189
  * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
3180
3190
  *
3181
3191
  * ```ts
3182
- * onAuthStateChange((event, 받은것) => {
3192
+ * onAuthStateChange((event, incoming) => {
3183
3193
  * getSession().then(({ data }) => {
3184
- * const 이탭것 = data.session
3185
- * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
3186
- * …이탭것 으로 상태를 맞춘다
3194
+ * const thisTab = data.session
3195
+ * if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
3196
+ * …thisTab 으로 상태를 맞춘다
3187
3197
  * })
3188
3198
  * })
3189
3199
  * ```
3190
3200
  *
3191
- * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
3192
- * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
3201
+ * @param incoming 이벤트가 실어 온 세션(없을 수 있다)
3202
+ * @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
3193
3203
  * @public
3194
3204
  */
3195
- export declare function isSessionForThisTab(받은것: TabSessionLike | null | undefined, 이탭것: TabSessionLike | null | undefined): boolean;
3205
+ export declare function isSessionForThisTab(incoming: TabSessionLike | null | undefined, thisTab: TabSessionLike | null | undefined): boolean;
3196
3206
 
3197
3207
  /**
3198
3208
  * @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
@@ -3991,7 +4001,7 @@ declare interface NoiseContext_2 {
3991
4001
  * @public 소음 `<audio>` 볼륨(0~1) = min(1, 들리는 크기 ÷ gain). gain 은 소음 파일이 원본보다 몇 배
3992
4002
  * 큰지 — 기본은 `configureStorage({ noiseFileGain })`(없으면 1).
3993
4003
  */
3994
- export declare function noiseElementVolume(settings: 비율설정, gain?: number): number;
4004
+ export declare function noiseElementVolume(settings: SpeechVolumeSettings, gain?: number): number;
3995
4005
 
3996
4006
  /** @public */
3997
4007
  export declare const NoisePlayerContext: Context<NoisePlayerContextValue | null>;
@@ -4379,10 +4389,8 @@ declare type PanelRowDescription = {
4379
4389
  export declare function PanelRowDevice({ devices, onRelease, emptyLabel }: PanelRowDeviceProps): JSX.Element;
4380
4390
 
4381
4391
  export declare interface PanelRowDeviceProps {
4382
- devices: {
4383
- 좌: DeviceEntry | null;
4384
- 우: DeviceEntry | null;
4385
- };
4392
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` 값이다(`HearingDeviceInfo` 와 같은 꼴) */
4393
+ devices: Record<DeviceSide, DeviceEntry | null>;
4386
4394
  /** 있으면 각 쪽 이름 옆에 '착용 해제'가 선다 — 그 쪽의 '지금 착용중' 만 내린다(이력은 남는다) */
4387
4395
  onRelease?: (side: DeviceSide) => void;
4388
4396
  /**
@@ -5287,7 +5295,7 @@ export declare const POS_LABEL: Record<string, string>;
5287
5295
  * 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
5288
5296
  * @public
5289
5297
  */
5290
- export declare function posLabels(t: 번역): Record<string, string>;
5298
+ export declare function posLabels(t: Translate): Record<string, string>;
5291
5299
 
5292
5300
  /**
5293
5301
  * **늦게 받는 화면 청크를 한가할 때 미리 받아 둔다.**
@@ -6754,7 +6762,7 @@ export declare type ShuffledItem = {
6754
6762
  text: string;
6755
6763
  };
6756
6764
 
6757
- declare type Side = '좌' | '우';
6765
+ declare type Side = 'left' | 'right';
6758
6766
 
6759
6767
  /** @public */
6760
6768
  export declare function SideNav(props: SideNavProps): React_2.JSX.Element;
@@ -7108,6 +7116,13 @@ export declare interface SpeakerPos {
7108
7116
  /** @public */
7109
7117
  export declare type SpeakerState = 'idle' | 'active' | 'played' | 'correct' | 'wrong';
7110
7118
 
7119
+ /** `effectiveSpeechVolume`·`effectiveNoiseVolume`·`noiseElementVolume` 이 받는 것 —
7120
+ * **공개 `d.ts` 에 이름이 그대로 나가는 자리**라 영어로 둔다. 안쪽 이름은 한글로 쓰는 저장소지만,
7121
+ * 소비자가 편집기에서 보는 이름은 영어라야 한다(사용자 지시 2026-10-13). */
7122
+ declare type SpeechVolumeSettings = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
7123
+ noiseRatio?: number;
7124
+ };
7125
+
7111
7126
  /**
7112
7127
  * 앱에 처음 들어올 때 로고를 한 번 보여 주는 연출.
7113
7128
  *
@@ -9525,6 +9540,13 @@ export declare interface TrainingWordCardProps {
9525
9540
  orderRank?: number | null;
9526
9541
  }
9527
9542
 
9543
+ /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
9544
+ /** i18next `t` 와 같은 꼴 — **공개 서명(`posLabels`·`wordTypeLabels`)이 받는 자리**라 이름을 영어로 둔다
9545
+ * (사용자 지시 2026-10-13). 안쪽 이름은 한글이어도 소비자가 보는 이름은 영어라야 한다. */
9546
+ declare type Translate = (k: string, o: {
9547
+ defaultValue: string;
9548
+ }) => string;
9549
+
9528
9550
  declare type TranslateFn = TFunction;
9529
9551
 
9530
9552
  /**
@@ -9770,22 +9792,10 @@ export declare function useDeviceManagement(): {
9770
9792
  setShowDevice: Dispatch<SetStateAction<boolean>>;
9771
9793
  deviceDirection: Side;
9772
9794
  setDeviceDirection: Dispatch<SetStateAction<Side>>;
9773
- devices: {
9774
- 좌: DeviceForm | null;
9775
- 우: DeviceForm | null;
9776
- };
9777
- setDevices: Dispatch<SetStateAction< {
9778
- 좌: DeviceForm | null;
9779
- 우: DeviceForm | null;
9780
- }>>;
9781
- deviceHistory: {
9782
- 좌: DeviceForm[];
9783
- 우: DeviceForm[];
9784
- };
9785
- setDeviceHistory: Dispatch<SetStateAction< {
9786
- 좌: DeviceForm[];
9787
- 우: DeviceForm[];
9788
- }>>;
9795
+ devices: Record<Side, DeviceForm | null>;
9796
+ setDevices: Dispatch<SetStateAction<Record<Side, DeviceForm | null>>>;
9797
+ deviceHistory: Record<Side, DeviceForm[]>;
9798
+ setDeviceHistory: Dispatch<SetStateAction<Record<Side, DeviceForm[]>>>;
9789
9799
  showDeviceList: boolean;
9790
9800
  setShowDeviceList: Dispatch<SetStateAction<boolean>>;
9791
9801
  listForSide: Side;
@@ -9995,15 +10005,15 @@ export declare function usePagedList<T>(items: T[], perPage: number, page: numbe
9995
10005
  * React 가 이 꼴을 위해 내준 길이다(같은 컴포넌트를 바로 다시 그린다).
9996
10006
  *
9997
10007
  * ```ts
9998
- * usePageResetOnChange(`${검색어}|${갈래}|${정렬}`, setPage)
10008
+ * usePageResetOnChange(`${검색어}|${갈래}|${sort}`, setPage)
9999
10009
  * const { pageItems, totalPages } = usePagedList(걸러낸것, 쪽크기, page)
10000
10010
  * ```
10001
10011
  *
10002
- * @param 열쇠 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
10012
+ * @param key 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
10003
10013
  * @param setPage 쪽 번호를 쥔 쪽에서 준다
10004
10014
  * @public
10005
10015
  */
10006
- export declare function usePageResetOnChange(열쇠: string, setPage: (n: number) => void): void;
10016
+ export declare function usePageResetOnChange(key: string, setPage: (n: number) => void): void;
10007
10017
 
10008
10018
  /**
10009
10019
  * 비밀번호 변경 폼 상태와 핸들러를 캡슐화한 훅.
@@ -10108,28 +10118,39 @@ export declare function useTablePaging<T>(rows: readonly T[], resetOn?: unknown,
10108
10118
  * **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
10109
10119
  * `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
10110
10120
  *
10111
- * `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
10121
+ * `sortRows(rows, getValue)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `compareCell`(숫자는 수로,
10112
10122
  * 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
10113
10123
  *
10114
10124
  * @example
10115
- * const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
10125
+ * const { sort, field, sortRows } = useTableSort<'name' | 'joined'>()
10116
10126
  * const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
10117
- * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
10118
- * <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
10127
+ * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, sort) // 정렬이 바뀌면 첫 쪽
10128
+ * <Table fields={[field('이름', 'name'), field('가입일', 'joined')]} … />
10119
10129
  *
10120
10130
  * @public
10121
10131
  */
10122
10132
  export declare function useTableSort<K extends string>(opts?: TableSortOptions<K>): {
10133
+ sort: {
10134
+ key: K;
10135
+ asc: boolean;
10136
+ } | null;
10137
+ field: (label: string, key: K) => {
10138
+ label: string;
10139
+ sortDir: "desc" | "asc" | null;
10140
+ onClick: () => void;
10141
+ };
10142
+ sortRows: <R>(rows: readonly R[], getValue: (row: R, key: K) => unknown) => readonly R[];
10143
+ /** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
10123
10144
  정렬: {
10124
10145
  key: K;
10125
10146
  asc: boolean;
10126
10147
  } | null;
10148
+ /** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
10127
10149
  칸: (label: string, key: K) => {
10128
10150
  label: string;
10129
10151
  sortDir: "desc" | "asc" | null;
10130
10152
  onClick: () => void;
10131
10153
  };
10132
- sortRows: <R>(rows: readonly R[], 값: (row: R, key: K) => unknown) => readonly R[];
10133
10154
  };
10134
10155
 
10135
10156
  /**
@@ -11601,7 +11622,7 @@ export declare type WordTrainingTypeKey = 'SAME_SOUND_PICK' | 'SAME_OR_DIFF' | '
11601
11622
  * **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
11602
11623
  * @public
11603
11624
  */
11604
- export declare function wordTypeLabels(t: 번역): Record<string, string>;
11625
+ export declare function wordTypeLabels(t: Translate): Record<string, string>;
11605
11626
 
11606
11627
  /**
11607
11628
  * @public 틀린 문항만 — 음소별 오류를 세는 쪽이 먼저 볼 것이다.
@@ -11610,21 +11631,4 @@ export declare function wordTypeLabels(t: 번역): Record<string, string>;
11610
11631
  */
11611
11632
  export declare function wrongItems(items: readonly TrainingItemResult[]): TrainingItemResult[];
11612
11633
 
11613
- /**
11614
- * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
11615
- *
11616
- * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
11617
- * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
11618
- */
11619
- export declare function 등급설명(t: TFunction): string[];
11620
-
11621
- /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
11622
- declare type 번역 = (k: string, o: {
11623
- defaultValue: string;
11624
- }) => string;
11625
-
11626
- declare type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
11627
- noiseRatio?: number;
11628
- };
11629
-
11630
11634
  export { }
@@ -1,5 +1,5 @@
1
1
  import './HearingDeviceInfo.css';
2
- export type DeviceSide = '좌' | '우';
2
+ export type DeviceSide = 'left' | 'right';
3
3
  export interface DeviceEntry {
4
4
  brand: string;
5
5
  device: string;
@@ -10,10 +10,10 @@ export interface DeviceEntry {
10
10
  wornDay: string;
11
11
  }
12
12
  interface HearingDeviceInfoProps {
13
- devices: {
14
- 좌: DeviceEntry | null;
15
- 우: DeviceEntry | null;
16
- };
13
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` **값**(`'left'`·`'right'`)이다.
14
+ * 그 값은 서버의 `side` 열과 그대로 오가는 **자료**라 건드리지 않았고, 공개 자리에 보이는
15
+ * **모양만** 영어 이름으로 돌렸다(`Record`). 값까지 바꾸는 것은 DB 결정이다(사용자 지시 2026-10-13). */
16
+ devices: Record<DeviceSide, DeviceEntry | null>;
17
17
  onAddDevice: (side: DeviceSide) => void;
18
18
  onOpenList: (side: DeviceSide) => void;
19
19
  onRemoveDevice: (side: DeviceSide) => void;
@@ -6,9 +6,9 @@ import { formatWornDate } from '../utils/wornDate';
6
6
  import './HearingDeviceInfo.css';
7
7
  export default function HearingDeviceInfo({ devices, onAddDevice, onOpenList, onRemoveDevice }) {
8
8
  const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
9
- return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['좌', '우'].map(side => {
9
+ return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['left', 'right'].map(side => {
10
10
  const d = devices[side];
11
- const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
11
+ const sideLabel = side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
12
12
  return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) && _jsx("span", { className: "hearing-device-info__device-date", children: formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
13
13
  }) })] }));
14
14
  }
@@ -153,10 +153,8 @@ export type PanelRowProps = PanelRowToggle | PanelRowSegmented | PanelRowDropdow
153
153
  export declare function PanelRow(props: PanelRowProps): import("react").JSX.Element;
154
154
  export type { DeviceSide };
155
155
  export interface PanelRowDeviceProps {
156
- devices: {
157
- 좌: DeviceEntry | null;
158
- 우: DeviceEntry | null;
159
- };
156
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` 값이다(`HearingDeviceInfo` 와 같은 꼴) */
157
+ devices: Record<DeviceSide, DeviceEntry | null>;
160
158
  /** 있으면 각 쪽 이름 옆에 '착용 해제'가 선다 — 그 쪽의 '지금 착용중' 만 내린다(이력은 남는다) */
161
159
  onRelease?: (side: DeviceSide) => void;
162
160
  /**
@@ -152,10 +152,10 @@ function 메타(d) {
152
152
  }
153
153
  export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
154
154
  const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
155
- const SIDES = ['좌', '우'];
155
+ const SIDES = ['left', 'right'];
156
156
  return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
157
157
  const d = devices[side];
158
- const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
158
+ const sideLabel = side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
159
159
  return (_jsxs("div", { className: "panel-row__device-column", children: [_jsxs("div", { className: "panel-row__device-head", children: [_jsx("span", { className: "panel-row__device-side body-2b", children: sideLabel }), d && onRelease && (
160
160
  /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
161
161
  이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
@@ -3,14 +3,10 @@ import type { DeviceSide } from '../Panel';
3
3
  import type { DeviceForm } from '../../types/device';
4
4
  import './DeviceListPopup.css';
5
5
  interface DeviceListPopupProps {
6
- devices: {
7
- 좌: DeviceEntry | null;
8
- 우: DeviceEntry | null;
9
- };
10
- deviceHistory: {
11
- 좌: DeviceEntry[];
12
- 우: DeviceEntry[];
13
- };
6
+ /** 쪽별로 지금 끼고 있는 기기 — 열쇠는 `DeviceSide` 값이다 */
7
+ devices: Record<DeviceSide, DeviceEntry | null>;
8
+ /** 쪽별로 바꿔 온 이력 */
9
+ deviceHistory: Record<DeviceSide, DeviceEntry[]>;
14
10
  onClose: () => void;
15
11
  onSelect: (d: DeviceForm) => void;
16
12
  onDelete: (side: DeviceSide, index: number) => void;
@@ -16,7 +16,7 @@ import './DeviceListPopup.css';
16
16
  */
17
17
  export default function DeviceListPopup({ devices, deviceHistory, onClose, onSelect, onDelete }) {
18
18
  const { t } = useTranslation();
19
- const rows = useMemo(() => ['좌', '우'].flatMap(side => deviceHistory[side].map((d, index) => ({
19
+ const rows = useMemo(() => ['left', 'right'].flatMap(side => deviceHistory[side].map((d, index) => ({
20
20
  index, side,
21
21
  brand: d.brand, device: d.device,
22
22
  fitType: d.fitType ?? '', productName: d.productName ?? '',
@@ -17,7 +17,7 @@ function 기기종류이름(device, t) {
17
17
  return k ? t(`device.type.${k}`, { defaultValue: device }) : device;
18
18
  }
19
19
  import { useTablePaging, useTableSort } from '../../hooks/useTablePaging';
20
- import { 칸순빈값뒤 } from '../../utils/textSort';
20
+ import { compareCellBlankLast } from '../../utils/textSort';
21
21
  import './DeviceListPopup.css';
22
22
  /**
23
23
  * 정렬에 쓸 문자열. 날짜는 **자리수를 채워** 잰다 — '2026.5.2' 와 '2026.11.5' 를
@@ -31,7 +31,7 @@ function 정렬값(r, key) {
31
31
  }
32
32
  export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }) {
33
33
  const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
34
- const 방향이름 = (side) => side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
34
+ const 방향이름 = (side) => side === 'left' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
35
35
  /* 머리행을 누르면 그 칸으로 정렬한다 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
36
36
  쪽넘김보다 **앞에서** 정렬한다(훅에 이미 정렬된 줄을 건넨다). */
37
37
  const { 정렬, 칸 } = useTableSort();
@@ -44,7 +44,7 @@ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onW
44
44
  const cmp = Number(b.worn) - Number(a.worn); // 켜진 것이 먼저
45
45
  return asc ? cmp : -cmp;
46
46
  }
47
- return 칸순빈값뒤(정렬값(a, key), 정렬값(b, key), asc); // 빈 칸(착용일 없음 등)은 오름·내림 모두 맨 뒤 — useTableSort 와 같은 규칙
47
+ return compareCellBlankLast(정렬값(a, key), 정렬값(b, key), asc); // 빈 칸(착용일 없음 등)은 오름·내림 모두 맨 뒤 — useTableSort 와 같은 규칙
48
48
  });
49
49
  }, [rows, 정렬]);
50
50
  /* 개수 보기·쪽넘김은 방문 표와 같은 것을 쓴다 — 같은 자리에서 같은 뜻으로 읽히게 */
@@ -6,7 +6,7 @@ import Popup from './Popup';
6
6
  import Table from '../Table';
7
7
  import Toolbar from '../Toolbar';
8
8
  import './ListPopup.css';
9
- import { 칸순빈값뒤 } from '../../utils/textSort';
9
+ import { compareCellBlankLast } from '../../utils/textSort';
10
10
  import { cx } from '../../utils/cx';
11
11
  /** @public */
12
12
  export default function ListPopup(props) {
@@ -48,9 +48,9 @@ export default function ListPopup(props) {
48
48
  return 0;
49
49
  /* `<`·`>` 로 견주면 코드포인트 차례가 된다 — 한글끼리는 맞아떨어져 안 드러나다가
50
50
  **한글과 영문이 섞이면** 어긋난다(라틴이 한글보다 앞이라 `fwef` 가
51
- `검수테스트2` 위에 선다). 숫자 칸은 숫자로 견주는 것을 `칸순` 이 지킨다.
51
+ `검수테스트2` 위에 선다). 숫자 칸은 숫자로 견주는 것을 `compareCell` 이 지킨다.
52
52
  빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤 — 전에는 '' 로 채워 오름차순 맨 앞에 섰다(useTableSort 와 같은 규칙). */
53
- return 칸순빈값뒤(va, vb, sortAsc);
53
+ return compareCellBlankLast(va, vb, sortAsc);
54
54
  });
55
55
  }, [items, search, sortKey, sortAsc, searchEnabled, effectiveSearchFn]);
56
56
  const totalPages = Math.max(1, Math.ceil(filtered.length / Number(pageSize)));
@@ -6,7 +6,7 @@ import Popup from './Popup';
6
6
  import Button from '../Button';
7
7
  import Dropdown from '../Dropdown';
8
8
  import { getSavedTables, deleteTable as localDeleteTable } from '../../utils/savedWordTables';
9
- import { 한글순 } from '../../utils/textSort';
9
+ import { compareKorean } from '../../utils/textSort';
10
10
  import { formatYmd } from '../../utils/date';
11
11
  import './LoadTablePopup.css';
12
12
  import { pressable } from '../../utils/pressable';
@@ -59,9 +59,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
59
59
  return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
60
60
  if (sortKey === 'oldest')
61
61
  return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
62
- /* 이름은 **`한글순`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
62
+ /* 이름은 **`compareKorean`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
63
63
  if (sortKey === 'name')
64
- return copy.sort((a, b) => 한글순(a.name, b.name));
64
+ return copy.sort((a, b) => compareKorean(a.name, b.name));
65
65
  if (sortKey === 'count')
66
66
  return copy.sort((a, b) => b.items.length - a.items.length);
67
67
  return copy;
@@ -19,21 +19,24 @@ export declare const NOISE_RATIO_STEP = 0.1;
19
19
  export declare function noiseRatioOf(settings: Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
20
20
  noiseRatio?: number;
21
21
  }): number;
22
- type 비율설정 = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
22
+ /** `effectiveSpeechVolume`·`effectiveNoiseVolume`·`noiseElementVolume` 이 받는 것 —
23
+ * **공개 `d.ts` 에 이름이 그대로 나가는 자리**라 영어로 둔다. 안쪽 이름은 한글로 쓰는 저장소지만,
24
+ * 소비자가 편집기에서 보는 이름은 영어라야 한다(사용자 지시 2026-10-13). */
25
+ type SpeechVolumeSettings = Pick<CoreNoiseSettings, 'level' | 'mainVolume'> & {
23
26
  noiseRatio?: number;
24
27
  };
25
28
  /** @public 문제 소리 볼륨(0~1) = mainVolume. 비율과 상관없다. */
26
- export declare function effectiveSpeechVolume(settings: 비율설정): number;
29
+ export declare function effectiveSpeechVolume(settings: SpeechVolumeSettings): number;
27
30
  /**
28
31
  * @public 소음이 **들리는 크기**(0~2, 원본 소음 파일 기준) = mainVolume × 비율. 켜짐 여부는 보지
29
32
  * 않는다. 결과 기록·파형이 쓴다. 소음 `<audio>` 에 거는 값은 `noiseElementVolume`.
30
33
  */
31
- export declare function effectiveNoiseVolume(settings: 비율설정): number;
34
+ export declare function effectiveNoiseVolume(settings: SpeechVolumeSettings): number;
32
35
  /**
33
36
  * @public 소음 `<audio>` 볼륨(0~1) = min(1, 들리는 크기 ÷ gain). gain 은 소음 파일이 원본보다 몇 배
34
37
  * 큰지 — 기본은 `configureStorage({ noiseFileGain })`(없으면 1).
35
38
  */
36
- export declare function noiseElementVolume(settings: 비율설정, gain?: number): number;
39
+ export declare function noiseElementVolume(settings: SpeechVolumeSettings, gain?: number): number;
37
40
  /**
38
41
  * @public 설정에 조각을 덮는다 — `{ ...base, ...patch }` 에 비율 규칙 하나를 더한 것.
39
42
  *
@@ -7,7 +7,7 @@ import Section from '../Section';
7
7
  import './TrainingBlock.css';
8
8
  import TrainingBlockButton from './TrainingBlockButton';
9
9
  import { useWindowEvent } from '../../hooks/useWindowEvent';
10
- import { 키보드로눌렀나 } from '../../hooks/inputModality';
10
+ import { wasKeyboardPress } from '../../hooks/inputModality';
11
11
  const BTN_COUNT_MAX = 20;
12
12
  /** @public */
13
13
  export default function TrainingBlock(props) {
@@ -35,7 +35,7 @@ export default function TrainingBlock(props) {
35
35
  const 더보기초점Ref = useRef(null);
36
36
  function loadMore() {
37
37
  const 단추 = document.activeElement;
38
- 더보기초점Ref.current = 키보드로눌렀나() && 단추 && 단추.closest?.('.training-block__more') ? visible : null;
38
+ 더보기초점Ref.current = wasKeyboardPress() && 단추 && 단추.closest?.('.training-block__more') ? visible : null;
39
39
  setVisible((v) => Math.min(total, v + BTN_COUNT_MAX));
40
40
  }
41
41
  useEffect(() => {
@@ -39,7 +39,7 @@ const 움직임최소화 = () => {
39
39
  };
40
40
  /** 결과를 계산하는 중인 결과 창 자리 — 이 객체와 **같은 것**이면 계산 중이다(결과 창의 '완료' 를 막는다). */
41
41
  const 결과계산중 = Object.freeze({});
42
- import { 키보드로눌렀나 } from '../../hooks/inputModality';
42
+ import { wasKeyboardPress } from '../../hooks/inputModality';
43
43
  // 문장 4종은 각 PagePopup 래퍼에서 useTrainingSessionLog로 이미 기록 → 베이스에서 중복 방지
44
44
  const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_STORY', 'ORDERING_SENTENCE']);
45
45
  /** 결과 창 자료에서 정답/전체를 센다 — 결과 항목 형태가 훈련 타입마다 달라(소음 하 듣기/문장 순서화는 correct 대신
@@ -909,7 +909,7 @@ function TrainingPagePopupInner(props) {
909
909
  if (앞 === undefined || 앞 === effectivePageIndex)
910
910
  return;
911
911
  // 키보드로 넘겼을 때만 — 마우스로 누른 사람에게 매 문제 '듣기' 초점 안내가 뜨지 않게
912
- if (!키보드로눌렀나())
912
+ if (!wasKeyboardPress())
913
913
  return;
914
914
  const panel = panelRef.current;
915
915
  if (!panel)
@@ -19,7 +19,7 @@ import Button from '../Button';
19
19
  import Icon from '../Icon';
20
20
  import InfoTooltip from '../Tooltip/InfoTooltip';
21
21
  import { STATUS_MESSAGES, ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
22
- import { 키보드로눌렀나 } from '../../hooks/inputModality';
22
+ import { wasKeyboardPress } from '../../hooks/inputModality';
23
23
  import { measureAudioDuration, waitForCanPlay } from './audioWaits';
24
24
  import { useWaveReady } from './useWaveReady';
25
25
  import { useObjectUrls } from './useObjectUrls';
@@ -1426,7 +1426,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1426
1426
  누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
1427
1427
  disabled: isDisabled,
1428
1428
  // 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
1429
- onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1429
+ onClick: () => { 듣기초점옮기기Ref.current = wasKeyboardPress(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1430
1430
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1431
1431
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), "aria-pressed": displayedPlaying, width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1432
1432
  handleJumpTo(idx);
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
- import { 키보드로눌렀나 } from '../../hooks/inputModality';
5
+ import { wasKeyboardPress } from '../../hooks/inputModality';
6
6
  import Button from '../Button';
7
7
  import Toast from '../Toast';
8
8
  import { useFontScale } from '../../hooks';
@@ -36,7 +36,7 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
36
36
  const 펼친뒤초점Ref = useRef(false);
37
37
  const handleActivate = useCallback(() => {
38
38
  // 키보드로 눌렀을 때만 — 마우스로 눌렀는데 옮기면 '글자 크기 감소' 초점 툴팁이 떴다
39
- 펼친뒤초점Ref.current = 키보드로눌렀나() && !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
39
+ 펼친뒤초점Ref.current = wasKeyboardPress() && !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
40
40
  setLocalStatus("active");
41
41
  }, []);
42
42
  const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
@@ -51,4 +51,4 @@ export interface OneShotSound {
51
51
  * 풀린다. 부르는 쪽이 `try` 로 감싸지 않아도 되게 하려는 것이다. 넷 가운데 셋이 저마다
52
52
  * `try` 를 적고 있었고, 그 안에서 하는 일이 조금씩 달랐다.
53
53
  */
54
- export declare function playOneShot(url: string, onEnd?: (들었나: boolean) => void): OneShotSound;
54
+ export declare function playOneShot(url: string, onEnd?: (played: boolean) => void): OneShotSound;
@@ -41,15 +41,15 @@ export function playOneShot(url, onEnd) {
41
41
  const done = new Promise((res) => { 풀기 = res; });
42
42
  /** 끝은 한 번이다 — 거절과 `error` 가 함께 오는 일이 실제로 있다 */
43
43
  let 끝났나 = false;
44
- const 끝 = (들었나) => {
44
+ const 끝 = (played) => {
45
45
  if (끝났나)
46
46
  return;
47
47
  끝났나 = true;
48
48
  try {
49
- onEnd?.(들었나);
49
+ onEnd?.(played);
50
50
  }
51
51
  catch { /* 부르는 쪽의 실패로 약속을 묶어 두지 않는다 */ }
52
- 풀기(들었나);
52
+ 풀기(played);
53
53
  };
54
54
  let el;
55
55
  try {
@@ -77,6 +77,6 @@
77
77
  * > **고치는 방식**을 말하는지가 갈렸다. 그 세 번 동안 전해 받은 말로 짓지 않은 덕에
78
78
  * > **되돌릴 것이 없었다.**
79
79
  */
80
- export type 단계상태 = 'disabled' | 'done' | 'active';
80
+ export type StepState = 'disabled' | 'done' | 'active';
81
81
  /** 답을 고를 수 있나 — 닿지 않은 단계(`disabled`)만 막는다. @public */
82
- export declare function canAnswerStep(state: 단계상태 | undefined): boolean;
82
+ export declare function canAnswerStep(state: StepState | undefined): boolean;
@@ -15,7 +15,7 @@
15
15
  * 비어 있어 **무엇을 틀렸는지 보여 줄 수가 없다.** 조용히 빼면 화면의 수가 전부인 줄
16
16
  * 알게 되므로 몇 건을 뺐는지 함께 돌려준다.
17
17
  */
18
- import { 한글순 } from '../../utils/textSort';
18
+ import { compareKorean } from '../../utils/textSort';
19
19
  /** 같은 시각 두 행의 차례 — 둘 다 id 가 있을 때만 가린다(숫자는 수 크기, 그 밖은 글자 사전순). 못 가리면 0. */
20
20
  function 같은시각차례(a, b) {
21
21
  if (a == null || b == null || a === '' || b === '')
@@ -99,7 +99,7 @@ export function summarizeMisses(records) {
99
99
  lastSelected: v.lastSelected,
100
100
  setIds: v.setIds,
101
101
  }));
102
- items.sort((a, b) => a.rate - b.rate || b.tried - a.tried || 한글순(a.title, b.title));
102
+ items.sort((a, b) => a.rate - b.rate || b.tried - a.tried || compareKorean(a.title, b.title));
103
103
  return { items, counted, skippedNoTitle };
104
104
  }
105
105
  /**