Add skip tracking, compressed heatmap, listening log, docs, tests, and OpenAI support

Major changes:
- Add skip tracking: poll currently-playing every 15s, detect skips (<30s listened)
- Add listening-log and sessions API endpoints
- Fix ReccoBeats client to extract spotify_id from href response
- Compress heatmap from 24 hours to 6 x 4-hour blocks
- Add OpenAI support in narrative service (use max_completion_tokens for new models)
- Add ListeningLog component with timeline and list views
- Update all frontend components to use real data (album art, play counts)
- Add docker-compose external network (dockernet) support
- Add comprehensive documentation (API, DATA_MODEL, ARCHITECTURE, FRONTEND)
- Add unit tests for ingest and API endpoints
This commit is contained in:
bnair123
2025-12-30 00:15:01 +04:00
parent faee830545
commit 887e78bf47
26 changed files with 1942 additions and 662 deletions

View File

@@ -5,7 +5,8 @@ import StatsGrid from './StatsGrid';
import VibeRadar from './VibeRadar';
import HeatMap from './HeatMap';
import TopRotation from './TopRotation';
import { Spin } from 'antd'; // Keeping Spin for loading state
import ListeningLog from './ListeningLog';
import { Spin } from 'antd';
const API_BASE_URL = '/api';
@@ -13,7 +14,7 @@ const Dashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const getTodayKey = () => `sonicstats_v1_${new Date().toISOString().split('T')[0]}`;
const getTodayKey = () => `sonicstats_v2_${new Date().toISOString().split('T')[0]}`;
const fetchData = async (forceRefresh = false) => {
setLoading(true);
@@ -73,9 +74,11 @@ const Dashboard = () => {
);
}
const vibeCheckFull = data?.narrative?.vibe_check || "";
const patterns = data?.narrative?.patterns || [];
return (
<>
{/* Navbar */}
<header className="sticky top-0 z-50 glass-panel border-b border-[#222f49]">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -98,27 +101,52 @@ const Dashboard = () => {
</header>
<main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* Hero */}
<NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} />
{/* Stats Bento Grid */}
<StatsGrid metrics={data?.metrics} />
{/* Sonic DNA & Chronobiology Split */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Col: Sonic DNA (2/3 width) */}
<div className="lg:col-span-2 space-y-8">
<VibeRadar vibe={data?.metrics?.vibe} />
<TopRotation volume={data?.metrics?.volume} />
</div>
{/* Right Col: Chronobiology (1/3 width) */}
<div className="lg:col-span-1 space-y-8">
<HeatMap timeHabits={data?.metrics?.time_habits} />
<HeatMap timeHabits={data?.metrics?.time_habits} sessions={data?.metrics?.sessions} />
</div>
</div>
{/* Footer: The Roast */}
<ListeningLog />
{(vibeCheckFull || patterns.length > 0) && (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary">psychology</span>
Full Analysis
</h3>
{vibeCheckFull && (
<div className="prose prose-invert max-w-none mb-6">
<p className="text-slate-300 leading-relaxed whitespace-pre-line">{vibeCheckFull}</p>
</div>
)}
{patterns.length > 0 && (
<div className="mt-4">
<h4 className="text-sm text-slate-400 uppercase font-medium mb-3">Patterns Detected</h4>
<ul className="space-y-2">
{patterns.map((pattern, idx) => (
<li key={idx} className="flex items-start gap-2 text-slate-300">
<span className="material-symbols-outlined text-primary text-sm mt-0.5">insights</span>
{pattern}
</li>
))}
</ul>
</div>
)}
</div>
)}
{data?.narrative?.roast && (
<footer className="pb-8">
<div className="paper-texture rounded-xl p-8 border border-white/10 relative overflow-hidden group">

View File

@@ -1,32 +1,44 @@
import React from 'react';
import { format, parseISO } from 'date-fns';
const HeatMap = ({ timeHabits }) => {
const HeatMap = ({ timeHabits, sessions }) => {
if (!timeHabits) return null;
// Helper to get intensity for a day/time slot
// Since we only have aggregate hourly and daily stats, we'll approximate:
// Cell(d, h) ~ Daily(d) * Hourly(h)
const heatmapCompressed = timeHabits.heatmap_compressed || timeHabits.heatmap || [];
const blockLabels = timeHabits.block_labels || ["12am-4am", "4am-8am", "8am-12pm", "12pm-4pm", "4pm-8pm", "8pm-12am"];
const sessionList = sessions?.session_list || [];
// Normalize daily distribution (0-6, Mon-Sun)
// API usually returns 0=Monday or 0=Sunday depending on backend. Let's assume 0=Monday for now.
const dailyDist = timeHabits.daily_distribution || {};
const hourlyDist = timeHabits.hourly_distribution || {};
const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const timeBlocks = [
{ label: 'Night', hours: [0, 1, 2, 3, 4, 5] },
{ label: 'Morning', hours: [6, 7, 8, 9, 10, 11] },
{ label: 'Noon', hours: [12, 13, 14, 15, 16, 17] },
{ label: 'Evening', hours: [18, 19, 20, 21, 22, 23] }
];
const maxDaily = Math.max(...Object.values(dailyDist)) || 1;
const maxHourly = Math.max(...Object.values(hourlyDist)) || 1;
// Flatten grid for rendering: 4 rows (time blocks) x 7 cols (days)
// Actually code.html has many small squares. It looks like each column is a day, and rows are finer time slots.
// Let's do 4 rows representing 6-hour blocks.
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const blocks = blockLabels.length > 0 ? blockLabels : Array.from({ length: 6 }, (_, i) => `${i*4}h-${(i+1)*4}h`);
const maxVal = Math.max(...heatmapCompressed.flat(), 1);
const getIntensityClass = (val) => {
if (val === 0) return "bg-[#1e293b]";
const ratio = val / maxVal;
if (ratio > 0.8) return "bg-primary";
if (ratio > 0.6) return "bg-primary/80";
if (ratio > 0.4) return "bg-primary/60";
if (ratio > 0.2) return "bg-primary/40";
return "bg-primary/20";
};
const recentSessions = sessionList.slice(-5).reverse();
const formatSessionTime = (isoString) => {
try {
return format(parseISO(isoString), 'MMM d, h:mm a');
} catch {
return isoString;
}
};
const getSessionTypeColor = (type) => {
if (type === "Marathon") return "bg-primary";
if (type === "Micro") return "bg-slate-600";
return "bg-primary/50";
};
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 h-full">
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
@@ -37,73 +49,66 @@ const HeatMap = ({ timeHabits }) => {
<div className="mb-8">
<h4 className="text-sm text-slate-400 mb-3 font-medium">Listening Heatmap</h4>
{/* Grid */}
<div className="grid grid-cols-7 gap-1">
{/* Header Days */}
{days.map((d, i) => (
<div key={i} className="text-[10px] text-center text-slate-500">{d}</div>
))}
{/* Generate cells: 4 rows x 7 cols */}
{timeBlocks.map((block, rowIdx) => (
<React.Fragment key={rowIdx}>
{days.map((_, colIdx) => {
// Calculate approximated intensity
const dayVal = dailyDist[colIdx] || 0;
const blockVal = block.hours.reduce((acc, h) => acc + (hourlyDist[h] || 0), 0);
// Normalize
const intensity = (dayVal / maxDaily) * (blockVal / (maxHourly * 6));
let bgClass = "bg-[#1e293b]"; // Default empty
if (intensity > 0.8) bgClass = "bg-primary";
else if (intensity > 0.6) bgClass = "bg-primary/80";
else if (intensity > 0.4) bgClass = "bg-primary/60";
else if (intensity > 0.2) bgClass = "bg-primary/40";
else if (intensity > 0) bgClass = "bg-primary/20";
return (
<div
key={`${rowIdx}-${colIdx}`}
className={`aspect-square rounded-sm ${bgClass}`}
title={`${block.label} on ${days[colIdx]}`}
></div>
);
})}
</React.Fragment>
))}
<div className="flex gap-2">
<div className="flex flex-col justify-between text-[10px] text-slate-500 pr-1 py-1">
{blocks.map((label, i) => (
<span key={i} className="leading-tight">{label.split('-')[0]}</span>
))}
</div>
<div className="flex-1">
<div className="grid grid-cols-7 gap-1 mb-2">
{days.map((d, i) => (
<div key={i} className="text-[10px] text-center text-slate-500 font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{blocks.map((block, blockIdx) => (
days.map((day, dayIdx) => {
const val = heatmapCompressed[dayIdx]?.[blockIdx] || 0;
return (
<div
key={`${dayIdx}-${blockIdx}`}
className={`h-6 rounded ${getIntensityClass(val)} transition-colors hover:ring-2 hover:ring-primary/50`}
title={`${day} ${block} - ${val} plays`}
/>
);
})
)).flat()}
</div>
</div>
</div>
<div className="flex justify-between mt-2 text-[10px] text-slate-500">
<span>00:00</span>
<span>12:00</span>
<span>23:59</span>
<div className="flex justify-end gap-1 mt-3 items-center">
<span className="text-[9px] text-slate-500">Less</span>
<div className="w-3 h-3 rounded-sm bg-primary/20"></div>
<div className="w-3 h-3 rounded-sm bg-primary/40"></div>
<div className="w-3 h-3 rounded-sm bg-primary/60"></div>
<div className="w-3 h-3 rounded-sm bg-primary/80"></div>
<div className="w-3 h-3 rounded-sm bg-primary"></div>
<span className="text-[9px] text-slate-500">More</span>
</div>
</div>
{/* Session Flow (Static for now as API doesn't provide session logs yet) */}
<div>
<h4 className="text-sm text-slate-400 mb-4 font-medium">Session Flow</h4>
<div className="relative pl-4 border-l border-[#334155] space-y-6">
<div className="relative">
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-card-dark"></span>
<p className="text-xs text-slate-400">Today, 2:30 PM</p>
<p className="text-white font-bold text-sm">Marathoning</p>
<p className="text-xs text-primary mt-0.5">3h 42m session</p>
<h4 className="text-sm text-slate-400 mb-4 font-medium">Recent Sessions</h4>
{recentSessions.length > 0 ? (
<div className="relative pl-4 border-l border-[#334155] space-y-4">
{recentSessions.map((session, idx) => (
<div key={idx} className="relative">
<span className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${getSessionTypeColor(session.type)} ring-4 ring-card-dark`}></span>
<p className="text-xs text-slate-400">{formatSessionTime(session.start_time)}</p>
<p className="text-white font-bold text-sm">{session.type} Session</p>
<p className="text-xs text-primary mt-0.5">
{session.duration_minutes}m · {session.track_count} tracks
</p>
</div>
))}
</div>
<div className="relative">
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-slate-600 ring-4 ring-card-dark"></span>
<p className="text-xs text-slate-400">Yesterday, 9:15 AM</p>
<p className="text-white font-bold text-sm">Micro-Dosing</p>
<p className="text-xs text-slate-500 mt-0.5">12m commute</p>
</div>
<div className="relative">
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary/50 ring-4 ring-card-dark"></span>
<p className="text-xs text-slate-400">Yesterday, 8:00 PM</p>
<p className="text-white font-bold text-sm">Deep Focus</p>
<p className="text-xs text-primary/70 mt-0.5">1h 15m session</p>
</div>
</div>
) : (
<p className="text-sm text-slate-500">No session data yet</p>
)}
</div>
</div>
);

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { format, parseISO, differenceInMinutes, startOfDay, endOfDay } from 'date-fns';
const API_BASE_URL = '/api';
const ListeningLog = () => {
const [plays, setPlays] = useState([]);
const [sessions, setSessions] = useState([]);
const [days, setDays] = useState(7);
const [loading, setLoading] = useState(true);
const [view, setView] = useState('timeline');
useEffect(() => {
fetchData();
}, [days]);
const fetchData = async () => {
setLoading(true);
try {
const [logRes, sessRes] = await Promise.all([
axios.get(`${API_BASE_URL}/listening-log?days=${days}&limit=500`),
axios.get(`${API_BASE_URL}/sessions?days=${days}`)
]);
setPlays(logRes.data.plays || []);
setSessions(sessRes.data.sessions || []);
} catch (error) {
console.error("Failed to fetch listening log", error);
} finally {
setLoading(false);
}
};
const formatTime = (isoString) => {
try {
return format(parseISO(isoString), 'MMM d, h:mm a');
} catch {
return isoString;
}
};
const formatDuration = (ms) => {
if (!ms) return '-';
const mins = Math.round(ms / 60000);
return `${mins}m`;
};
const groupSessionsByDay = () => {
const dayMap = {};
sessions.forEach(session => {
const dayKey = format(parseISO(session.start_time), 'yyyy-MM-dd');
if (!dayMap[dayKey]) dayMap[dayKey] = [];
dayMap[dayKey].push(session);
});
return dayMap;
};
const sessionsByDay = groupSessionsByDay();
const sortedDays = Object.keys(sessionsByDay).sort().reverse().slice(0, 7);
const getSessionPosition = (session) => {
const start = parseISO(session.start_time);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const leftPct = (startMinutes / 1440) * 100;
const widthPct = Math.max((session.duration_minutes / 1440) * 100, 1);
return { left: `${leftPct}%`, width: `${Math.min(widthPct, 100 - leftPct)}%` };
};
const getSessionColor = (type) => {
if (type === 'Marathon') return 'bg-primary';
if (type === 'Micro') return 'bg-slate-500';
return 'bg-primary/70';
};
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<span className="material-symbols-outlined text-primary">library_music</span>
Listening Log
</h3>
<div className="flex gap-2">
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="bg-card-darker border border-[#334155] rounded px-3 py-1 text-sm text-white"
>
<option value={1}>Last 24h</option>
<option value={7}>Last 7 days</option>
<option value={14}>Last 14 days</option>
<option value={30}>Last 30 days</option>
</select>
<div className="flex border border-[#334155] rounded overflow-hidden">
<button
onClick={() => setView('timeline')}
className={`px-3 py-1 text-sm ${view === 'timeline' ? 'bg-primary text-white' : 'bg-card-darker text-slate-400'}`}
>
Timeline
</button>
<button
onClick={() => setView('list')}
className={`px-3 py-1 text-sm ${view === 'list' ? 'bg-primary text-white' : 'bg-card-darker text-slate-400'}`}
>
List
</button>
</div>
</div>
</div>
{loading ? (
<div className="text-slate-400 text-center py-8">Loading...</div>
) : view === 'timeline' ? (
<div className="space-y-4">
<div className="flex text-[10px] text-slate-500 mb-2">
<div className="w-20"></div>
<div className="flex-1 flex justify-between">
<span>12am</span>
<span>6am</span>
<span>12pm</span>
<span>6pm</span>
<span>12am</span>
</div>
</div>
{sortedDays.map(day => (
<div key={day} className="flex items-center gap-2">
<div className="w-20 text-xs text-slate-400 shrink-0">
{format(parseISO(day), 'EEE, MMM d')}
</div>
<div className="flex-1 h-8 bg-card-darker rounded relative">
{sessionsByDay[day]?.map((session, idx) => {
const pos = getSessionPosition(session);
return (
<div
key={idx}
className={`absolute h-full rounded ${getSessionColor(session.type)} opacity-80 hover:opacity-100 cursor-pointer transition-opacity`}
style={{ left: pos.left, width: pos.width }}
title={`${session.type}: ${session.track_count} tracks, ${session.duration_minutes}m`}
/>
);
})}
</div>
</div>
))}
<div className="flex gap-4 mt-4 text-xs text-slate-400">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-primary"></div>
<span>Marathon (20+ tracks)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-primary/70"></div>
<span>Standard</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-slate-500"></div>
<span>Micro (1-3 tracks)</span>
</div>
</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-slate-400 border-b border-[#334155]">
<th className="pb-2 font-medium">Track</th>
<th className="pb-2 font-medium">Artist</th>
<th className="pb-2 font-medium">Played</th>
<th className="pb-2 font-medium">Listened</th>
<th className="pb-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{plays.slice(0, 50).map((play, idx) => (
<tr key={idx} className="border-b border-[#222f49] hover:bg-card-darker/50">
<td className="py-3">
<div className="flex items-center gap-3">
{play.image && (
<img src={play.image} alt="" className="w-10 h-10 rounded object-cover" />
)}
<span className="text-white font-medium truncate max-w-[200px]">{play.track_name}</span>
</div>
</td>
<td className="py-3 text-slate-400 truncate max-w-[150px]">{play.artist}</td>
<td className="py-3 text-slate-400">{formatTime(play.played_at)}</td>
<td className="py-3 text-slate-400">{formatDuration(play.listened_ms)}</td>
<td className="py-3">
{play.skipped ? (
<span className="px-2 py-0.5 rounded text-xs bg-red-500/20 text-red-400">Skipped</span>
) : (
<span className="px-2 py-0.5 rounded text-xs bg-green-500/20 text-green-400">Played</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default ListeningLog;

View File

@@ -5,37 +5,38 @@ const NarrativeSection = ({ narrative, vibe }) => {
if (!narrative) return null;
const persona = narrative.persona || "THE UNKNOWN LISTENER";
const vibeCheck = narrative.vibe_check || "Analyzing auditory aura...";
const vibeCheckShort = narrative.vibe_check_short || narrative.vibe_check?.substring(0, 120) + "..." || "Analyzing auditory aura...";
// Generate tags based on vibe metrics if available
const getTags = () => {
if (!vibe) return [];
const tags = [];
if (vibe.valence > 0.6) tags.push({ text: "HIGH VALENCE", color: "primary" });
else if (vibe.valence < 0.4) tags.push({ text: "MELANCHOLIC", color: "accent-purple" });
const valence = vibe.valence || 0;
const energy = vibe.energy || 0;
const danceability = vibe.danceability || 0;
if (valence > 0.6) tags.push({ text: "HIGH VALENCE", color: "primary" });
else if (valence < 0.4) tags.push({ text: "MELANCHOLIC", color: "accent-purple" });
if (vibe.energy > 0.6) tags.push({ text: "HIGH ENERGY", color: "accent-neon" });
else if (vibe.energy < 0.4) tags.push({ text: "CHILL VIBES", color: "accent-purple" });
if (energy > 0.6) tags.push({ text: "HIGH ENERGY", color: "accent-neon" });
else if (energy < 0.4) tags.push({ text: "CHILL VIBES", color: "accent-purple" });
if (vibe.danceability > 0.7) tags.push({ text: "DANCEABLE", color: "primary" });
if (danceability > 0.7) tags.push({ text: "DANCEABLE", color: "primary" });
return tags.slice(0, 3); // Max 3 tags
return tags.slice(0, 3);
};
const tags = getTags();
// Default tags if none generated
if (tags.length === 0) {
tags.push({ text: "ECLECTIC", color: "primary" });
tags.push({ text: "MYSTERIOUS", color: "accent-purple" });
}
return (
<section className="relative rounded-2xl overflow-hidden min-h-[400px] flex items-center justify-center p-8 bg-card-dark border border-[#222f49]">
{/* Dynamic Background */}
<section className="relative rounded-2xl overflow-hidden min-h-[300px] flex items-center justify-center p-8 bg-card-dark border border-[#222f49]">
<div className="absolute inset-0 mood-gradient"></div>
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl gap-6">
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl gap-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
@@ -47,11 +48,11 @@ const NarrativeSection = ({ narrative, vibe }) => {
</h1>
</motion.div>
<div className="font-mono text-primary/80 text-lg md:text-xl font-medium tracking-wide">
<span className="typing-cursor">{vibeCheck}</span>
<div className="font-mono text-primary/80 text-base md:text-lg font-medium tracking-wide max-w-lg">
<span className="typing-cursor">{vibeCheckShort}</span>
</div>
<div className="mt-4 flex gap-3 flex-wrap justify-center">
<div className="mt-2 flex gap-3 flex-wrap justify-center">
{tags.map((tag, i) => (
<span key={i} className={`px-3 py-1 rounded-full text-xs font-bold bg-${tag.color}/20 text-${tag.color} border border-${tag.color}/20`}>
{tag.text}

View File

@@ -4,27 +4,21 @@ const StatsGrid = ({ metrics }) => {
if (!metrics) return null;
const totalMinutes = Math.round((metrics.volume?.estimated_minutes || 0));
// Calculate days for the "That's X days straight" text
const daysListened = (totalMinutes / (24 * 60)).toFixed(1);
const obsessionTrack = metrics.volume?.top_tracks?.[0];
const obsessionName = obsessionTrack ? obsessionTrack.name : "N/A";
const obsessionArtist = obsessionTrack ? obsessionTrack.artist : "N/A";
const obsessionCount = obsessionTrack ? obsessionTrack.count : 0;
// Fallback image if we don't have one (API currently doesn't seem to return it in top_tracks simple list)
// We'll use a nice gradient or abstract pattern
const obsessionImage = "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?q=80&w=2070&auto=format&fit=crop";
const obsessionImage = obsessionTrack?.image || "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?q=80&w=400&auto=format&fit=crop";
const newDiscoveries = metrics.volume?.unique_artists || 0;
const uniqueArtists = metrics.volume?.unique_artists || 0;
// Mocking the "Underground" percentage for now as it's not in the standard payload
// Could derive from popularity if available, but let's randomize slightly based on unique artists to make it feel dynamic
const undergroundScore = Math.min(95, Math.max(10, Math.round((newDiscoveries % 100))));
const hipsterScore = metrics.taste?.hipster_score || 0;
const obscurityRating = metrics.taste?.obscurity_rating || 0;
return (
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Minutes Listened */}
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 flex flex-col justify-between h-full min-h-[200px] group hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between">
<span className="text-slate-400 text-sm font-medium uppercase tracking-wider">Minutes Listened</span>
@@ -39,7 +33,6 @@ const StatsGrid = ({ metrics }) => {
</div>
</div>
{/* Card 2: Obsession Track */}
<div className="bg-card-dark border border-[#222f49] rounded-xl relative overflow-hidden h-full min-h-[200px] group lg:col-span-2">
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
@@ -62,16 +55,13 @@ const StatsGrid = ({ metrics }) => {
</div>
</div>
{/* Card 3: New Discoveries & Mainstream Gauge */}
<div className="flex flex-col gap-4 h-full">
{/* Discoveries */}
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center">
<span className="material-symbols-outlined text-4xl text-primary mb-2">visibility</span>
<div className="text-3xl font-bold text-white">{newDiscoveries}</div>
<div className="text-3xl font-bold text-white">{uniqueArtists}</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Unique Artists</div>
</div>
{/* Gauge */}
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center">
<div className="relative size-20">
<svg className="size-full -rotate-90" viewBox="0 0 36 36">
@@ -81,15 +71,16 @@ const StatsGrid = ({ metrics }) => {
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeDasharray={`${undergroundScore}, 100`}
strokeDasharray={`${Math.min(hipsterScore, 100)}, 100`}
strokeWidth="3"
></path>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-sm font-bold text-white">{undergroundScore}%</span>
<span className="text-sm font-bold text-white">{hipsterScore.toFixed(0)}%</span>
</div>
</div>
<div className="text-slate-400 text-[10px] uppercase tracking-wider mt-2">Underground Certified</div>
<div className="text-slate-400 text-[10px] uppercase tracking-wider mt-2">Hipster Score</div>
<div className="text-slate-500 text-[9px] mt-1">Obscurity: {obscurityRating.toFixed(0)}%</div>
</div>
</div>
</section>

View File

@@ -3,14 +3,7 @@ import React from 'react';
const TopRotation = ({ volume }) => {
if (!volume || !volume.top_tracks) return null;
// Use placeholder images since API doesn't return album art in the simple list yet
const placeHolderImages = [
"https://images.unsplash.com/photo-1619983081563-430f63602796?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1514525253440-b393452e8d26?q=80&w=1000&auto=format&fit=crop"
];
const fallbackImage = "https://images.unsplash.com/photo-1619983081563-430f63602796?q=80&w=200&auto=format&fit=crop";
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 overflow-hidden">
@@ -24,17 +17,19 @@ const TopRotation = ({ volume }) => {
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-2">
{volume.top_tracks.slice(0, 5).map((track, i) => {
const name = track.name || track[0];
const artist = track.artist || track[1];
const name = track.name || "Unknown";
const artist = track.artist || "Unknown";
const image = track.image || fallbackImage;
return (
<div key={i} className={`min-w-[140px] flex flex-col gap-2 group cursor-pointer ${i === 0 ? 'min-w-[180px]' : 'opacity-80 hover:opacity-100 transition-opacity pt-4'}`}>
<div
className={`w-full aspect-square rounded-lg bg-cover bg-center ${i === 0 ? 'shadow-lg shadow-black/50 transition-transform group-hover:scale-105' : ''}`}
style={{ backgroundImage: `url('${placeHolderImages[i % placeHolderImages.length]}')` }}
style={{ backgroundImage: `url('${image}')` }}
></div>
<p className={`text-white font-medium truncate ${i === 0 ? 'font-bold' : 'text-sm'}`}>{name}</p>
<p className="text-xs text-slate-400 truncate">{artist}</p>
<p className="text-xs text-primary">{track.count} plays</p>
</div>
);
})}

View File

@@ -13,17 +13,30 @@ const VibeRadar = ({ vibe }) => {
{ subject: 'Live', A: vibe.liveness || 0, fullMark: 1 },
];
// Calculate mood percentages based on vibe metrics
const partyScore = Math.round(((vibe.energy + vibe.danceability) / 2) * 100);
const focusScore = Math.round(((vibe.instrumentalness + (1 - vibe.valence)) / 2) * 100);
const chillScore = Math.round(((vibe.acousticness + (1 - vibe.energy)) / 2) * 100);
const energy = vibe.energy || 0;
const danceability = vibe.danceability || 0;
const instrumentalness = vibe.instrumentalness || 0;
const valence = vibe.valence || 0;
const acousticness = vibe.acousticness || 0;
const partyScore = Math.round(((energy + danceability) / 2) * 100);
const focusScore = Math.round(((instrumentalness + (1 - valence)) / 2) * 100);
const chillScore = Math.round(((acousticness + (1 - energy)) / 2) * 100);
// Normalize to sum to 100 roughly (just for display)
const total = partyScore + focusScore + chillScore;
const total = partyScore + focusScore + chillScore || 1;
const partyPct = Math.round((partyScore / total) * 100);
const focusPct = Math.round((focusScore / total) * 100);
const chillPct = 100 - partyPct - focusPct;
const whiplash = vibe.whiplash || {};
const maxWhiplash = Math.max(
whiplash.tempo || 0,
(whiplash.energy || 0) * 100,
(whiplash.valence || 0) * 100
);
const volatilityLevel = maxWhiplash > 25 ? "HIGH" : maxWhiplash > 12 ? "MEDIUM" : "LOW";
const volatilityColor = volatilityLevel === "HIGH" ? "text-red-400" : volatilityLevel === "MEDIUM" ? "text-yellow-400" : "text-green-400";
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
@@ -34,7 +47,6 @@ const VibeRadar = ({ vibe }) => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Feature Radar */}
<div className="aspect-square relative flex items-center justify-center bg-card-darker rounded-lg border border-[#222f49]/50 p-4">
<ResponsiveContainer width="100%" height={200} minHeight={200}>
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
@@ -53,9 +65,7 @@ const VibeRadar = ({ vibe }) => {
</ResponsiveContainer>
</div>
{/* Mood Modes & Whiplash */}
<div className="flex flex-col gap-6">
{/* Mood Bubbles */}
<div className="flex-1 flex flex-col justify-center">
<h4 className="text-sm text-slate-400 mb-4 font-medium uppercase">Mood Clusters</h4>
<div className="relative h-40 w-full rounded-lg border border-dashed border-[#334155] bg-card-darker/50">
@@ -71,17 +81,42 @@ const VibeRadar = ({ vibe }) => {
</div>
</div>
{/* Whiplash Meter */}
<div>
<div className="flex justify-between items-end mb-2">
<h4 className="text-sm text-slate-400 font-medium uppercase">Whiplash Meter</h4>
<span className="text-xs text-red-400 font-bold">HIGH VOLATILITY</span>
<span className={`text-xs font-bold ${volatilityColor}`}>{volatilityLevel} VOLATILITY</span>
</div>
<div className="h-12 w-full bg-card-darker rounded flex items-center px-2 overflow-hidden relative">
{/* Fake waveform */}
<svg className="w-full h-full text-red-500" viewBox="0 0 300 50" preserveAspectRatio="none">
<path d="M0,25 Q10,5 20,25 T40,25 T60,45 T80,5 T100,25 T120,40 T140,10 T160,25 T180,25 T200,45 T220,5 T240,25 T260,40 T280,10 T300,25" fill="none" stroke="currentColor" strokeWidth="2"></path>
</svg>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16">Tempo</span>
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-red-500 transition-all"
style={{ width: `${Math.min((whiplash.tempo || 0) / 40 * 100, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-12 text-right">{(whiplash.tempo || 0).toFixed(1)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16">Energy</span>
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-yellow-500 transition-all"
style={{ width: `${Math.min((whiplash.energy || 0) * 100 / 0.4 * 100, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-12 text-right">{((whiplash.energy || 0) * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16">Valence</span>
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-green-500 transition-all"
style={{ width: `${Math.min((whiplash.valence || 0) * 100 / 0.4 * 100, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-12 text-right">{((whiplash.valence || 0) * 100).toFixed(0)}%</span>
</div>
</div>
</div>
</div>