reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 2026 reopt-ai. All rights reserved.

RetentionAnalysis

block

리텐션 히트맵 분석 Surface. RetentionHeatmap 래퍼.

컴포넌트 의존 관계

깊이
▼ USES (3)RetentionAnalysisretention-heatmaploading-overlaytime-range-selector
100%

기본 사용

User Retention

Weekly cohort retention analysis

CohortDay 0Day 1Day 7Day 14Day 30
Week 1100%80%65%55%48%
Week 2100%75%60%50%—
Week 3100%82%70%——
Week 4100%78%———

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

RetentionAnalysis 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

RetentionAnalysis Props

Prop타입기본값설명
data*RetentionRow[]—리텐션 데이터 배열
periodsstring[]—기간 라벨 배열
titlestring"Retention Analysis"제목
descriptionstring—설명 텍스트

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add retention-analysis

Consumer 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.tsx→retention-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";