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/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/lib ./lib COPY --from=builder /app/lib ./lib
COPY --from=builder /app/config ./config
RUN mkdir -p /app/data
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] 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 First, run the development server:
- **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 ```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 To learn more about Next.js, take a look at the following resources:
- Install dependencies: npm install
- Run the worker: npx tsx scripts/update-space.ts
- Start the dashboard: npm run dev
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 MissionControl from '@/components/MissionControl';
import db from '@/lib/db'; import db from '@/lib/db';
const FALLBACK_PASS_TIME = 1769610273000;
export default function Home() { export default function Home() {
const issRow = db.prepare(` const row = db.prepare(
SELECT pass_time, end_time "SELECT pass_time FROM iss_passes WHERE pass_time > datetime('now') LIMIT 1"
FROM iss_passes ).get() as { pass_time: string } | undefined;
WHERE datetime(end_time) > datetime('now')
ORDER BY datetime(pass_time) ASC LIMIT 1
`).get() as { pass_time: string, end_time: string } | undefined;
const moonRow = db.prepare(` const nextPass = row ? new Date(row.pass_time) : new Date(FALLBACK_PASS_TIME);
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;
return ( return (
<main> <main>
<MissionControl <MissionControl initialIssPass={nextPass} />
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 }}
/>
</main> </main>
); );
} }

View file

