• Listening session statistics analysis component

    Renders visualizations of session-level listening data, including timeline of recent sessions, device usage distribution, and session metrics. Supports multiple visualization modes for different perspectives.

    The component handles three main states:

    • Loading state with skeleton placeholders
    • Empty state with guidance for new users
    • Populated state with session statistics visualizations

    Parameters

    • props: SessionsTabProps

      Component properties

      Props for the SessionsTab component

      • loading: boolean

        Whether statistics data is currently being loaded

      • statistics: null | StatisticsData

        Raw statistics data object or null if unavailable

      • recentSessions: {
            id: string;
            formattedDate: string;
            formattedTime: string;
            formattedDuration: string;
            skipRate: number;
            trackIds: string[];
            skippedTracks: number;
            deviceName: string;
            deviceType: string;
        }[]

        Array of processed session data with formatting for display

    Returns Element

    React component with session statistics visualizations

    export function SessionsTab({
    loading,
    statistics,
    recentSessions,
    }: SessionsTabProps) {
    // Add state for chart types
    const [devicesChartType, setDevicesChartType] = useState<"progress" | "pie">(
    "progress",
    );
    const [sessionsChartType, setSessionsChartType] = useState<"list" | "chart">(
    "list",
    );

    if (loading) {
    return (
    <div className="space-y-4">
    <Card className="border-border/40 overflow-hidden transition-all duration-200">
    <CardHeader className="pb-2">
    <Skeleton className="h-5 w-48" />
    </CardHeader>
    <CardContent className="pt-4">
    <div className="space-y-4">
    {Array(3)
    .fill(0)
    .map((_, i) => (
    <Card key={i} className="border-border/40 overflow-hidden">
    <CardContent className="p-4">
    <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
    <div>
    <Skeleton className="mb-2 h-4 w-32" />
    <Skeleton className="h-3 w-40" />
    </div>
    <div>
    <Skeleton className="mb-2 h-4 w-24" />
    <Skeleton className="h-3 w-28" />
    </div>
    </div>
    </CardContent>
    </Card>
    ))}
    </div>
    </CardContent>
    </Card>

    <Card className="border-border/40 overflow-hidden transition-all duration-200">
    <CardHeader className="pb-2">
    <Skeleton className="h-5 w-36" />
    </CardHeader>
    <CardContent className="pt-4">
    <div className="space-y-3">
    {Array(4)
    .fill(0)
    .map((_, i) => (
    <div key={i} className="space-y-1">
    <div className="flex justify-between">
    <Skeleton className="h-4 w-24" />
    <Skeleton className="h-4 w-20" />
    </div>
    <div className="flex items-center gap-2">
    <Skeleton className="h-2 w-full flex-1" />
    <Skeleton className="h-3 w-16" />
    </div>
    </div>
    ))}
    </div>
    </CardContent>
    </Card>

    <Card className="border-border/40 overflow-hidden transition-all duration-200">
    <CardHeader className="pb-2">
    <Skeleton className="h-5 w-40" />
    </CardHeader>
    <CardContent className="pt-4">
    <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
    {Array(3)
    .fill(0)
    .map((_, i) => (
    <Skeleton key={i} className="h-24 rounded-lg" />
    ))}
    </div>
    </CardContent>
    </Card>
    </div>
    );
    }

    if (!statistics || statistics.sessions.length === 0) {
    return (
    <NoDataMessage message="No session data available yet. Keep listening to music to generate insights!" />
    );
    }

    /**
    * Determines text color based on session skip rate
    *
    * Maps skip rate values to appropriate colors to provide visual feedback:
    * - Low values (< 20%): Green for sessions with few skips
    * - Medium values (20-40%): Amber for sessions with moderate skips
    * - High values (> 40%): Red for sessions with frequent skips
    *
    * @param skipRate - Skip rate as a decimal (0-1)
    * @returns CSS class string for the text color
    */
    const getSkipRateColor = (skipRate: number) => {
    if (skipRate < 0.2) return "text-emerald-500";
    if (skipRate < 0.4) return "text-amber-500";
    return "text-rose-500";
    };

    /**
    * Returns appropriate device icon based on device type
    *
    * Maps device type strings to Lucide icon components with appropriate styling:
    * - Phones/mobile devices: Smartphone icon with sky blue color
    * - Tablets/iPads: Tablet icon with indigo color
    * - All other devices: Laptop icon with violet color
    *
    * @param deviceType - Device type string (e.g., "phone", "tablet", "computer")
    * @returns React element containing the appropriate icon component
    */
    const getDeviceIcon = (deviceType: string) => {
    const type = deviceType.toLowerCase();
    if (type.includes("phone") || type.includes("mobile")) {
    return <Smartphone className="h-4 w-4 text-sky-500" />;
    } else if (type.includes("tablet") || type.includes("ipad")) {
    return <Tablet className="h-4 w-4 text-indigo-500" />;
    } else {
    return <Laptop className="h-4 w-4 text-violet-500" />;
    }
    };

    // Calculate total listening time for percentage calculations
    const totalListeningTime = Object.entries(
    statistics.sessions.reduce(
    (acc, session) => {
    const deviceType = session.deviceType || "Unknown";
    if (!acc[deviceType]) {
    acc[deviceType] = {
    count: 0,
    totalDuration: 0,
    };
    }
    acc[deviceType].count += 1;
    acc[deviceType].totalDuration += session.durationMs;
    return acc;
    },
    {} as Record<string, { count: number; totalDuration: number }>,
    ),
    ).reduce((total, [, data]) => total + data.totalDuration, 0);

    // Prepare data for device usage pie chart
    const deviceUsageData = Object.entries(
    statistics.sessions.reduce(
    (acc, session) => {
    const deviceType = session.deviceType || "Unknown";
    if (!acc[deviceType]) {
    acc[deviceType] = {
    count: 0,
    totalDuration: 0,
    };
    }
    acc[deviceType].count += 1;
    acc[deviceType].totalDuration += session.durationMs;
    return acc;
    },
    {} as Record<string, { count: number; totalDuration: number }>,
    ),
    )
    .sort((a, b) => b[1].totalDuration - a[1].totalDuration)
    .map(([deviceType, data]) => ({
    name: deviceType,
    value: data.totalDuration,
    count: data.count,
    percentage:
    totalListeningTime > 0
    ? (data.totalDuration / totalListeningTime) * 100
    : 0,
    }));

    // Prepare data for sessions timeline chart
    const sessionsTimelineData = recentSessions
    .map((session) => {
    // Use both formatted date and time
    const dateTime = `${session.formattedDate} ${session.formattedTime}`;

    // Parse duration from formattedDuration string (assuming format like "5m 20s")
    let durationInMinutes = 0;
    const durationParts = session.formattedDuration.split(" ");
    durationParts.forEach((part) => {
    if (part.includes("h")) {
    durationInMinutes += parseFloat(part.replace("h", "")) * 60;
    } else if (part.includes("m")) {
    durationInMinutes += parseFloat(part.replace("m", ""));
    } else if (part.includes("s")) {
    durationInMinutes += parseFloat(part.replace("s", "")) / 60;
    }
    });

    return {
    date: session.formattedDate,
    time: session.formattedTime,
    dateTime: dateTime,
    tracks: session.trackIds.length,
    skipped: session.skippedTracks,
    skipRate: session.skipRate * 100,
    duration: parseFloat(durationInMinutes.toFixed(1)), // Round to 1 decimal
    };
    })
    .reverse();

    // Chart configs
    const devicesChartConfig: ChartConfig = {
    device: {
    label: "Device Usage",
    theme: {
    light: "hsl(var(--sky-500))",
    dark: "hsl(var(--sky-500))",
    },
    },
    };

    const sessionsChartConfig: ChartConfig = {
    tracks: {
    label: "Tracks Played",
    theme: {
    light: "hsl(var(--violet-500))",
    dark: "hsl(var(--violet-500))",
    },
    },
    skipped: {
    label: "Tracks Skipped",
    theme: {
    light: "hsl(var(--rose-500))",
    dark: "hsl(var(--rose-500))",
    },
    },
    duration: {
    label: "Duration (min)",
    theme: {
    light: "hsl(var(--sky-500))",
    dark: "hsl(var(--sky-500))",
    },
    },
    skipRate: {
    label: "Skip Rate (%)",
    theme: {
    light: "hsl(var(--amber-500))",
    dark: "hsl(var(--amber-500))",
    },
    },
    };

    // Colors for charts
    const COLORS = ["#0ea5e9", "#8b5cf6", "#f43f5e", "#10b981", "#f59e0b"];

    return (
    <div className="space-y-4">
    <Card className="hover:border-primary/30 group overflow-hidden transition-all duration-200 hover:shadow-md">
    <CardHeader className="pb-2">
    <div className="flex items-center justify-between">
    <CardTitle className="flex items-center gap-2 text-sm font-medium">
    <History className="text-primary h-4 w-4" />
    Recent Listening Sessions
    </CardTitle>
    <ToggleGroup
    type="single"
    value={sessionsChartType}
    onValueChange={(value: string) =>
    value && setSessionsChartType(value as "list" | "chart")
    }
    className="rounded-md border"
    >
    <ToggleGroupItem value="list" aria-label="List view">
    <List className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    <ToggleGroupItem value="chart" aria-label="Chart view">
    <LineChartIcon className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    </ToggleGroup>
    </div>
    </CardHeader>
    <CardContent className="pt-4">
    {sessionsChartType === "list" ? (
    // Original list view
    recentSessions.length > 0 ? (
    <div className="relative">
    <ScrollArea className="h-[450px] overflow-auto pr-2">
    <div className="space-y-4 pb-1">
    {recentSessions.map((session) => (
    <Card
    key={session.id}
    className="border-border/60 hover:border-primary/20 border bg-transparent transition-all duration-200 hover:shadow-sm"
    >
    <CardContent className="p-4">
    <div className="flex flex-col justify-between gap-3 md:flex-row md:items-center">
    <div>
    <div className="flex items-center gap-2 font-medium">
    <Calendar className="text-primary h-4 w-4" />
    {session.formattedDate}
    <span className="text-muted-foreground">•</span>
    <Clock className="h-4 w-4 text-indigo-500" />
    {session.formattedTime}
    </div>
    <div className="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
    <Timer className="h-3.5 w-3.5" />
    {session.formattedDuration}
    <span className="mx-1">•</span>
    {getDeviceIcon(session.deviceType)}
    {session.deviceName || "Unknown device"}
    </div>
    </div>
    <div className="space-y-1 text-sm">
    <div className="flex items-center gap-1.5 font-medium">
    <Music2 className="h-4 w-4 text-violet-500" />
    {session.trackIds.length}{" "}
    {session.trackIds.length === 1
    ? "track"
    : "tracks"}{" "}
    played
    </div>
    <div className="flex items-center gap-1.5">
    <SkipForward className="text-muted-foreground h-3.5 w-3.5" />
    <span
    className={`${getSkipRateColor(session.skipRate)}`}
    >
    {session.skippedTracks} skipped (
    {formatPercent(session.skipRate)})
    </span>
    </div>
    </div>
    </div>

    {/* Adding a subtle progress bar to indicate completion rate */}
    <div className="mt-3">
    <div className="mb-1.5 flex items-center justify-between text-xs">
    <span className="text-muted-foreground">
    Completion rate
    </span>
    <span
    className={
    session.skipRate < 0.3
    ? "text-emerald-500"
    : "text-amber-500"
    }
    >
    {formatPercent(1 - session.skipRate)}
    </span>
    </div>
    <Progress
    value={(1 - session.skipRate) * 100}
    className={`h-1.5 ${
    session.skipRate < 0.2
    ? "bg-emerald-500"
    : session.skipRate < 0.4
    ? "bg-amber-500"
    : "bg-rose-500"
    }`}
    />
    </div>
    </CardContent>
    </Card>
    ))}
    </div>
    </ScrollArea>
    </div>
    ) : (
    <p className="text-muted-foreground text-sm">
    No recent sessions recorded.
    </p>
    )
    ) : (
    <div className="h-[450px]">
    <ChartContainer
    config={sessionsChartConfig}
    className="h-full w-full"
    >
    <AreaChart
    data={sessionsTimelineData}
    margin={{ top: 10, right: 30, left: 30, bottom: 40 }}
    >
    <CartesianGrid strokeDasharray="3 3" opacity={0.3} />
    <XAxis
    dataKey="dateTime"
    tick={{ fontSize: 11 }}
    angle={-45}
    textAnchor="end"
    height={70}
    tickMargin={15}
    />
    <YAxis
    yAxisId="left"
    tick={{ fontSize: 11 }}
    label={{
    value: "Number of Tracks",
    angle: -90,
    position: "left",
    style: {
    fontSize: "12px",
    textAnchor: "middle",
    },
    }}
    />
    <Legend
    verticalAlign="bottom"
    height={36}
    wrapperStyle={{ paddingTop: "50px" }}
    />
    <ChartTooltip
    content={
    <ChartTooltipContent
    formatter={(value, name) => {
    switch (name) {
    case "Tracks Played":
    return [`${value} `, "Tracks Played"];
    case "Tracks Skipped":
    return [`${value} `, "Tracks Skipped"];
    default:
    return [String(value), name];
    }
    }}
    labelFormatter={(dateValue, payload) => {
    if (payload && payload.length > 0) {
    const entry = payload[0].payload;
    return `${entry.date} ${entry.time}`;
    }
    return dateValue;
    }}
    />
    }
    />
    <Area
    type="monotone"
    dataKey="tracks"
    name="Tracks Played"
    yAxisId="left"
    stackId="1"
    fill="#8b5cf6"
    stroke="#8b5cf6"
    fillOpacity={0.6}
    />
    <Area
    type="monotone"
    dataKey="skipped"
    name="Tracks Skipped"
    yAxisId="left"
    stackId="1"
    fill="#f43f5e"
    stroke="#f43f5e"
    fillOpacity={0.6}
    />
    </AreaChart>
    </ChartContainer>
    </div>
    )}
    </CardContent>
    </Card>

    <Card className="group overflow-hidden transition-all duration-200 hover:border-sky-200 hover:shadow-md">
    <CardHeader className="pb-2">
    <div className="flex items-center justify-between">
    <CardTitle className="flex items-center gap-2 text-sm font-medium">
    <Smartphone className="h-4 w-4 text-sky-500" />
    Listening Devices
    </CardTitle>
    <ToggleGroup
    type="single"
    value={devicesChartType}
    onValueChange={(value: string) =>
    value && setDevicesChartType(value as "progress" | "pie")
    }
    className="rounded-md border"
    size="sm"
    >
    <ToggleGroupItem value="progress" aria-label="Progress bars">
    <BarChart3 className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    <ToggleGroupItem value="pie" aria-label="Pie chart">
    <PieChartIcon className="h-3.5 w-3.5" />
    </ToggleGroupItem>
    </ToggleGroup>
    </div>
    </CardHeader>
    <CardContent className="pt-4">
    {devicesChartType === "progress" ? (
    // Original progress bar visualization
    <div className="space-y-3">
    {Object.entries(
    statistics.sessions.reduce(
    (acc, session) => {
    const deviceType = session.deviceType || "Unknown";
    if (!acc[deviceType]) {
    acc[deviceType] = {
    count: 0,
    totalDuration: 0,
    };
    }
    acc[deviceType].count += 1;
    acc[deviceType].totalDuration += session.durationMs;
    return acc;
    },
    {} as Record<
    string,
    { count: number; totalDuration: number }
    >,
    ),
    )
    .sort((a, b) => b[1].totalDuration - a[1].totalDuration)
    .map(([deviceType, data]) => {
    const timePercentage =
    totalListeningTime > 0
    ? (data.totalDuration / totalListeningTime) * 100
    : 0;

    // Determine if this is a primary device (most used)
    const isPrimaryDevice = timePercentage > 50;

    return (
    <div key={deviceType} className="space-y-1.5">
    <div className="flex justify-between text-sm">
    <span
    className={`flex items-center gap-1.5 font-medium ${isPrimaryDevice ? "text-sky-500" : ""}`}
    >
    {getDeviceIcon(deviceType)}
    {deviceType}
    </span>
    <span className="text-muted-foreground flex items-center gap-1.5 text-xs">
    <span>{data.count} sessions</span>
    <span className="mx-0.5">•</span>
    <span
    className={
    isPrimaryDevice ? "font-medium text-sky-500" : ""
    }
    >
    {formatPercent(timePercentage / 100)}
    </span>
    </span>
    </div>
    <div className="flex items-center gap-2">
    <div className="flex-1">
    <Progress
    value={timePercentage}
    className={`h-2 ${isPrimaryDevice ? "bg-sky-500" : "bg-sky-500/50"}`}
    />
    </div>
    <div className="w-24 text-right text-xs font-medium">
    {formatTime(data.totalDuration)}
    </div>
    </div>
    </div>
    );
    })}
    </div>
    ) : (
    // New pie chart visualization
    <div className="h-[300px]">
    <ChartContainer
    config={devicesChartConfig}
    className="h-full w-full"
    >
    <PieChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
    <Pie
    data={deviceUsageData}
    cx="50%"
    cy="50%"
    labelLine={true}
    outerRadius={110}
    dataKey="value"
    nameKey="name"
    label={({ name, percent }) =>
    `${name} (${(percent * 100).toFixed(0)}%)`
    }
    >
    {deviceUsageData.map((entry, index) => (
    <Cell
    key={`cell-${index}`}
    fill={COLORS[index % COLORS.length]}
    />
    ))}
    </Pie>
    <ChartTooltip
    content={
    <ChartTooltipContent
    formatter={(value) => [
    `${formatTime(value as number)}`,
    "Listening Time",
    ]}
    labelFormatter={(name) => {
    const device = deviceUsageData.find(
    (d) => d.name === name,
    );
    if (device) {
    return (
    <>
    {name}
    <div className="mt-1 text-xs">
    <span>{device.count} sessions</span>
    <span className="mx-1">•</span>
    <span>
    {formatPercent(device.percentage / 100)}
    </span>
    </div>
    </>
    );
    }
    return name;
    }}
    />
    }
    />
    </PieChart>
    </ChartContainer>
    </div>
    )}
    </CardContent>
    </Card>

    <Card className="group overflow-hidden transition-all duration-200 hover:border-indigo-200 hover:shadow-md">
    <CardHeader className="pb-2">
    <CardTitle className="flex items-center gap-2 text-sm font-medium">
    <Zap className="h-4 w-4 text-indigo-500" />
    Session Quality Metrics
    </CardTitle>
    </CardHeader>
    <CardContent className="pt-4">
    <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
    <Card className="border-primary/30 overflow-hidden transition-all duration-200 hover:shadow-sm">
    <CardContent className="p-4">
    <div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
    <Clock className="h-3.5 w-3.5" />
    Average session duration
    </div>
    <div className="flex items-end justify-between">
    <div className="text-primary text-2xl font-bold">
    {formatTime(statistics.avgSessionDurationMs || 0)}
    </div>
    <div className="text-muted-foreground text-xs">
    per session
    </div>
    </div>
    </CardContent>
    </Card>

    <Card className="overflow-hidden border-violet-300/70 transition-all duration-200 hover:shadow-sm">
    <CardContent className="p-4">
    <div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
    <Repeat className="h-3.5 w-3.5" />
    Repeat listening rate
    </div>
    <div className="flex items-end justify-between">
    <div className="text-2xl font-bold text-violet-500">
    {formatPercent(statistics.repeatListeningRate || 0)}
    </div>
    <div className="text-muted-foreground text-xs">of tracks</div>
    </div>
    </CardContent>
    </Card>

    <Card className="overflow-hidden border-emerald-300/70 transition-all duration-200 hover:shadow-sm">
    <CardContent className="p-4">
    <div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
    <Zap className="h-3.5 w-3.5" />
    Longest non-skip streak
    </div>
    <div className="flex items-end justify-between">
    <div className="text-2xl font-bold text-emerald-500">
    {Math.max(
    ...(statistics.sessions || []).map(
    (s) => s.longestNonSkipStreak || 0,
    ),
    0,
    )}
    </div>
    <div className="text-muted-foreground text-xs">
    tracks in a row
    </div>
    </div>
    </CardContent>
    </Card>
    </div>
    </CardContent>
    </Card>
    </div>
    );
    }