@ihabdevteam/core 0.169.0 → 0.171.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.
@@ -1,4 +1,4 @@
1
- import type { ComponentType } from 'react';
1
+ import { ComponentType } from 'react';
2
2
  import { Context } from 'react';
3
3
  import { CSSProperties } from 'react';
4
4
  import { Dispatch } from 'react';
@@ -10,6 +10,7 @@ import { ForwardRefExoticComponent } from 'react';
10
10
  import type { HTMLAttributes } from 'react';
11
11
  import { JSX } from 'react';
12
12
  import { KeyboardEvent as KeyboardEvent_2 } from 'react';
13
+ import { LazyExoticComponent } from 'react';
13
14
  import { MouseEvent as MouseEvent_2 } from 'react';
14
15
  import { default as React_2 } from 'react';
15
16
  import * as React_3 from 'react';
@@ -309,7 +310,7 @@ export declare type BadgeType = 'bar' | 'dot';
309
310
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
310
311
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
311
312
  */
312
- export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, seriesMode, loading, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, detailSize, barGap, baseline, className, inGrid, }: BarChartProps): JSX.Element;
313
+ export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, seriesMode, loading, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, cardTooltip, detailSize, barGap, baseline, className, inGrid, }: BarChartProps): JSX.Element;
313
314
 
314
315
  /** @public */
315
316
  export declare interface BarChartControl {
@@ -417,6 +418,8 @@ export declare interface BarChartProps {
417
418
  onClick?: () => void;
418
419
  /** 누르는 카드의 이름(낭독기) — 안 주면 제목 */
419
420
  cardAriaLabel?: string;
421
+ /** 카드에 포인터가 닿을 때(키보드는 초점) 뜨는 설명 — StatChart tooltip 과 같은 뜻 */
422
+ cardTooltip?: string;
420
423
  /** 제목 왼쪽 아이콘 — Section·ChartSection 과 같은 자리·같은 색 */
421
424
  icon?: string;
422
425
  /** 제목 아래 설명 한 줄 */
@@ -1053,7 +1056,7 @@ export declare interface ChartPoint {
1053
1056
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
1054
1057
  * @public
1055
1058
  */
1056
- export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, subRow, className, inGrid, children }: ChartSectionProps): JSX.Element;
1059
+ export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, subRow, className, inGrid, children }: ChartSectionProps): JSX.Element;
1057
1060
 
1058
1061
  /** @public */