@ -1,152 +1,67 @@
"use client"; "use client";
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { EventCardProps } from '@/types/space'; 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) { export default function EventCard({ title, targetDate, icon }: EventCardProps) {
const [isLive, setIsLive] = useState(false); // 1. Setup the state to hold our strings
const router = useRouter(); const [timeLeft, setTimeLeft] = useState({ h: "00", m: "00", s: "00" });
const [timeLeft, setTimeLeft] = useState({ d: "00", h: "00", m: "00", s: "00" });
const lastRefreshedRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const updateTimer = () => { const updateTimer = () => {
if (!targetDate || !endDate) return; const now = new Date().getTime();
const now = new Date(); const distance = targetDate.getTime() - now;
const timeToStart = targetDate.getTime() - now.getTime();
const timeToEnd = endDate.getTime() - now.getTime();
if (timeToStart <= 0 && timeToEnd > 0) { // If the time has passed, keep it at zero
if (!isLive) setIsLive(true); if (distance < 0) {
setTimeLeft({ h: "00", m: "00", s: "00" });
return; return;
} }
if (timeToEnd <= 0) { // Math to convert milliseconds to Hours, Minutes, and Seconds
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 h = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const s = Math.floor((distance % (1000 * 60)) / 1000); const s = Math.floor((distance % (1000 * 60)) / 1000);
// 2. Update the state (padStart ensures we always see '05' instead of '5')
setTimeLeft({ setTimeLeft({
d: d.toString().padStart(2, '0'),
h: h.toString().padStart(2, '0'), h: h.toString().padStart(2, '0'),
m: m.toString().padStart(2, '0'), m: m.toString().padStart(2, '0'),
s: s.toString().padStart(2, '0') s: s.toString().padStart(2, '0')
}); });
}; };
// Run the timer every 1000ms (1 second)
const interval = setInterval(updateTimer, 1000); const interval = setInterval(updateTimer, 1000);
// Call it once immediately so the user doesn't see 00:00:00 for the first second
updateTimer(); updateTimer();
// Important: Clean up the timer if the component disappears
return () => clearInterval(interval); return () => clearInterval(interval);
}, [targetDate, endDate, isLive, router]); }, [targetDate]);
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>
);
}
return ( 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'}`}> <div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-md overflow-hidden group">
{isLive ? ( <div className="flex items-center gap-4 mb-4">
<div className="flex flex-col items-center justify-center py-4 animate-pulse"> <div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
<Sparkles className="text-yellow-400 mb-2" size={32} /> {icon}
<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> </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"> <div className="text-4xl font-mono text-white flex gap-2">
{parseInt(timeLeft.d) >= 2 ? ( <span>{timeLeft.h}</span>
<> <span className="text-slate-500">:</span>
<span>{timeLeft.d}</span> <span>{timeLeft.m}</span>
<span className="text-slate-500 text-xl lowercase">Days</span> <span className="text-slate-500">:</span>
</> <span>{timeLeft.s}</span>
) : parseInt(timeLeft.d) === 1 ? ( </div>
<>
<span>01</span><span className="text-slate-500 text-sm">d</span> <p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest leading-none">
<span className="text-slate-500">:</span> T-Minus to Horizon
<span>{timeLeft.h}</span><span className="text-slate-500 text-sm">h</span> </p>
</>
) : (
<>
<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> </div>
); );
} }

View file

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

View file

@ -1,14 +1,9 @@
import db from '../lib/db'; import db from '../lib/db';
import * as satellite from 'satellite.js'; 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_LAT = 55.6683;
const MY_LON = 12.5333; const MY_LON = 12.5333;
const MY_ALT = 0; const MY_ALT = 0; // Altitude in kilometers
async function updateISSData() { async function updateISSData() {
console.log("🛰️ Fetching TLE data..."); console.log("🛰️ Fetching TLE data...");
@ -19,150 +14,47 @@ async function updateISSData() {
const line1 = lines[1]; const line1 = lines[1];
const line2 = lines[2]; const line2 = lines[2];
// 1. Initialize satellite record from TLE
const satrec = satellite.twoline2satrec(line1, line2); const satrec = satellite.twoline2satrec(line1, line2);
// 2. Observer coordinates in radians
const observerGd = { const observerGd = {
longitude: satellite.degreesToRadians(MY_LON), longitude: satellite.degreesToRadians(MY_LON),
latitude: satellite.degreesToRadians(MY_LAT), latitude: satellite.degreesToRadians(MY_LAT),
height: MY_ALT height: MY_ALT
}; };
const passesFound: { start: Date, end: Date }[] = []; let nextPassDate: Date | null = null;
const now = new Date(); const now = new Date();
const searchTime = new Date(now.getTime());
const maxSearchMinutes = 2880; // 3. Look ahead for the next 24 hours (1440 minutes)
let minutesSearched = 0; for (let i = 0; i < 1440; i++) {
const checkTime = new Date(now.getTime() + i * 60000);
while (passesFound.length < 2 && minutesSearched < maxSearchMinutes) {
const checkTime = new Date(searchTime.getTime() + (minutesSearched * 60000));
const positionAndVelocity = satellite.propagate(satrec, checkTime); const positionAndVelocity = satellite.propagate(satrec, checkTime);
const gmst = satellite.gstime(checkTime); const gmst = satellite.gstime(checkTime);
// Calculate Look Angles (Azimuth, Elevation, Range)
if (typeof positionAndVelocity?.position !== 'boolean') { if (typeof positionAndVelocity?.position !== 'boolean') {
const positionEcf = satellite.eciToEcf(positionAndVelocity!.position, gmst); const positionEcf = satellite.eciToEcf(positionAndVelocity!.position, gmst);
const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf); const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf);
const elevation = satellite.radiansToDegrees(lookAngles.elevation); const elevation = satellite.radiansToDegrees(lookAngles.elevation);
if (elevation > 0) { // If elevation > 0, the ISS is above your horizon!
const aos = checkTime; if (elevation > 0) {
let los = new Date(aos.getTime() + 5 * 60000); nextPassDate = checkTime;
break;
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++;
} }
db.prepare('DELETE FROM iss_passes').run(); if (nextPassDate) {
const insert = db.prepare('INSERT INTO iss_passes (pass_time, end_time) VALUES (?, ?)'); 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());
for (const pass of passesFound) { console.log(`✅ Real orbital pass found: ${nextPassDate.toISOString()}`);
insert.run(pass.start.toISOString(), pass.end.toISOString()); } else {
} console.log("❌ No pass found in the next 24 hours (check your TLE or coordinates).");
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}`);
} }
} }
async function updateRocketLaunches() { updateISSData().catch(console.error);
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);

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 { export interface ISSPass {
id: string; id: string;
timestamp: number; timestamp: number;
@ -22,22 +24,7 @@ export interface Star {
} }
export interface EventCardProps { export interface EventCardProps {
id: string;
title: string; title: string;
targetDate: Date | null; targetDate: Date;
endDate: Date | null; icon?: ReactNode;
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;
}