Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: archive #163

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ axum = { version = "0.7.5", features = ["http2"] }
tower-http = { version = "0.5.2", features = ["cors"] }
tower_governor = "0.4.2"
tokio-tungstenite = { version = "0.23.1", features = ["native-tls", "url"] }
reqwest = { version = "0.12.4", features = ["native-tls"] }
reqwest = { version = "0.12.4", features = ["native-tls", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }

Expand Down
85 changes: 85 additions & 0 deletions crates/api/src/endpoints/archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use axum::extract::Path;
use cached::proc_macro::io_cached;
use serde::{Deserialize, Serialize};
use serde_json;
use tracing::error;

// A meeting represents a full race or testing weekend.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all(serialize = "camelCase", deserialize = "PascalCase"))]
pub struct Meeting {
key: u32,
code: String,
number: u8,
location: String,
official_name: String,
name: String,
// i think the name as string should suffice, right?...
country: Country,
// might not need circuit?
// circuit: String,
sessions: Vec<RaceSession>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all(serialize = "camelCase", deserialize = "PascalCase"))]
struct Country {
key: i32,
code: String,
name: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all(serialize = "camelCase", deserialize = "PascalCase"))]
pub struct MeetingResponse {
year: u16,
meetings: Vec<Meeting>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all(serialize = "camelCase", deserialize = "PascalCase"))]
pub struct RaceSession {
key: i32,
r#type: String,
#[serde(default)]
name: String,
#[serde(default)]
path: String,
#[serde(default)]
start_date: String,
#[serde(default)]
end_date: String,
}

#[io_cached(
map_error = r##"|e| anyhow::anyhow!(format!("disk cache error {:?}", e))"##,
disk = true,
time = 1800
)]
pub async fn fetch_sessions_for_year(year: u32) -> Result<String, anyhow::Error> {
let url = format!("https://livetiming.formula1.com/static/{year}/Index.json");
let res = reqwest::get(url).await?;
let text = res.text().await?;
Ok(text)
}

pub async fn get_sessions_for_year(
Path(year): Path<u32>,
) -> Result<axum::Json<Vec<Meeting>>, axum::http::StatusCode> {
let text = fetch_sessions_for_year(year).await.unwrap();

let json = serde_json::from_str::<MeetingResponse>(&text);

match json {
Ok(mut json) => {
for (_, el) in json.meetings.iter_mut().enumerate() {
el.sessions.retain(|s| s.key != -1);
}
Ok(axum::Json(json.meetings))
}
Err(err) => {
error!("{}", err);
Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
7 changes: 6 additions & 1 deletion crates/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use tracing::level_filters::LevelFilter;
use env;

mod endpoints {
pub(crate) mod archive;
pub(crate) mod health;
pub(crate) mod schedule;
}
Expand All @@ -18,7 +19,11 @@ async fn main() {
let app = Router::new()
.route("/api/schedule", get(endpoints::schedule::get))
.route("/api/schedule/next", get(endpoints::schedule::get_next))
.route("/api/health", get(endpoints::health::check));
.route("/api/health", get(endpoints::health::check))
.route(
"/api/archive/:year",
get(endpoints::archive::get_sessions_for_year),
);

let addr = addr();

Expand Down
68 changes: 68 additions & 0 deletions dash/src/app/(nav)/archive/[year]/[key]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Footer from "@/components/Footer"; // Adjust the import path as necessary
import { utc } from "moment";
import { Meeting } from "@/types/archive.type";
import Link from "next/link";
import { env } from "@/env.mjs";

const getArchiveForYear = async (year: string): Promise<Meeting[] | null> => {
try {
const nextReq = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/archive/${year}`, {
next: { revalidate: 60 * 60 * 4 },
});
const schedule: Meeting[] = await nextReq.json();
return schedule;
} catch (e) {
return null;
}
};

export default async function MeetingDetailsPage({ params }: { params: Promise<{ key: string; year: string }> }) {
const { key, year } = await params;
const archive = await getArchiveForYear(year);
const meeting = archive?.find((meet) => meet.key.toString() === key);

if (!meeting) {
return (
<div className="container mx-auto max-w-screen-lg px-4 pb-8">
<div className="flex h-44 flex-col items-center justify-center">
<p>No meeting details found for key: {key}</p>
</div>
<Footer />
</div>
);
}

return (
<div className="container mx-auto max-w-screen-lg px-4 pb-8">
<Link href={`/archive/${year}`}>
<div className="mt-4 text-blue-500 hover:underline">← Back to Year Overview</div>
</Link>
<div className="my-4">
<h1 className="text-3xl font-bold">{meeting.officialName}</h1>
<p className="text-sm text-zinc-500">{meeting.country.name}</p>
<p className="mt-1 text-sm italic text-zinc-400">{meeting.location}</p>
<p className="mt-2 text-sm text-zinc-600">
{utc(meeting.sessions[0].startDate).local().format("MMMM D, YYYY")} -{" "}
{utc(meeting.sessions[meeting.sessions.length - 1].endDate)
.local()
.format("MMMM D, YYYY")}
</p>
<div className="mt-4">
<h2 className="text-2xl font-bold">Sessions</h2>
<ul className="mt-2">
{meeting.sessions.map((session, index) => (
<li key={index} className="mb-4">
<h3 className="text-xl font-semibold">{session.name}</h3>
<p className="text-sm text-zinc-500">{utc(session.startDate).local().format("MMMM D, YYYY")}</p>
<p className="text-sm text-zinc-500">
{utc(session.startDate).local().format("HH:mm")} -{utc(session.endDate).local().format("HH:mm")}
</p>
</li>
))}
</ul>
</div>
</div>
<Footer />
</div>
);
}
33 changes: 33 additions & 0 deletions dash/src/app/(nav)/archive/[year]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { env } from "@/env.mjs";
import { Meeting } from "@/types/archive.type";
import { ReactNode } from "react";

const getArchiveForYear = async (year: string): Promise<Meeting[] | null> => {
try {
const nextReq = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/archive/${year}`, {
next: { revalidate: 60 * 60 * 4 },
});
const schedule: Meeting[] = await nextReq.json();
return schedule;
} catch (e) {
return null;
}
};

export default async function ArchiveLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ year: string }>;
}) {
const currentYear = new Date(Date.now()).getFullYear();
let year = (await params).year;
if (year == null || year < "2018" || year > currentYear.toString() || typeof year !== "string") {
year = currentYear.toString();
}

await getArchiveForYear(year);

return <div>{children}</div>;
}
82 changes: 82 additions & 0 deletions dash/src/app/(nav)/archive/[year]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { env } from "@/env.mjs";
import Footer from "@/components/Footer"; // Adjust the import path as necessary
import SegmentedLinks from "@/components/SegmentedLinks";
import { utc } from "moment";
import Link from "next/link";
import { Meeting } from "@/types/archive.type";
import Dropdown from "@/components/Dropdown";