1059
1062
  export declare interface ChartSectionProps {
@@ -1081,6 +1084,9 @@ export declare interface ChartSectionProps {
1081
1084
  onClick?: () => void;
1082
1085
  /** 누르는 카드의 이름(낭독기) — 안 주면 제목이 글자일 때 그것을 쓴다 */
1083
1086
  ariaLabel?: string;
1087
+ /** 카드에 포인터가 닿을 때(키보드는 초점이 들어올 때) 뜨는 설명 — `StatChart tooltip` 과 같은 뜻.
1088
+ * 같은 줄에 선 StatChart 카드들만 설명이 뜨고 이 카드만 안 뜨던 것을 메운다. */
1089
+ tooltip?: string;
1084
1090
  /** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
1085
1091
  subRow?: ReactNode;
1086
1092
  /** 섹션 루트에 추가할 클래스 */
@@ -1550,7 +1556,7 @@ export declare function copyText(text: string): Promise<boolean>;
1550
1556
  *
1551
1557
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1552
1558
  */
1553
- export declare const CORE_VERSION = "0.169.0";
1559
+ export declare const CORE_VERSION = "0.171.0";
1554
1560
 
1555
1561
  /** @public */
1556
1562
  declare const coreDefault: Record<string, unknown>;
@@ -3008,6 +3014,29 @@ export declare interface LandingHeaderProps {
3008
3014
  };
3009
3015
  }
3010
3016
 
3017
+ /**
3018
+ * 화면을 늦게 받아 오는 자리 — `lazy()` 를 그냥 쓰지 않고 이걸 쓴다.
3019
+ *
3020
+ * 맨 `lazy(() => import(…))` 는 **모듈이 undefined 로 와도 그대로 `.default` 를 읽는다.**
3021
+ * 그 일이 실제로 난다: 배포 skew 복구가 `vite:preloadError` 를 `preventDefault()` 하면
3022
+ * Vite 헬퍼의 `catch` 가 아무것도 안 던지고 undefined 로 resolve 한다(자세한 것은
3023
+ * `chunkSkew.ts`). 그러면 새로고침이 이미 걸려 있는데도 그 틈에 React 가
3024
+ * `undefined.default` 를 읽어 **오류 화면이 한 번 뜨고 가짜 보고가 남는다.**
3025
+ *
3026
+ * 두 가지를 한다.
3027
+ * 1. **새로고침이 예약돼 있으면 아무것도 결정하지 않는다** — 영영 resolve 하지 않는
3028
+ * 약속을 돌려 Suspense 를 그대로 둔다. 곧 새 판이 뜬다.
3029
+ * 2. 그게 아닌데 모듈이 이상하면 **어느 화면인지 적어** 던진다. 지금까지 이 오류는
3030
+ * 스택이 통째로 vendor-react 안이라(minify 된 `q`) 어느 화면에서 났는지 알 수 없었다.
3031
+ *
3032
+ * Vite 가 아닌 번들러(Next 등)에서는 `vite:preloadError` 가 오지 않으니 이 감싸개는
3033
+ * "모듈이 이상하면 어느 화면인지 적어 던진다" 만 한다 — 그것만으로도 맨 `lazy` 보다 낫다.
3034
+ * @public
3035
+ */
3036
+ export declare function lazyPage<T extends ComponentType<any>>(name: string, load: () => Promise<{
3037
+ default: T;
3038
+ } | undefined>): LazyExoticComponent<T>;
3039
+
3011
3040
  declare type LeaderboardFn = (gameKey: string, limit?: number) => Promise<LeaderboardRow[] | null>;
3012
3041
 
3013
3042
  /**
@@ -5024,6 +5053,44 @@ export declare interface Recommendation {
5024
5053
  */
5025
5054
  export declare function recommendTables(tables: readonly TableCandidate[], weakLabels: readonly string[], limit?: number): Recommendation[];
5026
5055
 
5056
+ /**
5057
+ * 배포 skew 복구 — 그리고 그 복구가 내던 오류 화면.
5058
+ *
5059
+ * ── 무엇이 문제였나 ──
5060
+ * 새 배포로 청크 해시가 바뀌면 캐시된 옛 index.html 이 사라진 청크를 부른다. Vite 는
5061
+ * `vite:preloadError` 를 던지고, 우리는 그때 한 번 새로고침해 새 index.html 을 받는다.
5062
+ * 여기까지는 뜻대로 돈다.
5063
+ *
5064
+ * 그런데 그 이벤트를 `preventDefault()` 하면 Vite 헬퍼가 이렇게 한다(vite 소스 그대로):
5065
+ *
5066
+ * function handlePreloadError(err) {
5067
+ * const e = new Event('vite:preloadError', { cancelable: true })
5068
+ * window.dispatchEvent(e)
5069
+ * if (!e.defaultPrevented) throw err // ← 막으면 안 던진다
5070
+ * }
5071
+ * return baseModule().catch(handlePreloadError) // ← 안 던지니 undefined 로 resolve
5072
+ *
5073
+ * **`location.reload()` 는 곧바로 멈추지 않는다.** 새 판이 뜨기 전까지 React 는 계속
5074
+ * 그리고, `React.lazy` 는 resolve 된 `undefined` 의 `.default` 를 읽는다 —
5075
+ * `Cannot read properties of undefined (reading 'default')`. 운영에서 이 오류가
5076
+ * 07-09 ~ 08-28 동안 **아홉 사람에게 열세 번** 났다(/today·/customers·/my-word-table·
5077
+ * /word-pairs·/word-management·/training-store — 스택은 전부 vendor-react 의 lazy 초기화).
5078
+ *
5079
+ * 즉 복구는 되는데 그 길목에서 **오류 화면을 한 번 띄우고 가짜 보고를 남기고** 있었다.
5080
+ *
5081
+ * ── 어떻게 고치나 ──
5082
+ * 새로고침을 걸어 둔 동안에는 `lazy` 가 아무것도 결정하지 않게 한다(`lazyPage` 가
5083
+ * 영영 resolve 하지 않는 약속을 돌려 Suspense 를 그대로 둔다). 그러려면 "지금 새로고침이
5084
+ * 예약돼 있나"를 물을 수 있어야 해서, 그 판단을 여기 한 곳에 둔다.
5085
+ */
5086
+ /**
5087
+ * 배포 skew 로 판단되면 1회만 새로고침해 새 index.html 을 받는다.
5088
+ * 앱이 `vite:preloadError`(또는 청크 로드 실패)를 받았을 때 부른다.
5089
+ * @returns 새로고침을 걸었으면 true. 이미 최근에 걸었으면 false(진짜 오류로 넘긴다).
5090
+ * @public
5091
+ */
5092
+ export declare function recoverFromSkew(now?: number): boolean;
5093
+
5027
5094
  /** getBoundingClientRect()만 있으면 되는 최소 인터페이스(주로 이벤트의 currentTarget). */
5028
5095
  declare type Rectable = {
5029
5096
  getBoundingClientRect(): DOMRect;
@@ -5341,7 +5408,7 @@ export declare function reviewTableName(original: string | ReviewSource, today:
5341
5408
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
5342
5409
  * @public
5343
5410
  */
5344
- export declare function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, onClick, ariaLabel, ...ringProps }: RingChartProps): JSX.Element;
5411
+ export declare function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, onClick, ariaLabel, tooltip, ...ringProps }: RingChartProps): JSX.Element;
5345
5412
 
