• Skip pattern analysis component

    Renders visualizations of algorithmically detected patterns in skip behavior. Provides multiple chart types, filtering options, and detailed pattern information with evidence.

    The component handles multiple states:

    • Loading state with skeleton placeholders
    • Error state with message and retry option
    • Empty state for users with insufficient data
    • Populated state with pattern visualizations and details

    Parameters

    • props: SkipPatternsTabProps

      Component properties

      Props for the SkipPatternsTab component

      • loading: boolean

        Whether statistics data is currently being loaded

      • statistics: null | StatisticsData

        Raw statistics data object or null if unavailable

    Returns Element

    React component with skip pattern analysis

    export function SkipPatternsTab({ loading }: SkipPatternsTabProps) {
    const [patterns, setPatterns] = useState<DetectedPattern[]>([]);
    const [patternLoading, setPatternLoading] = useState(true);
    const [patternError, setPatternError] = useState<string | null>(null);
    const [expandedPattern, setExpandedPattern] = useState<string | null>(null);
    const [refreshTrigger, setRefreshTrigger] = useState(0);
    const [activeFilter, setActiveFilter] = useState<string | null>(null);
    const [typeChartView, setTypeChartView] = useState<"pie" | "bar">("pie");
    const [confidenceChartView, setConfidenceChartView] = useState<
    "bar" | "radial"
    >("bar");

    /**
    * Fetches skip pattern data from the API
    *
    * Retrieves algorithmically detected patterns through the statistics API.
    * Handles success, failure, and error states appropriately.
    * Updates component state with fetched data or error messages.
    */
    const fetchPatterns = async () => {
    setPatternLoading(true);
    try {
    // Use statisticsAPI instead of spotify
    const response = await window.statisticsAPI.getSkipPatterns();
    console.log("Fetched patterns response:", response);

    if (response.success && response.data) {
    setPatterns(response.data);
    setPatternError(null);
    } else {
    setPatterns([]);
    setPatternError(response.error || "Failed to load patterns");
    }
    } catch (err) {
    console.error("Error fetching skip patterns:", err);
    setPatternError("Failed to load skip patterns");
    window.spotify.saveLog(
    `Error fetching skip patterns: ${err instanceof Error ? err.message : String(err)}`,
    "ERROR",
    );
    } finally {
    setPatternLoading(false);
    }
    };

    // Fetch patterns on component mount and when refresh is triggered
    useEffect(() => {
    fetchPatterns();
    }, [refreshTrigger]);

    // Toggle pattern expansion
    const handleExpandPattern = (patternId: string) => {
    setExpandedPattern(expandedPattern === patternId ? null : patternId);
    };

    /**
    * Updates active filter for pattern types
    *
    * Controls filtering of patterns by type (artist aversion, time of day, etc.)
    * Toggles filter off if the same type is selected twice.
    *
    * @param type - Pattern type to filter by, or null to clear filter
    */
    const handleFilterChange = (type: string | null) => {
    setActiveFilter(activeFilter === type ? null : type);
    };

    // Get filtered patterns
    const filteredPatterns = activeFilter
    ? patterns.filter((p) => p.type === activeFilter)
    : patterns;

    /**
    * Generates aggregated data for pattern type distribution chart
    *
    * Groups patterns by type and formats data for visualization.
    * Adds display-friendly type names and appropriate colors.
    *
    * @returns Array of pattern type distribution data objects for charts
    */
    const getPatternsByType = () => {
    const groupedPatterns: Record<string, number> = {};

    patterns.forEach((pattern) => {
    groupedPatterns[pattern.type] = (groupedPatterns[pattern.type] || 0) + 1;
    });

    return Object.entries(groupedPatterns).map(([type, count]) => ({
    type: type
    .split("_")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" "),
    rawType: type,
    count,
    color: getTypeColor(type),
    }));
    };

    /**
    * Generates data for confidence level distribution chart
    *
    * Groups patterns by confidence level ranges and formats for visualization.
    * Maps confidence ranges to appropriate colors based on reliability.
    *
    * @returns Array of confidence distribution data objects for charts
    */
    const getConfidenceDistribution = () => {
    const ranges = [
    { range: "70-75%", count: 0, color: "#d1d5db" }, // Gray
    { range: "75-80%", count: 0, color: "#94a3b8" }, // Slate
    { range: "80-85%", count: 0, color: "#64748b" }, // Slate darker
    { range: "85-90%", count: 0, color: "#f59e0b" }, // Amber
    { range: "90-95%", count: 0, color: "#10b981" }, // Emerald
    { range: "95-100%", count: 0, color: "#059669" }, // Emerald darker
    ];

    const patternsToUse = activeFilter ? filteredPatterns : patterns;

    patternsToUse.forEach((pattern) => {
    const confidence = pattern.confidence * 100;
    if (confidence >= 95) ranges[5].count++;
    else if (confidence >= 90) ranges[4].count++;
    else if (confidence >= 85) ranges[3].count++;
    else if (confidence >= 80) ranges[2].count++;
    else if (confidence >= 75) ranges[1].count++;
    else ranges[0].count++;
    });

    return ranges;
    };

    // Generate data for the pattern type distribution chart
    const patternTypeData = getPatternsByType();

    // Generate data for confidence distribution chart
    const confidenceData = getConfidenceDistribution();

    /**
    * Generates data for pattern occurrence trend analysis
    *
    * Groups patterns by detection date to show trends over time.
    * Formats dates and counts for time-series visualization.
    *
    * @returns Array of trend data objects for timeline charts
    */
    const getOccurrenceTrend = () => {
    const patternsToUse = activeFilter ? filteredPatterns : patterns;
    const dateMap: Record<string, number> = {};

    patternsToUse.forEach((pattern) => {
    if (!pattern.firstDetected) return;

    // Group by month-year for trend
    const date = new Date(pattern.firstDetected);
    const monthYear = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}`;

    dateMap[monthYear] = (dateMap[monthYear] || 0) + 1;
    });

    // Sort dates and convert to chart format
    return Object.entries(dateMap)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([date, count]) => ({
    date,
    formattedDate: new Date(date).toLocaleDateString(undefined, {
    month: "short",
    year: "numeric",
    }),
    count,
    }));
    };

    // Chart data for occurrence trend
    const occurrenceTrendData = getOccurrenceTrend();

    // Chart configs
    const typeChartConfig: ChartConfig = {
    count: {
    label: "Pattern Count",
    theme: {
    light: "hsl(var(--primary))",
    dark: "hsl(var(--primary))",
    },
    },
    };

    const confidenceChartConfig: ChartConfig = {
    count: {
    label: "Patterns",
    theme: {
    light: "hsl(var(--primary))",
    dark: "hsl(var(--primary))",
    },
    },
    };

    /**
    * Triggers a refresh of pattern data from the API
    *
    * Initiates a new API call to retrieve the latest detected patterns.
    * Updates loading state and increment refresh trigger to re-run effect.
    */
    const handleRefresh = async () => {
    try {
    setPatternLoading(true);
    console.log("Detecting patterns...");

    // First trigger data aggregation
    const aggregationResult = await window.statisticsAPI.triggerAggregation();
    console.log("Aggregation result:", aggregationResult);

    if (!aggregationResult.success) {
    setPatternError(
    aggregationResult.message || "Failed to aggregate skip data",
    );
    setPatternLoading(false);
    return;
    }

    // Then detect patterns
    const detectionResult = await window.statisticsAPI.detectPatterns();
    console.log("Pattern detection result:", detectionResult);

    if (!detectionResult.success) {
    setPatternError(detectionResult.message || "Failed to detect patterns");
    setPatternLoading(false);
    return;
    }

    // Refresh the pattern list
    setRefreshTrigger((prev) => prev + 1);
    } catch (err) {
    console.error("Error detecting patterns:", err);
    setPatternError("Failed to analyze skip patterns");
    window.spotify.saveLog(`Error detecting patterns: ${err}`, "ERROR");
    setPatternLoading(false);
    }
    };

    // Loading state
    if (loading || patternLoading) {
    return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
    <Card className="lg:col-span-2">
    <CardHeader className="pb-2">
    <Skeleton className="h-5 w-48" />
    </CardHeader>
    <CardContent>
    <Skeleton className="h-64 w-full" />
    </CardContent>
    </Card>
    <Card>
    <CardHeader className="pb-2">
    <Skeleton className="h-5 w-32" />
    </CardHeader>
    <CardContent>
    <Skeleton className="h-64 w-full" />
    </CardContent>
    </Card>
    <Card className="md:col-span-2 lg:col-span-3">
    <CardHeader className="pb-2">
    <Skeleton className="h-5 w-40" />
    </CardHeader>
    <CardContent>
    <div className="grid gap-4 md:grid-cols-2">
    <Skeleton className="h-32 w-full" />
    <Skeleton className="h-32 w-full" />
    <Skeleton className="h-32 w-full" />
    <Skeleton className="h-32 w-full" />
    </div>
    </CardContent>
    </Card>
    </div>
    );
    }

    // No patterns state
    if (patterns.length === 0) {
    return (
    <Card className="col-span-2">
    <CardHeader>
    <CardTitle className="flex items-center gap-2">
    <SkipForward className="text-primary h-5 w-5" />
    Skip Pattern Analysis
    </CardTitle>
    </CardHeader>
    <CardContent>
    <div className="flex flex-col items-center justify-center p-6 text-center">
    <SkipForward className="text-muted-foreground mb-4 h-12 w-12" />
    <h3 className="mb-2 text-lg font-semibold">
    No Skip Patterns Detected Yet
    </h3>
    <p className="text-muted-foreground mb-6 max-w-md">
    We haven&apos;t detected any significant patterns in your
    listening behavior yet. Keep using Spotify, and we&apos;ll analyze
    your skip patterns over time.
    </p>
    <Button onClick={handleRefresh}>
    <RefreshCw className="mr-2 h-4 w-4" />
    Analyze Now
    </Button>
    </div>
    </CardContent>
    </Card>
    );
    }

    // Error state
    if (patternError) {
    return (
    <Card className="col-span-2">
    <CardHeader>
    <CardTitle className="flex items-center gap-2">
    <SkipForward className="text-primary h-5 w-5" />
    Skip Pattern Analysis
    </CardTitle>
    </CardHeader>
    <CardContent>
    <div className="flex flex-col items-center justify-center p-6 text-center">
    <SkipForward className="text-muted-foreground mb-4 h-10 w-10" />
    <h3 className="mb-2 text-lg font-semibold">
    Error Loading Patterns
    </h3>
    <p className="text-muted-foreground mb-4">{patternError}</p>
    <Button onClick={handleRefresh} variant="outline">
    <RefreshCw className="mr-2 h-4 w-4" />
    Try Again
    </Button>
    </div>
    </CardContent>
    </Card>
    );
    }

    return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
    {/* Pattern Type Distribution Chart */}
    <Card className="lg:col-span-2">
    <CardHeader className="flex flex-row items-center justify-between pb-2">
    <CardTitle className="flex items-center gap-2 text-sm font-medium">
    <PieChartIcon className="text-primary h-4 w-4" />
    Pattern Type Distribution
    </CardTitle>
    <ToggleGroup
    type="single"
    value={typeChartView}
    onValueChange={(value) =>
    value && setTypeChartView(value as "pie" | "bar")
    }
    className="rounded-md border"
    >
    <ToggleGroupItem value="pie" aria-label="Pie Chart">
    <PieChartIcon className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    <ToggleGroupItem value="bar" aria-label="Bar Chart">
    <BarChart3 className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    </ToggleGroup>
    </CardHeader>
    <CardContent className="pt-4">
    {typeChartView === "pie" ? (
    <ChartContainer
    config={typeChartConfig}
    className="h-[350px] w-full"
    >
    <PieChart margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
    <Pie
    data={patternTypeData}
    dataKey="count"
    nameKey="type"
    cx="50%"
    cy="50%"
    outerRadius={120}
    innerRadius={40}
    paddingAngle={1}
    onClick={(data) =>
    handleFilterChange(
    (data.payload as PatternTypeData).rawType,
    )
    }
    label={({ type, count }) => `${type} (${count})`}
    labelLine={{
    stroke: "rgba(156, 163, 175, 0.5)",
    strokeWidth: 1,
    }}
    >
    {patternTypeData.map((entry, index) => (
    <Cell
    key={`cell-${index}`}
    fill={entry.color}
    stroke={activeFilter === entry.rawType ? "#000" : "none"}
    strokeWidth={activeFilter === entry.rawType ? 2 : 0}
    />
    ))}
    </Pie>
    <Tooltip
    content={
    <ChartTooltipContent
    formatter={(value, name) => [
    `${value} patterns`,
    name as string,
    ]}
    />
    }
    />
    <Legend
    layout="horizontal"
    verticalAlign="bottom"
    align="center"
    onClick={(data) => {
    const item = data as unknown as PatternTypeData;
    handleFilterChange(item.rawType);
    }}
    />
    </PieChart>
    </ChartContainer>
    ) : (
    <ChartContainer
    config={typeChartConfig}
    className="h-[350px] w-full"
    >
    <BarChart
    data={patternTypeData}
    margin={{ top: 10, right: 10, bottom: 40, left: 10 }}
    >
    <CartesianGrid
    strokeDasharray="3 3"
    vertical={false}
    opacity={0.3}
    />
    <XAxis
    dataKey="type"
    tick={{ fontSize: 11 }}
    angle={-45}
    textAnchor="end"
    height={70}
    />
    <YAxis tick={{ fontSize: 11 }} />
    <Tooltip
    content={
    <ChartTooltipContent
    formatter={(value, name) => [
    `${value} patterns`,
    name as string,
    ]}
    />
    }
    />
    <Bar
    dataKey="count"
    onClick={(data) => {
    const item = data as unknown as {
    payload: PatternTypeData;
    };
    handleFilterChange(item.payload.rawType);
    }}
    radius={[4, 4, 0, 0]}
    >
    {patternTypeData.map((entry, index) => (
    <Cell
    key={`cell-${index}`}
    fill={entry.color}
    stroke={activeFilter === entry.rawType ? "#000" : "none"}
    strokeWidth={activeFilter === entry.rawType ? 2 : 0}
    />
    ))}
    </Bar>
    </BarChart>
    </ChartContainer>
    )}
    </CardContent>
    </Card>

    {/* Confidence Distribution Chart */}
    <Card>
    <CardHeader className="flex flex-row items-center justify-between pb-2">
    <CardTitle className="flex items-center gap-2 text-sm font-medium">
    <BarChart3 className="text-primary h-4 w-4" />
    Confidence Distribution
    </CardTitle>
    <ToggleGroup
    type="single"
    value={confidenceChartView}
    onValueChange={(value) =>
    value && setConfidenceChartView(value as "bar" | "radial")
    }
    className="rounded-md border"
    >
    <ToggleGroupItem value="bar" aria-label="Bar Chart">
    <BarChart3 className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    <ToggleGroupItem value="radial" aria-label="Radial Chart">
    <PieChartIcon className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    </ToggleGroup>
    </CardHeader>
    <CardContent className="pt-4">
    {confidenceChartView === "bar" ? (
    <ChartContainer
    config={confidenceChartConfig}
    className="h-[350px] w-full"
    >
    <BarChart
    data={confidenceData}
    margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
    layout="vertical"
    >
    <CartesianGrid
    strokeDasharray="3 3"
    horizontal={true}
    opacity={0.3}
    />
    <XAxis type="number" tick={{ fontSize: 11 }} />
    <YAxis
    dataKey="range"
    type="category"
    tick={{ fontSize: 11 }}
    width={60}
    />
    <Tooltip
    content={
    <ChartTooltipContent
    formatter={(value, name) => [
    `${value} patterns`,
    name as string,
    ]}
    />
    }
    />
    <Bar dataKey="count" radius={[0, 4, 4, 0]}>
    {confidenceData.map((entry, index) => (
    <Cell key={`cell-${index}`} fill={entry.color} />
    ))}
    </Bar>
    </BarChart>
    </ChartContainer>
    ) : (
    <ChartContainer
    config={confidenceChartConfig}
    className="h-[350px] w-full"
    >
    <RadialBarChart
    innerRadius="20%"
    outerRadius="90%"
    data={confidenceData}
    startAngle={180}
    endAngle={0}
    cy="70%"
    margin={{ top: 0, right: 0, bottom: 20, left: 0 }}
    >
    <RadialBar
    background
    dataKey="count"
    label={{
    position: "insideStart",
    fill: "#fff",
    fontSize: 10,
    }}
    >
    {confidenceData.map((entry, index) => (
    <Cell key={`cell-${index}`} fill={entry.color} />
    ))}
    </RadialBar>
    <Tooltip
    content={
    <ChartTooltipContent
    formatter={(value, name, data) => [
    `${value} patterns`,
    `Confidence: ${data.payload.range}`,
    ]}
    />
    }
    />
    <Legend
    iconSize={10}
    layout="horizontal"
    verticalAlign="bottom"
    wrapperStyle={{ fontSize: "10px" }}
    />
    </RadialBarChart>
    </ChartContainer>
    )}
    </CardContent>
    </Card>

    {/* Pattern Occurrence Chart */}
    <Card className="md:col-span-2 lg:col-span-3">
    <CardHeader className="flex flex-row items-center justify-between pb-2">
    <CardTitle className="flex items-center gap-2 text-sm font-medium">
    <Activity className="text-primary h-4 w-4" />
    Pattern Detection History
    {activeFilter && (
    <Badge
    variant="outline"
    className="ml-2 cursor-pointer"
    onClick={() => setActiveFilter(null)}
    >
    Filtered • Clear
    </Badge>
    )}
    </CardTitle>
    <Button variant="outline" size="sm" onClick={handleRefresh}>
    <RefreshCw className="mr-2 h-3.5 w-3.5" />
    Refresh Data
    </Button>
    </CardHeader>
    <CardContent className="pt-4">
    <ChartContainer
    config={confidenceChartConfig}
    className="h-[200px] w-full"
    >
    <BarChart
    data={occurrenceTrendData}
    margin={{ top: 10, right: 10, bottom: 30, left: 10 }}
    >
    <CartesianGrid
    strokeDasharray="3 3"
    vertical={false}
    opacity={0.3}
    />
    <XAxis
    dataKey="formattedDate"
    tick={{ fontSize: 11 }}
    interval={0}
    />
    <YAxis
    tick={{ fontSize: 11 }}
    allowDecimals={false}
    label={{
    value: "Patterns Detected",
    angle: -90,
    position: "insideLeft",
    style: { fontSize: 12 },
    }}
    />
    <Tooltip
    content={
    <ChartTooltipContent
    formatter={(value, name, data) => [
    `${value} patterns`,
    `${data.payload.formattedDate}`,
    ]}
    />
    }
    />
    <Bar
    dataKey="count"
    fill={
    activeFilter
    ? getTypeColor(activeFilter)
    : "var(--color-count)"
    }
    radius={[4, 4, 0, 0]}
    />
    </BarChart>
    </ChartContainer>
    </CardContent>
    </Card>

    {/* Pattern List */}
    <Card className="md:col-span-2 lg:col-span-3">
    <CardHeader>
    <CardTitle>Detected Patterns ({filteredPatterns.length})</CardTitle>
    </CardHeader>
    <CardContent>
    <div className="space-y-3">
    {filteredPatterns
    .sort((a, b) => b.confidence - a.confidence)
    .map((pattern, index) => {
    const patternId = `pattern-${index}`;
    const isExpanded = expandedPattern === patternId;
    const confidencePercent = Math.round(pattern.confidence * 100);

    return (
    <Collapsible
    key={patternId}
    open={isExpanded}
    onOpenChange={() => handleExpandPattern(patternId)}
    className="bg-card rounded-lg border shadow-sm"
    >
    <div className="flex items-center justify-between p-4">
    <div className="flex items-center gap-3">
    <div
    className="bg-muted flex h-9 w-9 items-center justify-center rounded-lg"
    style={{
    backgroundColor: `${getTypeColor(pattern.type)}15`,
    }}
    >
    {getPatternIcon(pattern.type)}
    </div>
    <div>
    <div className="font-medium">
    {pattern.description}
    </div>
    <div className="text-muted-foreground text-sm">
    First seen: {formatDate(pattern.firstDetected)}
    </div>
    </div>
    </div>
    <div className="flex items-center gap-2">
    <Badge
    variant={
    confidencePercent > 85 ? "default" : "outline"
    }
    className={`whitespace-nowrap ${getConfidenceColor(pattern.confidence)}`}
    >
    {confidencePercent}% confidence
    </Badge>
    <Badge variant="outline" className="whitespace-nowrap">
    {pattern.occurrences} occurrences
    </Badge>
    <CollapsibleTrigger className="hover:bg-muted rounded-md p-1 focus:outline-none">
    {isExpanded ? (
    <ChevronUp className="h-4 w-4" />
    ) : (
    <ChevronDown className="h-4 w-4" />
    )}
    </CollapsibleTrigger>
    </div>
    </div>
    <CollapsibleContent>
    <div className="border-t px-4 py-3">
    <div className="grid gap-4 md:grid-cols-2">
    <div>
    <h4 className="mb-2 text-sm font-medium">
    Related Items:
    </h4>
    <div className="flex flex-wrap gap-1.5">
    {pattern.relatedItems.map((item, i) => (
    <Badge
    key={i}
    variant="secondary"
    className="whitespace-nowrap"
    >
    {item}
    </Badge>
    ))}
    </div>

    {/* Hourly distribution chart for time patterns */}
    {pattern.type === "time_of_day" &&
    pattern.details.hourlyDistribution && (
    <div className="mt-4">
    <h4 className="mb-2 text-sm font-medium">
    Hourly Distribution:
    </h4>
    <ResponsiveContainer
    width="100%"
    height={150}
    >
    <BarChart
    data={
    Array.isArray(
    pattern.details.hourlyDistribution,
    )
    ? pattern.details.hourlyDistribution.map(
    (
    count: number,
    hour: number,
    ) => ({
    hour:
    hour === 0
    ? "12am"
    : hour === 12
    ? "12pm"
    : hour < 12
    ? `${hour}am`
    : `${hour - 12}pm`,
    count,
    }),
    )
    : []
    }
    margin={{
    top: 5,
    right: 5,
    bottom: 20,
    left: 5,
    }}
    >
    <XAxis
    dataKey="hour"
    scale="band"
    tick={{ fontSize: 10 }}
    interval={1}
    angle={-45}
    textAnchor="end"
    height={40}
    />
    <YAxis hide />
    <Tooltip
    formatter={(value) => [
    `${value} skips`,
    "Count",
    ]}
    />
    <Bar
    dataKey="count"
    fill="#8884d8"
    radius={[4, 4, 0, 0]}
    />
    </BarChart>
    </ResponsiveContainer>
    </div>
    )}
    </div>
    <div>
    <h4 className="mb-2 text-sm font-medium">
    Pattern Details:
    </h4>
    <div className="space-y-1">
    {Object.entries(pattern.details)
    .filter(
    ([key]) =>
    ![
    "hourlyDistribution",
    "dayOfWeekDistribution",
    "peakHours",
    ].includes(key),
    )
    .map(([key, value]) => {
    // Skip complex objects from details
    if (
    typeof value === "object" &&
    value !== null
    )
    return null;

    const formattedKey = key
    .replace(/([A-Z])/g, " $1")
    .replace(/^./, (str) => str.toUpperCase());

    return (
    <div key={key} className="text-sm">
    <span className="font-medium">
    {formattedKey}:
    </span>{" "}
    <span className="text-muted-foreground">
    {typeof value === "number" &&
    !Number.isInteger(value)
    ? formatPercent(value)
    : typeof value === "string"
    ? value
    : typeof value === "number"
    ? value.toString()
    : typeof value === "boolean"
    ? value.toString()
    : ""}
    </span>
    </div>
    );
    })}
    </div>
    </div>
    </div>
    </div>
    </CollapsibleContent>
    </Collapsible>
    );
    })}
    </div>
    </CardContent>
    </Card>
    </div>
    );
    }