DEV Community

Nebula
Nebula

Posted on

mvt-testing

import React, { useState, useEffect, useCallback } from 'react';
import { useUnleashContext, useUnleashClient } from '@unleash/proxy-client-react';
import styled, { keyframes, css } from 'styled-components';

// --- Styled Components ---

const fadeIn = keyframes`
  from { opacity: 0; transform: translateY(-10px); }
  to { opacity: 1; transform: translateY(0); }
`;

const spin = keyframes`
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
`;

const Container = styled.div`
  font-family: 'Inter', sans-serif;
  background-color: #f8fafc; /* Tailwind gray-50 */
  min-height: 100vh;
  padding: 2rem;
  display: flex;
  justify-content: center;
  align-items: flex-start;
`;

const MaxWidthWrapper = styled.div`
  max-width: 960px;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
`;

const Card = styled.div`
  background-color: white;
  border-radius: 0.75rem; /* rounded-xl */
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-xl */
  padding: 1.5rem;
  border: 1px solid #e2e8f0; /* border-gray-200 */
`;

const Header = styled.div`
  text-align: center;
  margin-bottom: 1rem;
`;

const Title = styled.h1`
  font-size: 2.25rem; /* text-4xl */
  font-weight: 700; /* font-bold */
  color: #1a202c; /* gray-900 */
  margin-bottom: 0.5rem;
`;

const Subtitle = styled.p`
  font-size: 1.125rem; /* text-lg */
  color: #4a5568; /* gray-600 */
`;

const SectionTitle = styled.h2`
  font-size: 1.5rem; /* text-2xl */
  font-weight: 600; /* font-semibold */
  color: #2d3748; /* gray-800 */
  margin-bottom: 1rem;
`;

const ConfigPanel = styled.div`
  display: flex;
  flex-direction: column;
  gap: 1rem;
  @media (min-width: 640px) { /* sm */
    flex-direction: row;
    align-items: flex-end;
    justify-content: space-between;
  }
`;

const InputGroup = styled.div`
  flex-grow: 1;
`;

const Label = styled.label`
  display: block;
  font-size: 0.875rem; /* text-sm */
  font-weight: 500; /* font-medium */
  color: #2d3748; /* gray-700 */
  margin-bottom: 0.5rem;
`;

const Input = styled.input`
  width: 100%;
  padding: 0.75rem 1rem;
  border: 1px solid #cbd5e0; /* border-gray-300 */
  border-radius: 0.5rem; /* rounded-lg */
  font-size: 1rem;
  color: #2d3748;
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
  &:focus {
    outline: none;
    border-color: #3b82f6; /* blue-500 */
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); /* ring-blue-300 */
  }
`;

const Button = styled.button`
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.75rem 1.5rem;
  background-color: #3b82f6; /* blue-500 */
  color: white;
  font-weight: 600; /* font-semibold */
  border-radius: 0.5rem; /* rounded-lg */
  border: none;
  cursor: pointer;
  transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* shadow-md */

  &:hover {
    background-color: #2563eb; /* blue-600 */
    box-shadow: 0 6px 8px -1px rgba(0, 0, 0, 0.15), 0 4px 6px -2px rgba(0, 0, 0, 0.08);
  }

  &:disabled {
    background-color: #9ca3af; /* gray-400 */
    cursor: not-allowed;
    box-shadow: none;
  }
`;

const Spinner = styled.div`
  border: 4px solid rgba(255, 255, 255, 0.3);
  border-top: 4px solid white;
  border-radius: 50%;
  width: 1.5rem;
  height: 1.5rem;
  animation: ${spin} 1s linear infinite;
`;

const StatusBanner = styled.div`
  background-color: #eff6ff; /* blue-50 */
  border: 1px solid #bfdbfe; /* blue-200 */
  color: #1e40af; /* blue-800 */
  padding: 0.75rem 1.5rem;
  border-radius: 0.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: ${fadeIn} 0.5s ease-out;
`;

const StatusContent = styled.div`
  display: flex;
  align-items: center;
  gap: 0.75rem;
`;

const StatusSpinner = styled(Spinner)`
  border-color: rgba(30, 64, 175, 0.3);
  border-top-color: #1e40af;
  width: 1.25rem;
  height: 1.25rem;
`;

const StatusText = styled.p`
  font-size: 1rem;
  font-weight: 500;
`;

const StatsGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 1rem;
`;

const StatCard = styled(Card)`
  padding: 1rem;
  text-align: center;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: #f0f9ff; /* blue-50 */
