@ihabdevteam/core 0.315.0 → 0.318.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 +189 -0
- package/dist/components.cjs.js +182 -182
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +188 -188
- package/dist/ihabdevteam-core.d.ts +26 -4
- package/dist/packages/core/src/components/BarChart.js +16 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +2 -2
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/ListPopup.d.ts +12 -2
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +11 -6
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +13 -8
- package/dist/packages/core/src/components/Training/audioWaits.js +22 -4
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +32 -0
- package/dist/packages/core/src/components/Training/stepAnswerable.js +4 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +5 -1
- package/dist/packages/core/src/components/wordTableLayout.d.ts +13 -1
- package/dist/packages/core/src/components/wordTableLayout.js +13 -1
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +6 -1
- package/dist/packages/core/src/i18n/locales/en.json +6 -1
- package/dist/packages/core/src/i18n/locales/ko.json +6 -1
- package/dist/packages/core/src/utils/wornDate.d.ts +14 -2
- package/dist/packages/core/src/utils/wornDate.js +38 -3
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1669,7 +1669,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1669
1669
|
*
|
|
1670
1670
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1671
1671
|
*/
|
|
1672
|
-
export declare const CORE_VERSION = "0.
|
|
1672
|
+
export declare const CORE_VERSION = "0.318.0";
|
|
1673
1673
|
|
|
1674
1674
|
/** @public */
|
|
1675
1675
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -5036,8 +5036,18 @@ export declare interface PopupListProps<T extends {
|
|
|
5036
5036
|
* 누르는 순간 닫힌다. 고른 뒤 닫으려면 `onSelect` 안에서 직접 닫는다.
|
|
5037
5037
|
*/
|
|
5038
5038
|
keepOpenOnSelect?: boolean;
|
|
5039
|
-
/**
|
|
5040
|
-
|
|
5039
|
+
/**
|
|
5040
|
+
* 행 선택 콜백 — **안 주면 줄을 누를 수 없는 표가 된다**(말귀 요청 (120), 2026-10-12).
|
|
5041
|
+
*
|
|
5042
|
+
* 이 값이 그대로 `Table` 의 `onRowClick` 이 되고, `Table` 은 **그것이 있다는 것만 보고** 줄을
|
|
5043
|
+
* 꾸민다(손가락 커서 · `tabindex="0"` · *"줄을 누르면 자세히 봅니다"*). 그래서 **누를 것이 없는
|
|
5044
|
+
* 표도 빈 함수를 넘겨야** 했고, 그 꾸밈이 그대로 섰다 — 눈으로 보는 사람에게는 손가락 커서가,
|
|
5045
|
+
* 자판으로 쓰는 사람에게는 **줄마다 멈춤**이(스무 줄이면 스무 번), 낭독기에는 아무 일도 안 하는
|
|
5046
|
+
* 설명이 읽혔다(말귀 실측: 눌러도 아무 일도 안 난다).
|
|
5047
|
+
*
|
|
5048
|
+
* 안 주면 `Table` 의 `onRowClick` 도 비어 **그 꾸밈이 저절로 사라진다.**
|
|
5049
|
+
*/
|
|
5050
|
+
onSelect?: (item: T) => void;
|
|
5041
5051
|
/** 닫기 콜백 */
|
|
5042
5052
|
onClose: () => void;
|
|
5043
5053
|
/** 페이지당 항목 수 옵션. 미지정 시 기본 [10, 25, 50, 100] */
|
|
@@ -10494,7 +10504,19 @@ export declare function withStepTimes(payload: unknown, shownAt?: string, answer
|
|
|
10494
10504
|
/** text 뒤에 와/과를 붙여 반환한다. */
|
|
10495
10505
|
export declare function withWaGwa(text: string): string;
|
|
10496
10506
|
|
|
10497
|
-
/**
|
|
10507
|
+
/**
|
|
10508
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
10509
|
+
*
|
|
10510
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
10511
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
10512
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
10513
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
10514
|
+
*
|
|
10515
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
10516
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
10517
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
10518
|
+
* @public
|
|
10519
|
+
*/
|
|
10498
10520
|
export declare const WORD_LABEL_I18N_KEYS: {
|
|
10499
10521
|
readonly pos: Record<string, string>;
|
|
10500
10522
|
readonly wordType: Record<string, string>;
|
|
@@ -430,9 +430,24 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
430
430
|
const isLarge = size === 'large';
|
|
431
431
|
const sectionClassName = [isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined, className]
|
|
432
432
|
.filter(Boolean).join(' ') || undefined;
|
|
433
|
+
/**
|
|
434
|
+
* **상세보기 단추 이름에 그 카드 이름을 담는다**(말귀 요청 (119), 2026-10-12).
|
|
435
|
+
*
|
|
436
|
+
* 한 쪽에 이 차트가 여섯 장 서고 그중 셋이 이 단추를 단다. 이름이 늘 `상세보기` 한 가지라
|
|
437
|
+
* 낭독기로는 **"Details · Details · Details"** 뿐이어서 어느 카드의 것인지 가릴 길이 없었다
|
|
438
|
+
* (말귀 실측: 앱이 제 손으로 붙이는 고르개는 `Homework Progress view` 처럼 카드 이름을 담는데
|
|
439
|
+
* **코어가 세우는 셋만** 똑같았다).
|
|
440
|
+
*
|
|
441
|
+
* 이름은 바깥에서 더 받지 않는다 — **이미 `titleKey` 를 받고 있다.** 제목이 없을 때만 예전
|
|
442
|
+
* 글자로 떨어뜨려 쓰던 자리가 안 깨지게 한다.
|
|
443
|
+
*/
|
|
444
|
+
const 카드이름 = titleKey ? t(titleKey) : '';
|
|
445
|
+
const 상세이름 = 카드이름
|
|
446
|
+
? t('trainingStatus.detailFor', { name: 카드이름, defaultValue: '{{name}} 상세보기' })
|
|
447
|
+
: t('trainingStatus.detail', '상세보기');
|
|
433
448
|
return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, onClick: onClick, ariaLabel: cardAriaLabel, tooltip: cardTooltip, headSegment: headSegment,
|
|
434
449
|
/* 좁아 고르개가 '⋯' 로 접히면 상세보기도 그 메뉴로 들어간다 — 머리에 단추 둘이 남지 않게 */
|
|
435
|
-
headMenuItems: onDetail ? [{ label:
|
|
450
|
+
headMenuItems: onDetail ? [{ label: 상세이름, icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), titleLevel: titleLevel, titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : ({ collapsed }) => _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && !collapsed && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: detailSize, type: "dot", icon: "list", ariaLabel: 상세이름, showTooltipInfo: 상세이름, onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
|
|
436
451
|
// 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
|
|
437
452
|
_jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
|
|
438
453
|
// 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
|
|
@@ -5,10 +5,10 @@ import Button from './Button';
|
|
|
5
5
|
import { formatWornDate } from '../utils/wornDate';
|
|
6
6
|
import './HearingDeviceInfo.css';
|
|
7
7
|
export default function HearingDeviceInfo({ devices, onAddDevice, onOpenList, onRemoveDevice }) {
|
|
8
|
-
const { t } = useTranslation();
|
|
8
|
+
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
9
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 => {
|
|
10
10
|
const d = devices[side];
|
|
11
11
|
const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
|
|
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) && _jsx("span", { className: "hearing-device-info__device-date", children: formatWornDate(d.wornYear, d.wornMonth, d.wornDay) })] }), _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));
|
|
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
|
}
|
|
@@ -151,7 +151,7 @@ function 메타(d) {
|
|
|
151
151
|
return [...new Set(조각)].join(' · ');
|
|
152
152
|
}
|
|
153
153
|
export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
154
|
-
const { t } = useTranslation();
|
|
154
|
+
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
155
155
|
const SIDES = ['좌', '우'];
|
|
156
156
|
return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
|
|
157
157
|
const d = devices[side];
|
|
@@ -162,6 +162,6 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
|
|
|
162
162
|
(잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
|
|
163
163
|
_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
|
|
164
164
|
// 좌·우 두 단추가 같은 이름이라 어느 쪽 기기를 내리는지 안 들렸다 — 이름에만 쪽을 붙인다(툴팁은 그대로)
|
|
165
|
-
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay) && (_jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: formatWornDate(d.wornYear, d.wornMonth, d.wornDay) }) }))] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
165
|
+
ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) && (_jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: formatWornDate(d.wornYear, d.wornMonth, d.wornDay, i18n.language) }) }))] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
|
|
166
166
|
}) }));
|
|
167
167
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useId } from 'react';
|
|
4
4
|
import { getErrorMessage } from '../../utils/errorMessage';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import Popup from './Popup';
|
|
@@ -12,6 +12,8 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
12
12
|
const [loading, setLoading] = useState(false);
|
|
13
13
|
const [error, setError] = useState(null);
|
|
14
14
|
const inputRef = useRef(null);
|
|
15
|
+
/* 글귀와 칸을 잇는 id — 한 쪽에 이 창이 둘 설 일은 없지만 id 는 문서에서 하나여야 한다 */
|
|
16
|
+
const 글귀id = useId();
|
|
15
17
|
// Popup 자체 포커스 로직(패널 내 첫 focusable = 헤더 닫기 버튼)보다 먼저 실행되도록
|
|
16
18
|
// 자식 effect가 부모보다 먼저 도는 순서를 이용해 입력창을 선점 포커스한다.
|
|
17
19
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
@@ -44,5 +46,5 @@ export default function CouponInputPopup({ onApply, onCancel }) {
|
|
|
44
46
|
return (_jsx(Popup, { className: "coupon-input-popup", title: t('usage.couponButton', '쿠폰 입력'), size: "small", onClose: onCancel, closeDisabled: loading, buttons: [
|
|
45
47
|
{ label: t('common.cancel', '취소'), variant: 'default', disabled: loading, onClick: ({ closeWithAnimation }) => closeWithAnimation() },
|
|
46
48
|
{ label: t('coupons.applyButton', '적용'), variant: 'main', disabled: !code.trim() || loading, onClick: ({ closeWithAnimation }) => void handleApply(closeWithAnimation) },
|
|
47
|
-
], children: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, className: "coupon-input-popup__input body-2", "aria-label": t('coupons.input.label', '쿠폰 코드'), placeholder: t('coupons.input.placeholder', '쿠폰 코드 입력'), value: code, onChange: handleChange, onKeyDown: e => handleKey(e, closeWithAnimation), disabled: loading, autoComplete: "off", spellCheck: false }), error && _jsx("p", { className: "coupon-input-popup__error caption-1", role: "alert", children: error })] })) }));
|
|
49
|
+
], children: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, className: "coupon-input-popup__input body-2", "aria-label": t('coupons.input.label', '쿠폰 코드'), "aria-invalid": error ? true : undefined, "aria-errormessage": error ? 글귀id : undefined, "aria-describedby": error ? 글귀id : undefined, placeholder: t('coupons.input.placeholder', '쿠폰 코드 입력'), value: code, onChange: handleChange, onKeyDown: e => handleKey(e, closeWithAnimation), disabled: loading, autoComplete: "off", spellCheck: false }), error && _jsx("p", { id: 글귀id, className: "coupon-input-popup__error caption-1", role: "alert", children: error })] })) }));
|
|
48
50
|
}
|
|
@@ -30,7 +30,7 @@ function 정렬값(r, key) {
|
|
|
30
30
|
return wornDateSortKey(r.wornYear, r.wornMonth, r.wornDay);
|
|
31
31
|
}
|
|
32
32
|
export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }) {
|
|
33
|
-
const { t } = useTranslation();
|
|
33
|
+
const { t, i18n } = useTranslation(); /* i18n.language — 착용일이 보는 말을 따른다((118)) */
|
|
34
34
|
const 방향이름 = (side) => side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
|
|
35
35
|
/* 머리행을 누르면 그 칸으로 정렬한다 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
|
|
36
36
|
쪽넘김보다 **앞에서** 정렬한다(훅에 이미 정렬된 줄을 건넨다). */
|
|
@@ -88,7 +88,7 @@ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onW
|
|
|
88
88
|
{ content: _jsx("span", { children: row.fitType || '-' }) },
|
|
89
89
|
{ content: _jsx("span", { children: row.productName || '-' }) },
|
|
90
90
|
{
|
|
91
|
-
content: _jsx("span", { className: "dlp-date", children: formatWornDate(row.wornYear, row.wornMonth, row.wornDay) || '-' }),
|
|
91
|
+
content: _jsx("span", { className: "dlp-date", children: formatWornDate(row.wornYear, row.wornMonth, row.wornDay, i18n.language) || '-' }),
|
|
92
92
|
},
|
|
93
93
|
/* 삭제를 넘겨준 화면에서만 이 칸이 선다 — 안 넘기면 칸 자체가 없어,
|
|
94
94
|
못 하는 일을 내밀지 않는다. 눌림은 줄로 새지 않게 막는다(줄을 누르면
|
|
@@ -52,8 +52,18 @@ export interface PopupListProps<T extends {
|
|
|
52
52
|
* 누르는 순간 닫힌다. 고른 뒤 닫으려면 `onSelect` 안에서 직접 닫는다.
|
|
53
53
|
*/
|
|
54
54
|
keepOpenOnSelect?: boolean;
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 행 선택 콜백 — **안 주면 줄을 누를 수 없는 표가 된다**(말귀 요청 (120), 2026-10-12).
|
|
57
|
+
*
|
|
58
|
+
* 이 값이 그대로 `Table` 의 `onRowClick` 이 되고, `Table` 은 **그것이 있다는 것만 보고** 줄을
|
|
59
|
+
* 꾸민다(손가락 커서 · `tabindex="0"` · *"줄을 누르면 자세히 봅니다"*). 그래서 **누를 것이 없는
|
|
60
|
+
* 표도 빈 함수를 넘겨야** 했고, 그 꾸밈이 그대로 섰다 — 눈으로 보는 사람에게는 손가락 커서가,
|
|
61
|
+
* 자판으로 쓰는 사람에게는 **줄마다 멈춤**이(스무 줄이면 스무 번), 낭독기에는 아무 일도 안 하는
|
|
62
|
+
* 설명이 읽혔다(말귀 실측: 눌러도 아무 일도 안 난다).
|
|
63
|
+
*
|
|
64
|
+
* 안 주면 `Table` 의 `onRowClick` 도 비어 **그 꾸밈이 저절로 사라진다.**
|
|
65
|
+
*/
|
|
66
|
+
onSelect?: (item: T) => void;
|
|
57
67
|
/** 닫기 콜백 */
|
|
58
68
|
onClose: () => void;
|
|
59
69
|
/** 페이지당 항목 수 옵션. 미지정 시 기본 [10, 25, 50, 100] */
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import TrainingStep from './TrainingStep';
|
|
6
|
+
import { canAnswerStep } from './stepAnswerable';
|
|
6
7
|
import TrainingWordCard from './TrainingWordCard';
|
|
7
8
|
import './TrainingStep.css';
|
|
8
9
|
import './TrainingStepCard.css';
|
|
@@ -108,11 +109,12 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
|
|
|
108
109
|
}
|
|
109
110
|
}, [playingAll, currentState, shown.length]);
|
|
110
111
|
const handleSelect = useCallback((item, i) => {
|
|
111
|
-
/*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
/* **한 번 고른 뒤에도 고칠 수 있다**(말귀 요청 (201), QA 버그 2026-10-12).
|
|
113
|
+
전에는 `!== 'active'` 로 막았는데, 고르는 순간 `onDone` 이 나가 단계가 넘어가므로
|
|
114
|
+
**자기 첫 고름에 자기가 막혔다** — 보기 셋에서 잘못 눌러도 바꿀 길이 없었다.
|
|
115
|
+
그 막이의 까닭(화면 선택은 바뀌는데 점수는 그대로)은 **아래에서 `onDone` 을 늘 보내
|
|
116
|
+
메운다** — 고치면 점수가 따라간다. 닿지 않은 단계만 막는다(`stepAnswerable`). */
|
|
117
|
+
if (!canAnswerStep(currentState))
|
|
116
118
|
return;
|
|
117
119
|
setSelectedIndex(i);
|
|
118
120
|
const payload = {
|
|
@@ -126,7 +128,10 @@ export default function TrainingStepCard({ index, stepIndex, isLast = false, loc
|
|
|
126
128
|
onChange(payload);
|
|
127
129
|
}
|
|
128
130
|
catch (e) { }
|
|
129
|
-
|
|
131
|
+
/* **고친 답도 보낸다** — 이것이 옛 막이의 까닭(화면만 바뀌고 점수는 그대로)을 메우는
|
|
132
|
+
자리다. 러너가 차례를 `Math.max(prev.stepIndex, index + 1)` 로 올리므로 이미 지난
|
|
133
|
+
단계가 다시 알려도 **앞으로 안 뛴다**(재서 확인했다 — `stepAnswerable` 머리말). */
|
|
134
|
+
if (typeof onDone === 'function') {
|
|
130
135
|
try {
|
|
131
136
|
onDone(payload);
|
|
132
137
|
}
|
|
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import Button from '../Button';
|
|
6
6
|
import "./TrainingStep.css";
|
|
7
7
|
import TrainingStep from "./TrainingStep";
|
|
8
|
+
import { canAnswerStep } from './stepAnswerable';
|
|
8
9
|
import TrainingStepTextBox from "./TrainingStepTextBox";
|
|
9
10
|
/** @public */
|
|
10
11
|
export default function TrainingStepConfirmWithText({ index, stepIndex, isLast = false, lockedHint, title, onDone, onChange, context, demoSelected }) {
|
|
@@ -51,27 +52,31 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
51
52
|
: { value: payload, __ts: Date.now() };
|
|
52
53
|
if (typeof onChange === 'function')
|
|
53
54
|
onChange(p);
|
|
54
|
-
|
|
55
|
+
/* **고친 답도 보낸다**((201) 을 이 자리까지, 말귀 실측 2026-10-12). 부르는 곳은 아래 답 길
|
|
56
|
+
셋뿐이고(소리 끝남에는 안 걸려 있다) 러너가 차례를 `Math.max` 로 올리므로 앞으로 안 뛴다. */
|
|
57
|
+
if (canAnswerStep(currentState) && typeof onDone === 'function')
|
|
55
58
|
onDone(p);
|
|
56
59
|
}, [onChange, currentState, onDone]);
|
|
57
60
|
const handleSelectFalse = useCallback(() => {
|
|
58
|
-
/*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/* **한 번 고른 뒤에도 고칠 수 있다** — (201) 과 같은 꼴이고 **이 자리는 한 가지가 더 나빴다**
|
|
62
|
+
(말귀 실측 2026-10-12, 소음 하 문장 듣기): `disabled` 가 `false` 라 **막혀 보이지도 않는다** —
|
|
63
|
+
누를 수 있게 생긴 단추를 눌렀는데 아무 일도 안 나면 쓰는 사람은 **자기가 잘못 눌렀다고 여기고
|
|
64
|
+
몇 번 더 누른다.** 그리고 여기는 `확인` 단추가 없어 *둘 중 하나를 누르는 것이 곧 답*이라,
|
|
65
|
+
여럿 고르기(`TrainingStepConfirm`)의 면제가 안 걸린다. 오늘의 듣기 **첫 칸**이다.
|
|
66
|
+
옛 막이의 까닭(화면만 바뀌고 점수는 그대로)은 `handleEndedLocal` 이 **고친 답도 보내** 메운다. */
|
|
67
|
+
if (!canAnswerStep(currentState))
|
|
63
68
|
return;
|
|
64
69
|
setSelectedAnswer(false);
|
|
65
70
|
handleEndedLocal(false);
|
|
66
71
|
}, [handleEndedLocal, currentState]);
|
|
67
72
|
const handleSelectTrue = useCallback(() => {
|
|
68
|
-
if (currentState
|
|
73
|
+
if (!canAnswerStep(currentState))
|
|
69
74
|
return;
|
|
70
75
|
setSelectedAnswer(true);
|
|
71
76
|
handleEndedLocal(true);
|
|
72
77
|
}, [handleEndedLocal, currentState]);
|
|
73
78
|
return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, lockedHint: lockedHint, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
|
|
74
|
-
if (currentState
|
|
79
|
+
if (!canAnswerStep(currentState))
|
|
75
80
|
return;
|
|
76
81
|
setSelectedAnswer(i);
|
|
77
82
|
if (handleEndedLocal)
|
|
@@ -16,10 +16,28 @@ export function measureAudioDuration(url, opts) {
|
|
|
16
16
|
return new Promise((resolve, reject) => {
|
|
17
17
|
const ta = document.createElement('audio');
|
|
18
18
|
let done = false;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/**
|
|
20
|
+
* **소스를 비우는 바른 꼴**(말귀 요청 (301), 실측 2026-10-12).
|
|
21
|
+
*
|
|
22
|
+
* `src = ''` 는 *"소스 없음"* 이 아니라 **"쪽 주소를 소스로 삼아라"** 다. 그래서 뒷정리가
|
|
23
|
+
* 끝난 뒤 브라우저가 쪽을 한 번 더 받으러 가고 `error code 4 "Empty src attribute"` 를 낸다 —
|
|
24
|
+
* **실패가 아니라 뒷정리가 낸 오류**이고, `done` 깃발 덕에 사람에게는 아무 일도 없지만
|
|
25
|
+
* **문장을 불러올 때마다 한 번씩** 쌓인다. 말귀 실측:
|
|
26
|
+
*
|
|
27
|
+
* 17888 loadedmetadata src=F03023.mp3 ← 길이 재기는 성공
|
|
28
|
+
* 17888 abort src=(없음) ← 뒷정리 직후
|
|
29
|
+
* 17894 error code 4 "MEDIA_ELEMENT_ERROR: Empty src attribute"
|
|
30
|
+
*
|
|
31
|
+
* **값은 콘솔이 깨끗해지는 것만이 아니다** — 말귀가 밤새 소리를 못 쟀던 까닭 중 하나가 이 줄이다.
|
|
32
|
+
* 미디어 오류가 이미 쌓여 있으니 *"원래 이런가 보다"* 로 지나갔다. **가짜 오류는 진짜 오류를 가린다.**
|
|
33
|
+
*/
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
try {
|
|
36
|
+
ta.removeAttribute('src');
|
|
37
|
+
ta.load();
|
|
38
|
+
}
|
|
39
|
+
catch (_) { }
|
|
40
|
+
};
|
|
23
41
|
const finishOk = () => {
|
|
24
42
|
if (done)
|
|
25
43
|
return;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **지금 답을 고칠 수 있나** — 단계 상태 하나로 가른다.
|
|
3
|
+
*
|
|
4
|
+
* 이 결정이 여섯 자리에 `currentState !== 'active'` 로 적혀 있었다(`TrainingStepCard` ·
|
|
5
|
+
* `TrainingStepConfirm`×2 · `TrainingStepConfirmWithText`×3 · `TrainingStepCardOrder` ·
|
|
6
|
+
* `TrainingStepOrderList`). 그 막이는 **까닭이 있어 들어왔다** — 단추는 `!== 'disabled'` 로 열려
|
|
7
|
+
* 있는데 기록은 `=== 'active'` 일 때만 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
|
|
8
|
+
*
|
|
9
|
+
* 그런데 그 막이가 **자기 첫 고름에 자기를 막는다**(말귀 요청 (201), QA 버그 보고 2026-10-12):
|
|
10
|
+
* 고르면 `onDone` 이 나가고 단계가 넘어가 `currentState` 가 `'done'` 이 된다 — 그 뒤로는
|
|
11
|
+
* **잘못 눌러도 바꿀 길이 없다.** 보기 셋에서 손이 미끄러지면 그 판은 그대로 끝난다.
|
|
12
|
+
*
|
|
13
|
+
* **둘 다 틀렸고 바라는 것은 셋째다 — 선택도 바뀌고 점수도 따라간다.** 그래서 가름을
|
|
14
|
+
* *"지금 서 있는 단계인가"* 에서 *"아직 닿지 않은 단계인가"* 로 옮긴다:
|
|
15
|
+
*
|
|
16
|
+
* active 고를 수 있다 — 처음 고르는 자리
|
|
17
|
+
* done 고칠 수 있다 — 고치면 `onDone` 이 다시 나가 점수가 따라간다
|
|
18
|
+
* disabled 막는다 — 아직 닿지 않았거나 되돌아본 판이다
|
|
19
|
+
*
|
|
20
|
+
* **두 번째 `onDone` 이 앞으로 안 뛰는 것을 확인했다**(그래서 이 고침이 안전하다):
|
|
21
|
+
* 러너가 `stepIndex: Math.max(prev.stepIndex, index + 1)` 로 올리므로, 이미 지난 단계가 다시
|
|
22
|
+
* 알려도 차례는 그대로다(`TrainingPagePopup.tsx`). 재지 않고 고쳤으면 **고친 답이 다음 단계를
|
|
23
|
+
* 한 칸 더 밀** 수도 있었다.
|
|
24
|
+
*
|
|
25
|
+
* **지금 쓰는 곳은 `TrainingStepCard` 하나다** — QA 가 올린 그 자리다. 나머지 넷도 같은 꼴로
|
|
26
|
+
* 보이지만(고르면 넘어가고 그 뒤 못 고친다) **화면에서 재 보지 않고 넷을 함께 바꾸지 않는다** —
|
|
27
|
+
* 순서 매기기(`CardOrder`·`OrderList`)는 "고침" 의 뜻이 다를 수 있고, 여럿 고르기(`Confirm`)는
|
|
28
|
+
* `확인` 단추가 이미 그 틈을 메운다. 셈은 적어 두었으니 그 넷은 **재고 나서** 옮긴다.
|
|
29
|
+
*/
|
|
30
|
+
export type 단계상태 = 'disabled' | 'done' | 'active';
|
|
31
|
+
/** 답을 고를 수 있나 — 닿지 않은 단계(`disabled`)만 막는다. @public */
|
|
32
|
+
export declare function canAnswerStep(state: 단계상태 | undefined): boolean;
|
|
@@ -168,9 +168,13 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, sequence
|
|
|
168
168
|
noiseAudioRef.current = noiseEl;
|
|
169
169
|
return () => {
|
|
170
170
|
cancelNoiseFade();
|
|
171
|
+
/* **`src = ''` 가 아니다** — 그것은 "쪽 주소를 소스로 삼아라" 라서 `error code 4` 를 낸다((301)).
|
|
172
|
+
소음은 훈련마다 서고 걷히니 그 가짜 오류도 그만큼 쌓인다 — 말귀가 소리를 재려는데 콘솔이
|
|
173
|
+
이미 미디어 오류로 덮여 있던 자리다. */
|
|
171
174
|
try {
|
|
172
175
|
noiseEl.pause();
|
|
173
|
-
noiseEl.src
|
|
176
|
+
noiseEl.removeAttribute('src');
|
|
177
|
+
noiseEl.load();
|
|
174
178
|
}
|
|
175
179
|
catch (e) { }
|
|
176
180
|
noiseAudioRef.current = null;
|
|
@@ -82,7 +82,19 @@ export declare function posLabels(t: 번역): Record<string, string>;
|
|
|
82
82
|
* @public
|
|
83
83
|
*/
|
|
84
84
|
export declare function wordTypeLabels(t: 번역): Record<string, string>;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
87
|
+
*
|
|
88
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
89
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
90
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
91
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
92
|
+
*
|
|
93
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
94
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
95
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
96
|
+
* @public
|
|
97
|
+
*/
|
|
86
98
|
export declare const WORD_LABEL_I18N_KEYS: {
|
|
87
99
|
readonly pos: Record<string, string>;
|
|
88
100
|
readonly wordType: Record<string, string>;
|
|
@@ -151,7 +151,19 @@ export function posLabels(t) {
|
|
|
151
151
|
export function wordTypeLabels(t) {
|
|
152
152
|
return Object.fromEntries(Object.entries(WORD_TYPE_LABEL).map(([k, v]) => [k, WORD_TYPE_I18N_KEY[k] ? t(`wordSearch.filter.type.${WORD_TYPE_I18N_KEY[k]}`, { defaultValue: v }) : v]));
|
|
153
153
|
}
|
|
154
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* 약호 → i18n 열쇠 조각 표(품사·어종).
|
|
156
|
+
*
|
|
157
|
+
* **차례가 뜻을 갖는다**(2026-10-10, 말귀_2 가 쓰기 시작한 뒤 적음). 처음에는 *사전 덮임을 세는*
|
|
158
|
+
* 데 쓰라고 내보냈는데(약호마다 말이 있나), 말귀가 **제안 폼 고르개의 차례**를 여기서 가져갔다 —
|
|
159
|
+
* *"말과 무관한 것은 말에서 안 꺼낸다"* 는 가름이고 맞다. 그래서 이 표는 이제 **두 가지를 뜻한다**:
|
|
160
|
+
* *어떤 약호가 있나* 와 *어떤 차례로 보이나.*
|
|
161
|
+
*
|
|
162
|
+
* **차례는 한국어 사전 차례다**(명 · 동 · 형 · 부 · 수 · 관 · 감 · 조 · 접 · 의 · 대 · 보동 · 보형).
|
|
163
|
+
* 약호를 **더하는 것은 뒤에 붙이면** 쓰는 쪽이 안 흔들리지만, **차례를 바꾸는 것은 쓰는 쪽 화면을
|
|
164
|
+
* 바꾼다** — 판 올릴 때 알려야 한다. *한 칸이 두 가지를 뜻하는 것은 어긋나는 날까지 안 보인다.*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
155
167
|
export const WORD_LABEL_I18N_KEYS = { pos: POS_I18N_KEY, wordType: WORD_TYPE_I18N_KEY };
|
|
156
168
|
/**
|
|
157
169
|
* 슬래시로 묶인 복합 약호(예: '명/관', '동/보동')를 라벨맵으로 각각 펼쳐 다시 '/'로 잇는다.
|
|
@@ -414,7 +414,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
414
414
|
// label만 오버라이드하고 나머지(items/allOption 등)는 공용 빌더 그대로 사용.
|
|
415
415
|
sections.push({
|
|
416
416
|
...buildGradeSection(t, attrKeys, {
|
|
417
|
-
|
|
417
|
+
/* **등급마다 무엇인지 적는다**(말귀 요청 (205), 사장님이 주신 글 — 2026-10-12). 전에는
|
|
418
|
+
*"국립국어원의 기초 어휘 등급이 결과에 반영됩니다"* 한 줄이라, 1등급이 쉬운 쪽인지
|
|
419
|
+
어려운 쪽인지도 알 수 없었다. 앱 두 화면에는 이미 이 다섯 줄이 들어가 있어
|
|
420
|
+
(앱 열쇠 `wordSearch.filter.gradeInfo`), 이 자리만 한 줄로 남아 **세 화면이 다른 말**을
|
|
421
|
+
하고 있었다 — 글자까지 같은 것을 쓴다. */
|
|
422
|
+
labelInfo: infoAction([1, 2, 3, 4, 5].map(n => t(`wordPairs.filter.difficultyInfo${n}`))),
|
|
418
423
|
}),
|
|
419
424
|
label: LABELS.gradeLabel,
|
|
420
425
|
});
|
|
@@ -1145,7 +1145,11 @@
|
|
|
1145
1145
|
],
|
|
1146
1146
|
"orderAny": "Any",
|
|
1147
1147
|
"orderMatch": "Match",
|
|
1148
|
-
"
|
|
1148
|
+
"difficultyInfo1": "Level 1 — everyday vocabulary acquired before school age and used for life",
|
|
1149
|
+
"difficultyInfo2": "Level 2 — difficulty of grades 1–2",
|
|
1150
|
+
"difficultyInfo3": "Level 3 — difficulty of grades 3–4",
|
|
1151
|
+
"difficultyInfo4": "Level 4 — difficulty of grades 5–6",
|
|
1152
|
+
"difficultyInfo5": "No level — frequent everyday words outside the national standard list, plus coinages and loanwords",
|
|
1149
1153
|
"selectLabel": "Select",
|
|
1150
1154
|
"onlyHasImage": "Only words with images",
|
|
1151
1155
|
"onlySameStructure": "Only same phoneme structure"
|
|
@@ -1363,6 +1367,7 @@
|
|
|
1363
1367
|
"title": "Linked Visit Records"
|
|
1364
1368
|
},
|
|
1365
1369
|
"detail": "Details",
|
|
1370
|
+
"detailFor": "{{name}} details",
|
|
1366
1371
|
"trainingType": {
|
|
1367
1372
|
"LISTEN_IN_NOISE": "Sentence in Noise",
|
|
1368
1373
|
"SHORT_STORY": "Short Story",
|
|
@@ -1134,7 +1134,11 @@
|
|
|
1134
1134
|
],
|
|
1135
1135
|
"orderAny": "무관",
|
|
1136
1136
|
"orderMatch": "일치",
|
|
1137
|
-
"
|
|
1137
|
+
"difficultyInfo1": "1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘",
|
|
1138
|
+
"difficultyInfo2": "2등급 : 초등학교 1-2학년 난이도 어휘",
|
|
1139
|
+
"difficultyInfo3": "3등급 : 초등학교 3-4학년 난이도 어휘",
|
|
1140
|
+
"difficultyInfo4": "4등급 : 초등학교 5-6학년 난이도 어휘",
|
|
1141
|
+
"difficultyInfo5": "등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어",
|
|
1138
1142
|
"selectLabel": "선택",
|
|
1139
1143
|
"onlyHasImage": "이미지 있는 단어만",
|
|
1140
1144
|
"onlySameStructure": "음소 구조 같은 단어만"
|
|
@@ -1350,6 +1354,7 @@
|
|
|
1350
1354
|
"title": "방문 연동 기록"
|
|
1351
1355
|
},
|
|
1352
1356
|
"detail": "상세보기",
|
|
1357
|
+
"detailFor": "{{name}} 상세보기",
|
|
1353
1358
|
"trainingType": {
|
|
1354
1359
|
"LISTEN_IN_NOISE": "소음 하 문장",
|
|
1355
1360
|
"SHORT_STORY": "짧은 이야기",
|
|
@@ -5,8 +5,20 @@
|
|
|
5
5
|
* `2026.null.null` 이 찍혔고, 정렬 열쇠를 만들 때는 `null.padStart` 에서 표가 통째로 터졌다. 빈 값은 '' 로 다룬다.
|
|
6
6
|
*/
|
|
7
7
|
type 칸값 = string | number | null | undefined;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* 화면에 적을 착용일.
|
|
10
|
+
*
|
|
11
|
+
* **`lang` 을 주면 보는 말을 따른다**(말귀 요청 (118), 사장님 지시 2026-10-11 *"날짜 꼴도 하나로
|
|
12
|
+
* 통일해"*). 한국어 `2026년 9월 9일` · 영어 `Sep 9, 2026` — 앱이 다른 화면 날짜를 모은 그 꼴이고,
|
|
13
|
+
* 코어 `formatDateLocale` 이 내는 것과 같다(글자를 또 짓지 않고 그것에 넘긴다).
|
|
14
|
+
*
|
|
15
|
+
* **연·월·일이 다 있을 때만** 그렇게 한다. 반쪽 값(연도만 · 연월만)은 그 말로 옮길 수가 없어
|
|
16
|
+
* 예전처럼 점으로 잇는다(`2026` · `2026.9`) — 서버에 월·일이 `null` 로 오는 옛 기록이 있다.
|
|
17
|
+
*
|
|
18
|
+
* `lang` 을 안 주면 **예전 그대로** 점 꼴이다(`2026.09.09`) — 부르는 자리가 말을 모르는 곳도 있을
|
|
19
|
+
* 수 있어 깨지지 않게 둔다. 화면에 그리는 자리는 주는 쪽이 맞다.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatWornDate(year: 칸값, month: 칸값, day: 칸값, lang?: string): string;
|
|
10
22
|
/** 착용일 정렬 열쇠 — 자리수를 채워(`2026-05-02`) 글자 비교가 날짜 차례와 같게. 연도가 없으면 ''. */
|
|
11
23
|
export declare function wornDateSortKey(year: 칸값, month: 칸값, day: 칸값): string;
|
|
12
24
|
export {};
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
+
import { formatDateLocale } from './date';
|
|
1
2
|
const 글자 = (v) => (v == null ? '' : String(v).trim());
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* 화면에 적을 착용일.
|
|
5
|
+
*
|
|
6
|
+
* **`lang` 을 주면 보는 말을 따른다**(말귀 요청 (118), 사장님 지시 2026-10-11 *"날짜 꼴도 하나로
|
|
7
|
+
* 통일해"*). 한국어 `2026년 9월 9일` · 영어 `Sep 9, 2026` — 앱이 다른 화면 날짜를 모은 그 꼴이고,
|
|
8
|
+
* 코어 `formatDateLocale` 이 내는 것과 같다(글자를 또 짓지 않고 그것에 넘긴다).
|
|
9
|
+
*
|
|
10
|
+
* **연·월·일이 다 있을 때만** 그렇게 한다. 반쪽 값(연도만 · 연월만)은 그 말로 옮길 수가 없어
|
|
11
|
+
* 예전처럼 점으로 잇는다(`2026` · `2026.9`) — 서버에 월·일이 `null` 로 오는 옛 기록이 있다.
|
|
12
|
+
*
|
|
13
|
+
* `lang` 을 안 주면 **예전 그대로** 점 꼴이다(`2026.09.09`) — 부르는 자리가 말을 모르는 곳도 있을
|
|
14
|
+
* 수 있어 깨지지 않게 둔다. 화면에 그리는 자리는 주는 쪽이 맞다.
|
|
15
|
+
*/
|
|
16
|
+
export function formatWornDate(year, month, day, lang) {
|
|
17
|
+
const y = 글자(year), m = 글자(month), d = 글자(day);
|
|
18
|
+
if (lang && y && m && d) {
|
|
19
|
+
/* 자리수를 채워 ISO 로 만든다 — `formatDateLocale` 이 읽는 꼴. */
|
|
20
|
+
const iso = `${y.padStart(4, '0')}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
|
21
|
+
/**
|
|
22
|
+
* **되돌아오는지 본다 — `new Date` 는 없는 날짜를 조용히 굴린다.**
|
|
23
|
+
* `2026-09-31` → **2026-10-01**, `2026-02-30` → **2026-03-02**(실측). 그러면 자료에 적힌 날과
|
|
24
|
+
* **다른 날**이 화면에 선다 — 잘못된 값을 보여 주는 것보다 **조용히 고쳐 보여 주는 것**이 나쁘다
|
|
25
|
+
* (사람이 틀린 줄 모른다). 지은 ISO 가 그대로 돌아올 때만 말로 옮기고, 아니면 아래 점 꼴로
|
|
26
|
+
* 자료에 적힌 그대로 보여 준다.
|
|
27
|
+
*
|
|
28
|
+
* *이 가름은 못이 안 울어서 찾았다* — `&& m && d` 조건만 깨뜨려 봤을 때는 조용했다(그때는
|
|
29
|
+
* `2026-00-00` 이 **못 읽는 날짜**라 저절로 떨어졌다). 못이 안 울면 **무엇이 막고 있나**를 묻는다.
|
|
30
|
+
*/
|
|
31
|
+
const 날 = new Date(iso);
|
|
32
|
+
const 되돌아옴 = !Number.isNaN(날.getTime()) && 날.toISOString().slice(0, 10) === iso;
|
|
33
|
+
if (되돌아옴) {
|
|
34
|
+
const 말대로 = formatDateLocale(iso, lang);
|
|
35
|
+
if (말대로)
|
|
36
|
+
return 말대로;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const 조각 = [y, m, d];
|
|
5
40
|
while (조각.length > 0 && 조각[조각.length - 1] === '')
|
|
6
41
|
조각.pop();
|
|
7
42
|
return 조각.join('.');
|