5346
5413
  /** @public */
5347
5414
  export declare interface RingChartProps {
@@ -5398,6 +5465,8 @@ export declare interface RingChartProps {
5398
5465
  onClick?: () => void;
5399
5466
  /** 누르는 카드의 이름(낭독기) — 안 주면 제목 */
5400
5467
  ariaLabel?: string;
5468
+ /** 카드에 포인터가 닿을 때(키보드는 초점) 뜨는 설명 — StatChart tooltip 과 같은 뜻(titleKey 지정 시에만) */
5469
+ tooltip?: string;
5401
5470
  }
5402
5471
 
5403
5472
  /** @public */
@@ -5868,6 +5937,15 @@ export declare interface SideNavProps {
5868
5937
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
5869
5938
  }
5870
5939
 
5940
+ /**
5941
+ * 새로고침을 걸어 두었고 아직 새 판이 안 떴는가.
5942
+ * @public
5943
+ *
5944
+ * `location.reload()` 가 비동기라 그 사이에 React 가 계속 도는데, 그동안 화면에
5945
+ * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
5946
+ */
5947
+ export declare function skewReloadPending(now?: number): boolean;
5948
+
5871
5949
  /**
5872
5950
  * 가로/세로 슬라이더. 접근성·키보드 지원을 위해 input[type=range] 기반.
5873
5951
  * 채워진 부분은 --slider-pct CSS 변수로 그라데이션 처리한다.
@@ -13,4 +13,4 @@ import type { BarChartProps } from './barChartTypes';
13
13
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
14
14
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
15
15
  */
16
- export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, seriesMode, loading, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, detailSize, barGap, baseline, className, inGrid, }: BarChartProps): import("react").JSX.Element;
16
+ export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, seriesMode, loading, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, cardTooltip, detailSize, barGap, baseline, className, inGrid, }: BarChartProps): import("react").JSX.Element;
@@ -261,7 +261,7 @@ const BAR_SIZE_PRESET = {
261
261
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
262
262
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
263
263
  */
264
- export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, rateAgg = 'dayMean', tightMax = false, avgMode = false, totalFormat, series, seriesMode = 'stack', loading = false, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, detailSize = 32, barGap, baseline, className, inGrid = false, }) {
264
+ export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, rateAgg = 'dayMean', tightMax = false, avgMode = false, totalFormat, series, seriesMode = 'stack', loading = false, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, cardTooltip, detailSize = 32, barGap, baseline, className, inGrid = false, }) {
265
265
  const { t, i18n } = useTranslation();
266
266
  const sizePreset = BAR_SIZE_PRESET[size];
267
267
  // 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
@@ -397,7 +397,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
397
397
  const isLarge = size === 'large';
398
398
  const sectionClassName = [isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined, className]
399
399
  .filter(Boolean).join(' ') || undefined;
400
- return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, onClick: onClick, ariaLabel: cardAriaLabel, headSegment: headSegment,
400
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, onClick: onClick, ariaLabel: cardAriaLabel, tooltip: cardTooltip, headSegment: headSegment,
401
401
  /* 좁아 고르개가 '⋯' 로 접히면 상세보기도 그 메뉴로 들어간다 — 머리에 단추 둘이 남지 않게 */
402
402
  headMenuItems: onDetail ? [{ label: t('trainingStatus.detail', '상세보기'), icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), 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: t('trainingStatus.detail', '상세보기'), showTooltipInfo: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
403
403
  // 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
@@ -61,6 +61,9 @@ export interface ChartSectionProps {
61
61
  onClick?: () => void;
62
62
  /** 누르는 카드의 이름(낭독기) — 안 주면 제목이 글자일 때 그것을 쓴다 */
63
63
  ariaLabel?: string;
64
+ /** 카드에 포인터가 닿을 때(키보드는 초점이 들어올 때) 뜨는 설명 — `StatChart tooltip` 과 같은 뜻.
65
+ * 같은 줄에 선 StatChart 카드들만 설명이 뜨고 이 카드만 안 뜨던 것을 메운다. */
66
+ tooltip?: string;
64
67
  /** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
65
68
  subRow?: ReactNode;
66
69
  /** 섹션 루트에 추가할 클래스 */
@@ -80,4 +83,4 @@ export interface ChartSectionProps {
80
83
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
81
84
  * @public
82
85
  */
83
- export default function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, subRow, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
86
+ export default function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, subRow, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
@@ -7,6 +7,8 @@ import Icon from './Icon';
7
7
  import Button from './Button';
8
8
  import SegmentedControl from './SegmentedControl';
9
9
  import Tooltip from './Tooltip/Tooltip';
10
+ import TooltipInfo from './Tooltip/InfoTooltip';
11
+ import { useHoverAnchor } from '../hooks/useHoverAnchor';
10
12
  import MenuTooltip from './Tooltip/MenuTooltip';
11
13
  /** 차트 범례 — 색상 점 + 라벨 목록. BarChart/RingChart 섹션이 공유. @public */
12
14
  export function ChartLegend({ items, className }) {
@@ -25,9 +27,14 @@ export function ChartTotal({ label, children }) {
25
27
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
26
28
  * @public
27
29
  */
28
- export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headSegment, headMenuItems, collapseBelow = 420, onClick, ariaLabel, subRow, className, inGrid = false, children }) {
30
+ export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headSegment, headMenuItems, collapseBelow = 420, onClick, ariaLabel, tooltip, subRow, className, inGrid = false, children }) {
29
31
  const { t } = useTranslation();
30
32
  const secRef = useRef(null);
33
+ /* 설명 툴팁 — StatChart 와 같은 결(카드 사각형 기준, 포인터가 나가거나 초점이 빠지면 닫힘).
34
+ hover 전용이면 키보드로는 볼 수 없어 초점에도 연다. */
35
+ const { anchorRect: 설명자리, openAt: 설명열기, close: 설명닫기 } = useHoverAnchor();
36
+ const 설명보이기 = useCallback(() => { if (tooltip && secRef.current)
37
+ 설명열기(secRef.current); }, [tooltip, 설명열기]);
31
38
  /* 좁은지는 **카드 제 폭** 으로 잰다 — 부르는 쪽이 격자 칸 폭으로 대신 재면 두 칸을 차지하는 카드에서 틀린다.
32
39
  ResizeObserver 가 없는 환경(구형·SSR)에서는 접지 않는다(넓은 쪽이 기본). */
33
40
  const [좁음, set좁음] = useState(false);
@@ -87,5 +94,12 @@ export default function ChartSection({ title, titleClassName = 'h3', icon, disc,
87
94
  }, [onClick]);
88
95
  const 오른쪽 = typeof headRight === 'function' ? headRight({ collapsed: 접힘 }) : headRight;
89
96
  const 머리오른쪽 = (오른쪽 || headSegment) ? (_jsxs("div", { className: "manager-section__head-right", children: [오른쪽, headSegment && (접힘 ? (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "ellipsis", ariaLabel: headSegment.ariaLabel ?? t('common.more', '더보기'), showTooltipInfo: headSegment.ariaLabel ?? t('common.more', '더보기'), "aria-haspopup": "menu", "aria-expanded": !!메뉴자리, onClick: 메뉴열기 })) : (_jsx(SegmentedControl, { size: 32, ariaLabel: headSegment.ariaLabel, options: headSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headSegment.value, onChange: headSegment.onChange })))] })) : null;
90
- return (_jsxs("section", { ref: secRef, className: ['manager-section', inGrid && 'manager-section--grid', onClick && 'manager-section--clickable', className].filter(Boolean).join(' '), onClick: onClick ? 카드누름 : undefined, onKeyDown: onClick ? 카드키 : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [_jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), 머리오른쪽, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), subRow && _jsx("div", { className: "barchart-sub-row", children: subRow }), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
97
+ return (_jsxs("section", { ref: secRef, className: ['manager-section', inGrid && 'manager-section--grid', onClick && 'manager-section--clickable', className].filter(Boolean).join(' '), onClick: onClick ? 카드누름 : undefined, onKeyDown: onClick ? 카드키 : undefined, onPointerEnter: tooltip ? 설명보이기 : undefined, onPointerLeave: tooltip ? 설명닫기 : undefined, onFocus: tooltip ? 설명보이기 : undefined, onBlur: tooltip ? 설명닫기 : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [tooltip && 설명자리 && (_jsx(TooltipInfo, { anchorRect: {
98
+ left: 설명자리.left,
99
+ top: 설명자리.top + 21,
100
+ right: 설명자리.right,
101
+ bottom: 설명자리.bottom,
102
+ width: 설명자리.width,
103
+ height: 설명자리.height,
104
+ }, text: tooltip })), _jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), 머리오른쪽, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), subRow && _jsx("div", { className: "barchart-sub-row", children: subRow }), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
91
105
  }
@@ -64,6 +64,8 @@ export interface RingChartProps {
64
64
  onClick?: () => void;
65
65
  /** 누르는 카드의 이름(낭독기) — 안 주면 제목 */
66
66
  ariaLabel?: string;
67
+ /** 카드에 포인터가 닿을 때(키보드는 초점) 뜨는 설명 — StatChart tooltip 과 같은 뜻(titleKey 지정 시에만) */
68
+ tooltip?: string;
67
69
  }
68
70
  /**
69
71
  * 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
@@ -71,4 +73,4 @@ export interface RingChartProps {
71
73
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
72
74
  * @public
73
75
  */
74
- export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, onClick, ariaLabel, ...ringProps }: RingChartProps): import("react").JSX.Element;
76
+ export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, onClick, ariaLabel, tooltip, ...ringProps }: RingChartProps): import("react").JSX.Element;
@@ -51,7 +51,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
51
51
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
52
52
  * @public
53
53
  */
54
- export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid = false, onClick, ariaLabel, ...ringProps }) {
54
+ export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid = false, onClick, ariaLabel, tooltip, ...ringProps }) {
55
55
  const { t } = useTranslation();
56
56
  if (!titleKey)
57
57
  return _jsx(RingChartCanvas, { ...ringProps });
@@ -64,5 +64,5 @@ export default function RingChart({ titleKey, headRight, icon, showTotal, totalL
64
64
  .filter(Boolean).join(' ') || undefined;
65
65
  // StatChart(.manager-stat-card__icon)와 동일한 톤 — headRight 자리에 렌더(둘 중 하나만 사용)
66
66
  const iconNode = icon && (_jsx("span", { className: "chart-section-icon", children: _jsx(Icon, { icon: icon, className: "chart-section-icon-inner" }) }));
67
- return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, onClick: onClick, ariaLabel: ariaLabel, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: iconNode || (isSmall ? undefined : headRight), subRow: (showTotal || legend) && _jsxs(_Fragment, { children: [showTotal ? (_jsxs(ChartTotal, { label: !isSmall && totalLabelKey ? t(totalLabelKey) : undefined, children: [_jsx("span", { className: "barchart-total__num h0", children: pct }), _jsx("span", { className: "barchart-total__unit h5", children: "%" })] })) : _jsx("span", {}), legend && _jsx(ChartLegend, { items: legend })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: ringProps.hideCenter ?? isSmall }) })) }));
67
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, onClick: onClick, ariaLabel: ariaLabel, tooltip: tooltip, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: iconNode || (isSmall ? undefined : headRight), subRow: (showTotal || legend) && _jsxs(_Fragment, { children: [showTotal ? (_jsxs(ChartTotal, { label: !isSmall && totalLabelKey ? t(totalLabelKey) : undefined, children: [_jsx("span", { className: "barchart-total__num h0", children: pct }), _jsx("span", { className: "barchart-total__unit h5", children: "%" })] })) : _jsx("span", {}), legend && _jsx(ChartLegend, { items: legend })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: ringProps.hideCenter ?? isSmall }) })) }));
68
68
  }
