ai-deadlines / src /components /ConferenceCard.tsx
kenza-ily's picture
columns width
fc3a8b0
import { CalendarDays, Globe, Tag, Clock } from "lucide-react";
import { Conference, Deadline } from "@/types/conference";
import { formatDistanceToNow, format, isValid, isPast } from "date-fns";
import ConferenceDialog from "./ConferenceDialog";
import { useEffect, useState } from "react";
import { getDeadlineInLocalTime } from '@/utils/dateUtils';
import { getAllDeadlines, getUpcomingDeadlines } from '@/utils/deadlineUtils';
import { Checkbox } from "@/components/ui/checkbox";
import { ConferenceProgress, getProgress, setProgress } from "@/utils/progressUtils";
type ConferenceCardProps = Conference & {
compact?: boolean;
};
const ConferenceCard = ({
title,
full_name,
year,
id,
date,
deadline,
timezone,
tags = [],
link,
note,
abstract_deadline,
city,
country,
venue,
compact = false,
...conferenceProps
}: ConferenceCardProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const confKey = `${id}:${year}`;
const [progress, setProgressState] = useState<ConferenceProgress>(() => getProgress(confKey));
// Get the next upcoming deadline or primary deadline for display
const conference = {
title, full_name, year, id, date, deadline, timezone, tags, link, note,
abstract_deadline, city, country, venue, ...conferenceProps
};
useEffect(() => {
setProgressState(getProgress(confKey));
}, [confKey]);
const upcomingDeadlines = getUpcomingDeadlines(conference);
const hasUpcomingDeadlines = upcomingDeadlines.length > 0;
const allDeadlines = getAllDeadlines(conference);
const isDeadlinePassed = (types: string[]) => {
const matching = allDeadlines.filter((deadlineItem) => types.includes(deadlineItem.type));
if (matching.length === 0) return false;
const latest = matching.reduce<Date | null>((acc, deadlineItem) => {
const deadlineDate = getDeadlineInLocalTime(
deadlineItem.date,
deadlineItem.timezone || timezone
);
if (!deadlineDate || !isValid(deadlineDate)) return acc;
if (!acc || deadlineDate.getTime() > acc.getTime()) return deadlineDate;
return acc;
}, null);
return latest ? isPast(latest) : false;
};
const paperDeadlinePassed = isDeadlinePassed(["paper", "submission", "paper_submission"]);
const posterDeadlinePassed = isDeadlinePassed(["poster", "poster_submission"]);
const notificationDeadlinePassed = isDeadlinePassed([
"notification",
"decision",
"acceptance_notification",
]);
const deadlineTypeToProgressKey = (deadlineItem: Deadline) => {
const type = deadlineItem.type;
if (["paper", "submission", "paper_submission"].includes(type)) return "paper";
if (["poster", "poster_submission"].includes(type)) return "poster";
if (["notification", "decision", "acceptance_notification"].includes(type)) return "notification";
return null;
};
const nextStepDeadline = upcomingDeadlines.find((deadlineItem) => {
const progressKey = deadlineTypeToProgressKey(deadlineItem);
return progressKey ? !progress[progressKey] : false;
}) || upcomingDeadlines[0];
const deadlineDate = nextStepDeadline
? getDeadlineInLocalTime(nextStepDeadline.date, nextStepDeadline.timezone || timezone)
: null;
// Add validation before using formatDistanceToNow
const getTimeRemaining = () => {
if (!deadlineDate || !isValid(deadlineDate)) {
return 'TBD';
}
if (isPast(deadlineDate)) {
return 'Deadline passed';
}
try {
return formatDistanceToNow(deadlineDate, { addSuffix: true });
} catch (error) {
console.error('Error formatting time remaining:', error);
return 'Invalid date';
}
};
const timeRemaining = getTimeRemaining();
// Create location string by concatenating city and country
const location = [city, country].filter(Boolean).join(", ");
const conferenceEnd = conference.end || conference.start;
const conferenceEndDate = conferenceEnd ? new Date(conferenceEnd) : null;
const conferenceOver = !hasUpcomingDeadlines
? true
: conferenceEndDate && isValid(conferenceEndDate)
? new Date().getTime() > conferenceEndDate.getTime()
: false;
const formatLocalDeadline = (deadlineItem: Deadline) => {
const localDate = getDeadlineInLocalTime(deadlineItem.date, deadlineItem.timezone || timezone);
if (!localDate || !isValid(localDate)) return deadlineItem.date;
return format(localDate, "MMM d, yyyy HH:mm");
};
const updateProgress = (key: keyof ConferenceProgress) => {
const nextProgress = { ...progress, [key]: !progress[key] };
setProgressState(nextProgress);
setProgress(confKey, nextProgress);
};
// Determine countdown color based on days remaining
const getCountdownColor = () => {
if (!deadlineDate || !isValid(deadlineDate)) return "text-neutral-600";
try {
const daysRemaining = Math.ceil((deadlineDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
if (daysRemaining <= 7) return "text-red-600";
if (daysRemaining <= 30) return "text-orange-600";
return "text-green-600";
} catch (error) {
console.error('Error calculating countdown color:', error);
return "text-neutral-600";
}
};
const handleCardClick = (e: React.MouseEvent) => {
if (!(e.target as HTMLElement).closest('a') &&
!(e.target as HTMLElement).closest('.tag-button') &&
!(e.target as HTMLElement).closest('.status-toggle')) {
setDialogOpen(true);
}
};
const handleTagClick = (e: React.MouseEvent, tag: string) => {
e.stopPropagation();
const searchParams = new URLSearchParams(window.location.search);
const currentTags = searchParams.get('tags')?.split(',') || [];
let newTags;
if (currentTags.includes(tag)) {
newTags = currentTags.filter(t => t !== tag);
} else {
newTags = [...currentTags, tag];
}
if (newTags.length > 0) {
searchParams.set('tags', newTags.join(','));
} else {
searchParams.delete('tags');
}
const newUrl = `${window.location.pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
window.history.pushState({}, '', newUrl);
window.dispatchEvent(new CustomEvent('urlchange', { detail: { tag } }));
};
return (
<>
<div
className={`bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow flex flex-col cursor-pointer ${
compact ? "p-3" : "p-4"
}`}
onClick={handleCardClick}
>
<div className="flex justify-between items-start mb-2">
<div className="flex flex-col">
<h3 className={`${compact ? "text-base" : "text-lg"} font-semibold text-foreground`}>
{title} {year}
</h3>
{full_name && (
<p className={`${compact ? "text-xs" : "text-sm"} italic text-neutral-500`}>
{full_name}
</p>
)}
</div>
{link && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Globe className="h-4 w-4 mr-2 flex-shrink-0" />
</a>
)}
</div>
<div className="flex flex-col gap-2 mb-3">
<div className="flex items-center text-neutral">
<CalendarDays className="h-4 w-4 mr-2 flex-shrink-0" />
<span className={`${compact ? "text-xs" : "text-sm"} truncate`}>{date}</span>
</div>
<div
className={`status-toggle flex flex-wrap items-center gap-3 rounded-md bg-neutral-50 ${
compact ? "p-1.5 text-[10px]" : "p-2 text-xs"
} text-neutral-700`}
>
<label
className="flex items-center gap-2"
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={!hasUpcomingDeadlines || progress.paper || paperDeadlinePassed}
onCheckedChange={() => updateProgress("paper")}
disabled={!hasUpcomingDeadlines || paperDeadlinePassed}
/>
Papers submitted
</label>
<label
className="flex items-center gap-2"
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={!hasUpcomingDeadlines || progress.poster || posterDeadlinePassed}
onCheckedChange={() => updateProgress("poster")}
disabled={!hasUpcomingDeadlines || posterDeadlinePassed}
/>
Posters sent
</label>
<label
className="flex items-center gap-2"
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={!hasUpcomingDeadlines || progress.notification || notificationDeadlinePassed}
onCheckedChange={() => updateProgress("notification")}
disabled={!hasUpcomingDeadlines || notificationDeadlinePassed}
/>
Notification
</label>
<label className="flex items-center gap-2 text-neutral-600">
<Checkbox checked={conferenceOver} disabled />
Conference over
</label>
</div>
<div className="flex items-start text-neutral">
<Clock className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<div className={compact ? "text-xs" : "text-sm"}>
<span className="font-medium">Next step:</span>{" "}
{nextStepDeadline
? `${formatLocalDeadline(nextStepDeadline)} — ${nextStepDeadline.label}`
: "No upcoming deadlines"}
{nextStepDeadline && (
<div className={`${compact ? "text-[10px]" : "text-xs"} ${getCountdownColor()}`}>
{timeRemaining}
</div>
)}
</div>
</div>
{hasUpcomingDeadlines && (
<div className={`space-y-1 ${compact ? "text-xs" : "text-sm"} text-neutral`}>
<p className="font-medium text-neutral-700">Upcoming deadlines</p>
<ul
className={`list-disc pl-4 ${compact ? "text-[10px]" : "text-xs"} text-neutral-600 space-y-1`}
>
{upcomingDeadlines.slice(0, 4).map((deadlineItem, index) => (
<li key={`${deadlineItem.type}-${index}`}>
{formatLocalDeadline(deadlineItem)} — {deadlineItem.label}
</li>
))}
{upcomingDeadlines.length > 4 && (
<li className={`list-none ${compact ? "text-[10px]" : "text-xs"} text-neutral-500`}>
+{upcomingDeadlines.length - 4} more
</li>
)}
</ul>
</div>
)}
{Array.isArray(tags) && tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag}
className="tag tag-button"
onClick={(e) => handleTagClick(e, tag)}
>
<Tag className="h-3 w-3 mr-1" />
{tag}
</button>
))}
</div>
)}
{location && (
<div className="flex items-center text-neutral">
<Globe className="h-4 w-4 mr-2 flex-shrink-0" />
<span className={`${compact ? "text-xs" : "text-sm"} truncate`}>{location}</span>
</div>
)}
</div>
</div>
<ConferenceDialog
conference={{
title,
full_name,
year,
date,
deadline,
timezone,
tags,
link,
note,
abstract_deadline,
city,
country,
venue,
...conferenceProps
}}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
);
};
export default ConferenceCard;