`;

const StatValue = styled.p`
  font-size: 2.5rem; /* text-5xl */
  font-weight: 700; /* font-bold */
  color: ${(props) => props.color || '#1a202c'};
  line-height: 1;
  margin-bottom: 0.5rem;
`;

const StatLabel = styled.p`
  font-size: 0.875rem; /* text-sm */
  color: #4a5568; /* gray-600 */
  font-weight: 500;
`;

const ProgressSection = styled(Card)`
  padding: 1.5rem;
`;

const ProgressTitle = styled(SectionTitle)`
  margin-bottom: 1.5rem;
  text-align: center;
`;

const ProgressBarContainer = styled.div`
  width: 100%;
  background-color: #e2e8f0; /* gray-200 */
  border-radius: 9999px; /* rounded-full */
  height: 1.5rem;
  overflow: hidden;
  display: flex; /* To allow multiple bars */
`;

const ProgressBarSegment = styled.div`
  height: 100%;
  background-color: ${(props) => props.color || '#3b82f6'};
  width: ${(props) => props.percentage}%;
  transition: width 0.5s ease-out;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: 600;
  font-size: 0.875rem;
  text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
`;

const ProgressLabels = styled.div`
  display: flex;
  justify-content: space-between;
  font-size: 0.875rem;
  color: #4a5568;
  margin-top: 0.5rem;
`;

const ProgressCenter = styled.span`
  text-align: center;
  flex-grow: 1;
`;

const ResultsCard = styled(Card)`
  padding: 0; /* Remove padding from card, table will handle it */
  overflow: hidden; /* For rounded corners on table */
`;

const ResultsHeader = styled.div`
  padding: 1.5rem;
  border-bottom: 1px solid #e2e8f0;
`;

const ResultsTitle = styled(SectionTitle)`
  margin-bottom: 0;
`;

const TableContainer = styled.div`
  overflow-x: auto;
  -webkit-overflow-scrolling: touch; /* For smooth scrolling on iOS */
`;

const Table = styled.table`
  width: 100%;
  border-collapse: collapse;
`;

const TableHead = styled.thead`
  background-color: #f8fafc; /* gray-50 */
`;

const TableHeader = styled.th`
  padding: 1rem 1.5rem;
  text-align: left;
  font-size: 0.875rem;
  font-weight: 600;
  color: #4a5568;
  text-transform: uppercase;
  letter-spacing: 0.05em;
`;

const TableRow = styled.tr`
  &:nth-child(even) {
    background-color: #f8fafc; /* gray-50 */
  }
  &:hover {
    background-color: #f0f4f8; /* slightly darker on hover */
  }
`;

const TableCell = styled.td`
  padding: 1rem 1.5rem;
  font-size: 0.9375rem;
  color: #2d3748;
  vertical-align: middle;
`;

const UserInfo = styled.div`
  display: flex;
  align-items: center;
  gap: 0.75rem;
`;

const UserAvatar = styled.div`
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 50%;
  background-color: #60a5fa; /* blue-400 */
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: 600;
  font-size: 0.875rem;
`;

const UserInitials = styled.span``;

const UserDetails = styled.div`
  display: flex;
  flex-direction: column;
`;

const UserName = styled.p`
  font-weight: 500;
`;

const UserEmail = styled.p`
  font-size: 0.8125rem; /* text-xs */
  color: #718096; /* gray-500 */
`;

const Badge = styled.span`
  display: inline-flex;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px; /* rounded-full */
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: capitalize;
  ${(props) => {
    switch (props.type) {
      case 'Premium':
        return css`
          background-color: #fef3c7; /* yellow-100 */
          color: #b45309; /* yellow-800 */
        `;
      case 'Gold':
        return css`
          background-color: #dbeafe; /* blue-100 */
          color: #1e40af; /* blue-800 */
        `;
      case 'Standard':
        return css`
          background-color: #e0e7ff; /* indigo-100 */
          color: #4338ca; /* indigo-800 */
        `;
      default:
        return css`
          background-color: #e2e8f0;
          color: #4a5568;
        `;
    }
  }}
