← 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>
  );
}