reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

RetentionAnalysis

surface

리텐션 히트맵 분석 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";