`;

const StatusBadge = styled.span`
  display: inline-flex;
  padding: 0.25rem 0.75rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 600;
  ${(props) => {
    switch (props.type) {
      case 'enabled':
        return css`
          background-color: #dcfce7; /* green-100 */
          color: #16a34a; /* green-700 */
        `;
      case 'disabled':
        return css`
          background-color: #fee2e2; /* red-100 */
          color: #dc2626; /* red-700 */
        `;
      case 'error':
        return css`
          background-color: #fee2e2; /* red-100 */
          color: #dc2626; /* red-700 */
        `;
      default:
        return css`
          background-color: #e2e8f0;
          color: #4a5568;
        `;
    }
  }}
`;

const EmptyState = styled.div`
  text-align: center;
  padding: 3rem 1.5rem;
  background-color: white;
  border-radius: 0.75rem;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
  border: 1px solid #e2e8f0;
  animation: ${fadeIn} 0.5s ease-out;
`;

const EmptyStateIcon = styled.span`
  font-size: 3rem;
  margin-bottom: 1rem;
  display: block;
`;

const EmptyStateTitle = styled.h3`
  font-size: 1.5rem;
  font-weight: 600;
  color: #2d3748;
  margin-bottom: 0.5rem;
`;

const EmptyStateText = styled.p`
  font-size: 1rem;
  color: #4a5568;
  margin-bottom: 1rem;
`;

const EmptyStateNote = styled.p`
  font-size: 0.875rem;
  color: #718096;
  font-style: italic;
`;

const VariantDisplaySection = styled(Card)`
  padding: 1.5rem;
`;

const VariantDisplayGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 1.5rem;
  margin-top: 1.5rem;
`;

const VariantCard = styled.div`
  background-color: #f0f9ff; /* blue-50 */
  border: 1px solid #bfdbfe; /* blue-200 */
  border-radius: 0.75rem;
  padding: 1.25rem;
  text-align: center;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;

const VariantName = styled.h4`
  font-size: 1.125rem;
  font-weight: 600;
  color: #1e40af; /* blue-800 */
  margin-bottom: 0.75rem;
`;

const SimulatedButton = styled.button`
  padding: 0.75rem 1.5rem;
  border-radius: 0.5rem;
  font-weight: 600;
  color: white;
  background-color: ${(props) => props.color || '#3b82f6'};
  border: none;
  cursor: pointer;
  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
  transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;

  &:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 8px rgba(0,0,0,0.25);
  }
`;

const VariantDescription = styled.p`
  font-size: 0.875rem;
  color: #4a5568;
  margin-top: 0.75rem;
