Component properties
Props for the DevicesTab component
Whether statistics data is currently being loaded
Complete statistics data object with device metrics
React component with device usage analytics
export function DevicesTab({ loading, statistics }: DevicesTabProps) {
if (loading) {
return (
<div className="grid gap-4 md:grid-cols-2">
<Card className="border-border/40 overflow-hidden transition-all duration-200 md:col-span-2">
<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) => (
<div key={i} className="space-y-1">
<div className="flex justify-between">
<Skeleton className="h-4 w-40" />
<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-28" />
</div>
<Skeleton className="h-3 w-36" />
</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="space-y-4">
{Array(4)
.fill(0)
.map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-2 w-full flex-1" />
<Skeleton className="h-4 w-12" />
</div>
))}
</div>
</CardContent>
</Card>
<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-3">
{Array(3)
.fill(0)
.map((_, i) => (
<div key={i} className="mb-4">
<Skeleton className="mb-2 h-4 w-32" />
<Skeleton className="h-8 w-full rounded-md" />
<div className="flex w-full justify-between pt-1">
<Skeleton className="h-3 w-8" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-3 w-8" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
if (
!statistics ||
!statistics.deviceMetrics ||
Object.keys(statistics.deviceMetrics).length === 0
) {
return (
<NoDataMessage message="No device data available yet. Keep listening to music on different devices to generate insights!" />
);
}
/**
* Determines the appropriate icon for different device types
*
* Maps device type strings to visual icons that represent
* the category of device (smartphone, tablet, computer).
* Uses keyword matching to handle various device naming conventions.
*
* @param deviceType - Device type string from Spotify API
* @returns React element with the appropriate device icon
*/
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" />;
}
};
/**
* Determines progress bar color based on skip rate value
*
* Maps skip rate values to appropriate colors for visual feedback:
* - Low values (< 20%): Green to indicate healthy listening
* - Medium values (20-40%): Amber to indicate moderate skipping
* - High values (> 40%): Red to indicate frequent skipping
*
* @param skipRate - Skip rate as a decimal (0-1)
* @returns CSS class string for the progress bar color
*/
const getSkipRateColor = (skipRate: number) => {
if (skipRate < 0.2) return "bg-emerald-500";
if (skipRate < 0.4) return "bg-amber-500";
return "bg-rose-500";
};
/**
* Determines text color for skip rate labels
*
* Applies consistent color coding to skip rate text labels
* using the same threshold values as the progress bar colors.
*
* @param skipRate - Skip rate as a decimal (0-1)
* @returns CSS class string for the text color
*/
const getSkipRateTextColor = (skipRate: number) => {
if (skipRate < 0.2) return "text-emerald-500";
if (skipRate < 0.4) return "text-amber-500";
return "text-rose-500";
};
/**
* Selects an appropriate icon for different times of day
*
* Maps hour values (0-23) to time-appropriate icons:
* - Early morning (5-8): Sunrise
* - Daytime (8-17): Sun
* - Evening (17-20): Sunset
* - Night (20-5): Moon
*
* @param hour - Hour in 24-hour format (0-23)
* @returns React element with the appropriate time icon
*/
const getTimeIcon = (hour: number) => {
if (hour >= 5 && hour < 8)
return <Sunrise className="h-4 w-4 text-amber-500" />;
if (hour >= 8 && hour < 17)
return <Sun className="h-4 w-4 text-amber-500" />;
if (hour >= 17 && hour < 20)
return <Sunset className="h-4 w-4 text-orange-500" />;
return <Moon className="h-4 w-4 text-indigo-500" />;
};
return (
<div className="grid gap-4 md:grid-cols-2">
<Card className="hover:border-primary/30 group overflow-hidden transition-all duration-200 hover:shadow-md md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Cpu className="text-primary h-4 w-4" />
Device Usage Comparison
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="relative">
<ScrollArea className="h-[300px] pr-4">
<div className="mr-2 space-y-4 pb-1">
{Object.entries(statistics.deviceMetrics || {})
.sort((a, b) => b[1].listeningTimeMs - a[1].listeningTimeMs)
.map(([deviceId, data], index) => {
const maxTime = Math.max(
...Object.values(statistics.deviceMetrics || {}).map(
(d) => d.listeningTimeMs,
),
);
const percentage =
maxTime > 0 ? (data.listeningTimeMs / maxTime) * 100 : 0;
// Determine if this is a primary device
const isPrimaryDevice = index === 0;
const progressBarColor = isPrimaryDevice
? "bg-primary"
: "bg-primary/60";
return (
<div key={deviceId} className="space-y-1">
<div className="flex justify-between text-sm">
<span
className={`flex items-center gap-1.5 font-medium ${isPrimaryDevice ? "text-primary" : ""}`}
>
{getDeviceIcon(data.deviceType)}
{data.deviceName}
<span className="text-muted-foreground text-xs">
{data.deviceType}
</span>
</span>
<span className="flex items-center gap-1.5 text-xs font-medium">
<Clock className="text-primary h-3.5 w-3.5" />
{formatTime(data.listeningTimeMs)}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Progress
value={percentage}
className={`h-2 ${progressBarColor}`}
/>
</div>
<div className="flex w-32 items-center justify-end gap-1.5 text-right text-xs">
<PlayCircle className="text-muted-foreground h-3.5 w-3.5" />
<span>{data.tracksPlayed}</span>
<span className="text-muted-foreground mx-1">
•
</span>
<SkipForward className="text-muted-foreground h-3.5 w-3.5" />
<span
className={getSkipRateTextColor(data.skipRate)}
>
{formatPercent(data.skipRate)}
</span>
</div>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlarmClock className="h-3.5 w-3.5" />
Peak usage: {getTimeIcon(
data.peakUsageHour || 0,
)}{" "}
{getHourLabel(data.peakUsageHour || 0)}
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
<Card className="group overflow-hidden transition-all duration-200 hover:border-rose-200 hover:shadow-md">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<SkipForward className="h-4 w-4 text-rose-500" />
Skip Rates by Device
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="relative">
<ScrollArea className="h-[260px] pr-4">
<div className="mr-2 space-y-4 pb-1">
{Object.entries(statistics.deviceMetrics || {})
.sort((a, b) => b[1].skipRate - a[1].skipRate)
.map(([deviceId, data]) => (
<div
key={deviceId}
className="hover:bg-muted/50 rounded-md px-2 py-1 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex min-w-8 items-center justify-center">
{getDeviceIcon(data.deviceType)}
</div>
<div
className="w-32 truncate text-sm font-medium"
title={`${data.deviceName} (${data.deviceType})`}
>
{data.deviceName}
</div>
<div className="flex-1">
<Progress
value={(data.skipRate || 0) * 100}
className={`h-2 ${getSkipRateColor(data.skipRate || 0)}`}
/>
</div>
<div
className={`w-16 text-right text-sm font-semibold ${getSkipRateTextColor(data.skipRate || 0)}`}
>
{formatPercent(data.skipRate || 0)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
<Card className="group overflow-hidden transition-all duration-200 hover:border-amber-200 hover:shadow-md">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Clock className="h-4 w-4 text-amber-500" />
Device Usage by Time of Day
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="relative">
<ScrollArea className="h-[260px] pr-4">
<div className="mr-2 space-y-4 pb-1">
{Object.entries(statistics.deviceMetrics || {})
.sort((a, b) => b[1].listeningTimeMs - a[1].listeningTimeMs)
.map(([deviceId, data]) => {
// Determine time period classification
const hour = data.peakUsageHour || 0;
const timePeriod =
hour >= 5 && hour < 12
? "Morning"
: hour >= 12 && hour < 17
? "Afternoon"
: hour >= 17 && hour < 22
? "Evening"
: "Night";
const timeColorClass =
timePeriod === "Morning"
? "bg-amber-500"
: timePeriod === "Afternoon"
? "bg-orange-500"
: timePeriod === "Evening"
? "bg-rose-500"
: "bg-indigo-500";
const textColorClass =
timePeriod === "Morning"
? "text-amber-500"
: timePeriod === "Afternoon"
? "text-orange-500"
: timePeriod === "Evening"
? "text-rose-500"
: "text-indigo-500";
return (
<div
key={deviceId}
className="hover:bg-muted/20 mb-6 rounded-md p-2 transition-colors"
>
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-medium">
{getDeviceIcon(data.deviceType)}
{data.deviceName}
</h4>
<div className="bg-muted/30 relative h-10 w-full rounded-md">
<div
className={`absolute top-0 h-full rounded-md opacity-80 ${timeColorClass}`}
style={{
left: `${((data.peakUsageHour || 0) / 24) * 100}%`,
width: "8.33%", // 2 hours width
}}
></div>
{/* Time markers */}
<div className="text-muted-foreground absolute flex w-full justify-between text-xs">
{[0, 6, 12, 18, 24].map((h) => (
<div
key={h}
className="border-border/30 absolute h-10 border-l"
style={{ left: `${(h / 24) * 100}%` }}
></div>
))}
</div>
</div>
{/* Time markers */}
<div className="text-muted-foreground flex w-full justify-between pt-1 text-xs">
<span className="flex items-center gap-1">
<Moon className="h-3 w-3" />
12am
</span>
<span className="flex items-center gap-1">
<Sunrise className="h-3 w-3" />
6am
</span>
<span className="flex items-center gap-1">
<Sun className="h-3 w-3" />
12pm
</span>
<span className="flex items-center gap-1">
<Sunset className="h-3 w-3" />
6pm
</span>
<span className="flex items-center gap-1">
<Moon className="h-3 w-3" />
12am
</span>
</div>
<div
className={`mt-2 flex items-center justify-center gap-1 text-xs ${textColorClass} font-medium`}
>
{getTimeIcon(data.peakUsageHour || 0)}
Peak: {getHourLabel(data.peakUsageHour || 0)} (
{timePeriod})
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
</div>
);
}
Device usage analysis dashboard
Renders a collection of visualizations showing how listening behavior varies across different devices. Provides comparative analysis of metrics such as listening time, skip rates, and time-of-day usage patterns.
The component handles multiple states:
Visual elements include progress bars for comparisons, color-coded indicators for skip rates, and specialized icons for different device types and times of day.