@ihabdevteam/core 0.257.0 → 0.259.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.
@@ -1651,7 +1651,7 @@ export declare function copyText(text: string): Promise<boolean>;
1651
1651
  *
1652
1652
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1653
1653
  */
1654
- export declare const CORE_VERSION = "0.257.0";
1654
+ export declare const CORE_VERSION = "0.259.0";
1655
1655
 
1656
1656
  /** @public */
1657
1657
  declare const coreDefault: Record<string, unknown>;
@@ -8654,6 +8654,23 @@ export declare interface TrainingItemResult {
8654
8654
  selected: string | null;
8655
8655
  /** 맞았나. 정오답 개념이 없는 유형이면 null */
8656
8656
  correct: boolean | null;
8657
+ /**
8658
+ * 이 문항이 **보이거나 들리기 시작한 때**(ISO 8601) — 소리 문항이면 재생 시작. 모르면 `null`.
8659
+ *
8660
+ * **반응 시간을 잴 길이 없었다**(말귀 요청 (68), 2026-10-02). 앱은 판이 끝날 때 문항을
8661
+ * 한꺼번에 적으므로 행의 `answered_at` 은 **"적은 때"** 다 — 말귀 실측(19행): 한 판 안 두
8662
+ * 문항의 차가 중앙값 **0.049초**, 최대 0.216초. 푼 간격이 아니라 **줄을 쓴 간격**이다.
8663
+ *
8664
+ * 찍는 것은 **화면의 몫**이다 — 코어는 `buildResults` 가 돌려준 것을 추릴 뿐이고, 한 쪽이
8665
+ * 곧 한 문항도 아니다(쪽 하나에 문항이 여럿 설 수 있다). 코어가 할 일은 **버리지 않는
8666
+ * 것**이었다: 이 칸이 없던 때는 화면이 찍어 넣어도 `toItemResults` 가 조용히 떨어뜨렸다.
8667
+ *
8668
+ * 다시 듣기를 눌러도 **처음 보인 때로 둔다** — 반응 시간에 다시 듣기 노력이 들어가는 쪽이
8669
+ * 실제에 가깝다(말귀와 같은 판단).
8670
+ */
8671
+ shownAt: string | null;
8672
+ /** 답을 고른(확정한) 때(ISO 8601). 안 골랐으면 `null`. 위 `shownAt` 머리글 참고 */
8673
+ answeredAt: string | null;
8657
8674
  }
8658
8675
 
8659
8676
  /** @public */
@@ -10183,6 +10200,25 @@ export declare function withEuRo(text: string): string;
10183
10200
  /** text 뒤에 문맥에 맞는 이/가를 붙여 반환한다. */
10184
10201
  export declare function withIga(text: string): string;
10185
10202
 
10203
+ /**
10204
+ * @public 스텝 payload 에 **때 둘을 얹는다** — 보인 때 · 답한 때(말귀 요청 (68) 후속, 2026-10-02).
10205
+ *
10206
+ * `TrainingPagePopup` 의 저장 길목(`setScenarioAnswer`)이 부른다. **셈을 조각 밖으로 뺀 것은
10207
+ * 실제 값으로 잴 수 있게 하려는 것이다** — 조각 안에 두면 "소스에 그 글자가 있나" 로밖에
10208
+ * 못 묻는데, 그런 시험은 셈이 틀려도 초록이다.
10209
+ *
10210
+ * - `answeredAt` 은 `__ts`(스텝이 고른 **바로 그 순간**)를 쓴다. 저장이 끝난 뒤 다시 재면
10211
+ * 답이 state 에 앉는 데 걸린 시간이 섞인다 — 말귀가 잰 0.049초가 그런 값이었다.
10212
+ * - `shownAt` 은 부르는 쪽이 적어 둔 것을 받는다. 답이 놓이는 순간에는 이미 사람이 다
10213
+ * 고른 뒤라 보인 때를 알 수 없다.
10214
+ * - **이미 들어 있는 때는 안 덮는다.** 스텝이 제 때를 찍어 보냈으면 그쪽이 더 정확하다.
10215
+ * - **객체가 아닌 payload 는 그대로 둔다** — 없던 칸을 지어 붙이지 않는다.
10216
+ *
10217
+ * 글자꼴은 `Z` 다. 여기서 짓는 때는 **epoch 하나**라 지킬 지역 시계가 없다 — 앱이 *글자로*
10218
+ * 보낸 때를 안 고치는 것(`utils/trainingItemResults` 의 `때`)과는 다른 자리다.
10219
+ */
10220
+ export declare function withStepTimes(payload: unknown, shownAt?: string, answeredTs?: number): unknown;
10221
+
10186
10222
  /** text 뒤에 와/과를 붙여 반환한다. */
