@ihabdevteam/core 0.321.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 +617 -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 +108 -66
- 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/HearingDeviceInfo.d.ts +5 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- 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.d.ts +2 -4
- package/dist/packages/core/src/components/PanelRow.js +8 -15
- 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/DeviceListPopup.d.ts +4 -8
- package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
- 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.d.ts +7 -4
- 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 +12 -12
- 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 +83 -83
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
- 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 +13 -13
- 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.d.ts +1 -1
- package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
- 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 +18 -18
- 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 +17 -17
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
- 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 +22 -7
- 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.d.ts +1 -1
- package/dist/packages/core/src/hooks/inputModality.js +14 -14
- 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/useDeviceManagement.d.ts +5 -17
- package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
- 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.d.ts +3 -3
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
- 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 +7 -7
- package/dist/packages/core/src/hooks/useTablePaging.js +40 -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.d.ts +3 -1
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/devWarn.js +7 -7
- 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.d.ts +1 -1
- package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/navScroll.js +6 -6
- 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/pressable.d.ts +1 -1
- package/dist/packages/core/src/utils/pressable.js +3 -3
- 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/sessionTab.d.ts +7 -7
- package/dist/packages/core/src/utils/sessionTab.js +9 -9
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +5 -5
- package/dist/packages/core/src/utils/textSort.js +47 -47
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
- 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
|
@@ -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;
|
|
@@ -56,9 +56,11 @@ export interface WordTrainingFilterCaps {
|
|
|
56
56
|
* 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
|
|
57
57
|
*
|
|
58
58
|
* **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
|
|
59
|
+
* 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
|
|
60
|
+
* (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
|
|
59
61
|
* 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
|
|
60
62
|
*/
|
|
61
|
-
export declare function
|
|
63
|
+
export declare function gradeDescriptions(t: TFunction): string[];
|
|
62
64
|
export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
|
|
63
65
|
resetOnMount?: boolean;
|
|
64
66
|
resetKey?: string;
|
|
@@ -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 없는 순수 계층).
|
|
@@ -78,7 +78,7 @@ function readComposition(saved) {
|
|
|
78
78
|
* @public
|
|
79
79
|
*/
|
|
80
80
|
/** 사전이 배열을 안 주면 한국어 원문으로 물러선다 — 빈 줄을 띄우지 않는다((205)) */
|
|
81
|
-
const
|
|
81
|
+
const GRADE_DESCRIPTIONS_KO = [
|
|
82
82
|
'1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘',
|
|
83
83
|
'2등급 : 초등학교 1-2학년 난이도 어휘',
|
|
84
84
|
'3등급 : 초등학교 3-4학년 난이도 어휘',
|
|
@@ -89,11 +89,13 @@ const 등급설명기본 = [
|
|
|
89
89
|
* 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
|
|
90
90
|
*
|
|
91
91
|
* **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
|
|
92
|
+
* 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
|
|
93
|
+
* (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
|
|
92
94
|
* 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
|
|
93
95
|
*/
|
|
94
|
-
export function
|
|
95
|
-
const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue:
|
|
96
|
-
return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v :
|
|
96
|
+
export function gradeDescriptions(t) {
|
|
97
|
+
const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue: GRADE_DESCRIPTIONS_KO });
|
|
98
|
+
return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v : GRADE_DESCRIPTIONS_KO;
|
|
97
99
|
}
|
|
98
100
|
export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
99
101
|
const { t, i18n } = useTranslation();
|
|
@@ -143,12 +145,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
143
145
|
// 음소 구조 같은 단어만(초성 ㅇ 유무·받침 유무 대립 제외) — 기본 true (reset 시 OFF)
|
|
144
146
|
const [sameStructureOnly, setSameStructureOnly] = useState(opts?.resetOnMount ? false : (saved?.sameStructureOnly ?? true));
|
|
145
147
|
// 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
|
|
146
|
-
const [gradeFilter, setGradeFilter] = useState(
|
|
148
|
+
const [gradeFilter, setGradeFilter] = useState(gradeCodesOnly(saved?.gradeFilter));
|
|
147
149
|
// 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
|
|
148
|
-
const
|
|
150
|
+
const labelLanguages = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
|
|
149
151
|
const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
|
|
150
|
-
const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf,
|
|
151
|
-
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);
|
|
152
154
|
const [posCodes, setPosCodes] = useState(() => posCodesOf(saved?.posFilter));
|
|
153
155
|
const [typeCodes, setTypeCodes] = useState(() => typeCodesOf(saved?.wordTypeFilter));
|
|
154
156
|
// 훈련 타입(resetKey) 변경 시 필터를 그 타입 기본값으로 초기화 — 이전 타입의 고정값(음절수/받침/한음절 시드)이
|
|
@@ -211,7 +213,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
211
213
|
setSameStructureOnly(s?.sameStructureOnly ?? true);
|
|
212
214
|
// 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
|
|
213
215
|
setPhonemeView(readPhonemeView(s));
|
|
214
|
-
setGradeFilter(
|
|
216
|
+
setGradeFilter(gradeCodesOnly(s?.gradeFilter));
|
|
215
217
|
setPosCodes(posCodesOf(s?.posFilter));
|
|
216
218
|
setTypeCodes(typeCodesOf(s?.wordTypeFilter));
|
|
217
219
|
}
|
|
@@ -450,7 +452,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
450
452
|
사전이 배열을 안 주면(옛 사전을 덮어쓴 소비자 등) **한국어 원문으로 물러선다** — 빈 줄을
|
|
451
453
|
띄우지 않는다. 재 보고 적는다: `returnObjects` 는 배열을 그대로 주고 `defaultValue` 도
|
|
452
454
|
배열로 물러선다(i18next 26.4.1). */
|
|
453
|
-
labelInfo: infoAction(
|
|
455
|
+
labelInfo: infoAction(gradeDescriptions(t)),
|
|
454
456
|
}),
|
|
455
457
|
label: LABELS.gradeLabel,
|
|
456
458
|
});
|
|
@@ -619,7 +621,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
619
621
|
setExcludeNoImage(s.excludeNoImage);
|
|
620
622
|
setSameStructureOnly(s.sameStructureOnly ?? true);
|
|
621
623
|
setPhonemeView(s.phonemeView);
|
|
622
|
-
setGradeFilter(
|
|
624
|
+
setGradeFilter(gradeCodesOnly(s.gradeFilter));
|
|
623
625
|
if (Array.isArray(s.posFilter))
|
|
624
626
|
setPosCodes(posCodesOf(s.posFilter));
|
|
625
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": {
|
|
@@ -110,6 +110,16 @@
|
|
|
110
110
|
"금",
|
|
111
111
|
"토"
|
|
112
112
|
],
|
|
113
|
+
"weekOrdinal": [
|
|
114
|
+
"첫째",
|
|
115
|
+
"둘째",
|
|
116
|
+
"셋째",
|
|
117
|
+
"넷째",
|
|
118
|
+
"다섯째",
|
|
119
|
+
"여섯째",
|
|
120
|
+
"일곱째"
|
|
121
|
+
],
|
|
122
|
+
"weekLabel": "{{ord}}주",
|
|
113
123
|
"yearMonth": "{{year}}년 {{month}}월",
|
|
114
124
|
"prevMonth": "이전 달",
|
|
115
125
|
"addOnDate": "{{date}}에 방문 등록",
|
|
@@ -1048,7 +1058,8 @@
|
|
|
1048
1058
|
"removeDevice": "착용 해제",
|
|
1049
1059
|
"type": {
|
|
1050
1060
|
"hearingAid": "보청기",
|
|
1051
|
-
"cochlearImplant": "인공와우"
|
|
1061
|
+
"cochlearImplant": "인공와우",
|
|
1062
|
+
"other": "기타"
|
|
1052
1063
|
}
|
|
1053
1064
|
},
|
|
1054
1065
|
"manager": {
|
|
@@ -19,7 +19,16 @@ 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
|
+
*/
|
|
22
29
|
diffSection?: string;
|
|
30
|
+
/** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
|
|
31
|
+
diffSectionCode?: import('../utils/phonemeSectionCodes').PhonemeSectionCode;
|
|
23
32
|
diffHasEmpty?: boolean;
|
|
24
33
|
}
|
|
25
34
|
/**
|
|
@@ -20,19 +20,19 @@ const _graphs = new WeakMap();
|
|
|
20
20
|
const _desired = new WeakMap();
|
|
21
21
|
const _pending = new WeakMap();
|
|
22
22
|
let _unlockInstalled = false;
|
|
23
|
-
const
|
|
23
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, Number.isFinite(v) ? v : 0));
|
|
24
24
|
/** 첫 사용자 제스처(및 이후 제스처)마다 멈춘 컨텍스트를 깨운다 — iOS 는 제스처 안에서만 resume 이 먹는다 */
|
|
25
25
|
function installGestureUnlock() {
|
|
26
26
|
if (_unlockInstalled || typeof document === 'undefined')
|
|
27
27
|
return;
|
|
28
28
|
_unlockInstalled = true;
|
|
29
|
-
const
|
|
29
|
+
const resumeCtx = () => { try {
|
|
30
30
|
if (_ctx && _ctx.state !== 'running')
|
|
31
31
|
void _ctx.resume();
|
|
32
32
|
}
|
|
33
33
|
catch { /* noop */ } };
|
|
34
34
|
for (const type of ['pointerdown', 'touchend', 'keydown']) {
|
|
35
|
-
document.addEventListener(type,
|
|
35
|
+
document.addEventListener(type, resumeCtx, { capture: true, passive: true });
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
function getContext() {
|
|
@@ -68,7 +68,7 @@ function build(ctx, el) {
|
|
|
68
68
|
analyser.fftSize = 1024;
|
|
69
69
|
analyser.smoothingTimeConstant = 0.6;
|
|
70
70
|
// 태우기 직전의 요소 볼륨을 gain 으로 옮기고 요소는 1 로 — 순서가 바뀌면 한 순간 두 번 곱해진다
|
|
71
|
-
gain.gain.value =
|
|
71
|
+
gain.gain.value = clamp01(_desired.get(el) ?? el.volume);
|
|
72
72
|
source.connect(gain);
|
|
73
73
|
gain.connect(analyser);
|
|
74
74
|
gain.connect(ctx.destination);
|
|
@@ -137,7 +137,7 @@ export function getElementAnalyser(el) {
|
|
|
137
137
|
* 불러 램프를 만들어도 된다.
|
|
138
138
|
*/
|
|
139
139
|
export function setElementVolume(el, volume) {
|
|
140
|
-
const v =
|
|
140
|
+
const v = clamp01(volume);
|
|
141
141
|
_desired.set(el, v);
|
|
142
142
|
const graph = _graphs.get(el);
|
|
143
143
|
if (graph) {
|
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
* 문장의 앞 무음은 진짜 그 소리의 일부이고, 앞뒤 뜸((74))이 그 위에서 셈된다.
|
|
7
7
|
* @public
|
|
8
8
|
*/
|
|
9
|
-
export declare function computePeaksFromUrl(url: string, bars: number, windowSeconds?: number,
|
|
9
|
+
export declare function computePeaksFromUrl(url: string, bars: number, windowSeconds?: number, skipLeadingSilence?: boolean): Promise<number[] | null>;
|
|
@@ -26,10 +26,10 @@ import { getErrorMessage } from './errorMessage';
|
|
|
26
26
|
*
|
|
27
27
|
* 0.001 은 디더까지 잡고 0.02 는 진짜 작은 소리를 잘라 낸다 — 가운데를 쓴다.
|
|
28
28
|
*/
|
|
29
|
-
const
|
|
29
|
+
const SILENCE_LEVEL = 0.01;
|
|
30
30
|
/** 아무리 조용한 파일이라도 이만큼 넘게는 안 건너뛴다 — 통째로 미끄러지지 않게 */
|
|
31
|
-
const
|
|
32
|
-
async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds,
|
|
31
|
+
const SKIP_LIMIT_SEC = 0.3;
|
|
32
|
+
async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, skipLeadingSilence = false) {
|
|
33
33
|
/* 컨텍스트는 **어느 길로 나가든** 닫는다 — 디코딩 실패(404·만료 주소의 HTML)·채널 없음에서 닫지 않아
|
|
34
34
|
파형 하나 실패할 때마다 AudioContext 가 하나씩 남았다(브라우저 동시 한도에 닿으면 이후 파형이 모두 실패). */
|
|
35
35
|
let audioCtx = null;
|
|
@@ -42,24 +42,24 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음
|
|
|
42
42
|
return null;
|
|
43
43
|
/* **앞머리 무음을 건너뛴다**(소음처럼 루프되는 바탕에서만 켠다). 소리 있는 첫 자리를 찾아
|
|
44
44
|
거기서부터 창을 연다 — 그래야 그린 결이 **실제로 들리는 결**이 된다. */
|
|
45
|
-
let
|
|
46
|
-
if (
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
while (
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
45
|
+
let offset = 0;
|
|
46
|
+
if (skipLeadingSilence) {
|
|
47
|
+
const limit = Math.floor(SKIP_LIMIT_SEC * decoded.sampleRate);
|
|
48
|
+
const end = Math.min(channelData.length, limit);
|
|
49
|
+
while (offset < end && Math.abs(channelData[offset]) <= SILENCE_LEVEL)
|
|
50
|
+
offset++;
|
|
51
|
+
if (offset >= end)
|
|
52
|
+
offset = 0; // 통째로 조용한 파일 — 건너뛰지 않는다
|
|
53
53
|
}
|
|
54
|
-
const
|
|
54
|
+
const remaining = channelData.length - offset;
|
|
55
55
|
const total = (windowSeconds && windowSeconds > 0)
|
|
56
|
-
? Math.min(
|
|
57
|
-
:
|
|
56
|
+
? Math.min(remaining, Math.floor(windowSeconds * decoded.sampleRate))
|
|
57
|
+
: remaining;
|
|
58
58
|
const usableLen = Math.max(bars, total);
|
|
59
59
|
const blockSize = Math.floor(usableLen / bars) || 1;
|
|
60
60
|
const peaks = new Array(bars).fill(0).map((_, i) => {
|
|
61
|
-
const start =
|
|
62
|
-
const end = Math.min(start + blockSize,
|
|
61
|
+
const start = offset + i * blockSize;
|
|
62
|
+
const end = Math.min(start + blockSize, offset + usableLen, channelData.length);
|
|
63
63
|
let max = 0;
|
|
64
64
|
for (let j = start; j < end; j++) {
|
|
65
65
|
const v = Math.abs(channelData[j]);
|
|
@@ -89,14 +89,14 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음
|
|
|
89
89
|
* 문장의 앞 무음은 진짜 그 소리의 일부이고, 앞뒤 뜸((74))이 그 위에서 셈된다.
|
|
90
90
|
* @public
|
|
91
91
|
*/
|
|
92
|
-
export async function computePeaksFromUrl(url, bars, windowSeconds,
|
|
92
|
+
export async function computePeaksFromUrl(url, bars, windowSeconds, skipLeadingSilence = false) {
|
|
93
93
|
try {
|
|
94
94
|
const resp = await fetch(url);
|
|
95
95
|
// 404·만료 주소는 소리가 아니다 — 디코딩까지 가지 않는다
|
|
96
96
|
if (resp.ok === false)
|
|
97
97
|
return null;
|
|
98
98
|
const arrayBuffer = await resp.arrayBuffer();
|
|
99
|
-
return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds,
|
|
99
|
+
return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, skipLeadingSilence);
|
|
100
100
|
}
|
|
101
101
|
catch (e) {
|
|
102
102
|
try {
|
|
@@ -20,9 +20,9 @@ export function columnsFitting(width, cardWidth, gap) {
|
|
|
20
20
|
export function balancedColumns(count, perRow) {
|
|
21
21
|
if (!(count > 0))
|
|
22
22
|
return 1;
|
|
23
|
-
const
|
|
24
|
-
if (count <=
|
|
23
|
+
const perRowInt = Math.max(1, Math.floor(perRow));
|
|
24
|
+
if (count <= perRowInt)
|
|
25
25
|
return count;
|
|
26
|
-
const
|
|
27
|
-
return Math.max(1, Math.ceil(count /
|
|
26
|
+
const rowCount = Math.ceil(count / perRowInt);
|
|
27
|
+
return Math.max(1, Math.ceil(count / rowCount));
|
|
28
28
|
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
const SKEW_KEY = 'chunk-reload-ts';
|
|
32
32
|
/** 이 시간 안에 다시 실패하면 배포 skew 가 아니라 진짜 오류로 본다(무한 새로고침 방지). */
|
|
33
33
|
const COOLDOWN_MS = 10_000;
|
|
34
|
-
function
|
|
34
|
+
function lastAt() {
|
|
35
35
|
try {
|
|
36
36
|
return Number(sessionStorage.getItem(SKEW_KEY) || 0);
|
|
37
37
|
}
|
|
@@ -48,7 +48,7 @@ function 마지막시각() {
|
|
|
48
48
|
* @public
|
|
49
49
|
*/
|
|
50
50
|
export function recoverFromSkew(now = Date.now()) {
|
|
51
|
-
if (now -
|
|
51
|
+
if (now - lastAt() <= COOLDOWN_MS)
|
|
52
52
|
return false;
|
|
53
53
|
try {
|
|
54
54
|
sessionStorage.setItem(SKEW_KEY, String(now));
|
|
@@ -65,7 +65,7 @@ export function recoverFromSkew(now = Date.now()) {
|
|
|
65
65
|
* 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
|
|
66
66
|
*/
|
|
67
67
|
export function skewReloadPending(now = Date.now()) {
|
|
68
|
-
const last =
|
|
68
|
+
const last = lastAt();
|
|
69
69
|
return last > 0 && now - last <= COOLDOWN_MS;
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
@@ -97,18 +97,18 @@ export function skewReloadPending(now = Date.now()) {
|
|
|
97
97
|
export function installSkewRecovery() {
|
|
98
98
|
if (typeof window === 'undefined')
|
|
99
99
|
return () => { };
|
|
100
|
-
const
|
|
100
|
+
const onChunkError = (e) => { if (recoverFromSkew())
|
|
101
101
|
e.preventDefault(); };
|
|
102
102
|
/* 스타일시트만 본다 — 그림 하나가 깨졌다고 판을 새로 받으면 안 된다 */
|
|
103
|
-
const
|
|
103
|
+
const onStyleError = (e) => {
|
|
104
104
|
const el = e.target;
|
|
105
105
|
if (el instanceof HTMLLinkElement && el.rel === 'stylesheet')
|
|
106
106
|
recoverFromSkew();
|
|
107
107
|
};
|
|
108
|
-
window.addEventListener('vite:preloadError',
|
|
109
|
-
window.addEventListener('error',
|
|
108
|
+
window.addEventListener('vite:preloadError', onChunkError);
|
|
109
|
+
window.addEventListener('error', onStyleError, true);
|
|
110
110
|
return () => {
|
|
111
|
-
window.removeEventListener('vite:preloadError',
|
|
112
|
-
window.removeEventListener('error',
|
|
111
|
+
window.removeEventListener('vite:preloadError', onChunkError);
|
|
112
|
+
window.removeEventListener('error', onStyleError, true);
|
|
113
113
|
};
|
|
114
114
|
}
|
|
@@ -12,7 +12,7 @@ export async function copyText(text) {
|
|
|
12
12
|
if (typeof document !== 'undefined') {
|
|
13
13
|
// 숨긴 textarea 에 포커스를 옮기므로, 끝나면 원래 자리로 돌려준다 — 안 돌리면 복사 단추를
|
|
14
14
|
// 누른 뒤 키보드 포커스가 body 로 떨어져 스크린리더·Tab 이동이 처음부터 다시 시작된다.
|
|
15
|
-
const
|
|
15
|
+
const previousFocus = document.activeElement;
|
|
16
16
|
let ta = null;
|
|
17
17
|
try {
|
|
18
18
|
ta = document.createElement('textarea');
|
|
@@ -38,9 +38,9 @@ export async function copyText(text) {
|
|
|
38
38
|
// 성공·실패·던짐 어느 길이든 textarea 를 거두고 포커스를 되돌린다
|
|
39
39
|
if (ta?.parentNode)
|
|
40
40
|
ta.parentNode.removeChild(ta);
|
|
41
|
-
if (
|
|
41
|
+
if (previousFocus && previousFocus !== document.activeElement && typeof previousFocus.focus === 'function' && previousFocus.isConnected) {
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
previousFocus.focus({ preventScroll: true });
|
|
44
44
|
}
|
|
45
45
|
catch { /* 무시 */ }
|
|
46
46
|
}
|
|
@@ -26,12 +26,12 @@ function mulberry32(seed) {
|
|
|
26
26
|
// shuffle/sample은 공용 utils/array로 통일(시드 rng 지원 동일). 여기선 sampleArray 이름만 유지해 내부에서 사용.
|
|
27
27
|
const sampleArray = sample;
|
|
28
28
|
/** KST 날짜 'YYYY-MM-DD' → 날 번호(1970-01-01 = 0). 홀짝으로 이틀마다 번갈아 쓸 반쪽을 고른다. */
|
|
29
|
-
const
|
|
29
|
+
const dayNumber = (ymd) => {
|
|
30
30
|
const [y, m, d] = ymd.split('-').map(Number);
|
|
31
31
|
return Math.round(Date.UTC(y, (m || 1) - 1, d || 1) / 86_400_000);
|
|
32
32
|
};
|
|
33
33
|
/** 항목을 가리키는 열쇠 — 자료의 id(없으면 name). 자료가 늘거나 차례가 바뀌어도 그 항목의 반쪽이 안 바뀌게 */
|
|
34
|
-
const
|
|
34
|
+
const itemKey = (it, i) => {
|
|
35
35
|
const o = (it ?? {});
|
|
36
36
|
return String(o.id ?? o.name ?? i);
|
|
37
37
|
};
|
|
@@ -47,23 +47,23 @@ const 항목열쇠 = (it, i) => {
|
|
|
47
47
|
* 정하고, 날 번호의 홀짝에 맞는 반쪽에서만 뽑는다** — 이웃한 두 날은 늘 다른 반쪽이라 겹치지 않고, 어제 보기는 어제 계산과 같다.
|
|
48
48
|
* 두 반쪽이 모두 필요한 개수 이상일 때만(후보가 충분할 때) 이렇게 하고, 모자라면 전처럼 전체에서 뽑는다(빈 세트로 두지 않는다).
|
|
49
49
|
*/
|
|
50
|
-
function
|
|
50
|
+
function typeSet(pool, n, userId, ymd, type) {
|
|
51
51
|
if (!pool.length || n <= 0)
|
|
52
52
|
return [];
|
|
53
53
|
const rng = mulberry32(hashSeed(`${userId}|${ymd}|${type}`));
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
return sampleArray((
|
|
54
|
+
const side = dayNumber(ymd) & 1;
|
|
55
|
+
const thisSide = pool.filter((it, i) => ((hashSeed(`${userId}|${type}|${itemKey(it, i)}`) >>> 7) & 1) === side);
|
|
56
|
+
const enough = thisSide.length >= n && pool.length - thisSide.length >= n;
|
|
57
|
+
return sampleArray((enough ? thisSide : pool), n, rng);
|
|
58
58
|
}
|
|
59
59
|
// (user, 날짜)로 결정적 추출 → 기기/세션 무관 동일 선택. 유형마다 시드가 따로다(위 유형세트).
|
|
60
60
|
function createSelection(counts, userId, ymd) {
|
|
61
|
-
const
|
|
61
|
+
const countOf = (k) => Math.max(0, counts[k] || 0);
|
|
62
62
|
return {
|
|
63
|
-
LISTEN_IN_NOISE:
|
|
64
|
-
SHORT_STORY:
|
|
65
|
-
LONG_STORY:
|
|
66
|
-
ORDERING_SENTENCE:
|
|
63
|
+
LISTEN_IN_NOISE: typeSet(NOISE_DATAS, countOf('LISTEN_IN_NOISE'), userId, ymd, 'LISTEN_IN_NOISE'),
|
|
64
|
+
SHORT_STORY: typeSet(SHORT_STORIES, countOf('SHORT_STORY'), userId, ymd, 'SHORT_STORY'),
|
|
65
|
+
LONG_STORY: typeSet(LONG_STORIES, countOf('LONG_STORY'), userId, ymd, 'LONG_STORY'),
|
|
66
|
+
ORDERING_SENTENCE: typeSet(ORDER_SETS, countOf('ORDERING_SENTENCE'), userId, ymd, 'ORDERING_SENTENCE'),
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
export function hasItems(s) {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const KST = 'Asia/Seoul';
|
|
19
19
|
/* 시간대 없는 벽시계 시각 — 'YYYY-MM-DDTHH:mm[:ss[.fff…]]' 또는 가운데가 빈칸('YYYY-MM-DD HH:mm:ss', Postgres timestamp 꼴).
|
|
20
20
|
뒤돌아보기 없이(iOS 15 Safari) 끝까지 맞춘다 — 뒤에 Z·±hh:mm 이 붙으면 걸리지 않는다. */
|
|
21
|
-
const
|
|
21
|
+
const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?$/;
|
|
22
22
|
/**
|
|
23
23
|
* 날짜·시각 값을 순간(Date)으로 읽는 **이 파일의 공용 파서.** 못 읽는 값(빈 값·깨진 글자)이면 null.
|
|
24
24
|
*
|
|
@@ -28,13 +28,13 @@ const 벽시계시각 = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2})(?:\.(\
|
|
|
28
28
|
* 날짜만 있는 'YYYY-MM-DD' 는 지금 규칙(UTC 자정 = KST 같은 날 9시)을 그대로 따른다.
|
|
29
29
|
* Date·숫자·오프셋 있는 글자는 그대로.
|
|
30
30
|
*/
|
|
31
|
-
function
|
|
31
|
+
function toInstant(when) {
|
|
32
32
|
let d;
|
|
33
33
|
if (when instanceof Date)
|
|
34
34
|
d = when;
|
|
35
35
|
else if (typeof when === 'string') {
|
|
36
36
|
const s = when.trim();
|
|
37
|
-
const m =
|
|
37
|
+
const m = WALL_CLOCK.exec(s);
|
|
38
38
|
/* 소수 초는 세 자리까지만 — 여섯 자리(Postgres)는 엔진마다 읽기가 갈린다 */
|
|
39
39
|
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);
|
|
40
40
|
}
|
|
@@ -44,16 +44,16 @@ function 순간으로(when) {
|
|
|
44
44
|
}
|
|
45
45
|
/* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
|
|
46
46
|
(같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
|
|
47
|
-
const
|
|
47
|
+
const parsed = (iso) => toInstant(iso);
|
|
48
48
|
export function formatDateKo(iso) {
|
|
49
|
-
const d =
|
|
49
|
+
const d = parsed(iso);
|
|
50
50
|
if (!d)
|
|
51
51
|
return '';
|
|
52
52
|
return d.toLocaleDateString('ko-KR', { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
|
|
53
53
|
}
|
|
54
54
|
/** "2024년 1월 15일 오후 03:30" (long month + time) */
|
|
55
55
|
export function formatDateLongKo(iso) {
|
|
56
|
-
const d =
|
|
56
|
+
const d = parsed(iso);
|
|
57
57
|
if (!d)
|
|
58
58
|
return '';
|
|
59
59
|
return d.toLocaleString('ko-KR', {
|
|
@@ -70,7 +70,7 @@ export function formatDateLongKo(iso) {
|
|
|
70
70
|
*/
|
|
71
71
|
export function formatDateLocale(iso, lang) {
|
|
72
72
|
const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
|
|
73
|
-
const d =
|
|
73
|
+
const d = parsed(iso);
|
|
74
74
|
if (!d)
|
|
75
75
|
return '';
|
|
76
76
|
return d.toLocaleDateString(locale, { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
|
|
@@ -78,7 +78,7 @@ export function formatDateLocale(iso, lang) {
|
|
|
78
78
|
/** formatDateLongKo의 로케일 인식 버전 — en: "January 15, 2024, 03:30 PM" */
|
|
79
79
|
export function formatDateLongLocale(iso, lang) {
|
|
80
80
|
const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
|
|
81
|
-
const d =
|
|
81
|
+
const d = parsed(iso);
|
|
82
82
|
if (!d)
|
|
83
83
|
return '';
|
|
84
84
|
return d.toLocaleString(locale, {
|
|
@@ -89,7 +89,7 @@ export function formatDateLongLocale(iso, lang) {
|
|
|
89
89
|
}
|
|
90
90
|
/** "2024. 01. 15. 오후 03:30" (2-digit month + time) */
|
|
91
91
|
export function formatDatetimeKo(iso) {
|
|
92
|
-
const d =
|
|
92
|
+
const d = parsed(iso);
|
|
93
93
|
if (!d)
|
|
94
94
|
return '';
|
|
95
95
|
return d.toLocaleString('ko-KR', {
|
|
@@ -110,7 +110,7 @@ export function formatDatetimeKo(iso) {
|
|
|
110
110
|
export function toKstHm(when) {
|
|
111
111
|
if (when === null || when === undefined || when === '')
|
|
112
112
|
return '';
|
|
113
|
-
const d =
|
|
113
|
+
const d = toInstant(when);
|
|
114
114
|
if (!d)
|
|
115
115
|
return '';
|
|
116
116
|
return new Intl.DateTimeFormat('en-GB', {
|
|
@@ -177,7 +177,7 @@ export function parseLocalYmd(ymd) {
|
|
|
177
177
|
export function toKstYmd(when) {
|
|
178
178
|
if (when === null || when === undefined || when === '')
|
|
179
179
|
return '';
|
|
180
|
-
const d =
|
|
180
|
+
const d = toInstant(when);
|
|
181
181
|
if (!d)
|
|
182
182
|
return '';
|
|
183
183
|
return new Intl.DateTimeFormat('fr-CA', { timeZone: 'Asia/Seoul' }).format(d);
|
|
@@ -191,7 +191,7 @@ export function toKstYmd(when) {
|
|
|
191
191
|
export function formatRelativeTime(input, t) {
|
|
192
192
|
if (!input)
|
|
193
193
|
return '';
|
|
194
|
-
const d =
|
|
194
|
+
const d = toInstant(input);
|
|
195
195
|
if (!d)
|
|
196
196
|
return '';
|
|
197
197
|
const then = d.getTime();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const isDev: boolean;
|
|
2
|
-
/**
|
|
3
|
-
export declare function devWarnOnce(
|
|
2
|
+
/** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
|
|
3
|
+
export declare function devWarnOnce(key: string, ...args: unknown[]): void;
|
|
4
4
|
/** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
|
|
5
5
|
export declare function __resetDevWarnOnce(): void;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* **왜 한 번만인가** — 컴포넌트는 다시 그려진다. 그릴 때마다 찍으면 콘솔이
|
|
9
9
|
* 넘쳐서 진짜 봐야 할 것이 묻힌다.
|
|
10
10
|
*/
|
|
11
|
-
const
|
|
11
|
+
const printed = new Set();
|
|
12
12
|
export const isDev = (() => {
|
|
13
13
|
try {
|
|
14
14
|
if (import.meta.env?.DEV)
|
|
@@ -27,14 +27,14 @@ export const isDev = (() => {
|
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
29
|
})();
|
|
30
|
-
/**
|
|
31
|
-
export function devWarnOnce(
|
|
32
|
-
if (!isDev ||
|
|
30
|
+
/** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
|
|
31
|
+
export function devWarnOnce(key, ...args) {
|
|
32
|
+
if (!isDev || printed.has(key))
|
|
33
33
|
return;
|
|
34
|
-
|
|
35
|
-
console.warn(
|
|
34
|
+
printed.add(key);
|
|
35
|
+
console.warn(...args);
|
|
36
36
|
}
|
|
37
37
|
/** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
|
|
38
38
|
export function __resetDevWarnOnce() {
|
|
39
|
-
|
|
39
|
+
printed.clear();
|
|
40
40
|
}
|