@@ -103,6 +103,8 @@ export interface BarChartProps {
103
103
  onClick?: () => void;
104
104
  /** 누르는 카드의 이름(낭독기) — 안 주면 제목 */
105
105
  cardAriaLabel?: string;
106
+ /** 카드에 포인터가 닿을 때(키보드는 초점) 뜨는 설명 — StatChart tooltip 과 같은 뜻 */
107
+ cardTooltip?: string;
106
108
  /** 제목 왼쪽 아이콘 — Section·ChartSection 과 같은 자리·같은 색 */
107
109
  icon?: string;
108
110
  /** 제목 아래 설명 한 줄 */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 배포 skew 복구 — 그리고 그 복구가 내던 오류 화면.
3
+ *
4
+ * ── 무엇이 문제였나 ──
5
+ * 새 배포로 청크 해시가 바뀌면 캐시된 옛 index.html 이 사라진 청크를 부른다. Vite 는
6
+ * `vite:preloadError` 를 던지고, 우리는 그때 한 번 새로고침해 새 index.html 을 받는다.
7
+ * 여기까지는 뜻대로 돈다.
8
+ *
9
+ * 그런데 그 이벤트를 `preventDefault()` 하면 Vite 헬퍼가 이렇게 한다(vite 소스 그대로):
10
+ *
11
+ * function handlePreloadError(err) {
12
+ * const e = new Event('vite:preloadError', { cancelable: true })
13
+ * window.dispatchEvent(e)
14
+ * if (!e.defaultPrevented) throw err // ← 막으면 안 던진다
15
+ * }
16
+ * return baseModule().catch(handlePreloadError) // ← 안 던지니 undefined 로 resolve
17
+ *
18
+ * **`location.reload()` 는 곧바로 멈추지 않는다.** 새 판이 뜨기 전까지 React 는 계속
19
+ * 그리고, `React.lazy` 는 resolve 된 `undefined` 의 `.default` 를 읽는다 —
20
+ * `Cannot read properties of undefined (reading 'default')`. 운영에서 이 오류가
21
+ * 07-09 ~ 08-28 동안 **아홉 사람에게 열세 번** 났다(/today·/customers·/my-word-table·
22
+ * /word-pairs·/word-management·/training-store — 스택은 전부 vendor-react 의 lazy 초기화).
23
+ *
24
+ * 즉 복구는 되는데 그 길목에서 **오류 화면을 한 번 띄우고 가짜 보고를 남기고** 있었다.
25
+ *
26
+ * ── 어떻게 고치나 ──
27
+ * 새로고침을 걸어 둔 동안에는 `lazy` 가 아무것도 결정하지 않게 한다(`lazyPage` 가
28
+ * 영영 resolve 하지 않는 약속을 돌려 Suspense 를 그대로 둔다). 그러려면 "지금 새로고침이
29
+ * 예약돼 있나"를 물을 수 있어야 해서, 그 판단을 여기 한 곳에 둔다.
30
+ */
31
+ /**
32
+ * 배포 skew 로 판단되면 1회만 새로고침해 새 index.html 을 받는다.
33
+ * 앱이 `vite:preloadError`(또는 청크 로드 실패)를 받았을 때 부른다.
34
+ * @returns 새로고침을 걸었으면 true. 이미 최근에 걸었으면 false(진짜 오류로 넘긴다).
35
+ * @public
36
+ */
37
+ export declare function recoverFromSkew(now?: number): boolean;
38
+ /**
39
+ * 새로고침을 걸어 두었고 아직 새 판이 안 떴는가.
40
+ * @public
41
+ *
42
+ * `location.reload()` 가 비동기라 그 사이에 React 가 계속 도는데, 그동안 화면에
43
+ * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
44
+ */
45
+ export declare function skewReloadPending(now?: number): boolean;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 배포 skew 복구 — 그리고 그 복구가 내던 오류 화면.
3
+ *
4
+ * ── 무엇이 문제였나 ──
5
+ * 새 배포로 청크 해시가 바뀌면 캐시된 옛 index.html 이 사라진 청크를 부른다. Vite 는
6
+ * `vite:preloadError` 를 던지고, 우리는 그때 한 번 새로고침해 새 index.html 을 받는다.
7
+ * 여기까지는 뜻대로 돈다.
8
+ *
9
+ * 그런데 그 이벤트를 `preventDefault()` 하면 Vite 헬퍼가 이렇게 한다(vite 소스 그대로):
10
+ *
11
+ * function handlePreloadError(err) {
12
+ * const e = new Event('vite:preloadError', { cancelable: true })
13
+ * window.dispatchEvent(e)
14
+ * if (!e.defaultPrevented) throw err // ← 막으면 안 던진다
15
+ * }
16
+ * return baseModule().catch(handlePreloadError) // ← 안 던지니 undefined 로 resolve
17
+ *
18
+ * **`location.reload()` 는 곧바로 멈추지 않는다.** 새 판이 뜨기 전까지 React 는 계속
19
+ * 그리고, `React.lazy` 는 resolve 된 `undefined` 의 `.default` 를 읽는다 —
20
+ * `Cannot read properties of undefined (reading 'default')`. 운영에서 이 오류가
21
+ * 07-09 ~ 08-28 동안 **아홉 사람에게 열세 번** 났다(/today·/customers·/my-word-table·
22
+ * /word-pairs·/word-management·/training-store — 스택은 전부 vendor-react 의 lazy 초기화).
23
+ *
24
+ * 즉 복구는 되는데 그 길목에서 **오류 화면을 한 번 띄우고 가짜 보고를 남기고** 있었다.
25
+ *
26
+ * ── 어떻게 고치나 ──
27
+ * 새로고침을 걸어 둔 동안에는 `lazy` 가 아무것도 결정하지 않게 한다(`lazyPage` 가
28
+ * 영영 resolve 하지 않는 약속을 돌려 Suspense 를 그대로 둔다). 그러려면 "지금 새로고침이
29
+ * 예약돼 있나"를 물을 수 있어야 해서, 그 판단을 여기 한 곳에 둔다.
30
+ */
31
+ const SKEW_KEY = 'chunk-reload-ts';
32
+ /** 이 시간 안에 다시 실패하면 배포 skew 가 아니라 진짜 오류로 본다(무한 새로고침 방지). */
33
+ const COOLDOWN_MS = 10_000;
34
+ function 마지막시각() {
35
+ try {
36
+ return Number(sessionStorage.getItem(SKEW_KEY) || 0);
37
+ }
38
+ catch {
39
+ /* 사생활 보호 모드 등에서 sessionStorage 가 막힌다. 못 읽으면 '없음' 으로 본다 —
40
+ 그러면 새로고침을 한 번 더 하게 될 뿐, 화면이 멈추지는 않는다. */
41
+ return 0;
42
+ }
43
+ }
44
+ /**
45
+ * 배포 skew 로 판단되면 1회만 새로고침해 새 index.html 을 받는다.
46
+ * 앱이 `vite:preloadError`(또는 청크 로드 실패)를 받았을 때 부른다.
47
+ * @returns 새로고침을 걸었으면 true. 이미 최근에 걸었으면 false(진짜 오류로 넘긴다).
48
+ * @public
49
+ */
50
+ export function recoverFromSkew(now = Date.now()) {
51
+ if (now - 마지막시각() <= COOLDOWN_MS)
52
+ return false;
53
+ try {
54
+ sessionStorage.setItem(SKEW_KEY, String(now));
55
+ }
56
+ catch { /* 못 써도 새로고침은 한다 */ }
57
+ window.location.reload();
58
+ return true;
59
+ }
60
+ /**
61
+ * 새로고침을 걸어 두었고 아직 새 판이 안 떴는가.
62
+ * @public
63
+ *
64
+ * `location.reload()` 가 비동기라 그 사이에 React 가 계속 도는데, 그동안 화면에
65
+ * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
66
+ */
67
+ export function skewReloadPending(now = Date.now()) {
68
+ const last = 마지막시각();
69
+ return last > 0 && now - last <= COOLDOWN_MS;
70
+ }
@@ -0,0 +1,23 @@
1
+ import { type ComponentType, type LazyExoticComponent } from 'react';
2
+ /**
3
+ * 화면을 늦게 받아 오는 자리 — `lazy()` 를 그냥 쓰지 않고 이걸 쓴다.
4
+ *
5
+ * 맨 `lazy(() => import(…))` 는 **모듈이 undefined 로 와도 그대로 `.default` 를 읽는다.**
6
+ * 그 일이 실제로 난다: 배포 skew 복구가 `vite:preloadError` 를 `preventDefault()` 하면
7
+ * Vite 헬퍼의 `catch` 가 아무것도 안 던지고 undefined 로 resolve 한다(자세한 것은
8
+ * `chunkSkew.ts`). 그러면 새로고침이 이미 걸려 있는데도 그 틈에 React 가
9
+ * `undefined.default` 를 읽어 **오류 화면이 한 번 뜨고 가짜 보고가 남는다.**
10
+ *
11
+ * 두 가지를 한다.
12
+ * 1. **새로고침이 예약돼 있으면 아무것도 결정하지 않는다** — 영영 resolve 하지 않는
13
+ * 약속을 돌려 Suspense 를 그대로 둔다. 곧 새 판이 뜬다.
14
+ * 2. 그게 아닌데 모듈이 이상하면 **어느 화면인지 적어** 던진다. 지금까지 이 오류는
15
+ * 스택이 통째로 vendor-react 안이라(minify 된 `q`) 어느 화면에서 났는지 알 수 없었다.
16
+ *
17
+ * Vite 가 아닌 번들러(Next 등)에서는 `vite:preloadError` 가 오지 않으니 이 감싸개는
18
+ * "모듈이 이상하면 어느 화면인지 적어 던진다" 만 한다 — 그것만으로도 맨 `lazy` 보다 낫다.
19
+ * @public
20
+ */
21
+ export declare function lazyPage<T extends ComponentType<any>>(name: string, load: () => Promise<{
22
+ default: T;
23
+ } | undefined>): LazyExoticComponent<T>;
@@ -0,0 +1,45 @@
1
+ import { lazy } from 'react';
2
+ import { skewReloadPending } from './chunkSkew';
3
+ /**
4
+ * 화면을 늦게 받아 오는 자리 — `lazy()` 를 그냥 쓰지 않고 이걸 쓴다.
5
+ *
6
+ * 맨 `lazy(() => import(…))` 는 **모듈이 undefined 로 와도 그대로 `.default` 를 읽는다.**
7
+ * 그 일이 실제로 난다: 배포 skew 복구가 `vite:preloadError` 를 `preventDefault()` 하면
8
+ * Vite 헬퍼의 `catch` 가 아무것도 안 던지고 undefined 로 resolve 한다(자세한 것은
9
+ * `chunkSkew.ts`). 그러면 새로고침이 이미 걸려 있는데도 그 틈에 React 가
10
+ * `undefined.default` 를 읽어 **오류 화면이 한 번 뜨고 가짜 보고가 남는다.**
11
+ *
12
+ * 두 가지를 한다.
13
+ * 1. **새로고침이 예약돼 있으면 아무것도 결정하지 않는다** — 영영 resolve 하지 않는
14
+ * 약속을 돌려 Suspense 를 그대로 둔다. 곧 새 판이 뜬다.
15
+ * 2. 그게 아닌데 모듈이 이상하면 **어느 화면인지 적어** 던진다. 지금까지 이 오류는
16
+ * 스택이 통째로 vendor-react 안이라(minify 된 `q`) 어느 화면에서 났는지 알 수 없었다.
17
+ *
18
+ * Vite 가 아닌 번들러(Next 등)에서는 `vite:preloadError` 가 오지 않으니 이 감싸개는
19
+ * "모듈이 이상하면 어느 화면인지 적어 던진다" 만 한다 — 그것만으로도 맨 `lazy` 보다 낫다.
20
+ * @public
21
+ */
22
+ /* 서명은 React 의 `lazy` 를 그대로 따른다 — 화면마다 props 가 달라서, 여기서 좁히면
23
+ 쓰는 쪽이 전부 `Property 'userId' does not exist` 로 막힌다(실제로 그렇게 됐다).
24
+ React 자신도 같은 이유로 이 자리에 any 를 쓴다(@types/react 의 lazy 서명). */
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ export function lazyPage(name,
27
+ /* 반환 타입에 undefined 를 적어 둔다 — 실제로 그렇게 온다(아래 참고). 타입이
28
+ "그럴 리 없다"고 말하면 부르는 쪽이 `.then(m => ({ default: m.X }))` 처럼 모듈을
29
+ 그대로 읽는 코드를 쓰게 되고, 그러면 여기 닿기도 전에 터진다. */
30
+ load) {
31
+ return lazy(async () => {
32
+ /* **undefined 로 resolve 될 수 있다.** 타입은 그럴 리 없다고 하지만 실제로 그렇게 온다
33
+ (배포 skew 복구가 preventDefault 한 뒤). 타입을 믿지 말고 확인한다. */
34
+ const mod = await load();
35
+ if (mod && typeof mod === 'object' && 'default' in mod) {
36
+ return mod;
37
+ }
38
+ /* 여기 오는 길은 사실상 하나뿐이다 — 청크를 못 받아 복구가 도는 중. 새 판을
39
+ 기다린다. 영영 resolve 하지 않는 약속을 돌려 React 가 이 경계를 건드리지 않게 한다. */
40
+ if (skewReloadPending())
41
+ return new Promise(() => { });
42
+ throw new Error(`[lazyPage] ${name}: 화면 모듈을 받지 못했습니다(default 내보내기 없음). ` +
43
+ '배포 직후라면 새로고침으로 풀립니다.');
44
+ });
45
+ }
@@ -55,4 +55,6 @@ export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wor
55
55
  export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
56
56
  export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
57
57
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
58
+ export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
59
+ export { lazyPage } from './utils/lazyPage';
58
60
  export { CORE_VERSION } from './version';
@@ -44,4 +44,6 @@ export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, } fr
44
44
  말귀어드민(단어 제안 관리)이 같은 숫자를 봐야 하는데, 한쪽 화면 폴더에 두면
45
45
  다른 쪽이 그 폴더 없이는 빌드되지 않는다. 값과 순수 함수뿐이다. */
46
46
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
47
+ export { recoverFromSkew, skewReloadPending } from './utils/chunkSkew';
48
+ export { lazyPage } from './utils/lazyPage';
47
49
  export { CORE_VERSION } from './version';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.169.0";
49
+ export declare const CORE_VERSION = "0.171.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.169.0';
49
+ export const CORE_VERSION = '0.171.0';