const getArchiveForYear = async (year: string): Promise<Meeting[] | null> => {
try {
const nextReq = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/archive/${year}`, {
next: { revalidate: 60 * 60 * 4 },
});
const schedule: Meeting[] = await nextReq.json();
return schedule;
} catch (e) {
return null;
}
};

export default async function ArchivePage({ params }: { params: Promise<{ year: string }> }) {
const currentYear = new Date(Date.now()).getFullYear();
let year = (await params).year;
if (year == null || year < "2018" || year > currentYear.toString() || typeof year !== "string") {
year = currentYear.toString();
}
const archive = await getArchiveForYear(year);

const years = [];
for (let i = 2018; i <= currentYear; i++) {
years.push({ label: i.toString(), href: `/archive/${i.toString()}` });
}

const firstThreeYears = years.slice(years.length - 3);
const previousYears = years.slice(0, years.length - 3).reverse();

return (
<div className="container mx-auto max-w-screen-lg px-4 pb-8">
<div className="my-4 flex items-center justify-between">
<h1 className="text-3xl font-bold">Archive for {year}</h1>
<div className="flex items-center space-x-2">
<Dropdown options={previousYears} />
<SegmentedLinks id="year" selected={`/archive/${year}`} options={firstThreeYears} />
</div>
</div>
{!archive ? (
<div className="flex h-44 flex-col items-center justify-center">
<p>No archive data found for {year}</p>
</div>
) : (
<>
<p className="text-zinc-600">All times are local time</p>
<ul className="grid grid-cols-1 gap-8 md:grid-cols-2">
{archive.map((meet) => (
<li className="rounded-md border border-zinc-700 p-4 shadow-md" key={meet.key}>
<div className="flex h-full flex-col justify-between">
<div>
<h2 className="text-xl font-bold text-white">{meet.officialName}</h2>
<p className="text-sm text-zinc-500">{meet.country.name}</p>
<p className="mt-1 text-sm italic text-zinc-400">{meet.location}</p>
</div>
<div className="mt-2">
<p className="text-sm text-zinc-600">
{utc(meet.sessions[0].startDate).local().format("MMMM D, YYYY")} -{" "}
{utc(meet.sessions[meet.sessions.length - 1].endDate)
.local()
.format("MMMM D, YYYY")}
</p>
</div>
<Link href={`/archive/${year}/${meet.key}`}>
<div className="mt-2 text-blue-500 hover:underline">View Details</div>
</Link>
</div>
</li>
))}
</ul>
</>
)}
<Footer />
</div>
);
}
5 changes: 5 additions & 0 deletions dash/src/app/(nav)/archive/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";

export default function ArchiveRedirectPage() {
redirect(`/archive/${new Date(Date.now()).getFullYear()}`);
}
56 changes: 56 additions & 0 deletions dash/src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { useState, useRef } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";

type Props = {
options: { label: string; href: string }[];
};

export default function Dropdown({ options }: Props) {
const [showDropdown, setShowDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
};

if (typeof window !== "undefined") {
document.addEventListener("mousedown", handleClickOutside);
}

return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-5 py-1.5 text-white hover:bg-zinc-700"
>
Previous Years
</button>
<AnimatePresence>
{showDropdown && (
<motion.div
transition={{ ease: "easeOut", duration: 0.125 }}
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute left-1/2 mt-2 w-48 -translate-x-1/2 rounded-lg border border-zinc-700 bg-zinc-800 shadow-lg"
>
<ul>
{options.map((option) => (
<li key={option.label}>
<Link href={option.href} className="block rounded-md px-5 py-2 text-white hover:bg-zinc-600">
{option.label}
</Link>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
5 changes: 5 additions & 0 deletions dash/src/components/Menubar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export default function Menubar({ connected }: Props) {
router.prefetch("/");
router.prefetch("/dashboard");
router.prefetch("/schedule");
// should we prefetch archive?
router.prefetch("/archive");
router.prefetch("/settings");
router.prefetch("/help");
}, []);
Expand All @@ -65,6 +67,9 @@ export default function Menubar({ connected }: Props) {
<motion.a className="cursor-pointer" whileTap={{ scale: 0.95 }} onClick={() => liveTimingGuard("/schedule")}>
Schedule
</motion.a>
<motion.a className="cursor-pointer" whileTap={{ scale: 0.95 }} onClick={() => liveTimingGuard("/archive")}>
Archive
</motion.a>
<motion.a className="cursor-pointer" whileTap={{ scale: 0.95 }} onClick={() => liveTimingGuard("/settings")}>
Settings
</motion.a>
Expand Down
Loading