Compare commits

..

No commits in common. "1f43a7c29e3b4799bd59566b9bec2ecd10121891" and "7f9a745b1614f393afab2ee5c3c4151d62ae6026" have entirely different histories.

10 changed files with 125 additions and 466 deletions

View file

@ -12,9 +12,6 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/lib ./lib
COPY --from=builder /app/config ./config
RUN mkdir -p /app/data
EXPOSE 3000
CMD ["node", "server.js"]

View file

@ -1,63 +1,36 @@
# 🛰️ Mission Control: ISS Tracker
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).
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.
## Getting Started
## 🚀 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.
First, run the development server:
```bash
docker buildx build --platform linux/amd64 -t mission-control-web .
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
### Woodpecker CI
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
The pipeline is automated via .woodpecker.yaml, handling the build, registry push, and remote deployment via Docker Compose.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
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.
## 🚦 Getting Started
## Learn More
- Clone the repo
- Install dependencies: npm install
- Run the worker: npx tsx scripts/update-space.ts
- Start the dashboard: npm run dev
To learn more about Next.js, take a look at the following resources:
Ground Station Coordinates: 55.6761° N, 12.5683° E
- [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.

View file

@ -1,50 +1,18 @@
import MissionControl from '@/components/MissionControl';
import db from '@/lib/db';
const FALLBACK_PASS_TIME = 1769610273000;
export default function Home() {
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 row = db.prepare(
"SELECT pass_time FROM iss_passes WHERE pass_time > datetime('now') LIMIT 1"
).get() as { pass_time: string } | undefined;
const moonRow = db.prepare(`
SELECT title, event_time
FROM global_events
WHERE id = 'moon_phase'
`).get() as { title: string, event_time: string } | undefined;
const cosmicRow = db.prepare(`
SELECT title, event_time
FROM global_events
WHERE id = 'next_cosmic_event'
`).get() as { title: string, event_time: string } | undefined;
const launchRow = db.prepare(
"SELECT title, event_time FROM global_events WHERE id = 'next_launch'"
).get() as { title: string, event_time: string } | undefined;
const launchStart = launchRow ? new Date(launchRow.event_time) : null;
const launchEnd = launchStart ? new Date(launchStart.getTime() + 7200000) : null;
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;
const moonEnd = moonStart ? new Date(moonStart.getTime() + 86400000) : null;
const cosmicStart = cosmicRow ? new Date(cosmicRow.event_time) : null;
const cosmicEnd = cosmicStart ? new Date(cosmicStart.getTime() + 14400000) : null;
const nextPass = row ? new Date(row.pass_time) : new Date(FALLBACK_PASS_TIME);
return (
<main>
<MissionControl
iss={{ start: issStart, end: issEnd }}
moon={{ title: moonRow?.title || "Moon Phase", start: moonStart, end: moonEnd }}
cosmic={{ title: cosmicRow?.title || "Cosmic Event", start: cosmicStart, end: cosmicEnd }}
launch={{ title: launchRow?.title || "Rocket Launch", start: launchStart, end: launchEnd }}
/>
<MissionControl initialIssPass={nextPass} />
</main>
);
}

View file

@ -1,152 +1,67 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { EventCardProps } from '@/types/space';
import { useRouter } from 'next/navigation';
import { Sparkles } from 'lucide-react';
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<string | null>(null);
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" });
useEffect(() => {
const updateTimer = () => {
if (!targetDate || !endDate) return;
const now = new Date();
const timeToStart = targetDate.getTime() - now.getTime();
const timeToEnd = endDate.getTime() - now.getTime();
const now = new Date().getTime();
const distance = targetDate.getTime() - now;
if (timeToStart <= 0 && timeToEnd > 0) {
if (!isLive) setIsLive(true);
// If the time has passed, keep it at zero
if (distance < 0) {
setTimeLeft({ h: "00", m: "00", s: "00" });
return;
}
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));
// Math to convert milliseconds to Hours, Minutes, and Seconds
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, endDate, isLive, router]);
if (!targetDate) {
return (
<div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/20 backdrop-blur-md overflow-hidden opacity-60">
<div className="flex items-center gap-4 mb-4">
<div className="p-2 bg-slate-700/30 rounded-lg text-slate-500">
{icon}
</div>
<h3 className="text-slate-500 font-mono tracking-widest uppercase text-xs">
{title}
</h3>
</div>
<div className="py-2">
<div className="h-8 w-48 bg-slate-800 animate-pulse rounded" />
<p className="text-[10px] text-slate-600 mt-4 font-mono uppercase tracking-widest italic">
Waiting for orbital telemetry...
</p>
</div>
</div>
);
}
}, [targetDate]);
return (
<div className={`relative p-6 rounded-xl border transition-all duration-500 ${isLive ? 'border-blue-500 bg-blue-900/20 shadow-[0_0_20px_rgba(59,130,246,0.3)]' : 'border-slate-800 bg-slate-900/40'}`}>
{isLive ? (
<div className="flex flex-col items-center justify-center py-4 animate-pulse">
<Sparkles className="text-yellow-400 mb-2" size={32} />
<h3 className="text-xl font-bold text-white text-center">
{id === 'iss' ? 'Look Up!' : 'Event in Progress'}
</h3>
<p className="text-blue-300 text-sm text-center mt-1">
{id === 'iss'
? 'The ISS is directly above. Give them a wave! 👋'
: `The ${title} is occurring right now.`}
</p>
</div>
) : (
<>
<div className="flex items-center gap-4 mb-4">
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
{icon}
</div>
<h3 className="text-slate-300 font-mono tracking-widest uppercase text-xs">
{title}
</h3>
<div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-md overflow-hidden group">
<div className="flex items-center gap-4 mb-4">
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
{icon}
</div>
<h3 className="text-slate-300 font-mono tracking-widest uppercase text-xs">
{title}
</h3>
</div>
<div className="text-4xl font-mono text-white flex items-baseline gap-2">
{parseInt(timeLeft.d) >= 2 ? (
<>
<span>{timeLeft.d}</span>
<span className="text-slate-500 text-xl lowercase">Days</span>
</>
) : parseInt(timeLeft.d) === 1 ? (
<>
<span>01</span><span className="text-slate-500 text-sm">d</span>
<span className="text-slate-500">:</span>
<span>{timeLeft.h}</span><span className="text-slate-500 text-sm">h</span>
</>
) : (
<>
<span>{timeLeft.h}</span>
<span className="text-slate-500">:</span>
<span>{timeLeft.m}</span>
<span className="text-slate-500">:</span>
<span>{timeLeft.s}</span>
</>
)}
</div>
<p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest leading-none">
{isLive ? (
"Mission in Progress"
) : id === 'iss' ? (
"T-Minus to Horizon"
) : id === 'launch' ? (
"T-Minus to Ignition"
) : id === 'moon' ? (
"Until Lunar Phase"
) : (
"Days to Peak Phase"
)}
</p>
</>
)}
<div className="text-4xl font-mono text-white flex gap-2">
<span>{timeLeft.h}</span>
<span className="text-slate-500">:</span>
<span>{timeLeft.m}</span>
<span className="text-slate-500">:</span>
<span>{timeLeft.s}</span>
</div>
<p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest leading-none">
T-Minus to Horizon
</p>
</div>
);
}

View file

@ -4,45 +4,47 @@ 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) {
const BASE_TIME = 1769610273000;
export default function MissionControl({ iss, moon, cosmic, launch }: MissionControlProps) {
const events = useMemo(() => [
{
id: 'iss',
id: 'iss', // Add an ID to distinguish the ISS card
title: "ISS Overhead: Home",
date: iss.start,
endDate: iss.end,
date: initialIssPass, // Use the real data from SQLite here!
icon: <Satellite size={20} />,
},
{
id: 'moon',
title: moon.title,
date: moon.start,
endDate: moon.end,
title: "Next Lunar Phase: Full",
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 3),
icon: <Moon size={20} />,
},
{
id: 'cosmic',
title: cosmic.title,
date: cosmic.start,
endDate: cosmic.end,
icon: <Sparkles size={20} />,
id: 'starlink',
title: "Starlink Train",
date: new Date(BASE_TIME + 1000 * 60 * 45),
icon: <Rocket size={20} />,
},
{
id: 'launch',
title: launch.title,
date: launch.start,
endDate: launch.end,
icon: <Rocket size={20} />,
id: 'meteor',
title: "Meteor Shower",
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10),
icon: <Sparkles size={20} />,
}
], [iss, moon, cosmic, launch]);
], [initialIssPass]); // Re-memoize if the pass updates
return (
<div className="relative min-h-screen flex flex-col items-center p-8 overflow-hidden">
@ -60,11 +62,9 @@ export default function MissionControl({ iss, moon, cosmic, launch }: MissionCon
<div className="z-10 grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-5xl">
{events.map((event) => (
<EventCard
key={`${event.id}-${event.date?.getTime() || 'none'}`}
id={event.id}
key={event.id}
title={event.title}
targetDate={event.date}
endDate={event.endDate}
targetDate={event.date} // This now correctly uses the specific date for each card
icon={event.icon}
/>
))}

View file

@ -1,67 +0,0 @@
[
{
"id": "lunar_eclipse_mar_2026",
"title": "Total Lunar Eclipse",
"event_time": "2026-03-03T11:34:00Z"
},
{
"id": "solar_eclipse_aug_2026",
"title": "Solar Eclipse: Partial (85%)",
"event_time": "2026-08-12T17:10:00Z"
},
{
"id": "leonids_2026",
"title": "Leonid Meteor Shower",
"event_time": "2026-11-17T12:00:00Z"
},
{
"id": "solar_eclipse_aug_2027",
"title": "Solar Eclipse: Partial",
"event_time": "2027-08-02T10:05:00Z"
},
{
"id": "perseids_2027",
"title": "Perseid Meteor Shower",
"event_time": "2027-08-13T01:00:00Z"
},
{
"id": "lunar_eclipse_dec_2028",
"title": "Total Lunar Eclipse",
"event_time": "2028-12-31T16:53:00Z"
},
{
"id": "solar_eclipse_jun_2029",
"title": "Solar Eclipse: Partial",
"event_time": "2029-06-12T04:06:00Z"
},
{
"id": "lunar_eclipse_dec_2029",
"title": "Total Lunar Eclipse",
"event_time": "2029-12-20T22:42:00Z"
},
{
"id": "geminids_2030",
"title": "Geminid Meteor Shower",
"event_time": "2030-12-14T07:00:00Z"
},
{
"id": "solar_eclipse_nov_2032",
"title": "Solar Eclipse: Partial",
"event_time": "2032-11-03T05:34:00Z"
},
{
"id": "lunar_eclipse_apr_2033",
"title": "Total Lunar Eclipse",
"event_time": "2033-04-14T19:13:00Z"
},
{
"id": "solar_eclipse_mar_2034",
"title": "Solar Eclipse: Partial",
"event_time": "2034-03-20T10:18:00Z"
},
{
"id": "lunar_eclipse_feb_2035",
"title": "Total Lunar Eclipse",
"event_time": "2035-02-22T09:05:00Z"
}
]

View file

@ -17,19 +17,8 @@ const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS iss_passes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pass_time TEXT NOT NULL,
end_time TEXT NOT NULL
pass_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;

View file

@ -1,14 +1,9 @@
import db from '../lib/db';
import * as satellite from 'satellite.js';
import path from 'path';
import fs from 'fs';
import { CosmicEvent } from '@/types/space';
const MY_LAT = 55.6683;
const MY_LON = 12.5333;
const MY_ALT = 0;
const MY_ALT = 0; // Altitude in kilometers
async function updateISSData() {
console.log("🛰️ Fetching TLE data...");
@ -19,150 +14,47 @@ 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
};
const passesFound: { start: Date, end: Date }[] = [];
let nextPassDate: Date | null = null;
const now = new Date();
const searchTime = new Date(now.getTime());
const maxSearchMinutes = 2880;
let minutesSearched = 0;
while (passesFound.length < 2 && minutesSearched < maxSearchMinutes) {
const checkTime = new Date(searchTime.getTime() + (minutesSearched * 60000));
// 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 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) {
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;
}
}
// If elevation > 0, the ISS is above your horizon!
if (elevation > 0) {
nextPassDate = checkTime;
break;
}
passesFound.push({ start: aos, end: los });
minutesSearched += 90;
continue;
}
}
minutesSearched++;
}
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.`);
}
function updateMoonPhase() {
const LUNAR_CYCLE = 29.53059;
const KNOWN_NEW_MOON = new Date('2024-01-11T11:57:00Z');
const now = new Date();
const daysSince = (now.getTime() - KNOWN_NEW_MOON.getTime()) / 86400000;
const progress = daysSince % LUNAR_CYCLE;
const daysToFull = (LUNAR_CYCLE / 2) - progress;
const daysToNew = LUNAR_CYCLE - progress;
const moon = daysToFull > 0
? { title: "Next Full Moon", date: new Date(now.getTime() + daysToFull * 86400000) }
: { title: "Next New Moon", date: new Date(now.getTime() + daysToNew * 86400000) };
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
`).run(moon.title, moon.date.toISOString());
console.log(`🌙 Moon: ${moon.title}`);
}
function updateCosmicEvents() {
const configPath = path.resolve(process.cwd(), 'config/events.json');
if (!fs.existsSync(configPath)) return;
const events: CosmicEvent[] = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const now = new Date();
const next = events
.filter((e) => new Date(e.event_time) > now)
.sort((a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime())[0];
if (next) {
db.prepare(`
INSERT INTO global_events (id, title, event_time) VALUES ('next_cosmic_event', ?, ?)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, event_time=excluded.event_time
`).run(next.title, next.event_time);
console.log(`✨ Cosmic: ${next.title}`);
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).");
}
}
async function updateRocketLaunches() {
try {
const nowISO = new Date().toISOString();
const url = `https://lldev.thespacedevs.com/2.2.0/launch/upcoming/?limit=1&mode=list&net__gt=${nowISO}`;
const response = await fetch(url);
const data = await response.json();
if (data.results && data.results.length > 0) {
const launch = data.results[0];
db.prepare(`
INSERT INTO global_events (id, title, event_time)
VALUES ('next_launch', ?, ?)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, event_time=excluded.event_time
`).run(
`Launch: ${launch.name}`,
launch.net
);
console.log(`🚀 Rocket Sync: ${launch.name}`);
}
} catch (error) {
console.error("❌ Rocket Fetch Error:", error);
}
}
async function updateAllData() {
console.log("📡 Starting Ground Station sync...");
try {
await updateISSData();
updateMoonPhase();
updateCosmicEvents();
await updateRocketLaunches();
console.log("✅ All systems synchronized.");
} catch (error) {
console.error("❌ Sync failed:", error);
}
}
updateAllData().catch(console.error);
updateISSData().catch(console.error);

5
types/cards.ts Normal file
View file

@ -0,0 +1,5 @@
interface EventCardProps {
title: string;
targetDate: Date;
icon?: React.ReactNode;
}

View file

@ -1,3 +1,5 @@
import { ReactNode } from "react";
export interface ISSPass {
id: string;
timestamp: number;
@ -22,22 +24,7 @@ export interface Star {
}
export interface EventCardProps {
id: string;
title: string;
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 };
cosmic: { title: string; start: Date | null; end: Date | null };
launch: { title: string; start: Date | null; end: Date | null };
}
export interface CosmicEvent {
id: string;
title: string;
event_time: string;
targetDate: Date;
icon?: ReactNode;
}