From 8aa1726b12f6d94c990e59f8b108f59b3f27760c Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:59:29 -0800 Subject: [PATCH] feat: highlight owned participants in standings and fix schedule event badges (#83) - SeasonStandings: highlight rows for the logged-in user's drafted participants with electric accent (in-points) or muted (outside points) left border + star icon - Loader: derive userParticipantIds from current user's teams and draft picks - EventSchedule: hide type badge for schedule_event; show "Results Pending" only when event is past and not yet marked complete (matches admin page logic) Co-authored-by: Claude Sonnet 4.6 --- app/components/scoring/SeasonStandings.tsx | 93 +++-- app/components/scoring/SportSeasonDisplay.tsx | 15 +- app/components/sport-season/EventSchedule.tsx | 148 ++++++++ app/components/sports/SportSeasonCard.tsx | 33 +- app/models/scoring-calculator.ts | 3 +- app/models/scoring-event.ts | 93 ++++- app/routes.ts | 4 + ...orts-seasons.$id.events.$eventId.server.ts | 31 ++ ...min.sports-seasons.$id.events.$eventId.tsx | 128 ++++++- .../admin.sports-seasons.$id.events.server.ts | 58 +++- .../admin.sports-seasons.$id.events.tsx | 165 ++++++--- .../admin.sports-seasons.$id.standings.tsx | 182 ++++++++++ app/routes/admin.sports-seasons.$id.tsx | 113 ++++++- app/routes/admin.sports-seasons.new.tsx | 5 +- app/routes/leagues/$leagueId.server.ts | 28 +- ...d.sports-seasons.$sportsSeasonId.server.ts | 52 ++- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 68 +++- app/routes/leagues/$leagueId.tsx | 1 + database/schema.ts | 4 +- drizzle/0032_consolidate_playoff_pattern.sql | 20 ++ drizzle/0033_add_schedule_event_type.sql | 5 + drizzle/meta/_journal.json | 14 + plans/sport-season-pages.md | 320 ++++++++++++++++++ 23 files changed, 1409 insertions(+), 174 deletions(-) create mode 100644 app/components/sport-season/EventSchedule.tsx create mode 100644 app/routes/admin.sports-seasons.$id.standings.tsx create mode 100644 drizzle/0032_consolidate_playoff_pattern.sql create mode 100644 drizzle/0033_add_schedule_event_type.sql create mode 100644 plans/sport-season-pages.md diff --git a/app/components/scoring/SeasonStandings.tsx b/app/components/scoring/SeasonStandings.tsx index f8bdb0c..abf11d7 100644 --- a/app/components/scoring/SeasonStandings.tsx +++ b/app/components/scoring/SeasonStandings.tsx @@ -14,7 +14,7 @@ import { TableRow, } from "~/components/ui/table"; import { Badge } from "~/components/ui/badge"; -import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2 } from "lucide-react"; +import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2, Star } from "lucide-react"; interface SeasonStanding { id: string; @@ -37,6 +37,7 @@ interface TeamOwnership { interface SeasonStandingsProps { standings: SeasonStanding[]; teamOwnerships?: TeamOwnership[]; // Which teams own which participants + userParticipantIds?: string[]; // Participants drafted by the current user showOwnership?: boolean; isFinalized?: boolean; // Whether season is complete title?: string; @@ -56,11 +57,13 @@ interface SeasonStandingsProps { export function SeasonStandings({ standings, teamOwnerships = [], + userParticipantIds = [], showOwnership = true, isFinalized = false, title = "Championship Standings", description, }: SeasonStandingsProps) { + const userParticipantSet = new Set(userParticipantIds); // Create ownership map for fast lookup const ownershipMap = new Map(); teamOwnerships.forEach((ownership) => { @@ -130,45 +133,11 @@ export function SeasonStandings({ } }; - // Get position badge with medals for top 3 + // Get position display const getPositionBadge = (position: number, isTied: boolean) => { - const positionText = isTied ? `T${position}` : position.toString(); - - if (position === 1 && !isTied) { - return ( -
- πŸ₯‡ - {positionText} -
- ); - } else if (position === 2 && !isTied) { - return ( -
- πŸ₯ˆ - {positionText} -
- ); - } else if (position === 3 && !isTied) { - return ( -
- πŸ₯‰ - {positionText} -
- ); - } else { - return ( - - {positionText} - {position === 1 - ? "st" - : position === 2 - ? "nd" - : position === 3 - ? "rd" - : "th"} - - ); - } + const suffix = position === 1 ? "st" : position === 2 ? "nd" : position === 3 ? "rd" : "th"; + const positionText = isTied ? `T${position}` : `${position}${suffix}`; + return {positionText}; }; // Check if multiple participants share the same position @@ -238,9 +207,9 @@ export function SeasonStandings({ Change )} Participant - Points + Points {showOwnership && ( - Team + Drafted By )} @@ -251,17 +220,23 @@ export function SeasonStandings({ const ownership = getOwnership(standing.participant.id); const isTied = checkIfTied(standing); const isTop8 = standing.position <= 8; + const isOwned = userParticipantSet.has(standing.participant.id); + + let rowClass = ""; + if (isOwned && isTop8) { + rowClass = "bg-electric/8 border-l-2 border-l-electric font-medium"; + } else if (isOwned && !isTop8) { + rowClass = "bg-muted/20 border-l-2 border-l-muted-foreground/40 opacity-80"; + } else if (isTop8) { + rowClass = standing.position <= 3 ? "bg-muted/30 font-medium" : ""; + } else { + rowClass = "opacity-60"; + } return ( {getPositionBadge(standing.position, isTied)} @@ -275,7 +250,12 @@ export function SeasonStandings({ )} - {standing.participant.name} + {standing.participant.name} + {isOwned && ( + + )} - {parseFloat(standing.championshipPoints).toFixed(1)} pts + {Math.round(parseFloat(standing.championshipPoints))} {showOwnership && ( - + {ownership ? ( -
+
{getTeamAvatar(ownership.teamName)} +
+

