Finalised moon phases and iss

This commit is contained in:
GeorgeWebberley 2026-01-28 20:11:44 +01:00
parent 7f9a745b16
commit 0c5c3cf2d0
8 changed files with 309 additions and 113 deletions

View file

@ -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 ```bash
npm run dev docker buildx build --platform linux/amd64 -t mission-control-web .
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
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. Ground Station Coordinates: 55.6761° N, 12.5683° E
- [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,18 +1,36 @@
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 row = db.prepare( // SQLite handles the 'now' comparison perfectly
"SELECT pass_time FROM iss_passes WHERE pass_time > datetime('now') LIMIT 1" const issRow = db.prepare(`
).get() as { pass_time: string } | undefined; 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 ( return (
<main> <main>
<MissionControl initialIssPass={nextPass} /> <MissionControl
iss={{ start: issStart, end: issEnd }}
moon={{ title: moonRow?.title || "Moon Phase", start: moonStart, end: moonEnd }}
/>
</main> </main>
); );
} }

View file

@ -1,67 +1,146 @@
"use client"; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } 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({ title, targetDate, icon }: EventCardProps) { export default function EventCard({ id, title, targetDate, endDate, icon }: EventCardProps) {
// 1. Setup the state to hold our strings const [isLive, setIsLive] = useState(false);
const [timeLeft, setTimeLeft] = useState({ h: "00", m: "00", s: "00" }); const router = useRouter();
const [timeLeft, setTimeLeft] = useState({ d: "00", h: "00", m: "00", s: "00" });
const lastRefreshedRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const updateTimer = () => { const updateTimer = () => {
const now = new Date().getTime(); if (!targetDate || !endDate) return;
const distance = targetDate.getTime() - now; 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 (timeToStart <= 0 && timeToEnd > 0) {
if (distance < 0) { if (!isLive) setIsLive(true);
setTimeLeft({ h: "00", m: "00", s: "00" });
return; 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 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]); }, [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>
);
}
return ( return (
<div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-md overflow-hidden group"> <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="flex items-center gap-4 mb-4"> {isLive ? (
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400"> <div className="flex flex-col items-center justify-center py-4 animate-pulse">
{icon} <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> </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 gap-2"> <div className="text-4xl font-mono text-white flex items-baseline gap-2">
<span>{timeLeft.h}</span> {parseInt(timeLeft.d) >= 2 ? (
<span className="text-slate-500">:</span> /* Case 1: More than 2 days - Keep it super simple */
<span>{timeLeft.m}</span> <>
<span className="text-slate-500">:</span> <span>{timeLeft.d}</span>
<span>{timeLeft.s}</span> <span className="text-slate-500 text-xl lowercase">Days</span>
</div> </>
) : parseInt(timeLeft.d) === 1 ? (
/* Case 2: Between 24 and 48 hours - Show Day + Hours */
<>
<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>
</>
) : (
/* Case 3: Under 24 hours - The full high-precision clock */
<>
<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"> <p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest leading-none">
T-Minus to Horizon {parseInt(timeLeft.d) > 0 ? "Until event" : "T-Minus to Horizon"}
</p> </p>
</>
)}
</div> </div>
); );
} }

View file

@ -4,47 +4,48 @@ 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({ iss, moon }: MissionControlProps) {
export default function MissionControl({ initialIssPass }: MissionControlProps) {
const BASE_TIME = 1769610273000; const BASE_TIME = 1769610273000;
const events = useMemo(() => [ const events = useMemo(() => [
{ {
id: 'iss', // Add an ID to distinguish the ISS card id: 'iss',
title: "ISS Overhead: Home", title: "ISS Overhead: Home",
date: initialIssPass, // Use the real data from SQLite here! date: iss.start,
endDate: iss.end,
icon: <Satellite size={20} />, icon: <Satellite size={20} />,
}, },
{ {
id: 'moon', id: 'moon',
title: "Next Lunar Phase: Full", title: moon.title,
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 3), date: moon.start,
endDate: moon.end,
icon: <Moon size={20} />, icon: <Moon size={20} />,
}, },
{ {
id: 'starlink', id: 'starlink',
title: "Starlink Train", title: "Starlink Train",
date: new Date(BASE_TIME + 1000 * 60 * 45), date: new Date(BASE_TIME + 1000 * 60 * 45),
endDate: new Date(BASE_TIME + 1000 * 60 * 55),
icon: <Rocket size={20} />, icon: <Rocket size={20} />,
}, },
{ {
id: 'meteor', id: 'meteor',
title: "Meteor Shower", title: "Meteor Shower",
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10), date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10),
endDate: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10.1),
icon: <Sparkles size={20} />, icon: <Sparkles size={20} />,
} }
], [initialIssPass]); // Re-memoize if the pass updates ], [iss, moon]);
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">
@ -62,9 +63,11 @@ export default function MissionControl({ initialIssPass }: MissionControlProps)
<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} key={`${event.id}-${event.date?.getTime()}`}
id={event.id}
title={event.title} title={event.title}
targetDate={event.date} // This now correctly uses the specific date for each card targetDate={event.date}
endDate={event.endDate}
icon={event.icon} icon={event.icon}
/> />
))} ))}

View file

@ -17,8 +17,19 @@ 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

@ -3,7 +3,7 @@ import * as satellite from 'satellite.js';
const MY_LAT = 55.6683; const MY_LAT = 55.6683;
const MY_LON = 12.5333; const MY_LON = 12.5333;
const MY_ALT = 0; // Altitude in kilometers const MY_ALT = 0;
async function updateISSData() { async function updateISSData() {
console.log("🛰️ Fetching TLE data..."); console.log("🛰️ Fetching TLE data...");
@ -14,47 +14,105 @@ 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
}; };
let nextPassDate: Date | null = null; const passesFound: { start: Date, end: Date }[] = [];
const now = new Date(); const now = new Date();
const searchTime = new Date(now.getTime());
// 3. Look ahead for the next 24 hours (1440 minutes) const maxSearchMinutes = 2880;
for (let i = 0; i < 1440; i++) { let minutesSearched = 0;
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, the ISS is above your horizon! if (elevation > 0) {
if (elevation > 0) { const aos = checkTime;
nextPassDate = checkTime; let los = new Date(aos.getTime() + 5 * 60000);
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++;
} }
if (nextPassDate) { db.prepare('DELETE FROM iss_passes').run();
const upsert = db.prepare('INSERT INTO iss_passes (id, pass_time) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET pass_time=excluded.pass_time'); const insert = db.prepare('INSERT INTO iss_passes (pass_time, end_time) VALUES (?, ?)');
upsert.run(nextPassDate.toISOString());
console.log(`✅ Real orbital pass found: ${nextPassDate.toISOString()}`); for (const pass of passesFound) {
} else { insert.run(pass.start.toISOString(), pass.end.toISOString());
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.`);
} }
updateISSData().catch(console.error); // 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);

View file

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

View file

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