mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 11:46:07 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
202
frontend/src/components/ListeningLog.jsx
Normal file
202
frontend/src/components/ListeningLog.jsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user