@ihabdevteam/core 0.322.0 → 0.323.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +517 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +54 -16
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +6 -13
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
- package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.js +13 -13
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
- package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.js +4 -4
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.js +2 -2
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +38 -48
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -5,13 +5,13 @@ import { resolveAssetUrl } from '../utils';
|
|
|
5
5
|
/* 속성 값이 **문서 밖을 가리키는** url( 을 품었나. `url(#그라데이션)`·`url(#clip)` 처럼 같은 SVG 안의 조각만 가리키는 것은 둔다 —
|
|
6
6
|
코어 로고 서른여섯 장이 clip-path·fill 로 그것을 쓴다(걷으면 인라인으로 그린 로고가 깨진다).
|
|
7
7
|
CSS 이스케이프로 url 을 숨긴 꼴(역슬래시 + 괄호)도 걷는다 — 아이콘 속성에 그런 값을 쓸 까닭이 없다. */
|
|
8
|
-
function
|
|
8
|
+
function hasExternalUrl(value) {
|
|
9
9
|
if (value.includes('\\') && value.includes('('))
|
|
10
10
|
return true;
|
|
11
11
|
if (!/url\s*\(/i.test(value))
|
|
12
12
|
return false;
|
|
13
|
-
const
|
|
14
|
-
return /url\s*\(/i.test(
|
|
13
|
+
const withoutFragments = value.replace(/url\(\s*(['"]?)#[^)'"]*\1\s*\)/gi, '');
|
|
14
|
+
return /url\s*\(/i.test(withoutFragments);
|
|
15
15
|
}
|
|
16
16
|
function sanitizeSvg(raw) {
|
|
17
17
|
if (typeof window === 'undefined')
|
|
@@ -19,11 +19,11 @@ function sanitizeSvg(raw) {
|
|
|
19
19
|
/* 인라인 SVG 에서 **바깥 자원을 부르는 길** 을 걷는다 — `<style>`(@import·background:url) 과 바깥 url( 이 든 속성(style·fill·filter…).
|
|
20
20
|
그대로 두면 아이콘 한 장이 제3자 주소로 요청을 보내거나(추적) CSS 로 화면을 덮을 수 있었다.
|
|
21
21
|
훅은 DOMPurify 전역에 붙으므로 이 한 번의 sanitize 동안만 걸고 곧바로 뗀다(앱의 다른 DOMPurify 쓰임에 새지 않게). */
|
|
22
|
-
const
|
|
23
|
-
if (typeof data.attrValue === 'string' &&
|
|
22
|
+
const hook = (_node, data) => {
|
|
23
|
+
if (typeof data.attrValue === 'string' && hasExternalUrl(data.attrValue))
|
|
24
24
|
data.keepAttr = false;
|
|
25
25
|
};
|
|
26
|
-
DOMPurify.addHook('uponSanitizeAttribute',
|
|
26
|
+
DOMPurify.addHook('uponSanitizeAttribute', hook);
|
|
27
27
|
try {
|
|
28
28
|
return DOMPurify.sanitize(raw, {
|
|
29
29
|
USE_PROFILES: { svg: true, svgFilters: true },
|
|
@@ -32,7 +32,7 @@ function sanitizeSvg(raw) {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
finally {
|
|
35
|
-
DOMPurify.removeHook('uponSanitizeAttribute',
|
|
35
|
+
DOMPurify.removeHook('uponSanitizeAttribute', hook);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
/** @public */
|
|
@@ -42,8 +42,8 @@ export function useListControls(opts = {}) {
|
|
|
42
42
|
const [page, setPage] = useState(1);
|
|
43
43
|
/* 못 읽히는 쪽크기('' · 'all' · '0')는 기본값으로 — 그 셈이 화면마다 한 벌씩 있었다.
|
|
44
44
|
기본값 자체가 못 읽히면 25 다. 목록이 통째로 사라지는 값(0·NaN)을 여기서 닫는다. */
|
|
45
|
-
const
|
|
46
|
-
const perPage = Number(pageSize) ||
|
|
45
|
+
const defaultSize = Number(defaultPageSize) || 25;
|
|
46
|
+
const perPage = Number(pageSize) || defaultSize;
|
|
47
47
|
/* **검색어와 쪽크기를 여기서 넣는다** — 부르는 쪽이 적으면 빠뜨릴 수 있고, 빠뜨려도 안 빨개진다.
|
|
48
48
|
이어 붙이는 것은 `usePageResetOnChange` 가 글 하나를 받기 때문이고, 값이 글·수·참거짓뿐이라
|
|
49
49
|
`[object Object]` 로 뭉개질 일이 없다(타입이 막는다). */
|
|
@@ -22,9 +22,9 @@ import { useState } from 'react';
|
|
|
22
22
|
* @public
|
|
23
23
|
*/
|
|
24
24
|
export function usePageResetOnChange(key, setPage) {
|
|
25
|
-
const [
|
|
26
|
-
if (
|
|
27
|
-
|
|
25
|
+
const [seenKey, setSeenKey] = useState(key);
|
|
26
|
+
if (seenKey !== key) {
|
|
27
|
+
setSeenKey(key);
|
|
28
28
|
setPage(1);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -17,7 +17,7 @@ export function usePasswordChange(onChangePassword) {
|
|
|
17
17
|
const clearTimerRef = useRef(null);
|
|
18
18
|
/* 처리 중에는 다시 받지 않는다 — 두 번 누르면 변경이 두 번 나가, 먼저 온 성공 뒤에 늦게 온 "현재 비밀번호 틀림" 이
|
|
19
19
|
남아 바뀌었는데 오류 문구가 떴다(말귀 계정 화면 단추는 로딩 중에도 눌린다) */
|
|
20
|
-
const
|
|
20
|
+
const busyRef = useRef(false);
|
|
21
21
|
useEffect(() => {
|
|
22
22
|
return () => {
|
|
23
23
|
if (clearTimerRef.current !== null)
|
|
@@ -29,7 +29,7 @@ export function usePasswordChange(onChangePassword) {
|
|
|
29
29
|
setPwMsg(null);
|
|
30
30
|
}, []);
|
|
31
31
|
const handleChangePw = useCallback(async () => {
|
|
32
|
-
if (
|
|
32
|
+
if (busyRef.current)
|
|
33
33
|
return;
|
|
34
34
|
if (!pw.current) {
|
|
35
35
|
setPwMsg({ type: 'error', text: t('account.security.password.currentRequired', '현재 비밀번호를 입력하세요.') });
|
|
@@ -46,7 +46,7 @@ export function usePasswordChange(onChangePassword) {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
setPwLoading(true);
|
|
49
|
-
|
|
49
|
+
busyRef.current = true;
|
|
50
50
|
// 콜백이 문구 대신 **거절**하면(망 끊김 등) 로딩이 true 로 남아 단추가 영영 잠겼고,
|
|
51
51
|
// reset() 도 로딩은 안 건드려 풀 길이 없었다. 거절도 오류 문구로 보이고 로딩은 늘 내린다.
|
|
52
52
|
let err;
|
|
@@ -57,7 +57,7 @@ export function usePasswordChange(onChangePassword) {
|
|
|
57
57
|
err = getErrorMessage(e);
|
|
58
58
|
}
|
|
59
59
|
finally {
|
|
60
|
-
|
|
60
|
+
busyRef.current = false;
|
|
61
61
|
setPwLoading(false);
|
|
62
62
|
}
|
|
63
63
|
if (err) {
|
|
@@ -7,19 +7,19 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
7
7
|
*/
|
|
8
8
|
export function usePendingPress(fn) {
|
|
9
9
|
const [pending, setPending] = useState(false);
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
useEffect(() => {
|
|
10
|
+
const running = useRef(false);
|
|
11
|
+
const mounted = useRef(true);
|
|
12
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
13
13
|
const press = useCallback(() => {
|
|
14
|
-
if (!fn ||
|
|
14
|
+
if (!fn || running.current)
|
|
15
15
|
return;
|
|
16
16
|
const r = fn();
|
|
17
17
|
if (r && typeof r.then === 'function') {
|
|
18
|
-
|
|
18
|
+
running.current = true;
|
|
19
19
|
setPending(true);
|
|
20
|
-
const
|
|
20
|
+
const settle = () => { running.current = false; if (mounted.current)
|
|
21
21
|
setPending(false); };
|
|
22
|
-
r.then(
|
|
22
|
+
r.then(settle, settle);
|
|
23
23
|
}
|
|
24
24
|
}, [fn]);
|
|
25
25
|
return { pending, press };
|
|
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
|
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { getTodayKst, toKstYmd } from '../utils/date';
|
|
5
5
|
/** 'YYYY-MM-DD' 두 날짜 사이의 달력날 수 */
|
|
6
|
-
function
|
|
6
|
+
function dayDiff(from, to) {
|
|
7
7
|
const [fy, fm, fd] = from.split('-').map(Number);
|
|
8
8
|
const [ty, tm, td] = to.split('-').map(Number);
|
|
9
9
|
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86400000);
|
|
@@ -23,10 +23,10 @@ export function useRelativeDay() {
|
|
|
23
23
|
return useCallback((iso) => {
|
|
24
24
|
if (!iso)
|
|
25
25
|
return '';
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
26
|
+
const ymd = toKstYmd(iso);
|
|
27
|
+
if (!ymd)
|
|
28
28
|
return '';
|
|
29
|
-
const days =
|
|
29
|
+
const days = dayDiff(ymd, getTodayKst());
|
|
30
30
|
if (days <= 0)
|
|
31
31
|
return t('myHomework.relativeDay.today', '오늘');
|
|
32
32
|
if (days === 1)
|
|
@@ -26,7 +26,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
26
26
|
useEffect(() => () => clearTimers(), []);
|
|
27
27
|
const answerId = queue[index]?.id ?? null;
|
|
28
28
|
/** 이미 고른 문항 번호 — 새 문항이 대기에 들어가면 풀린다(playQuestion) */
|
|
29
|
-
const
|
|
29
|
+
const pickLockRef = useRef(null);
|
|
30
30
|
// ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
|
|
31
31
|
const runPlayback = useCallback(() => {
|
|
32
32
|
clearTimers();
|
|
@@ -49,7 +49,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
49
49
|
onDoneRef.current?.({ score: scoreRef.current, total: q.length });
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
pickLockRef.current = null;
|
|
53
53
|
setIndex(i);
|
|
54
54
|
setSelectedId(null);
|
|
55
55
|
setPhase('playing');
|
|
@@ -87,7 +87,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
87
87
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
88
88
|
/* 배치가 바뀌었는지는 **스피커 id 들**로 본다 — 개수만 보면 수가 같은 다른 배치(HORIZON_7 ↔ PRESET_27 가로)로 바꿔도
|
|
89
89
|
초기화되지 않아 옛 문제 줄이 남고 정답 id 가 새 배치에 없어 무엇을 골라도 오답이었다. */
|
|
90
|
-
const
|
|
90
|
+
const layoutKey = speakers.map((sp) => sp.id).join('|');
|
|
91
91
|
useEffect(() => {
|
|
92
92
|
if (autoStart)
|
|
93
93
|
start();
|
|
@@ -96,14 +96,14 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
|
|
|
96
96
|
// 배치·모드가 바뀌거나 떠날 때 앞 판의 예약을 걷는다
|
|
97
97
|
return () => clearTimers();
|
|
98
98
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
-
}, [mode,
|
|
99
|
+
}, [mode, layoutKey, autoStart]);
|
|
100
100
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
101
101
|
const onSelectSpeaker = useCallback((id) => {
|
|
102
102
|
/* 같은 대기에서 **한 번만** 받는다 — 다시 그려지기 전에 같은 핸들러가 두 번 불리면(렌더 시점의 phase 만 보면) 점수가 +2,
|
|
103
103
|
다음 문제가 두 번 예약됐다. 고르는 순간 ref 로 닫는다. */
|
|
104
|
-
if (phase !== 'waiting' || mode === 'playback' ||
|
|
104
|
+
if (phase !== 'waiting' || mode === 'playback' || pickLockRef.current === index)
|
|
105
105
|
return;
|
|
106
|
-
|
|
106
|
+
pickLockRef.current = index;
|
|
107
107
|
setSelectedId(id);
|
|
108
108
|
const correct = id === queue[index]?.id;
|
|
109
109
|
if (correct)
|
|
@@ -78,15 +78,4 @@ export declare function useTableSort<K extends string>(opts?: TableSortOptions<K
|
|
|
78
78
|
onClick: () => void;
|
|
79
79
|
};
|
|
80
80
|
sortRows: <R>(rows: readonly R[], getValue: (row: R, key: K) => unknown) => readonly R[];
|
|
81
|
-
/** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
|
|
82
|
-
정렬: {
|
|
83
|
-
key: K;
|
|
84
|
-
asc: boolean;
|
|
85
|
-
} | null;
|
|
86
|
-
/** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
|
|
87
|
-
칸: (label: string, key: K) => {
|
|
88
|
-
label: string;
|
|
89
|
-
sortDir: "desc" | "asc" | null;
|
|
90
|
-
onClick: () => void;
|
|
91
|
-
};
|
|
92
81
|
};
|
|
@@ -26,17 +26,17 @@ import { compareCellBlankLast } from '../utils/textSort';
|
|
|
26
26
|
export function useTablePaging(rows, resetOn, opts) {
|
|
27
27
|
const { t } = useTranslation();
|
|
28
28
|
const [pageSize, setPageSizeRaw] = useState(() => {
|
|
29
|
-
const
|
|
30
|
-
return
|
|
29
|
+
const initial = opts?.initialPageSize;
|
|
30
|
+
return initial && VAP_PAGE_SIZE_OPTIONS.some(o => o.value === initial) ? initial : '25';
|
|
31
31
|
});
|
|
32
32
|
const [page, setPage] = useState(1);
|
|
33
33
|
/* 늘어놓는 차례가 바뀌면 첫 쪽으로 — 3쪽을 보던 중에 정렬하면 무엇이 위로 왔는지
|
|
34
34
|
못 본다. 렌더 중에 되돌린다(저장소의 다른 곳들과 같은 꼴: 값이 바뀐 그 렌더에서
|
|
35
35
|
바로 맞춰야 한 박자 늦은 화면이 안 뜬다). */
|
|
36
|
-
const [
|
|
36
|
+
const [seen, setSeen] = useState(resetOn);
|
|
37
37
|
// Object.is — NaN 이면 `!==` 가 늘 참이라 렌더가 끝나지 않았다. 객체는 렌더마다 새로 만들면 매번 바뀐 것이다(원시값을 넘길 것)
|
|
38
|
-
if (!Object.is(
|
|
39
|
-
|
|
38
|
+
if (!Object.is(seen, resetOn)) {
|
|
39
|
+
setSeen(resetOn);
|
|
40
40
|
if (page !== 1)
|
|
41
41
|
setPage(1);
|
|
42
42
|
}
|
|
@@ -50,14 +50,14 @@ export function useTablePaging(rows, resetOn, opts) {
|
|
|
50
50
|
// 1 보다 작은 쪽(setPage(0)·-1)도 막는다 — 0 은 빈 쪽, -1 은 음수 slice 로 엉뚱한 줄이 나왔다
|
|
51
51
|
if (page > total || page < 1 || !Number.isFinite(page))
|
|
52
52
|
setPage(Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total));
|
|
53
|
-
const
|
|
54
|
-
const pageRows = useMemo(() => rows.slice((
|
|
53
|
+
const clampedPage = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
|
|
54
|
+
const pageRows = useMemo(() => rows.slice((clampedPage - 1) * Number(pageSize), clampedPage * Number(pageSize)), [rows, clampedPage, pageSize]);
|
|
55
55
|
return {
|
|
56
56
|
pageRows,
|
|
57
57
|
/** Toolbar 의 pageSize 자리에 그대로 넣는다 */
|
|
58
58
|
pageSizeProps: { options: pageSizeOptions, value: pageSize, onChange: setPageSize },
|
|
59
59
|
/** Toolbar 의 pagination 자리에 그대로 넣는다 */
|
|
60
|
-
paginationProps: { page:
|
|
60
|
+
paginationProps: { page: clampedPage, total, onChange: setPage },
|
|
61
61
|
setPage,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
@@ -79,42 +79,37 @@ export function useTablePaging(rows, resetOn, opts) {
|
|
|
79
79
|
* @public
|
|
80
80
|
*/
|
|
81
81
|
export function useTableSort(opts) {
|
|
82
|
-
const [
|
|
82
|
+
const [sortState, setSortState] = useState(null);
|
|
83
83
|
/* 다음 정렬은 **ref 에서** 계산해 알린다 — 갱신 함수 안에서 부르면 StrictMode 가 두 번 부른다 */
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
const sortRef = useRef(sortState);
|
|
85
|
+
const onChangeRef = useRef(opts?.onSortChange);
|
|
86
|
+
onChangeRef.current = opts?.onSortChange;
|
|
87
|
+
const onHeadClick = useCallback((key) => {
|
|
88
|
+
const prev = sortRef.current;
|
|
89
|
+
const next = prev?.key === key ? { key, asc: !prev.asc } : { key, asc: true };
|
|
90
|
+
sortRef.current = next;
|
|
91
|
+
setSortState(next);
|
|
92
|
+
onChangeRef.current?.(next);
|
|
93
93
|
}, []);
|
|
94
94
|
/** Table 의 field 하나에 붙일 것 — 라벨과 함께 쓴다 */
|
|
95
|
-
const
|
|
95
|
+
const fieldHead = useCallback((label, key) => ({
|
|
96
96
|
label,
|
|
97
|
-
sortDir:
|
|
98
|
-
onClick: () =>
|
|
99
|
-
}), [
|
|
97
|
+
sortDir: sortState?.key === key ? (sortState.asc ? 'asc' : 'desc') : null,
|
|
98
|
+
onClick: () => onHeadClick(key),
|
|
99
|
+
}), [sortState, onHeadClick]);
|
|
100
100
|
/** 지금 정렬로 세운 새 배열(정렬 없으면 받은 배열 그대로). 빈 값은 오름·내림 모두 맨 뒤. */
|
|
101
101
|
const sortRows = useCallback((rows, getValue) => {
|
|
102
|
-
if (
|
|
102
|
+
if (!sortState)
|
|
103
103
|
return rows;
|
|
104
|
-
const { key, asc } =
|
|
104
|
+
const { key, asc } = sortState;
|
|
105
105
|
return [...rows].sort((a, b) => compareCellBlankLast(getValue(a, key), getValue(b, key), asc));
|
|
106
|
-
}, [
|
|
106
|
+
}, [sortState]);
|
|
107
107
|
/* 쪽넘김에 그대로 건넨다 — 차례가 바뀌면 첫 쪽으로 돌아가게.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
풀어 쓰고 있어, 한 번에 걷으면 그 저장소가 통째로 빨개진다. 그쪽이 옮긴 뒤 걷는다. */
|
|
108
|
+
**옛 한글 이름(`정렬`·`칸`)은 2026-10-13 걷었다** — 소비자가 다 옮긴 것을 세어 보고 걷었다
|
|
109
|
+
(말귀의 코어 `useTableSort` 자리 열, 전부 `{ sort, field }`. kscan·소리방향성·fe 는 0). */
|
|
111
110
|
return {
|
|
112
|
-
sort:
|
|
113
|
-
field:
|
|
111
|
+
sort: sortState,
|
|
112
|
+
field: fieldHead,
|
|
114
113
|
sortRows,
|
|
115
|
-
/** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
|
|
116
|
-
정렬,
|
|
117
|
-
/** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
|
|
118
|
-
칸,
|
|
119
114
|
};
|
|
120
115
|
}
|
|
@@ -54,72 +54,72 @@ import { logTrainingTime } from '../utils/trainingLogger';
|
|
|
54
54
|
* @public
|
|
55
55
|
*/
|
|
56
56
|
/** 지금 그 갈래의 시간을 적고 있는 주인 — 갈래마다 하나(위 머리말 참고) */
|
|
57
|
-
const
|
|
57
|
+
const writers = new Set();
|
|
58
58
|
export default function useTrainingSessionLog(trainingType) {
|
|
59
59
|
useEffect(() => {
|
|
60
60
|
if (!trainingType)
|
|
61
61
|
return; // 타입 미상이면 기록하지 않음
|
|
62
62
|
/* **갈래마다 주인은 하나다.** 이미 누가 적고 있으면 조용히 물러난다 — 안 그러면
|
|
63
63
|
두 창이 제 시간을 따로 적어 겹친 만큼이 두 번 세어진다. */
|
|
64
|
-
if (
|
|
64
|
+
if (writers.has(trainingType))
|
|
65
65
|
return;
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
let
|
|
69
|
-
let
|
|
70
|
-
let
|
|
71
|
-
let
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
66
|
+
writers.add(trainingType);
|
|
67
|
+
const isVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
68
|
+
let firstVisibleAt = null;
|
|
69
|
+
let spanStartedAt = null;
|
|
70
|
+
let accumulatedMs = 0;
|
|
71
|
+
let sentMs = 0; // 지금까지 줄로 내보낸 누계 — 다음 줄은 여기서부터 이어 붙인다
|
|
72
|
+
const openSpan = () => { const t = Date.now(); if (firstVisibleAt == null)
|
|
73
|
+
firstVisibleAt = t; if (spanStartedAt == null)
|
|
74
|
+
spanStartedAt = t; };
|
|
75
|
+
const closeSpan = () => { if (spanStartedAt != null) {
|
|
76
|
+
accumulatedMs += Date.now() - spanStartedAt;
|
|
77
|
+
spanStartedAt = null;
|
|
78
78
|
} };
|
|
79
|
-
if (
|
|
80
|
-
|
|
79
|
+
if (isVisible())
|
|
80
|
+
openSpan();
|
|
81
81
|
/* 아직 안 보낸 만큼을 한 줄로. 끝 시각은 처음 보인 때 + 모은 시간, 시작 시각은 앞 줄이
|
|
82
82
|
끝난 자리 — 줄끼리 겹치지 않으니 합산해도 이중으로 세지 않는다.
|
|
83
83
|
전달은 등록된 로거(logTrainingTime)로 간다. 코어는 보낼 주소를 모르므로
|
|
84
84
|
navigator.sendBeacon 을 여기서 직접 쓸 수 없다 — 앱이 등록하는 로거의 몫이다. */
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
if (
|
|
85
|
+
const send = () => {
|
|
86
|
+
closeSpan();
|
|
87
|
+
if (firstVisibleAt == null)
|
|
88
88
|
return;
|
|
89
|
-
const
|
|
89
|
+
const pendingMs = accumulatedMs - sentMs;
|
|
90
90
|
/* 1초가 안 되는 조각은 보내지 않는다 — 서버(log_training_category_time)가 1초 미만 줄을 버리므로
|
|
91
91
|
부르기 제한(10분 60번)만 축낸다(말귀 확인 2026-09-16). */
|
|
92
|
-
if (
|
|
92
|
+
if (pendingMs < 1000)
|
|
93
93
|
return;
|
|
94
94
|
// 첫 줄에만 5초 문턱을 건다(잘못 열었다 닫은 판). 한 번 보낸 판은 이미 실재하는
|
|
95
95
|
// 훈련이라, 돌아와 더 한 시간은 짧아도 버리지 않는다.
|
|
96
|
-
if (
|
|
96
|
+
if (sentMs === 0 && accumulatedMs < 5000)
|
|
97
97
|
return;
|
|
98
|
-
const
|
|
99
|
-
|
|
98
|
+
const startMs = firstVisibleAt + sentMs;
|
|
99
|
+
sentMs = accumulatedMs;
|
|
100
100
|
logTrainingTime({
|
|
101
101
|
trainingType,
|
|
102
|
-
startedAt: new Date(
|
|
103
|
-
endedAt: new Date(
|
|
102
|
+
startedAt: new Date(startMs).toISOString(),
|
|
103
|
+
endedAt: new Date(firstVisibleAt + accumulatedMs).toISOString(),
|
|
104
104
|
});
|
|
105
105
|
};
|
|
106
106
|
// 숨으면 보낸다(앱이 그대로 죽을 수 있다). 다시 보이면 이어서 모은다.
|
|
107
|
-
const
|
|
108
|
-
|
|
107
|
+
const onVisibilityChange = () => { if (isVisible())
|
|
108
|
+
openSpan();
|
|
109
109
|
else
|
|
110
|
-
|
|
110
|
+
send(); };
|
|
111
111
|
if (typeof document !== 'undefined')
|
|
112
|
-
document.addEventListener('visibilitychange',
|
|
112
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
113
113
|
// 페이지가 내려가면(탭·앱 닫기) React 정리가 안 돈다 — 그때 보낸다
|
|
114
114
|
if (typeof window !== 'undefined')
|
|
115
|
-
window.addEventListener('pagehide',
|
|
115
|
+
window.addEventListener('pagehide', send);
|
|
116
116
|
return () => {
|
|
117
117
|
if (typeof document !== 'undefined')
|
|
118
|
-
document.removeEventListener('visibilitychange',
|
|
118
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
119
119
|
if (typeof window !== 'undefined')
|
|
120
|
-
window.removeEventListener('pagehide',
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
window.removeEventListener('pagehide', send);
|
|
121
|
+
send();
|
|
122
|
+
writers.delete(trainingType);
|
|
123
123
|
};
|
|
124
124
|
}, [trainingType]);
|
|
125
125
|
}
|
|
@@ -13,10 +13,10 @@ import { useEditableList, isSameEditableItem } from './useEditableList';
|
|
|
13
13
|
* 없었다** — V8 의 TimSort 는 같은 값 구간에서 비교를 아예 안 불러, 뒤섞인 자료로
|
|
14
14
|
* 재 봐도 결과가 같았다(실측). 그래서 이 한 가지는 시험으로 못 박는다.
|
|
15
15
|
*/
|
|
16
|
-
const
|
|
16
|
+
const sortKeyOf = (v) => `${v.year.padStart(4, '0')}-${v.month.padStart(2, '0')}-${v.day.padStart(2, '0')}` +
|
|
17
17
|
` ${v.hour.padStart(2, '0')}:${v.minute.padStart(2, '0')}`;
|
|
18
18
|
const sortByDate = (list) => [...list].sort((a, b) => {
|
|
19
|
-
const x =
|
|
19
|
+
const x = sortKeyOf(a), y = sortKeyOf(b);
|
|
20
20
|
return x < y ? -1 : x > y ? 1 : 0;
|
|
21
21
|
});
|
|
22
22
|
const emptyScheduleItem = { year: '', month: '', day: '', hour: '09', minute: '00', memo: '' };
|
|
@@ -34,8 +34,8 @@ initialSchedule = []) {
|
|
|
34
34
|
useMemo 로 묶는 것은 성능 때문이 아니라 **되돌이를 막기 위해서다** —
|
|
35
35
|
useEditableList 는 초기 목록을 원소 참조로 견주는데, 렌더마다 새 배열을 만들면
|
|
36
36
|
원소는 같아도 매번 setList 가 돌아 무한 렌더가 된다. */
|
|
37
|
-
const
|
|
38
|
-
const { list: scheduleList, setList: setScheduleList, selected: selectedSchedule, setSelected: setSelectedSchedule, isEditing: isEditingSchedule, setIsEditing: setIsEditingSchedule, editForm: editScheduleForm, setEditForm: setEditScheduleForm, openDetail: openScheduleDetail, commitEdit, remove, } = useEditableList(emptyScheduleItem,
|
|
37
|
+
const sortedInitial = useMemo(() => sortByDate(initialSchedule), [initialSchedule]);
|
|
38
|
+
const { list: scheduleList, setList: setScheduleList, selected: selectedSchedule, setSelected: setSelectedSchedule, isEditing: isEditingSchedule, setIsEditing: setIsEditingSchedule, editForm: editScheduleForm, setEditForm: setEditScheduleForm, openDetail: openScheduleDetail, commitEdit, remove, } = useEditableList(emptyScheduleItem, sortedInitial);
|
|
39
39
|
/** 미방문 처리된 예약 — 삭제하지 않고 보존해 통합 방문 목록에서 뱃지로 표시 */
|
|
40
40
|
const [noShowList, setNoShowList] = useState([]);
|
|
41
41
|
const { year: ty, month: tm, day: td } = today();
|
|
@@ -119,22 +119,22 @@ export function buildPositionSection(t, opts) {
|
|
|
119
119
|
* 저장할 때는 `toWordAttributeFilterCodes` 로 코드 모양을 함께 담는다.
|
|
120
120
|
*/
|
|
121
121
|
export function toggleAttributeFilter(prev, value, sectionLabel) {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
125
|
-
const
|
|
126
|
-
const
|
|
122
|
+
const section = wordAttributeSectionOf(sectionLabel);
|
|
123
|
+
const valueCode = section && value !== FILTER_ALL_VALUE ? wordAttributeValueCodeOf(section, value) : null;
|
|
124
|
+
if (section && (value === FILTER_ALL_VALUE || valueCode)) {
|
|
125
|
+
const kept = {};
|
|
126
|
+
const sameSection = {};
|
|
127
127
|
for (const [k, v] of Object.entries(prev))
|
|
128
|
-
(wordAttributeSectionOf(k) ===
|
|
128
|
+
(wordAttributeSectionOf(k) === section ? sameSection : kept)[k] = v;
|
|
129
129
|
if (value === FILTER_ALL_VALUE)
|
|
130
|
-
return
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
return
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
return {
|
|
130
|
+
return kept;
|
|
131
|
+
const current = toWordAttributeFilterCodes(sameSection)[section] ?? [];
|
|
132
|
+
const nextCodes = current.includes(valueCode) ? current.filter(c => c !== valueCode) : [...current, valueCode];
|
|
133
|
+
if (nextCodes.length === 0)
|
|
134
|
+
return kept;
|
|
135
|
+
const labels = wordAttributeFilterFromCodes({ [section]: nextCodes }, wordAttributeLabelLanguageOf(sectionLabel));
|
|
136
|
+
const values = Object.values(labels)[0] ?? [];
|
|
137
|
+
return { ...kept, [sectionLabel]: values };
|
|
138
138
|
}
|
|
139
139
|
if (value === FILTER_ALL_VALUE) {
|
|
140
140
|
const { [sectionLabel]: _omit, ...rest } = prev;
|
|
@@ -28,8 +28,8 @@ const memCache = new Map();
|
|
|
28
28
|
const noMeaning = new Set();
|
|
29
29
|
/* 빈 응답(한 줄도 못 받음)으로 '뜻 없음' 이 된 열쇠와 그 때 — 조회 함수는 실패도 빈 배열로 돌려주므로 영영 굳히지 않고, 사용자 추가
|
|
30
30
|
낱말처럼 정말 뜻이 없는 쪽을 오갈 때마다 다시 묻지도 않게 **잠시만**(1분) 묻지 않는다. */
|
|
31
|
-
const
|
|
32
|
-
const
|
|
31
|
+
const emptyAt = new Map();
|
|
32
|
+
const EMPTY_GRACE_MS = 60_000;
|
|
33
33
|
export function useWordMeanings(rows, fetchMeanings) {
|
|
34
34
|
/* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
|
|
35
35
|
올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
|
|
@@ -39,8 +39,8 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
39
39
|
const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
|
|
40
40
|
useEffect(() => {
|
|
41
41
|
const wanted = wantedKey ? wantedKey.split('|') : [];
|
|
42
|
-
const
|
|
43
|
-
const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(now - (emptyAt.get(k) ?? -Infinity) < EMPTY_GRACE_MS));
|
|
44
44
|
if (missing.length === 0)
|
|
45
45
|
return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
|
|
46
46
|
let cancelled = false;
|
|
@@ -62,7 +62,7 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
62
62
|
else {
|
|
63
63
|
const t = Date.now();
|
|
64
64
|
for (const k of missing)
|
|
65
|
-
|
|
65
|
+
emptyAt.set(k, t);
|
|
66
66
|
}
|
|
67
67
|
if (cancelled || got.length === 0)
|
|
68
68
|
return;
|
|
@@ -8,7 +8,7 @@ import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection
|
|
|
8
8
|
import { matchesFilters } from '../utils/phonemePairSearch';
|
|
9
9
|
import { wordAttributeValueCodeOf, wordAttributeValueLabelOf, isGradeCode } from '../utils/wordAttributeFilterCodes';
|
|
10
10
|
/** 저장된 등급을 되읽을 때 코드 아닌 것은 버린다 — 0.320.0 작업본이 잠깐 `['']` 를 저장했다 */
|
|
11
|
-
const
|
|
11
|
+
const gradeCodesOnly = (v) => (Array.isArray(v) ? v.filter(isGradeCode) : []);
|
|
12
12
|
import { hasSel } from '../utils/wordContrast';
|
|
13
13
|
import { phoneticLabel } from '../utils/phonemeGroups';
|
|
14
14
|
/* 글자↔발음 변환과 음절 상수는 wordTrainingFilterModel.ts 로 옮겼다(React 없는 순수 계층).
|
|
@@ -145,12 +145,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
145
145
|
// 음소 구조 같은 단어만(초성 ㅇ 유무·받침 유무 대립 제외) — 기본 true (reset 시 OFF)
|
|
146
146
|
const [sameStructureOnly, setSameStructureOnly] = useState(opts?.resetOnMount ? false : (saved?.sameStructureOnly ?? true));
|
|
147
147
|
// 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
|
|
148
|
-
const [gradeFilter, setGradeFilter] = useState(
|
|
148
|
+
const [gradeFilter, setGradeFilter] = useState(gradeCodesOnly(saved?.gradeFilter));
|
|
149
149
|
// 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
|
|
150
|
-
const
|
|
150
|
+
const labelLanguages = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
|
|
151
151
|
const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
|
|
152
|
-
const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf,
|
|
153
|
-
const typeCodesOf = (v) => toAttrCodes(v, TYPE_CODES, c => `wordSearch.filter.type.${c}`, labelOf,
|
|
152
|
+
const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf, labelLanguages);
|
|
153
|
+
const typeCodesOf = (v) => toAttrCodes(v, TYPE_CODES, c => `wordSearch.filter.type.${c}`, labelOf, labelLanguages);
|
|
154
154
|
const [posCodes, setPosCodes] = useState(() => posCodesOf(saved?.posFilter));
|
|
155
155
|
const [typeCodes, setTypeCodes] = useState(() => typeCodesOf(saved?.wordTypeFilter));
|
|
156
156
|
// 훈련 타입(resetKey) 변경 시 필터를 그 타입 기본값으로 초기화 — 이전 타입의 고정값(음절수/받침/한음절 시드)이
|
|
@@ -213,7 +213,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
213
213
|
setSameStructureOnly(s?.sameStructureOnly ?? true);
|
|
214
214
|
// 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
|
|
215
215
|
setPhonemeView(readPhonemeView(s));
|
|
216
|
-
setGradeFilter(
|
|
216
|
+
setGradeFilter(gradeCodesOnly(s?.gradeFilter));
|
|
217
217
|
setPosCodes(posCodesOf(s?.posFilter));
|
|
218
218
|
setTypeCodes(typeCodesOf(s?.wordTypeFilter));
|
|
219
219
|
}
|
|
@@ -621,7 +621,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
621
621
|
setExcludeNoImage(s.excludeNoImage);
|
|
622
622
|
setSameStructureOnly(s.sameStructureOnly ?? true);
|
|
623
623
|
setPhonemeView(s.phonemeView);
|
|
624
|
-
setGradeFilter(
|
|
624
|
+
setGradeFilter(gradeCodesOnly(s.gradeFilter));
|
|
625
625
|
if (Array.isArray(s.posFilter))
|
|
626
626
|
setPosCodes(posCodesOf(s.posFilter));
|
|
627
627
|
if (Array.isArray(s.wordTypeFilter))
|
|
@@ -114,6 +114,7 @@
|
|
|
114
114
|
"Fri",
|
|
115
115
|
"Sat"
|
|
116
116
|
],
|
|
117
|
+
"weekLabel": "Week {{ord}}",
|
|
117
118
|
"yearMonth": "{{month}}/{{year}}",
|
|
118
119
|
"prevMonth": "Previous month",
|
|
119
120
|
"addOnDate": "Add a visit on {{date}}",
|
|
@@ -128,7 +129,16 @@
|
|
|
128
129
|
"periodSelect": "Period unit",
|
|
129
130
|
"visitCount_one": "{{count}} visit",
|
|
130
131
|
"visitCount_other": "{{count}} visits",
|
|
131
|
-
"statusCount": "{{count}} {{label}}"
|
|
132
|
+
"statusCount": "{{count}} {{label}}",
|
|
133
|
+
"weekOrdinal": [
|
|
134
|
+
"1",
|
|
135
|
+
"2",
|
|
136
|
+
"3",
|
|
137
|
+
"4",
|
|
138
|
+
"5",
|
|
139
|
+
"6",
|
|
140
|
+
"7"
|
|
141
|
+
]
|
|
132
142
|
},
|
|
133
143
|
"passCard": {
|
|
134
144
|
"planSuffix": "Plan",
|
|
@@ -1059,7 +1069,8 @@
|
|
|
1059
1069
|
"removeDevice": "Remove",
|
|
1060
1070
|
"type": {
|
|
1061
1071
|
"hearingAid": "Hearing Aid",
|
|
1062
|
-
"cochlearImplant": "Cochlear Implant"
|
|
1072
|
+
"cochlearImplant": "Cochlear Implant",
|
|
1073
|
+
"other": "Other"
|
|
1063
1074
|
}
|
|
1064
1075
|
},
|
|
1065
1076
|
"manager": {
|