`;

// --- Helper Functions ---

const generateRandomId = () => {
  return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};

const generateDummyUsers = (count = 100) => { // Increased default count for better stats
  return Array.from({ length: count }, (_, i) => ({
    id: generateRandomId(),
    name: `Test User ${i + 1}`,
    email: `user${i + 1}@example.com`,
    accountType: i % 3 === 0 ? 'Premium' : i % 2 === 0 ? 'Gold' : 'Standard',
  }));
};

// --- Main Component ---

const ABTestSimulator = () => {
  const [testResults, setTestResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [currentTestingUser, setCurrentTestingUser] = useState('');
  const [featureFlagName, setFeatureFlagName] = useState('ab-test-variant-feature'); // Default flag name
  const updateContext = useUnleashContext();
  const unleashClient = useUnleashClient();

  // Define expected variants for display purposes (these should match Unleash config)
  // In a real app, you might fetch these or have them configured.
  const expectedVariants = [
    { name: 'control-cta', buttonText: 'Learn More', buttonColor: '#2563eb', description: 'Standard blue button' },
    { name: 'variant-a-cta', buttonText: 'Discover Now', buttonColor: '#16a34a', description: 'Green button for discovery' },
    { name: 'variant-b-cta', buttonText: 'Explore Features', buttonColor: '#dc2626', description: 'Red button to explore' },
  ];

  const dummyUsers = generateDummyUsers(100); // Generate 100 dummy users for better distribution

  const runABCTest = useCallback(async () => {
    setIsLoading(true);
    setTestResults([]);
    const results = [];

    for (let i = 0; i < dummyUsers.length; i++) {
      const user = dummyUsers[i];
      setCurrentTestingUser(user.name);

      try {
        // Update context for this user
        await updateContext({
          userId: user.id,
          // You can add other context properties if your Unleash strategies use them
          // e.g., accountType: user.accountType,
        });

        // Small delay to allow context to fully propagate and client to potentially re-evaluate
        // This is more for visual effect in a rapid loop, less for functional necessity with useUnleashClient
        await new Promise((resolve) => setTimeout(resolve, 100));

        // Get the variant for the current user and feature flag
        const variant = unleashClient.getVariant(featureFlagName);

        results.push({
          ...user,
          variantName: variant.name,
          variantPayload: variant.payload ? JSON.parse(variant.payload.value) : null, // Parse payload if it exists
          testTimestamp: new Date().toLocaleTimeString(),
          flagEnabled: variant.enabled, // Track if the flag was enabled for this user
        });
      } catch (error) {
        console.error(`Error testing user ${user.id}:`, error);
        results.push({
          ...user,
          variantName: 'error',
          variantPayload: null,
          testTimestamp: new Date().toLocaleTimeString(),
          flagEnabled: false,
          error: true,
        });
      }
    }

    setTestResults(results);
    setCurrentTestingUser('');
    setIsLoading(false);
  }, [dummyUsers, featureFlagName, updateContext, unleashClient]);

  // Calculate rollout statistics for each variant
  const calculateStats = useCallback(() => {
    if (testResults.length === 0) {
      return {
        total: 0,
        variants: expectedVariants.map(v => ({
          name: v.name,
          count: 0,
          percentage: 0,
          color: v.buttonColor,
          buttonText: v.buttonText,
        })),
        totalEnabled: 0,
        totalEnabledPercentage: 0,
      };
    }

    const variantCounts = {};
    let totalEnabled = 0;

    testResults.forEach((result) => {
      if (result.flagEnabled) { // Only count if the flag was actually enabled for the user
        totalEnabled++;
        variantCounts[result.variantName] = (variantCounts[result.variantName] || 0) + 1;
      }
    });

    const stats = expectedVariants.map(v => {
      const count = variantCounts[v.name] || 0;
      const percentage = totalEnabled > 0 ? Math.round((count / totalEnabled) * 100) : 0;
      return {
        name: v.name,
        count,
        percentage,
        color: v.buttonColor,
        buttonText: v.buttonText,
      };
    });

    return {
      total: testResults.length,
      variants: stats,
      totalEnabled: totalEnabled,
      totalEnabledPercentage: testResults.length > 0 ? Math.round((totalEnabled / testResults.length) * 100) : 0,
    };
  }, [testResults, expectedVariants]);

  const stats = calculateStats();

  return (
    <Container>
      <MaxWidthWrapper>
        {/* Header */}
        <Card>
          <Header>
            <Title>โœจ Unleash A/B/C Test Simulator</Title>
            <Subtitle>
              Simulate multivariate testing with real Unleash flag variants and observe distribution.
            </Subtitle>
          </Header>
        </Card>

        {/* Configuration Panel */}
        <Card>
          <SectionTitle>Configuration</SectionTitle>
          <ConfigPanel>
            <InputGroup>
              <Label htmlFor="flagName">Feature Flag Name</Label>
              <Input
                id="flagName"
                type="text"
                value={featureFlagName}
                onChange={(e) => setFeatureFlagName(e.target.value)}
                placeholder="e.g., ab-test-variant-feature"
              />
            </InputGroup>
            <Button onClick={runABCTest} disabled={isLoading}>
              {isLoading ? (
                <>
                  <Spinner />
                  Testing...
                </>
              ) : (
                <>๐Ÿš€ Start A/B/C Test ({dummyUsers.length} Users)</>
              )}
            </Button>
          </ConfigPanel>
        </Card>

        {/* Current Status */}
        {isLoading && (
          <StatusBanner>
            <StatusContent>
              <StatusSpinner />
              <StatusText>Currently testing: {currentTestingUser}</StatusText>
            </StatusContent>
          </StatusBanner>
        )}

        {/* Visual Representation of Variants */}
        <VariantDisplaySection>
          <SectionTitle>Simulated Variant Preview</SectionTitle>
          <p style={{ color: '#4a5568', fontSize: '0.9rem', marginBottom: '1rem' }}>
            These are the expected UI variations based on the payload configured in Unleash.
            Your application would render one of these for each user.
          </p>
          <VariantDisplayGrid>
            {expectedVariants.map((variant) => (
              <VariantCard key={variant.name}>
                <VariantName>{variant.name}</VariantName>
                <SimulatedButton color={variant.buttonColor}>
                  {variant.buttonText}
                </SimulatedButton>
                <VariantDescription>{variant.description}</VariantDescription>
              </VariantCard>
            ))}
          </VariantDisplayGrid>
        </VariantDisplaySection>

        {/* Statistics Dashboard */}
        {testResults.length > 0 && (
          <>
            <StatsGrid>
              <StatCard>
                <StatValue color="#2563eb">{stats.total}</StatValue>
                <StatLabel>Total Users Tested</StatLabel>
              </StatCard>
              <StatCard>
                <StatValue color="#16a34a">{stats.totalEnabled}</StatValue>
                <StatLabel>Flag Enabled Users</StatLabel>
              </StatCard>
              <StatCard>
                <StatValue color="#7c3aed">{stats.totalEnabledPercentage}%</StatValue>
                <StatLabel>Total Rollout Percentage</StatLabel>
              </StatCard>
            </StatsGrid>

            <ProgressSection>
              <ProgressTitle>Variant Distribution</ProgressTitle>
              <ProgressBarContainer>
                {stats.variants.map((variantStat) => (
                  <ProgressBarSegment key={variantStat.name} percentage={variantStat.percentage} color={variantStat.color}>
                    {variantStat.percentage > 0 && `${variantStat.percentage}%`}
                  </ProgressBarSegment>
                ))}
              </ProgressBarContainer>
              <ProgressLabels>
                {stats.variants.map((variantStat) => (
                  <span key={`label-${variantStat.name}`} style={{ flex: 1, textAlign: 'center', color: variantStat.color }}>
                    {variantStat.name} ({variantStat.count})
                  </span>
                ))}
              </ProgressLabels>
            </ProgressSection>
          </>
        )}

        {/* Results Table */}
        {testResults.length > 0 && (
          <ResultsCard>
            <ResultsHeader>
              <ResultsTitle>Detailed Test Results</ResultsTitle>
            </ResultsHeader>
            <TableContainer>
              <Table>
                <TableHead>
                  <tr>
                    <TableHeader>User</TableHeader>
                    <TableHeader>Account Type</TableHeader>
                    <TableHeader>Assigned Variant</TableHeader>
                    <TableHeader>Flag Enabled</TableHeader>
                    <TableHeader>Test Time</TableHeader>
                  </tr>
                </TableHead>
                <tbody>
                  {testResults.map((result, index) => (
                    <TableRow key={result.id}>
                      <TableCell>
                        <UserInfo>
                          <UserAvatar>
                            <UserInitials>
                              {result.name
                                .split(' ')
                                .map((n) => n[0])
                                .join('')
                                .slice(0, 2)}
                            </UserInitials>
                          </UserAvatar>
                          <UserDetails>
                            <UserName>{result.name}</UserName>
                            <UserEmail>{result.email}</UserEmail>
                          </UserDetails>
                        </UserInfo>
                      </TableCell>
                      <TableCell>
                        <Badge type={result.accountType}>
                          {result.accountType}
                        </Badge>
                      </TableCell>
                      <TableCell>
                        {result.error ? (
                          <StatusBadge type="error">โŒ Error</StatusBadge>
                        ) : result.flagEnabled ? (
                          <StatusBadge type="enabled">{result.variantName}</StatusBadge>
                        ) : (
                          <StatusBadge type="disabled">N/A (Disabled)</StatusBadge>
                        )}
                      </TableCell>
                      <TableCell>
                        {result.error ? (
                          <StatusBadge type="error">โŒ No</StatusBadge>
                        ) : result.flagEnabled ? (
                          <StatusBadge type="enabled">โœ… Yes</StatusBadge>
                        ) : (
                          <StatusBadge type="disabled">โŒ No</StatusBadge>
                        )}
                      </TableCell>
                      <TableCell>{result.testTimestamp}</TableCell>
                    </TableRow>
                  ))}
                </tbody>
              </Table>
            </TableContainer>
          </ResultsCard>
        )}

        {/* Initial State */}
        {testResults.length === 0 && !isLoading && (
          <EmptyState>
            <EmptyStateIcon>๐Ÿ“Š</EmptyStateIcon>
            <EmptyStateTitle>Ready to Simulate A/B/C Tests</EmptyStateTitle>
            <EmptyStateText>
              Enter your feature flag name and click "Start Test" to see how Unleash distributes users across variants.
            </EmptyStateText>
            <EmptyStateNote>
              Ensure your flag "{featureFlagName}" is configured in Unleash with at least three variants and a gradual rollout strategy.
            </EmptyStateNote>
          </EmptyState>
        )}
      </MaxWidthWrapper>
    </Container>
  );
};

export default ABTestSimulator;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)