feat: Add ListeningStreakCard component for tracking listening streaks

feat: Implement InfiniteScroll component for loading more items on scroll

feat: Create useListeningStreak hook to manage listening streak data and statistics

feat: Develop useProgressiveAlbumLoading hook for progressive loading of albums

feat: Implement background sync service worker for automatic data synchronization
This commit is contained in:
2025-08-11 14:50:57 +00:00
committed by GitHub
parent 02d60f4805
commit d467796b31
15 changed files with 2112 additions and 81 deletions

View File

@@ -228,6 +228,40 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
if (currentTrack) {
setPlayedTracks((prev) => [...prev, currentTrack]);
// Record the play for listening streak
// This will store timestamp with the track play
try {
const today = new Date().toISOString().split('T')[0];
const streakData = localStorage.getItem('navidrome-streak-data');
if (streakData) {
const parsedData = JSON.parse(streakData);
const todayData = parsedData[today] || {
date: today,
tracks: 0,
uniqueArtists: [],
uniqueAlbums: [],
totalListeningTime: 0
};
// Update today's listening data
todayData.tracks += 1;
if (!todayData.uniqueArtists.includes(currentTrack.artistId)) {
todayData.uniqueArtists.push(currentTrack.artistId);
}
if (!todayData.uniqueAlbums.includes(currentTrack.albumId)) {
todayData.uniqueAlbums.push(currentTrack.albumId);
}
todayData.totalListeningTime += currentTrack.duration;
// Save updated data
parsedData[today] = todayData;
localStorage.setItem('navidrome-streak-data', JSON.stringify(parsedData));
}
} catch (error) {
console.error('Failed to update listening streak data:', error);
}
}
// Set autoPlay flag on the track

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { useListeningStreak } from '@/hooks/use-listening-streak';
import { Card, CardContent } from '@/components/ui/card';
import { Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
export default function CompactListeningStreak() {
const { stats, hasListenedToday, getStreakEmoji } = useListeningStreak();
const [animate, setAnimate] = useState(false);
// Trigger animation when streak increases
useEffect(() => {
if (stats.currentStreak > 0) {
setAnimate(true);
const timer = setTimeout(() => setAnimate(false), 1000);
return () => clearTimeout(timer);
}
}, [stats.currentStreak]);
const hasCompletedToday = hasListenedToday();
const streakEmoji = getStreakEmoji();
// Only show if the streak is 3 days or more
if (stats.currentStreak < 3) {
return null;
}
return (
<Card className="mb-4">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame className={cn(
"w-5 h-5",
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
)} />
<AnimatePresence>
<motion.div
key={stats.currentStreak}
initial={{ scale: animate ? 0.8 : 1 }}
animate={{ scale: 1 }}
className="flex items-center"
>
<span className="text-xl font-bold">
{stats.currentStreak}
</span>
<span className="ml-1 text-sm text-muted-foreground">
day streak
</span>
{streakEmoji && (
<motion.span
className="ml-1 text-xl"
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
>
{streakEmoji}
</motion.span>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="text-sm text-muted-foreground">
{hasCompletedToday ? "Today's goal complete!" : "Keep listening!"}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,627 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/hooks/use-toast';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import { useNavidrome } from '@/app/components/NavidromeContext';
import {
Download,
Trash2,
RefreshCw,
Wifi,
WifiOff,
Database,
Clock,
AlertCircle,
CheckCircle,
Music,
User,
List,
HardDrive,
Disc,
Search,
Filter,
SlidersHorizontal
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import Image from 'next/image';
import { Album, Playlist } from '@/lib/navidrome';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { OfflineManagement } from './OfflineManagement';
import { Skeleton } from '@/components/ui/skeleton';
// Helper functions
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(date: Date | null): string {
if (!date) return 'Never';
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
}
// Album card for selection
function AlbumSelectionCard({
album,
isSelected,
onToggleSelection,
isDownloading,
downloadProgress,
estimatedSize
}: {
album: Album;
isSelected: boolean;
onToggleSelection: () => void;
isDownloading: boolean;
downloadProgress?: number;
estimatedSize: string;
}) {
const { api } = useNavidrome();
return (
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
<div className="flex p-3">
<div className="shrink-0">
<Image
src={album.coverArt ? (api?.getCoverArtUrl(album.coverArt) || '/default-user.jpg') : '/default-user.jpg'}
alt={album.name}
width={60}
height={60}
className="rounded-md object-cover"
/>
</div>
<div className="ml-3 flex-1 overflow-hidden">
<h4 className="font-medium truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">{album.songCount} songs {estimatedSize}</span>
<Switch
checked={isSelected}
onCheckedChange={onToggleSelection}
disabled={isDownloading}
/>
</div>
</div>
</div>
{isDownloading && downloadProgress !== undefined && (
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
)}
</Card>
);
}
// Playlist selection card
function PlaylistSelectionCard({
playlist,
isSelected,
onToggleSelection,
isDownloading,
downloadProgress,
estimatedSize
}: {
playlist: Playlist;
isSelected: boolean;
onToggleSelection: () => void;
isDownloading: boolean;
downloadProgress?: number;
estimatedSize: string;
}) {
const { api } = useNavidrome();
return (
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
<div className="flex p-3">
<div className="shrink-0">
<div className="w-[60px] h-[60px] rounded-md bg-accent flex items-center justify-center">
<List className="h-6 w-6 text-primary" />
</div>
</div>
<div className="ml-3 flex-1 overflow-hidden">
<h4 className="font-medium truncate">{playlist.name}</h4>
<p className="text-sm text-muted-foreground truncate">by {playlist.owner}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">{playlist.songCount} songs {estimatedSize}</span>
<Switch
checked={isSelected}
onCheckedChange={onToggleSelection}
disabled={isDownloading}
/>
</div>
</div>
</div>
{isDownloading && downloadProgress !== undefined && (
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
)}
</Card>
);
}
export default function EnhancedOfflineManager() {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState('overview');
const [albums, setAlbums] = useState<Album[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState({
albums: false,
playlists: false
});
const [searchQuery, setSearchQuery] = useState('');
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(new Set());
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string>>(new Set());
const [downloadingItems, setDownloadingItems] = useState<Map<string, number>>(new Map());
// Filter state
const [sortBy, setSortBy] = useState('recent');
const [filtersVisible, setFiltersVisible] = useState(false);
const offline = useOfflineLibrary();
const { api } = useNavidrome();
// Load albums and playlists
// ...existing code...
// ...existing code...
// Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists
// Load albums data
const loadAlbums = async () => {
setLoading(prev => ({ ...prev, albums: true }));
try {
const albumData = await offline.getAlbums();
setAlbums(albumData);
// Load previously selected albums from localStorage
const savedSelections = localStorage.getItem('navidrome-offline-albums');
if (savedSelections) {
setSelectedAlbums(new Set(JSON.parse(savedSelections)));
}
} catch (error) {
console.error('Failed to load albums:', error);
toast({
title: 'Error',
description: 'Failed to load albums. Please try again.',
variant: 'destructive'
});
} finally {
setLoading(prev => ({ ...prev, albums: false }));
}
};
// Load playlists data
const loadPlaylists = async () => {
setLoading(prev => ({ ...prev, playlists: true }));
try {
const playlistData = await offline.getPlaylists();
setPlaylists(playlistData);
// Load previously selected playlists from localStorage
const savedSelections = localStorage.getItem('navidrome-offline-playlists');
if (savedSelections) {
setSelectedPlaylists(new Set(JSON.parse(savedSelections)));
}
} catch (error) {
console.error('Failed to load playlists:', error);
toast({
title: 'Error',
description: 'Failed to load playlists. Please try again.',
variant: 'destructive'
});
} finally {
setLoading(prev => ({ ...prev, playlists: false }));
}
};
// Toggle album selection
const toggleAlbumSelection = (albumId: string) => {
setSelectedAlbums(prev => {
const newSelection = new Set(prev);
if (newSelection.has(albumId)) {
newSelection.delete(albumId);
} else {
newSelection.add(albumId);
}
// Save to localStorage
localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection]));
return newSelection;
});
};
// Toggle playlist selection
const togglePlaylistSelection = (playlistId: string) => {
setSelectedPlaylists(prev => {
const newSelection = new Set(prev);
if (newSelection.has(playlistId)) {
newSelection.delete(playlistId);
} else {
newSelection.add(playlistId);
}
// Save to localStorage
localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection]));
return newSelection;
});
};
// Download selected items
const downloadSelected = async () => {
// Mock implementation - in a real implementation, you'd integrate with the download system
const selectedIds = [...selectedAlbums, ...selectedPlaylists];
if (selectedIds.length === 0) {
toast({
title: 'No items selected',
description: 'Please select albums or playlists to download.',
});
return;
}
toast({
title: 'Download Started',
description: `Downloading ${selectedIds.length} items for offline use.`,
});
// Mock download progress
const downloadMap = new Map<string, number>();
selectedIds.forEach(id => downloadMap.set(id, 0));
setDownloadingItems(downloadMap);
// Simulate download progress
const interval = setInterval(() => {
setDownloadingItems(prev => {
const updated = new Map(prev);
let allComplete = true;
for (const [id, progress] of prev.entries()) {
if (progress < 100) {
updated.set(id, Math.min(progress + Math.random() * 10, 100));
allComplete = false;
}
}
if (allComplete) {
clearInterval(interval);
toast({
title: 'Download Complete',
description: `${selectedIds.length} items are now available offline.`,
});
setTimeout(() => {
setDownloadingItems(new Map());
}, 1000);
}
return updated;
});
}, 500);
};
// Filter and sort albums
const filteredAlbums = albums
.filter(album => {
if (!searchQuery) return true;
return album.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
album.artist.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
switch (sortBy) {
case 'recent':
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
case 'name':
return a.name.localeCompare(b.name);
case 'artist':
return a.artist.localeCompare(b.artist);
default:
return 0;
}
});
// Filter and sort playlists
const filteredPlaylists = playlists
.filter(playlist => {
if (!searchQuery) return true;
return playlist.name.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
switch (sortBy) {
case 'recent':
return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime();
case 'name':
return a.name.localeCompare(b.name);
default:
return 0;
}
});
// Estimate album size (mock implementation)
const estimateSize = (songCount: number) => {
const averageSongSizeMB = 8;
const totalSizeMB = songCount * averageSongSizeMB;
if (totalSizeMB > 1000) {
return `${(totalSizeMB / 1000).toFixed(1)} GB`;
}
return `${totalSizeMB.toFixed(0)} MB`;
};
return (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="space-y-4"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="albums">Albums</TabsTrigger>
<TabsTrigger value="playlists">Playlists</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OfflineManagement />
</TabsContent>
<TabsContent value="albums" className="space-y-4">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Disc className="h-5 w-5" />
Select Albums
</CardTitle>
<CardDescription>
Choose albums to make available offline
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search albums..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiltersVisible(!filtersVisible)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
{filtersVisible && (
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div className="text-sm font-medium">Sort By</div>
<div className="flex flex-wrap gap-2">
<Button
variant={sortBy === 'recent' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('recent')}
>
Recent
</Button>
<Button
variant={sortBy === 'name' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('name')}
>
Name
</Button>
<Button
variant={sortBy === 'artist' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('artist')}
>
Artist
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedAlbums(new Set())}
disabled={selectedAlbums.size === 0}
>
Clear Selection
</Button>
</div>
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
{loading.albums ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<div className="flex p-3">
<Skeleton className="h-[60px] w-[60px] rounded-md" />
<div className="ml-3 flex-1">
<Skeleton className="h-5 w-2/3 mb-1" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
))
) : filteredAlbums.length > 0 ? (
filteredAlbums.map(album => (
<AlbumSelectionCard
key={album.id}
album={album}
isSelected={selectedAlbums.has(album.id)}
onToggleSelection={() => toggleAlbumSelection(album.id)}
isDownloading={downloadingItems.has(album.id)}
downloadProgress={downloadingItems.get(album.id)}
estimatedSize={estimateSize(album.songCount)}
/>
))
) : (
<div className="text-center py-8">
<Disc className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? 'No albums found matching your search' : 'No albums available'}
</p>
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={downloadSelected}
disabled={selectedAlbums.size === 0 || downloadingItems.size > 0}
>
<Download className="h-4 w-4 mr-2" />
Download {selectedAlbums.size} Selected Album{selectedAlbums.size !== 1 ? 's' : ''}
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="playlists" className="space-y-4">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<List className="h-5 w-5" />
Select Playlists
</CardTitle>
<CardDescription>
Choose playlists to make available offline
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search playlists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiltersVisible(!filtersVisible)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
{filtersVisible && (
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div className="text-sm font-medium">Sort By</div>
<div className="flex flex-wrap gap-2">
<Button
variant={sortBy === 'recent' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('recent')}
>
Recent
</Button>
<Button
variant={sortBy === 'name' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('name')}
>
Name
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedPlaylists(new Set())}
disabled={selectedPlaylists.size === 0}
>
Clear Selection
</Button>
</div>
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
{loading.playlists ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<div className="flex p-3">
<Skeleton className="h-[60px] w-[60px] rounded-md" />
<div className="ml-3 flex-1">
<Skeleton className="h-5 w-2/3 mb-1" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
))
) : filteredPlaylists.length > 0 ? (
filteredPlaylists.map(playlist => (
<PlaylistSelectionCard
key={playlist.id}
playlist={playlist}
isSelected={selectedPlaylists.has(playlist.id)}
onToggleSelection={() => togglePlaylistSelection(playlist.id)}
isDownloading={downloadingItems.has(playlist.id)}
downloadProgress={downloadingItems.get(playlist.id)}
estimatedSize={estimateSize(playlist.songCount)}
/>
))
) : (
<div className="text-center py-8">
<List className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? 'No playlists found matching your search' : 'No playlists available'}
</p>
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={downloadSelected}
disabled={selectedPlaylists.size === 0 || downloadingItems.size > 0}
>
<Download className="h-4 w-4 mr-2" />
Download {selectedPlaylists.size} Selected Playlist{selectedPlaylists.size !== 1 ? 's' : ''}
</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useEffect, useState } from 'react';
import { useListeningStreak } from '@/hooks/use-listening-streak';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Flame, Calendar, Clock, Music, Disc, User2 } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export default function ListeningStreakCard() {
const { stats, hasListenedToday, getStreakEmoji, getTodaySummary, streakThresholds } = useListeningStreak();
const [animate, setAnimate] = useState(false);
// Trigger animation when streak increases
useEffect(() => {
if (stats.currentStreak > 0) {
setAnimate(true);
const timer = setTimeout(() => setAnimate(false), 1000);
return () => clearTimeout(timer);
}
}, [stats.currentStreak]);
const todaySummary = getTodaySummary();
const hasCompletedToday = hasListenedToday();
// Calculate progress towards today's goal
const trackProgress = Math.min(100, (todaySummary.tracks / streakThresholds.tracks) * 100);
const timeInMinutes = parseInt(todaySummary.time.replace('m', ''), 10) || 0;
const timeThresholdMinutes = Math.floor(streakThresholds.time / 60);
const timeProgress = Math.min(100, (timeInMinutes / timeThresholdMinutes) * 100);
// Overall progress (highest of the two metrics)
const overallProgress = Math.max(trackProgress, timeProgress);
return (
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame className={cn(
"w-5 h-5 transition-all",
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
)} />
<span>Listening Streak</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-normal text-muted-foreground">
{stats.totalDaysListened} days
</span>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center py-2">
<AnimatePresence>
<motion.div
key={stats.currentStreak}
initial={{ scale: animate ? 0.5 : 1 }}
animate={{ scale: 1 }}
exit={{ scale: 0.5 }}
className="relative mb-2"
>
<div className="text-5xl font-bold text-center">
{stats.currentStreak}
</div>
<div className="text-sm text-center text-muted-foreground">
day{stats.currentStreak !== 1 ? 's' : ''} streak
</div>
{getStreakEmoji() && (
<motion.div
className="absolute -top-2 -right-4 text-2xl"
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
transition={{ duration: 0.5 }}
>
{getStreakEmoji()}
</motion.div>
)}
</motion.div>
</AnimatePresence>
<div className="w-full mt-4">
<div className="flex justify-between items-center text-sm mb-1">
<span className="text-muted-foreground">Today&apos;s Progress</span>
<span className={cn(
hasCompletedToday ? "text-green-500 font-medium" : "text-muted-foreground"
)}>
{hasCompletedToday ? "Complete!" : "In progress..."}
</span>
</div>
<Progress
value={overallProgress}
className={cn(
"h-2",
hasCompletedToday ? "bg-green-500/20" : "",
hasCompletedToday ? "[&>div]:bg-green-500" : ""
)}
/>
</div>
<div className="grid grid-cols-2 gap-4 w-full mt-6">
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
<div className="flex items-center gap-2 mb-1">
<Music className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Tracks</span>
</div>
<span className="text-xl font-semibold">{todaySummary.tracks}</span>
<span className="text-xs text-muted-foreground">
Goal: {streakThresholds.tracks}
</span>
</div>
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Time</span>
</div>
<span className="text-xl font-semibold">{todaySummary.time}</span>
<span className="text-xs text-muted-foreground">
Goal: {timeThresholdMinutes}m
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 w-full mt-4">
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
<div className="flex items-center gap-2 mb-1">
<User2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Artists</span>
</div>
<span className="text-xl font-semibold">{todaySummary.artists}</span>
</div>
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
<div className="flex items-center gap-2 mb-1">
<Disc className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Albums</span>
</div>
<span className="text-xl font-semibold">{todaySummary.albums}</span>
</div>
</div>
<div className="mt-4 text-xs text-center text-muted-foreground">
{hasCompletedToday ? (
<span>You&#39;ve met your daily listening goal! 🎵</span>
) : (
<span>Listen to {streakThresholds.tracks} tracks or {timeThresholdMinutes} minutes to continue your streak!</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}