diff --git a/README.md b/README.md index e215bc4..f688aea 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,63 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# 🛰️ Mission Control: ISS Tracker -## Getting Started +A production-grade, full-stack dashboard for tracking the International Space Station in real-time. This project combines orbital mechanics (SGP4 propagation), a background worker architecture, and a resilient Next.js frontend. -First, run the development server: +## 🚀 The Stack + +- **Frontend**: Next.js 15 (App Router) + Tailwind CSS + Lucide Icons +- **Backend/Worker**: Node.js + `satellite.js` (Orbital Physics) +- **Database**: SQLite (`better-sqlite3`) +- **Infrastructure**: Docker & Docker Compose +- **CI/CD**: Woodpecker CI +- **Deployment**: Hetzner VPS + Caddy (Reverse Proxy) + +## 🏗️ Architecture + +The system is split into two distinct services that share a persistent SQLite volume: + +1. **The Worker (`iss-worker`)**: + * Fetches Two-Line Element (TLE) data from CelesTrak. + * Performs SGP4 propagation to calculate ISS coordinates. + * Determines upcoming "Pass Windows" (AOS to LOS) relative to a fixed ground station. + * Updates the shared database with the next two valid passes. +2. **The Web App (`mc-web`)**: + * Server-side queries the database for the next future pass. + * Calculates real-time "T-Minus" countdowns. + * Triggers a "Live" state when the ISS is above the horizon. + * Uses `router.refresh()` with a `useRef` lock to transition between passes without page reloads. + + + +## 🛠️ Key Technical Solutions + +### Orbital Propagation +Instead of relying on 3rd-party APIs for pass predictions, this project runs its own physics engine. Using `satellite.js`, it calculates the elevation of the ISS relative to the observer's latitude and longitude. + +### Self-Healing Database +The database includes an automatic migration layer in `lib/db.ts`. On startup, the application inspects the SQLite schema and applies necessary `ALTER TABLE` commands, ensuring seamless updates during CI/CD deployments without manual intervention. + +### Resilient Synchronization +To prevent infinite refresh loops in the Next.js frontend, the `EventCard` component implements a "Refresh & Quiet" logic. It uses a `useRef` to ensure that only one server-side data sync is requested per orbital event. + +## 📦 Deployment + +### Docker Multi-Platform Build +The images are explicitly built for the `linux/amd64` architecture to ensure compatibility with Node.js native modules (like `better-sqlite3`) on the Hetzner VPS. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +docker buildx build --platform linux/amd64 -t mission-control-web . ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### Woodpecker CI -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +The pipeline is automated via .woodpecker.yaml, handling the build, registry push, and remote deployment via Docker Compose. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. -## Learn More +## 🚦 Getting Started -To learn more about Next.js, take a look at the following resources: +- Clone the repo +- Install dependencies: npm install +- Run the worker: npx tsx scripts/update-space.ts +- Start the dashboard: npm run dev -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Ground Station Coordinates: 55.6761° N, 12.5683° E \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index effa34d..6bf2a80 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,36 @@ import MissionControl from '@/components/MissionControl'; import db from '@/lib/db'; -const FALLBACK_PASS_TIME = 1769610273000; - export default function Home() { - const row = db.prepare( - "SELECT pass_time FROM iss_passes WHERE pass_time > datetime('now') LIMIT 1" - ).get() as { pass_time: string } | undefined; + // SQLite handles the 'now' comparison perfectly + const issRow = db.prepare(` + SELECT pass_time, end_time + FROM iss_passes + WHERE datetime(end_time) > datetime('now') + ORDER BY datetime(pass_time) ASC LIMIT 1 + `).get() as { pass_time: string, end_time: string } | undefined; - const nextPass = row ? new Date(row.pass_time) : new Date(FALLBACK_PASS_TIME); + // 2. Fetch Moon + const moonRow = db.prepare(` + SELECT title, event_time + FROM global_events + WHERE id = 'moon_phase' + `).get() as { title: string, event_time: string } | undefined; + + // Prepare Dates + const issStart = issRow ? new Date(issRow.pass_time) : null; + const issEnd = issRow ? new Date(issRow.end_time) : null; + + const moonStart = moonRow ? new Date(moonRow.event_time) : null; + // Moon "Event" lasts 24 hours + const moonEnd = moonStart ? new Date(moonStart.getTime() + 86400000) : null; return (
- +
); } \ No newline at end of file diff --git a/components/EventCard.tsx b/components/EventCard.tsx index da86994..bec5998 100644 --- a/components/EventCard.tsx +++ b/components/EventCard.tsx @@ -1,67 +1,146 @@ "use client"; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { EventCardProps } from '@/types/space'; +import { useRouter } from 'next/navigation'; +import { Sparkles } from 'lucide-react'; -export default function EventCard({ title, targetDate, icon }: EventCardProps) { - // 1. Setup the state to hold our strings - const [timeLeft, setTimeLeft] = useState({ h: "00", m: "00", s: "00" }); +export default function EventCard({ id, title, targetDate, endDate, icon }: EventCardProps) { + const [isLive, setIsLive] = useState(false); + const router = useRouter(); + const [timeLeft, setTimeLeft] = useState({ d: "00", h: "00", m: "00", s: "00" }); + + const lastRefreshedRef = useRef(null); useEffect(() => { const updateTimer = () => { - const now = new Date().getTime(); - const distance = targetDate.getTime() - now; + if (!targetDate || !endDate) return; + const now = new Date(); + const timeToStart = targetDate.getTime() - now.getTime(); + const timeToEnd = endDate.getTime() - now.getTime(); - // If the time has passed, keep it at zero - if (distance < 0) { - setTimeLeft({ h: "00", m: "00", s: "00" }); + if (timeToStart <= 0 && timeToEnd > 0) { + if (!isLive) setIsLive(true); return; } - // Math to convert milliseconds to Hours, Minutes, and Seconds + if (timeToEnd <= 0) { + if (isLive) setIsLive(false); + + const passId = endDate.toISOString(); + + if (lastRefreshedRef.current !== passId) { + + const isStaleDataFromServer = endDate.getTime() < (new Date().getTime() - 5000); + + if (!isStaleDataFromServer) { + console.log("🛰️ Pass complete. Syncing with Mission Control..."); + lastRefreshedRef.current = passId; + router.refresh(); + } else { + console.warn("⚠️ Server returned stale ISS data. Standing by for worker update..."); + lastRefreshedRef.current = passId; + } + } + return; + } + + const distance = timeToStart; + const d = Math.floor(distance / (1000 * 60 * 60 * 24)); const h = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); const s = Math.floor((distance % (1000 * 60)) / 1000); - // 2. Update the state (padStart ensures we always see '05' instead of '5') setTimeLeft({ + d: d.toString().padStart(2, '0'), h: h.toString().padStart(2, '0'), m: m.toString().padStart(2, '0'), s: s.toString().padStart(2, '0') }); }; - // Run the timer every 1000ms (1 second) const interval = setInterval(updateTimer, 1000); - - // Call it once immediately so the user doesn't see 00:00:00 for the first second updateTimer(); - // Important: Clean up the timer if the component disappears return () => clearInterval(interval); - }, [targetDate]); + }, [targetDate, endDate, isLive, router]); + + if (!targetDate) { + return ( +
+
+
+ {icon} +
+

+ {title} +

+
+
+
+

+ Waiting for orbital telemetry... +

+
+
+ ); + } return ( -
-
-
- {icon} +
+ {isLive ? ( +
+ +

+ {id === 'iss' ? 'Look Up!' : 'Event in Progress'} +

+

+ {id === 'iss' + ? 'The ISS is directly above. Give them a wave! 👋' + : `The ${title} is occurring right now.`} +

+
+ ) : ( + <> +
+
+ {icon} +
+

+ {title} +

-

- {title} -

-
-
- {timeLeft.h} - : - {timeLeft.m} - : - {timeLeft.s} -
- -

- T-Minus to Horizon -

+
+ {parseInt(timeLeft.d) >= 2 ? ( + /* Case 1: More than 2 days - Keep it super simple */ + <> + {timeLeft.d} + Days + + ) : parseInt(timeLeft.d) === 1 ? ( + /* Case 2: Between 24 and 48 hours - Show Day + Hours */ + <> + 01d + : + {timeLeft.h}h + + ) : ( + /* Case 3: Under 24 hours - The full high-precision clock */ + <> + {timeLeft.h} + : + {timeLeft.m} + : + {timeLeft.s} + + )} +
+ +

+ {parseInt(timeLeft.d) > 0 ? "Until event" : "T-Minus to Horizon"} +

+ + )}
); } \ No newline at end of file diff --git a/components/MissionControl.tsx b/components/MissionControl.tsx index 3cc1142..a27d312 100644 --- a/components/MissionControl.tsx +++ b/components/MissionControl.tsx @@ -4,47 +4,48 @@ import { useMemo } from 'react'; import dynamic from 'next/dynamic'; import EventCard from '@/components/EventCard'; import { Satellite, Rocket, Moon, Sparkles } from 'lucide-react'; +import { MissionControlProps } from '@/types/space'; + const Starfield = dynamic(() => import('@/components/Starfield'), { ssr: false }); -// 1. Define the interface -interface MissionControlProps { - initialIssPass: Date; -} -// 2. Accept the prop here -export default function MissionControl({ initialIssPass }: MissionControlProps) { +export default function MissionControl({ iss, moon }: MissionControlProps) { const BASE_TIME = 1769610273000; const events = useMemo(() => [ { - id: 'iss', // Add an ID to distinguish the ISS card + id: 'iss', title: "ISS Overhead: Home", - date: initialIssPass, // Use the real data from SQLite here! + date: iss.start, + endDate: iss.end, icon: , }, { id: 'moon', - title: "Next Lunar Phase: Full", - date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 3), + title: moon.title, + date: moon.start, + endDate: moon.end, icon: , }, { id: 'starlink', title: "Starlink Train", date: new Date(BASE_TIME + 1000 * 60 * 45), + endDate: new Date(BASE_TIME + 1000 * 60 * 55), icon: , }, { id: 'meteor', title: "Meteor Shower", date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10), + endDate: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10.1), icon: , } - ], [initialIssPass]); // Re-memoize if the pass updates + ], [iss, moon]); return (
@@ -62,9 +63,11 @@ export default function MissionControl({ initialIssPass }: MissionControlProps)
{events.map((event) => ( ))} diff --git a/lib/db.ts b/lib/db.ts index 09f81b4..985e69e 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -17,8 +17,19 @@ const db = new Database(dbPath); db.exec(` CREATE TABLE IF NOT EXISTS iss_passes ( id INTEGER PRIMARY KEY AUTOINCREMENT, - pass_time TEXT NOT NULL + pass_time TEXT NOT NULL, + end_time TEXT NOT NULL ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS global_events ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + event_time TEXT NOT NULL + ); +`); + + + export default db; \ No newline at end of file diff --git a/scripts/update-space.ts b/scripts/update-space.ts index afd93d6..03dd115 100644 --- a/scripts/update-space.ts +++ b/scripts/update-space.ts @@ -3,7 +3,7 @@ import * as satellite from 'satellite.js'; const MY_LAT = 55.6683; const MY_LON = 12.5333; -const MY_ALT = 0; // Altitude in kilometers +const MY_ALT = 0; async function updateISSData() { console.log("🛰️ Fetching TLE data..."); @@ -14,47 +14,105 @@ async function updateISSData() { const line1 = lines[1]; const line2 = lines[2]; - - // 1. Initialize satellite record from TLE const satrec = satellite.twoline2satrec(line1, line2); - // 2. Observer coordinates in radians const observerGd = { longitude: satellite.degreesToRadians(MY_LON), latitude: satellite.degreesToRadians(MY_LAT), height: MY_ALT }; - let nextPassDate: Date | null = null; + const passesFound: { start: Date, end: Date }[] = []; const now = new Date(); + const searchTime = new Date(now.getTime()); - // 3. Look ahead for the next 24 hours (1440 minutes) - for (let i = 0; i < 1440; i++) { - const checkTime = new Date(now.getTime() + i * 60000); + const maxSearchMinutes = 2880; + let minutesSearched = 0; + + while (passesFound.length < 2 && minutesSearched < maxSearchMinutes) { + const checkTime = new Date(searchTime.getTime() + (minutesSearched * 60000)); const positionAndVelocity = satellite.propagate(satrec, checkTime); const gmst = satellite.gstime(checkTime); - // Calculate Look Angles (Azimuth, Elevation, Range) if (typeof positionAndVelocity?.position !== 'boolean') { - const positionEcf = satellite.eciToEcf(positionAndVelocity!.position, gmst); - const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf); - const elevation = satellite.radiansToDegrees(lookAngles.elevation); + const positionEcf = satellite.eciToEcf(positionAndVelocity!.position, gmst); + const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf); + const elevation = satellite.radiansToDegrees(lookAngles.elevation); - // If elevation > 0, the ISS is above your horizon! - if (elevation > 0) { - nextPassDate = checkTime; - break; + if (elevation > 0) { + const aos = checkTime; + let los = new Date(aos.getTime() + 5 * 60000); + + for (let j = 1; j < 20; j++) { + const exitTime = new Date(aos.getTime() + j * 60000); + const pos = satellite.propagate(satrec, exitTime); + const gmstExit = satellite.gstime(exitTime); + if (typeof pos?.position !== 'boolean') { + const lookExit = satellite.ecfToLookAngles(observerGd, satellite.eciToEcf(pos!.position, gmstExit)); + if (satellite.radiansToDegrees(lookExit.elevation) < 0) { + los = exitTime; + break; + } + } } + + passesFound.push({ start: aos, end: los }); + + minutesSearched += 90; + continue; + } } + minutesSearched++; } - if (nextPassDate) { - const upsert = db.prepare('INSERT INTO iss_passes (id, pass_time) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET pass_time=excluded.pass_time'); - upsert.run(nextPassDate.toISOString()); - console.log(`✅ Real orbital pass found: ${nextPassDate.toISOString()}`); - } else { - console.log("❌ No pass found in the next 24 hours (check your TLE or coordinates)."); + db.prepare('DELETE FROM iss_passes').run(); + const insert = db.prepare('INSERT INTO iss_passes (pass_time, end_time) VALUES (?, ?)'); + + for (const pass of passesFound) { + insert.run(pass.start.toISOString(), pass.end.toISOString()); } + + console.log(`✅ Stored ${passesFound.length} future passes with start and end times.`); } -updateISSData().catch(console.error); \ No newline at end of file +// Add this to your worker script +function getNextMoonPhase() { + const LUNAR_CYCLE = 29.53059; // Days + const KNOWN_NEW_MOON = new Date('2024-01-11T11:57:00Z'); // A reference New Moon + + const now = new Date(); + const msSinceReference = now.getTime() - KNOWN_NEW_MOON.getTime(); + const daysSinceReference = msSinceReference / (1000 * 60 * 60 * 24); + + const currentCycleProgress = daysSinceReference % LUNAR_CYCLE; + const daysToFullMoon = (LUNAR_CYCLE / 2) - currentCycleProgress; + const daysToNewMoon = LUNAR_CYCLE - currentCycleProgress; + + // If we've passed the Full Moon in this cycle, count to New Moon + let targetDate, title; + if (daysToFullMoon > 0) { + targetDate = new Date(now.getTime() + daysToFullMoon * 86400000); + title = "Next Full Moon"; + } else { + targetDate = new Date(now.getTime() + daysToNewMoon * 86400000); + title = "Next New Moon"; + } + + return { title, targetDate }; +} + +async function updateAllData() { + await updateISSData(); + + const moon = getNextMoonPhase(); + const upsertMoon = db.prepare(` + INSERT INTO global_events (id, title, event_time) + VALUES ('moon_phase', ?, ?) + ON CONFLICT(id) DO UPDATE SET title=excluded.title, event_time=excluded.event_time + `); + upsertMoon.run(moon.title, moon.targetDate.toISOString()); + + console.log(`🌙 Updated Moon Phase: ${moon.title}`); +} + +updateAllData().catch(console.error); diff --git a/types/cards.ts b/types/cards.ts deleted file mode 100644 index e0385e5..0000000 --- a/types/cards.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface EventCardProps { - title: string; - targetDate: Date; - icon?: React.ReactNode; -} \ No newline at end of file diff --git a/types/space.ts b/types/space.ts index 3beb20c..38b2d20 100644 --- a/types/space.ts +++ b/types/space.ts @@ -1,5 +1,3 @@ -import { ReactNode } from "react"; - export interface ISSPass { id: string; timestamp: number; @@ -24,7 +22,14 @@ export interface Star { } export interface EventCardProps { + id: string; title: string; - targetDate: Date; - icon?: ReactNode; + targetDate: Date | null; + endDate: Date | null; + icon: React.ReactNode; +} + +export interface MissionControlProps { + iss: { start: Date | null; end: Date | null }; + moon: { title: string; start: Date | null; end: Date | null }; } \ No newline at end of file