← back to Watches
src/components/FreshnessIndicator.jsx
113 lines
import React, { useMemo } from 'react';
import { motion } from 'framer-motion';
/**
* FreshnessIndicator - Shows how fresh the market data is
* @param {number} timestamp - Unix timestamp of when data was fetched
* @param {boolean} compact - If true, shows only the colored dot (for cards)
* @param {string} className - Additional CSS classes
*/
export default function FreshnessIndicator({ timestamp, compact = false, className = '' }) {
const { relativeTime, freshnessLevel, dotColor, textColor } = useMemo(() => {
if (!timestamp) {
return {
relativeTime: 'No data',
freshnessLevel: 'stale',
dotColor: 'bg-gray-500',
textColor: 'text-gray-400'
};
}
const now = Date.now();
const diffMs = now - timestamp;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// Calculate relative time string
let relativeTime;
if (diffMinutes < 1) {
relativeTime = 'Just now';
} else if (diffMinutes < 60) {
relativeTime = `${diffMinutes} min ago`;
} else if (diffHours < 24) {
relativeTime = `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
} else {
relativeTime = `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
}
// Determine freshness level and colors
let freshnessLevel, dotColor, textColor;
if (diffMinutes < 5) {
freshnessLevel = 'fresh';
dotColor = 'bg-green-500';
textColor = 'text-green-400';
} else if (diffMinutes < 30) {
freshnessLevel = 'moderate';
dotColor = 'bg-yellow-500';
textColor = 'text-yellow-400';
} else {
freshnessLevel = 'stale';
dotColor = 'bg-red-500';
textColor = 'text-red-400';
}
return { relativeTime, freshnessLevel, dotColor, textColor };
}, [timestamp]);
// Compact mode - just the dot with tooltip
if (compact) {
return (
<motion.div
className={`relative group ${className}`}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
<span
className={`inline-block w-2 h-2 rounded-full ${dotColor} shadow-lg`}
style={{
boxShadow: freshnessLevel === 'fresh'
? '0 0 6px rgba(34, 197, 94, 0.6)'
: freshnessLevel === 'moderate'
? '0 0 6px rgba(234, 179, 8, 0.6)'
: '0 0 6px rgba(239, 68, 68, 0.6)'
}}
/>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
{relativeTime}
</div>
</motion.div>
);
}
// Full mode - dot + text
return (
<motion.div
className={`flex items-center gap-2 ${className}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<motion.span
className={`inline-block w-2.5 h-2.5 rounded-full ${dotColor}`}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 30, delay: 0.1 }}
style={{
boxShadow: freshnessLevel === 'fresh'
? '0 0 8px rgba(34, 197, 94, 0.6)'
: freshnessLevel === 'moderate'
? '0 0 8px rgba(234, 179, 8, 0.6)'
: '0 0 8px rgba(239, 68, 68, 0.6)'
}}
/>
<span className={`text-sm ${textColor}`}>
Last updated {relativeTime.toLowerCase()}
</span>
</motion.div>
);
}