RetentionAnalysis
surface리텐션 히트맵 분석 Surface. RetentionHeatmap 래퍼.
컴포넌트 의존 관계
깊이
100%
기본 사용
User Retention
Weekly cohort retention analysis
| Cohort | Day 0 | Day 1 | Day 7 | Day 14 | Day 30 |
|---|---|---|---|---|---|
| Week 1 | 100% | 80% | 65% | 55% | 48% |
| Week 2 | 100% | 75% | 60% | 50% | — |
| Week 3 | 100% | 82% | 70% | — | — |
| Week 4 | 100% | 78% | — | — | — |
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
RetentionAnalysis 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
RetentionAnalysis Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
data* | RetentionRow[] | — | 리텐션 데이터 배열 |
periods | string[] | — | 기간 라벨 배열 |
title | string | "Retention Analysis" | 제목 |
description | string | — | 설명 텍스트 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add retention-analysisConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { RetentionAnalysis } from "@/components/surfaces/retention-analysis";Registry metadata
- 설명
- 리텐션 히트맵 분석 Surface. RetentionHeatmap 래퍼.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- chartanalytics
- Install notes
- 없음
포함 파일
retention-analysis.tsxretention-analysis.tsx
Surface 소스 보기
retention-analysis.tsx
"use client";
import * as React from "react";
import {
SurfaceLayout,
TimeRangeSelector,
cn,
type DateRange,
} from "@reopt-ai/opt-ui";
import { RetentionHeatmap, type RetentionRow } from "@reopt-ai/opt-charts";
/** Type definition for `RetentionInterval`. */
export type RetentionInterval = "daily" | "weekly" | "monthly";
/** Type definition for `RetentionType`. */
export type RetentionType = "retention" | "churn";
const INTERVALS: { value: RetentionInterval; label: string }[] = [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
];
const RETENTION_TYPES: { value: RetentionType; label: string }[] = [
{ value: "retention", label: "Retention" },
{ value: "churn", label: "Churn" },
];
/** Labels for `RetentionAnalysis`. */
export interface RetentionAnalysisLabels {
title?: string;
description?: string;
emptyTitle?: string;
emptyDescription?: string;
interpretationTitle?: string;
startEventLabel?: string;
returnEventLabel?: string;
intervalLabel?: string;
retentionTypeLabel?: string;
}
const defaultLabels: Required<RetentionAnalysisLabels> = {
title: "Retention Analysis",
description: "",
emptyTitle: "No retention data",
emptyDescription: "Retention data will appear here once available.",
interpretationTitle: "Interpretation Guide",
startEventLabel: "Start event",
returnEventLabel: "Return event",
intervalLabel: "Interval",
retentionTypeLabel: "Type",
};
/** Props for `RetentionAnalysis`. */
export interface RetentionAnalysisProps {
data: RetentionRow[];
periods?: string[];
title?: string;
description?: string;
/** Custom controls rendered between header and heatmap (e.g., event selectors, interval buttons) */
filterContent?: React.ReactNode;
/** Interpretation guide rendered below the heatmap */
interpretationGuide?: React.ReactNode;
/** Selected start event id */
startEvent?: string;
/** Selected return event id */
returnEvent?: string;
/** Available events for selectors */
eventOptions?: { id: string; name: string }[];
/** Start event change handler */
onStartEventChange?: (eventId: string) => void;
/** Return event change handler */
onReturnEventChange?: (eventId: string) => void;
/** Current interval selection */
interval?: RetentionInterval;
/** Interval change handler */
onIntervalChange?: (interval: RetentionInterval) => void;
/** Retention or churn toggle */
retentionType?: RetentionType;
/** Retention type change handler */
onRetentionTypeChange?: (type: RetentionType) => void;
header?: React.ReactNode;
actions?: React.ReactNode;
labels?: RetentionAnalysisLabels;
loading?: boolean;
timeRange?: DateRange;
onTimeRangeChange?: (range: DateRange) => void;
className?: string;
}
/** Renders the `RetentionAnalysis` component. */
export function RetentionAnalysis({
data,
periods,
title,
description,
filterContent,
interpretationGuide,
startEvent,
returnEvent,
eventOptions,
onStartEventChange,
onReturnEventChange,
interval,
onIntervalChange,
retentionType,
onRetentionTypeChange,
header,
actions,
labels: customLabels,
loading = false,
timeRange,
onTimeRangeChange,
className,
}: RetentionAnalysisProps) {
const labels = { ...defaultLabels, ...customLabels };
const titleId = React.useId();
// Allow direct title/description props to override labels for backwards compat
const resolvedTitle = title ?? labels.title;
const resolvedDescription = description ?? labels.description;
const isEmpty = data.length === 0;
const hasEventSelectors = eventOptions && eventOptions.length > 0;
const hasInterval = interval !== undefined;
const hasRetentionType = retentionType !== undefined;
const hasStructuredControls =
hasEventSelectors || hasInterval || hasRetentionType;
return (
<SurfaceLayout loading={loading} className={className}>
{header || actions ? (
<div className="flex items-center justify-between">
{header && <div>{header}</div>}
<div className="gap-element flex items-center">
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
{actions}
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div>
<h2
id={titleId}
className="text-text-primary text-lg font-semibold"
>
{resolvedTitle}
</h2>
{resolvedDescription && (
<p className="text-text-tertiary text-sm">
{resolvedDescription}
</p>
)}
</div>
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
</div>
)}
{hasStructuredControls && (
<div className="gap-group flex flex-wrap items-end">
{hasEventSelectors && (
<>
<div>
<label className="text-text-tertiary mb-1 block text-xs font-medium">
{labels.startEventLabel}
</label>
<select
value={startEvent ?? ""}
onChange={(e) => onStartEventChange?.(e.target.value)}
aria-label={labels.startEventLabel}
className="border-border text-text-primary rounded-md border bg-transparent px-2 py-1 text-sm"
>
{eventOptions.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.name}
</option>
))}
</select>
</div>
<div>
<label className="text-text-tertiary mb-1 block text-xs font-medium">
{labels.returnEventLabel}
</label>
<select
value={returnEvent ?? ""}
onChange={(e) => onReturnEventChange?.(e.target.value)}
aria-label={labels.returnEventLabel}
className="border-border text-text-primary rounded-md border bg-transparent px-2 py-1 text-sm"
>
{eventOptions.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.name}
</option>
))}
</select>
</div>
</>
)}
{hasInterval && (
<div>
<label className="text-text-tertiary mb-1 block text-xs font-medium">
{labels.intervalLabel}
</label>
<div className="gap-element flex">
{INTERVALS.map((i) => (
<button
key={i.value}
type="button"
onClick={() => onIntervalChange?.(i.value)}
className={cn(
"focus-visible:ring-accent rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none",
interval === i.value
? "bg-accent text-accent-fg"
: "bg-bg-subtle text-text-secondary hover:bg-bg-muted",
)}
>
{i.label}
</button>
))}
</div>
</div>
)}
{hasRetentionType && (
<div>
<label className="text-text-tertiary mb-1 block text-xs font-medium">
{labels.retentionTypeLabel}
</label>
<div className="gap-element flex">
{RETENTION_TYPES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => onRetentionTypeChange?.(t.value)}
className={cn(
"focus-visible:ring-accent rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none",
retentionType === t.value
? "bg-accent text-accent-fg"
: "bg-bg-subtle text-text-secondary hover:bg-bg-muted",
)}
>
{t.label}
</button>
))}
</div>
</div>
)}
</div>
)}
{filterContent && <div>{filterContent}</div>}
{isEmpty ? (
<div
role="status"
className="flex flex-col items-center justify-center py-16 text-center"
>
<h3 className="text-text-primary text-lg font-medium">
{labels.emptyTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.emptyDescription}
</p>
</div>
) : (
<>
<RetentionHeatmap
data={data}
periods={periods}
aria-label="Retention heatmap"
/>
{interpretationGuide && (
<section aria-labelledby={`${titleId}-guide`}>
<h3
id={`${titleId}-guide`}
className="text-text-secondary mb-2 text-sm font-medium"
>
{labels.interpretationTitle}
</h3>
{interpretationGuide}
</section>
)}
</>
)}
</SurfaceLayout>
);
}
RetentionAnalysis.displayName = "RetentionAnalysis";