{ownership.teamName}

+ {ownership.ownerName && ( +

{ownership.ownerName}

+ )} +
) : ( - diff --git a/app/components/scoring/SportSeasonDisplay.tsx b/app/components/scoring/SportSeasonDisplay.tsx index 1be8b63..8cbd780 100644 --- a/app/components/scoring/SportSeasonDisplay.tsx +++ b/app/components/scoring/SportSeasonDisplay.tsx @@ -15,8 +15,7 @@ import { AlertCircle } from "lucide-react"; */ type ScoringPattern = - | "single_elimination_playoff" - | "page_playoff" + | "playoff_bracket" | "season_standings" | "qualifying_points"; @@ -107,6 +106,7 @@ interface SportSeasonDisplayProps { // Shared data teamOwnerships?: TeamOwnership[]; + userParticipantIds?: string[]; scoringRules?: ScoringRules | null; showOwnership?: boolean; } @@ -125,13 +125,13 @@ export function SportSeasonDisplay({ majorsCompleted = 0, canFinalize = false, teamOwnerships = [], + userParticipantIds = [], scoringRules = null, showOwnership = true, }: SportSeasonDisplayProps) { // Pattern detection and component selection switch (scoringPattern) { - case "single_elimination_playoff": - case "page_playoff": + case "playoff_bracket": // Display playoff bracket return ( ); @@ -154,6 +150,7 @@ export function SportSeasonDisplay({ 0; + const hasRecent = recentEvents.length > 0; + + if (!hasUpcoming && !hasRecent) return null; + + return ( +
+ {/* Upcoming Events */} + {hasUpcoming && ( + + + + + Upcoming + + + +
    + {upcomingEvents.map((event, index) => ( +
  • +
    +

    + {index === 0 && ( + + )} + {event.name} +

    +

    + + {formatEventDate(event.eventDate)} +

    +
    + {event.eventType === "schedule_event" ? ( + !event.isComplete && event.eventDate && event.eventDate < new Date().toISOString().split("T")[0] && ( + + Results Pending + + ) + ) : ( + + {getEventTypeLabel(event.eventType)} + + )} +
  • + ))} +
+
+
+ )} + + {/* Recent Results */} + {hasRecent && ( + + + + + Recent Results + + + +
    + {recentEvents.map((event, index) => ( +
  • +
    +

    + {event.name} +

    +

    + + {formatEventDate(event.eventDate)} +

    +
    + {event.eventType !== "schedule_event" && ( + + Done + + )} +
  • + ))} +
+
+
+ )} +
+ ); +} diff --git a/app/components/sports/SportSeasonCard.tsx b/app/components/sports/SportSeasonCard.tsx index 67a7397..a40d1f9 100644 --- a/app/components/sports/SportSeasonCard.tsx +++ b/app/components/sports/SportSeasonCard.tsx @@ -1,4 +1,5 @@ import { Link } from "react-router"; +import { format, parseISO } from "date-fns"; import { Badge } from "~/components/ui/badge"; import { Card, @@ -7,7 +8,7 @@ import { CardHeader, CardTitle, } from "~/components/ui/card"; -import { Trophy, Target, Flag } from "lucide-react"; +import { Trophy, Target, Flag, Calendar } from "lucide-react"; interface SportSeasonCardProps { leagueId: string; @@ -16,6 +17,16 @@ interface SportSeasonCardProps { seasonName: string; status: "upcoming" | "active" | "completed"; scoringPattern?: string | null; + nextEvent?: { name: string; eventDate: string | null } | null; +} + +function formatNextEventDate(dateStr: string | null | undefined): string { + if (!dateStr) return ""; + try { + return format(parseISO(dateStr), "MMM d"); + } catch { + return ""; + } } export function SportSeasonCard({ @@ -25,6 +36,7 @@ export function SportSeasonCard({ seasonName, status, scoringPattern, + nextEvent, }: SportSeasonCardProps) { const getStatusBadge = () => { switch (status) { @@ -56,10 +68,8 @@ export function SportSeasonCard({ if (!scoringPattern) return null; switch (scoringPattern) { - case "single_elimination_playoff": + case "playoff_bracket": return "Playoff Bracket"; - case "page_playoff": - return "AFL Finals"; case "season_standings": return "Season Standings"; case "qualifying_points": @@ -70,7 +80,8 @@ export function SportSeasonCard({ }; const scoringLabel = getScoringPatternLabel(); - const isPlayoffType = scoringPattern === "single_elimination_playoff" || scoringPattern === "page_playoff"; + const isPlayoffType = scoringPattern === "playoff_bracket"; + const nextEventDate = formatNextEventDate(nextEvent?.eventDate); return ( @@ -96,6 +107,18 @@ export function SportSeasonCard({ {scoringLabel}
)} + {nextEvent && ( +
+ + + Next: + {nextEvent.name} + {nextEventDate && ( + β€” {nextEventDate} + )} + +
+ )} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 374a449..a991076 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -11,8 +11,7 @@ import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; */ export type ScoringPattern = - | "single_elimination_playoff" - | "page_playoff" + | "playoff_bracket" | "season_standings" | "qualifying_points"; diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index b2e9e0f..b28281e 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -1,8 +1,18 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, desc, asc, gte, lte } from "drizzle-orm"; -export type EventType = "playoff_game" | "major_tournament" | "final_standings"; +export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event"; + +export function getEventTypeLabel(eventType: string): string { + switch (eventType) { + case "playoff_game": return "Bracket"; + case "major_tournament": return "Major Tournament"; + case "final_standings": return "Final Standings"; + case "schedule_event": return "Non-Scoring"; + default: return eventType; + } +} export interface CreateScoringEventData { sportsSeasonId: string; @@ -202,3 +212,82 @@ export async function areAllEventsComplete( const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db); return incompleteEvents.length === 0; } + +/** + * Get upcoming (not yet complete) scoring events for a sports season, + * ordered by eventDate ascending (soonest first). + * Events without a date are included last. + */ +export async function getUpcomingScoringEvents( + sportsSeasonId: string, + limit = 5, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isComplete, false) + ), + orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.createdAt)], + limit, + }); +} + +/** + * Get the single next upcoming event for a sports season (for card display). + */ +export async function getNextScoringEvent( + sportsSeasonId: string, + providedDb?: ReturnType +) { + const upcoming = await getUpcomingScoringEvents(sportsSeasonId, 1, providedDb); + return upcoming[0] || null; +} + +/** + * Get recently completed scoring events for a sports season, + * ordered by completedAt descending (most recent first). + */ +export async function getRecentCompletedEvents( + sportsSeasonId: string, + limit = 3, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + return db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isComplete, true) + ), + orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.eventDate)], + limit, + }); +} + +/** + * Bulk create scoring events for a sports season. + * Returns created events in order. + */ +export async function bulkCreateScoringEvents( + sportsSeasonId: string, + events: Array<{ name: string; eventDate?: Date; eventType: EventType; isQualifyingEvent?: boolean }>, + providedDb?: ReturnType +) { + const db = providedDb || database(); + + if (events.length === 0) return []; + + const rows = events.map((e) => ({ + sportsSeasonId, + name: e.name, + eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined, + eventType: e.eventType, + isQualifyingEvent: e.isQualifyingEvent ?? false, + isComplete: false, + })); + + return db.insert(schema.scoringEvents).values(rows).returning(); +} diff --git a/app/routes.ts b/app/routes.ts index dca08d8..2e95f87 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -85,6 +85,10 @@ export default [ "sports-seasons/:id/recalculate-probabilities", "routes/admin.sports-seasons.$id.recalculate-probabilities.tsx" ), + route( + "sports-seasons/:id/standings", + "routes/admin.sports-seasons.$id.standings.tsx" + ), route("participants", "routes/admin.participants.tsx"), route("templates", "routes/admin.templates.tsx"), route("templates/new", "routes/admin.templates.new.tsx"), diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index 40ab8a5..5779c63 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -4,6 +4,7 @@ import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { getScoringEventById, completeScoringEvent, + updateScoringEvent, } from "~/models/scoring-event"; import { getEventResults, @@ -135,6 +136,36 @@ export async function action({ request, params }: Route.ActionArgs) { } } + if (intent === "uncomplete") { + try { + await updateScoringEvent(params.eventId, { isComplete: false }); + return { success: "Event marked as not updated" }; + } catch (error) { + console.error("Error uncompleting event:", error); + return { error: "Failed to update event" }; + } + } + + if (intent === "update-event") { + const name = formData.get("name"); + const eventDate = formData.get("eventDate"); + + if (typeof name !== "string" || !name.trim()) { + return { error: "Event name is required" }; + } + + try { + await updateScoringEvent(params.eventId, { + name: name.trim(), + eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, + }); + return { success: "Event updated" }; + } catch (error) { + console.error("Error updating event:", error); + return { error: "Failed to update event" }; + } + } + if (intent === "add-result") { const participantId = formData.get("participantId"); const placement = formData.get("placement"); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index eee6a44..7d5548d 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -20,6 +20,7 @@ import { } from "~/components/ui/select"; import { Badge } from "~/components/ui/badge"; import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react"; +import { getEventTypeLabel } from "~/models/scoring-event"; import { Table, TableBody, @@ -59,18 +60,121 @@ export default function EventResults({ ) : new Map(); - const getEventTypeLabel = (type: string) => { - switch (type) { - case "playoff_game": - return "Bracket"; - case "major_tournament": - return "Major Tournament"; - case "final_standings": - return "Final Standings"; - default: - return type; - } - }; + // Non-scoring events have a simple view β€” edit name/date and toggle updated status + if (event.eventType === "schedule_event") { + const today = new Date().toISOString().split("T")[0]; + const isPast = event.eventDate && event.eventDate < today; + + return ( +
+
+
+ +
+
+

{event.name}

+

+ {sportsSeason.sport.name} β€” {sportsSeason.name} β€’ Non-Scoring +

+
+ {event.isComplete ? ( + + + Updated + + ) : ( + + {isPast ? "Results Pending" : "Upcoming"} + + )} +
+
+ + {actionData?.error && ( +
+ {actionData.error} +
+ )} + {actionData?.success && ( +
+ {actionData.success} +
+ )} + +
+ + + Edit Event + + +
+ +
+ + +
+
+ + +
+ +
+
+
+ + + + Scoring Status + + {event.isComplete + ? "This event has been noted as updated in standings." + : isPast + ? "This event has passed β€” mark it once standings have been updated." + : "This event hasn't occurred yet."} + + + + {event.isComplete ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+
+
+
+ ); + } return (
diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts index 3b39285..996a673 100644 --- a/app/routes/admin.sports-seasons.$id.events.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -5,6 +5,7 @@ import { getScoringEventsForSportsSeason, createScoringEvent, deleteScoringEvent, + bulkCreateScoringEvents, type CreateScoringEventData, } from "~/models/scoring-event"; import { getQPStandings } from "~/models/qualifying-points"; @@ -56,6 +57,59 @@ export async function action({ request, params }: Route.ActionArgs) { } } + if (intent === "bulk-create") { + const bulkText = formData.get("bulkEvents"); + const bulkEventType = formData.get("bulkEventType"); + + if (typeof bulkText !== "string" || !bulkText.trim()) { + return { error: "Event list is required" }; + } + + const validTypes = ["playoff_game", "major_tournament", "schedule_event"] as const; + type ValidEventType = typeof validTypes[number]; + const eventType: ValidEventType = + validTypes.includes(bulkEventType as ValidEventType) + ? (bulkEventType as ValidEventType) + : "playoff_game"; + + const sportsSeason = await findSportsSeasonById(params.id); + if (!sportsSeason) return { error: "Sports season not found" }; + + const isQualifyingDefault = + sportsSeason.scoringPattern === "qualifying_points" && + eventType === "major_tournament"; + + // Parse lines: "Name, YYYY-MM-DD" or "Name\tYYYY-MM-DD" or just "Name" + const lines = bulkText + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + const parsedEvents = lines.map((line) => { + const parts = line.split(/[,\t]/).map((p) => p.trim()); + const name = parts[0]; + const dateStr = parts[1]; + const eventDate = + dateStr && /^\d{4}-\d{2}-\d{2}$/.test(dateStr) + ? new Date(dateStr) + : undefined; + return { name, eventDate, eventType, isQualifyingEvent: isQualifyingDefault }; + }); + + const validEvents = parsedEvents.filter((e) => e.name); + if (validEvents.length === 0) { + return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" }; + } + + try { + await bulkCreateScoringEvents(params.id, validEvents); + return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` }; + } catch (error) { + console.error("Error bulk creating events:", error); + return { error: "Failed to create events. Please try again." }; + } + } + if (intent === "delete-event") { const eventId = formData.get("eventId"); @@ -84,7 +138,7 @@ export async function action({ request, params }: Route.ActionArgs) { if ( eventType !== "playoff_game" && eventType !== "major_tournament" && - eventType !== "final_standings" + eventType !== "schedule_event" ) { return { error: "Invalid event type" }; } @@ -103,7 +157,7 @@ export async function action({ request, params }: Route.ActionArgs) { const eventData: CreateScoringEventData = { sportsSeasonId: params.id, name: name.trim(), - eventType: eventType as "playoff_game" | "major_tournament" | "final_standings", + eventType: eventType as "playoff_game" | "major_tournament" | "schedule_event", eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, isQualifyingEvent, }; diff --git a/app/routes/admin.sports-seasons.$id.events.tsx b/app/routes/admin.sports-seasons.$id.events.tsx index fa70cb9..f076446 100644 --- a/app/routes/admin.sports-seasons.$id.events.tsx +++ b/app/routes/admin.sports-seasons.$id.events.tsx @@ -19,9 +19,22 @@ import { SelectValue, } from "~/components/ui/select"; import { Badge } from "~/components/ui/badge"; -import { Calendar, Trophy, ArrowLeft, Trash2 } from "lucide-react"; -import { format } from "date-fns"; +import { Textarea } from "~/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; +import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react"; +import { format, parseISO } from "date-fns"; import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings"; +import { getEventTypeLabel } from "~/models/scoring-event"; export { loader, action }; @@ -31,27 +44,31 @@ export default function SportsSeasonEvents({ }: Route.ComponentProps) { const { sportsSeason, events, qpStandings, scoringRules } = loaderData; - const getEventTypeLabel = (type: string) => { - switch (type) { - case "playoff_game": - return "Bracket"; - case "major_tournament": - return "Major Tournament"; - case "final_standings": - return "Final Standings"; - default: - return type; - } - }; + const defaultEventType = + sportsSeason.scoringPattern === "qualifying_points" + ? "major_tournament" + : sportsSeason.scoringPattern === "season_standings" + ? "schedule_event" + : "playoff_game"; - const getStatusBadge = (isComplete: boolean) => { - return isComplete ? ( - - Completed - - ) : ( - In Progress - ); + const getStatusBadge = (isComplete: boolean, eventType: string, eventDate?: string | null) => { + if (isComplete) { + return ( + + Completed + + ); + } + if (eventType === "schedule_event") { + const today = new Date().toISOString().split("T")[0]; + const isPast = eventDate && eventDate < today; + return isPast ? ( + Results Pending + ) : ( + Upcoming + ); + } + return In Progress; }; return ( @@ -132,16 +149,16 @@ export default function SportsSeasonEvents({ {sportsSeason.scoringPattern === "qualifying_points" && ( @@ -170,6 +187,54 @@ export default function SportsSeasonEvents({ + {/* Bulk Create Card */} + + + + + Bulk Import Events + + + Paste a schedule β€” one event per line. Format:{" "} + + Event Name, YYYY-MM-DD + {" "} + (date is optional). + + + +
+ +
+ + +
+
+ +