10187
10223
  export declare function withWaGwa(text: string): string;
10188
10224
 
@@ -16,7 +16,7 @@ import TrainingStepConfirm from './TrainingStepConfirm';
16
16
  import TrainingStepConfirmWithText from './TrainingStepConfirmWithText';
17
17
  import TrainingStepListen from './TrainingStepListen';
18
18
  import TrainingStepOrderList from './TrainingStepOrderList';
19
- import { getPayloadTimestamp } from './trainingPopupUtils';
19
+ import { getPayloadTimestamp, withStepTimes } from './trainingPopupUtils';
20
20
  import { TrainingPagePopupContext } from './TrainingPagePopupContext';
21
21
  import { NoisePlayerContext, DEFAULT_NOISE_SETTINGS, mergeNoiseSettings } from './NoisePlayerContext';
22
22
  import { lockBodyScrollWithPadding } from '../../hooks/bodyScrollLock';
@@ -393,6 +393,37 @@ function TrainingPagePopupInner(props) {
393
393
  : isChildrenMode
394
394
  ? effectiveChildrenPageCount
395
395
  : (typeof pageCount === 'number' ? pageCount : 0);
396
+ /**
397
+ * **지금 선 스텝이 처음 보인 때를 적어 둔다**(말귀 요청 (68) 후속, 2026-10-02).
398
+ *
399
+ * 답이 놓이는 자리(`setScenarioAnswer`)는 **답한 때**만 알고 **보인 때**는 모른다 —
400
+ * 그때는 이미 사람이 다 고른 뒤다. 그래서 스텝이 설 때 미리 적어 둔다.
401
+ *
402
+ * **`0 ~ 지금` 을 다 적는다**: 한 쪽에 스텝이 여럿 서는 꼴(듣기 → 고르기)에서는 앞 스텝도
403
+ * 이미 보였는데, 지금 것만 적으면 그 스텝의 `shownAt` 이 영영 빈다. 이미 적힌 것은 안 고친다 —
404
+ * 다시 듣기로 되돌아와도 **처음 보인 때**로 둔다.
405
+ *
406
+ * 쪽이 바뀌면 열쇠(`쪽\0스텝`)가 달라지므로 쪽마다 따로 쌓인다.
407
+ */
408
+ useEffect(() => {
409
+ if (!hasScenario && !isChildrenMode)
410
+ return;
411
+ const pageId = hasScenario
412
+ ? getScenarioPageId(currentScenarioPage, effectivePageIndex)
413
+ : (currentChildrenPage?.id || `children-page-${effectivePageIndex ?? 0}`);
414
+ const 지금 = new Date().toISOString();
415
+ for (let i = 0; i <= effectiveStepIndex; i += 1) {
416
+ const stepKey = hasScenario
417
+ ? getScenarioStepKey(currentScenarioSteps[i], i)
418
+ : `step-${i}`;
419
+ const k = 보인때열쇠(pageId, stepKey);
420
+ if (!스텝보인때.current.has(k))
421
+ 스텝보인때.current.set(k, 지금);
422
+ }
423
+ /* `currentScenarioSteps` 는 그릴 때마다 새 배열이라 의존성에 넣으면 매번 돈다 —
424
+ 쪽·스텝 번호가 바뀔 때만 돌면 되고, 그 둘이 바뀌면 스텝 목록도 함께 바뀐다. */
425
+ // eslint-disable-next-line react-hooks/exhaustive-deps
426
+ }, [hasScenario, isChildrenMode, effectivePageIndex, effectiveStepIndex, currentChildrenPage?.id]);
396
427
  // 페이지가 앞으로 진행되면 안내 메시지를 띄우는 의도된 동기화다
397
428
  /* eslint-disable react-hooks/set-state-in-effect */
398
429
  useLayoutEffect(() => {
@@ -486,6 +517,16 @@ function TrainingPagePopupInner(props) {
486
517
  if (panelRef.current)
487
518
  panelRef.current._childOnResult = fn;
488
519
  }, []);
520
+ /**
521
+ * **스텝이 처음 보인 때** — `쪽\0스텝` 하나당 한 번만 적고 **다시 와도 안 고친다**
522
+ * (말귀 요청 (68) 후속, 2026-10-02). 다시 듣기를 눌러도 처음 보인 때로 둔다 —
523
+ * 반응 시간에 다시 듣기 노력이 들어가는 쪽이 실제에 가깝다.
524
+ *
525
+ * `state` 가 아니라 `ref` 다: 이 값은 **그릴 것을 바꾸지 않는다.** state 로 두면 스텝이
526
+ * 바뀔 때마다 한 번씩 더 그린다.
527
+ */
528
+ const 스텝보인때 = useRef(new Map());
529
+ const 보인때열쇠 = (pageId, stepKey) => `${pageId}\u0000${stepKey}`;
489
530
  const setScenarioAnswer = useCallback((pageId, stepKey, payload) => {
490
531
  setFlowState((prev) => {
491
532
  const pageAnswers = prev.answersByPage[pageId] || {};
@@ -494,13 +535,22 @@ function TrainingPagePopupInner(props) {
494
535
  const existingTs = getPayloadTimestamp(existing);
495
536
  if (existing && existingTs > incomingTs)
496
537
  return prev;
538
+ /* **때를 여기서 얹는다**(말귀 요청 (68) 후속). 스텝 답은 모두 이 한 길목을 지나므로,
539
+ 화면마다 따로 찍을 까닭이 없다.
540
+ - `answeredAt` 은 payload 의 `__ts` 를 쓴다 — **스텝이 고른 바로 그 순간**이라
541
+ 여기서 다시 재는 것보다 정확하다(state 가 앉는 데 걸린 시간이 안 섞인다).
542
+ `__ts` 가 없는 payload 는 지금 잰다.
543
+ - `shownAt` 은 위 `스텝보인때` 에 적어 둔 것을 꺼낸다.
544
+ 글자꼴은 `Z` 다 — **여기서 짓는 때는 epoch 하나**라 지킬 지역 시계가 없다
545
+ (앱이 글자로 보낸 때를 안 고치는 것과 다른 자리다, `trainingItemResults` 머리글). */
546
+ const 얹은payload = withStepTimes(payload, 스텝보인때.current.get(보인때열쇠(pageId, stepKey)), incomingTs);
497
547
  return {
498
548
  ...prev,
499
549
  answersByPage: {
500
550
  ...prev.answersByPage,
501
551
  [pageId]: {
502
552
  ...pageAnswers,
503
- [stepKey]: payload,
553
+ [stepKey]: 얹은payload,
504
554
  },
505
555
  },
506
556
  };
@@ -26,6 +26,24 @@ export declare function resolveTrainingPopupMeta(trainingData: Record<string, un
26
26
  };
27
27
  /** @public */
28
28
  export declare function getPayloadTimestamp(payload: unknown): number;
29
+ /**
30
+ * @public 스텝 payload 에 **때 둘을 얹는다** — 보인 때 · 답한 때(말귀 요청 (68) 후속, 2026-10-02).
31
+ *
32
+ * `TrainingPagePopup` 의 저장 길목(`setScenarioAnswer`)이 부른다. **셈을 조각 밖으로 뺀 것은
33
+ * 실제 값으로 잴 수 있게 하려는 것이다** — 조각 안에 두면 "소스에 그 글자가 있나" 로밖에
34
+ * 못 묻는데, 그런 시험은 셈이 틀려도 초록이다.
35
+ *
36
+ * - `answeredAt` 은 `__ts`(스텝이 고른 **바로 그 순간**)를 쓴다. 저장이 끝난 뒤 다시 재면
37
+ * 답이 state 에 앉는 데 걸린 시간이 섞인다 — 말귀가 잰 0.049초가 그런 값이었다.
38
+ * - `shownAt` 은 부르는 쪽이 적어 둔 것을 받는다. 답이 놓이는 순간에는 이미 사람이 다
39
+ * 고른 뒤라 보인 때를 알 수 없다.
40
+ * - **이미 들어 있는 때는 안 덮는다.** 스텝이 제 때를 찍어 보냈으면 그쪽이 더 정확하다.
41
+ * - **객체가 아닌 payload 는 그대로 둔다** — 없던 칸을 지어 붙이지 않는다.
42
+ *
43
+ * 글자꼴은 `Z` 다. 여기서 짓는 때는 **epoch 하나**라 지킬 지역 시계가 없다 — 앱이 *글자로*
44
+ * 보낸 때를 안 고치는 것(`utils/trainingItemResults` 의 `때`)과는 다른 자리다.
45
+ */
46
+ export declare function withStepTimes(payload: unknown, shownAt?: string, answeredTs?: number): unknown;
29
47
  /** @public */
30
48
  export declare function resolveTrainingItems(trainingData: unknown): unknown[];
31
49
  interface CreateTrainingScenarioParams {
@@ -83,6 +83,34 @@ export function getPayloadTimestamp(payload) {
83
83
  }
84
84
  return 0;
85
85
  }
86
+ /**
87
+ * @public 스텝 payload 에 **때 둘을 얹는다** — 보인 때 · 답한 때(말귀 요청 (68) 후속, 2026-10-02).
88
+ *
89
+ * `TrainingPagePopup` 의 저장 길목(`setScenarioAnswer`)이 부른다. **셈을 조각 밖으로 뺀 것은
90
+ * 실제 값으로 잴 수 있게 하려는 것이다** — 조각 안에 두면 "소스에 그 글자가 있나" 로밖에
91
+ * 못 묻는데, 그런 시험은 셈이 틀려도 초록이다.
92
+ *
93
+ * - `answeredAt` 은 `__ts`(스텝이 고른 **바로 그 순간**)를 쓴다. 저장이 끝난 뒤 다시 재면
94
+ * 답이 state 에 앉는 데 걸린 시간이 섞인다 — 말귀가 잰 0.049초가 그런 값이었다.
95
+ * - `shownAt` 은 부르는 쪽이 적어 둔 것을 받는다. 답이 놓이는 순간에는 이미 사람이 다
96
+ * 고른 뒤라 보인 때를 알 수 없다.
97
+ * - **이미 들어 있는 때는 안 덮는다.** 스텝이 제 때를 찍어 보냈으면 그쪽이 더 정확하다.
98
+ * - **객체가 아닌 payload 는 그대로 둔다** — 없던 칸을 지어 붙이지 않는다.
99
+ *
100
+ * 글자꼴은 `Z` 다. 여기서 짓는 때는 **epoch 하나**라 지킬 지역 시계가 없다 — 앱이 *글자로*
101
+ * 보낸 때를 안 고치는 것(`utils/trainingItemResults` 의 `때`)과는 다른 자리다.
102
+ */
103
+ export function withStepTimes(payload, shownAt, answeredTs) {
104
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
105
+ return payload;
106
+ const p = payload;
107
+ const ts = typeof answeredTs === 'number' && answeredTs > 0 ? answeredTs : Date.now();
108
+ return {
109
+ ...p,
110
+ ...(p.shownAt === undefined && shownAt ? { shownAt } : {}),
111
+ ...(p.answeredAt === undefined ? { answeredAt: new Date(ts).toISOString() } : {}),
112
+ };
113
+ }
86
114
  /** @public */
87
115
  export function resolveTrainingItems(trainingData) {
88
116
  const td = trainingData;
@@ -288,7 +288,7 @@ export * from './components/Training/TrainingStepConfirmWithText';
288
288
  export * from './components/Training/TrainingStepListen';
289
289
  export * from './components/Training/TrainingStepOrderList';
290
290
  export * from './components/Training/TrainingStepCard';
291
- export { closeTrainingPopup, deriveNoiseContexts, getSteppedPayload, getSelectedFromPayload, getStepPayload, getSelectedText, splitTrainingVariants, resolveTrainingPopupMeta, createTrainingScenario, toTrainingText, isSelectionCorrect, getPayloadTimestamp, resolveTrainingItems, } from './components/Training/trainingPopupUtils';
291
+ export { closeTrainingPopup, deriveNoiseContexts, getSteppedPayload, getSelectedFromPayload, getStepPayload, getSelectedText, splitTrainingVariants, resolveTrainingPopupMeta, createTrainingScenario, toTrainingText, isSelectionCorrect, getPayloadTimestamp, withStepTimes, resolveTrainingItems, } from './components/Training/trainingPopupUtils';
292
292
  export type { AccordionProps, AccordionItem } from './components/Accordion';
293
293
  export type { NoticeBoxProps } from './components/NoticeBox';
294
294
  export type { ProgressBarProps } from './components/ProgressBar';
@@ -228,7 +228,7 @@ export * from './components/Training/TrainingStepConfirmWithText';
228
228
  export * from './components/Training/TrainingStepListen';
229
229
  export * from './components/Training/TrainingStepOrderList';
230
230
  export * from './components/Training/TrainingStepCard';
231
- export { closeTrainingPopup, deriveNoiseContexts, getSteppedPayload, getSelectedFromPayload, getStepPayload, getSelectedText, splitTrainingVariants, resolveTrainingPopupMeta, createTrainingScenario, toTrainingText, isSelectionCorrect, getPayloadTimestamp, resolveTrainingItems, } from './components/Training/trainingPopupUtils';
231
+ export { closeTrainingPopup, deriveNoiseContexts, getSteppedPayload, getSelectedFromPayload, getStepPayload, getSelectedText, splitTrainingVariants, resolveTrainingPopupMeta, createTrainingScenario, toTrainingText, isSelectionCorrect, getPayloadTimestamp, withStepTimes, resolveTrainingItems, } from './components/Training/trainingPopupUtils';
232
232
  export { default as PageTitle } from './components/PageTitle';
233
233
  export { companyTypeLabel } from './utils/companyTypes';
234
234
  export { cx } from './utils/cx';
@@ -38,6 +38,23 @@ export interface TrainingItemResult {
38
38
  selected: string | null;
39
39
  /** 맞았나. 정오답 개념이 없는 유형이면 null */
40
40
  correct: boolean | null;
41
+ /**
42
+ * 이 문항이 **보이거나 들리기 시작한 때**(ISO 8601) — 소리 문항이면 재생 시작. 모르면 `null`.
43
+ *
44
+ * **반응 시간을 잴 길이 없었다**(말귀 요청 (68), 2026-10-02). 앱은 판이 끝날 때 문항을
45
+ * 한꺼번에 적으므로 행의 `answered_at` 은 **"적은 때"** 다 — 말귀 실측(19행): 한 판 안 두
46
+ * 문항의 차가 중앙값 **0.049초**, 최대 0.216초. 푼 간격이 아니라 **줄을 쓴 간격**이다.
47
+ *
48
+ * 찍는 것은 **화면의 몫**이다 — 코어는 `buildResults` 가 돌려준 것을 추릴 뿐이고, 한 쪽이
49
+ * 곧 한 문항도 아니다(쪽 하나에 문항이 여럿 설 수 있다). 코어가 할 일은 **버리지 않는
50
+ * 것**이었다: 이 칸이 없던 때는 화면이 찍어 넣어도 `toItemResults` 가 조용히 떨어뜨렸다.
51
+ *
52
+ * 다시 듣기를 눌러도 **처음 보인 때로 둔다** — 반응 시간에 다시 듣기 노력이 들어가는 쪽이
53
+ * 실제에 가깝다(말귀와 같은 판단).
54
+ */
55
+ shownAt: string | null;
56
+ /** 답을 고른(확정한) 때(ISO 8601). 안 골랐으면 `null`. 위 `shownAt` 머리글 참고 */
57
+ answeredAt: string | null;
41
58
  }
42
59
  /** 문항 결과 배열이 들고 있을 법한 모양 — 훈련 유형마다 조금씩 다르다 */
43
60
  type RawResult = Record<string, unknown>;
@@ -15,6 +15,30 @@
15
15
  * 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
16
16
  * 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
17
17
  */
18
+ /**
19
+ * 때 하나 — ISO 8601 글자로 남긴다. 못 읽는 값은 `null`(없는 때를 지어내지 않는다).
20
+ *
21
+ * **글자로 온 것은 고쳐 쓰지 않는다.** `new Date(s).toISOString()` 로 다시 쓰면 `+09:00` 이
22
+ * `Z` 로 바뀌어 **앱이 가리킨 시계가 무엇이었는지가 지워진다.** 이 저장소는 날짜를 KST 하나로
23
+ * 읽기로 해 두었고(`utils/date.ts` 머리글), 그 판단은 "어느 날인가" 를 지키려는 것이다 —
24
+ * 여기서 멋대로 UTC 로 돌리면 그 뜻이 깎인다. 읽히는지만 보고 **그대로** 둔다.
25
+ * `Date`·숫자(epoch)로 오면 그때는 쓸 글자가 없으니 ISO 로 짓는다.
26
+ */
27
+ function 때(v) {
28
+ if (typeof v === 'string') {
29
+ const s = v.trim();
30
+ if (!s)
31
+ return null;
32
+ return Number.isNaN(new Date(s).getTime()) ? null : s;
33
+ }
34
+ if (v instanceof Date)
35
+ return Number.isNaN(v.getTime()) ? null : v.toISOString();
36
+ if (typeof v === 'number' && Number.isFinite(v)) {
37
+ const d = new Date(v);
38
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
39
+ }
40
+ return null;
41
+ }
18
42
  /** 사람이 읽을 수 있는 글자만 남긴다. 객체·빈 문자열은 없는 것으로 본다 */
19
43
  function text(v) {
20
44
  if (typeof v === 'string')
@@ -61,6 +85,10 @@ export function toItemResults(raw) {
61
85
  answer: text(r.answer),
62
86
  selected: text(r.selectedText) ?? text(r.selected),
63
87
  correct: itemCorrectness(r),
88
+ /* 칸 이름은 **camelCase 와 snake_case 둘 다** 본다 — 화면이 서버 꼴(`shown_at`)을 그대로
89
+ 들고 있는 자리가 있다. 이름이 하나라고 믿었다가 조용히 떨어뜨리는 것이 이 청의 뿌리다. */
90
+ shownAt: 때(r.shownAt) ?? 때(r.shown_at),
91
+ answeredAt: 때(r.answeredAt) ?? 때(r.answered_at),
64
92
  };
65
93
  });
66
94
  }
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.257.0";
49
+ export declare const CORE_VERSION = "0.259.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.257.0';
49
+ export const CORE_VERSION = '0.259.0';