diff --git a/backend/apps/devices/migrations/0002_device_fe_language.py b/backend/apps/devices/migrations/0002_device_fe_language.py
new file mode 100644
index 0000000..eb46982
--- /dev/null
+++ b/backend/apps/devices/migrations/0002_device_fe_language.py
@@ -0,0 +1,15 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("devices", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="device",
+ name="fe_language",
+ field=models.CharField(blank=True, default="cs", max_length=5),
+ ),
+ ]
diff --git a/backend/apps/devices/models.py b/backend/apps/devices/models.py
index c1081fd..55beaa5 100644
--- a/backend/apps/devices/models.py
+++ b/backend/apps/devices/models.py
@@ -14,6 +14,10 @@ class Device(models.Model):
public_key = models.TextField() # base64, raw Ed25519 public key (32 bytes)
label = models.CharField(max_length=120, blank=True, default="")
+ # Preferred web-frontend language for this device (a per-device setting,
+ # stored server-side so it follows the device across browsers).
+ fe_language = models.CharField(max_length=5, blank=True, default="cs")
+
created_at = models.DateTimeField(auto_now_add=True)
last_seen_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)
diff --git a/backend/apps/ingest/management/__init__.py b/backend/apps/ingest/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/ingest/management/commands/__init__.py b/backend/apps/ingest/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/ingest/management/commands/parse_pending.py b/backend/apps/ingest/management/commands/parse_pending.py
new file mode 100644
index 0000000..301643d
--- /dev/null
+++ b/backend/apps/ingest/management/commands/parse_pending.py
@@ -0,0 +1,44 @@
+"""Parse raw ingests into ParsedMeasurement rows (parse-later worker).
+
+Run on a schedule or after a deploy. Picks up rows that have never been parsed
+or whose parse_version is stale, so bumping PARSE_VERSION re-derives everything.
+
+ python manage.py parse_pending # parse new / stale rows
+ python manage.py parse_pending --all # re-parse every row
+ python manage.py parse_pending --limit 500
+"""
+from django.core.management.base import BaseCommand
+from django.db.models import Q
+
+from apps.ingest.models import RawIngest
+from apps.ingest.parser import PARSE_VERSION, ParseError, parse_and_store
+
+
+class Command(BaseCommand):
+ help = "Parse raw ingests into render-ready ParsedMeasurement rows."
+
+ def add_arguments(self, parser):
+ parser.add_argument("--all", action="store_true", help="Re-parse every row.")
+ parser.add_argument("--limit", type=int, default=1000, help="Max rows per run.")
+
+ def handle(self, *args, **options):
+ qs = RawIngest.objects.all().order_by("received_at")
+ if not options["all"]:
+ # Never parsed, or parsed by an older version.
+ qs = qs.filter(
+ Q(parsed__isnull=True) | ~Q(parsed__parse_version=PARSE_VERSION)
+ )
+ rows = list(qs[: options["limit"]])
+
+ ok = failed = 0
+ for raw in rows:
+ try:
+ parse_and_store(raw)
+ ok += 1
+ except ParseError as exc:
+ failed += 1
+ self.stderr.write(f" failed {raw.id}: {exc}")
+
+ self.stdout.write(
+ self.style.SUCCESS(f"Parsed {ok} row(s), {failed} failed, {len(rows)} seen.")
+ )
diff --git a/backend/apps/ingest/migrations/0002_parsedmeasurement.py b/backend/apps/ingest/migrations/0002_parsedmeasurement.py
new file mode 100644
index 0000000..03f5214
--- /dev/null
+++ b/backend/apps/ingest/migrations/0002_parsedmeasurement.py
@@ -0,0 +1,34 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ParsedMeasurement",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("parse_version", models.PositiveIntegerField(default=0)),
+ ("event_time", models.DateTimeField(blank=True, null=True)),
+ ("event_tz", models.CharField(blank=True, default="", max_length=64)),
+ ("start_alt", models.FloatField(blank=True, null=True)),
+ ("start_az", models.FloatField(blank=True, null=True)),
+ ("end_alt", models.FloatField(blank=True, null=True)),
+ ("end_az", models.FloatField(blank=True, null=True)),
+ ("start_ra", models.FloatField(blank=True, null=True)),
+ ("start_dec", models.FloatField(blank=True, null=True)),
+ ("end_ra", models.FloatField(blank=True, null=True)),
+ ("end_dec", models.FloatField(blank=True, null=True)),
+ ("lat", models.FloatField(blank=True, null=True)),
+ ("lon", models.FloatField(blank=True, null=True)),
+ ("accuracy", models.FloatField(blank=True, null=True)),
+ ("quality", models.FloatField(blank=True, null=True)),
+ ("parsed_at", models.DateTimeField(auto_now=True)),
+ ("raw", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="parsed", to="ingest.rawingest")),
+ ],
+ ),
+ ]
diff --git a/backend/apps/ingest/models.py b/backend/apps/ingest/models.py
index 82c6be5..51bfe11 100644
--- a/backend/apps/ingest/models.py
+++ b/backend/apps/ingest/models.py
@@ -41,3 +41,42 @@ class Meta:
]
indexes = [models.Index(fields=["status", "received_at"])]
ordering = ["-received_at"]
+
+
+class ParsedMeasurement(models.Model):
+ """Render-ready record derived from a RawIngest (the parse-later output).
+
+ One row per raw measurement. Holds the validated horizontal coordinates of
+ the trail's two aim points, the derived equatorial coordinates (RA/Dec) for
+ plotting on a star map, the absolute UTC event time, the observing site, and
+ the site's IANA time zone. ``parse_version`` lets stored rows be re-derived
+ when the parsing logic changes.
+ """
+
+ raw = models.OneToOneField(
+ RawIngest, on_delete=models.CASCADE, related_name="parsed"
+ )
+ parse_version = models.PositiveIntegerField(default=0)
+
+ event_time = models.DateTimeField(null=True, blank=True) # absolute UTC
+ event_tz = models.CharField(max_length=64, blank=True, default="") # IANA name
+
+ start_alt = models.FloatField(null=True, blank=True)
+ start_az = models.FloatField(null=True, blank=True)
+ end_alt = models.FloatField(null=True, blank=True)
+ end_az = models.FloatField(null=True, blank=True)
+
+ start_ra = models.FloatField(null=True, blank=True)
+ start_dec = models.FloatField(null=True, blank=True)
+ end_ra = models.FloatField(null=True, blank=True)
+ end_dec = models.FloatField(null=True, blank=True)
+
+ lat = models.FloatField(null=True, blank=True)
+ lon = models.FloatField(null=True, blank=True)
+ accuracy = models.FloatField(null=True, blank=True)
+ quality = models.FloatField(null=True, blank=True)
+
+ parsed_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self) -> str:
+ return f"ParsedMeasurement(raw={self.raw_id}, v{self.parse_version})"
diff --git a/backend/apps/ingest/parser.py b/backend/apps/ingest/parser.py
new file mode 100644
index 0000000..109d0f6
--- /dev/null
+++ b/backend/apps/ingest/parser.py
@@ -0,0 +1,220 @@
+"""Parse raw measurement payloads into render-ready scientific records.
+
+Follows the ingest-first / parse-later contract: an upload only lands a
+``RawIngest`` row, and turning that into a ``ParsedMeasurement`` happens here,
+separately. A parse failure is recorded on the raw row (status=failed) and
+never touches or discards the raw payload, so it can be re-parsed later.
+
+The mobile app reports a meteor as two horizontal-coordinate aim points
+(altitude/azimuth) plus the observing site and the event time. For the sky
+view we also derive equatorial coordinates (RA/Dec) so any star map can place
+the trail among the stars. The phone's orientation accuracy is degree-level,
+so a compact closed-form alt/az -> RA/Dec conversion (arc-minute accuracy) is
+already far more precise than the input -- no heavy astrometry dependency is
+warranted.
+
+The event time arrives as ``Date.now()`` epoch milliseconds, which is an
+absolute UTC instant already (not a local wall-clock time). From the GPS site
+we additionally resolve the IANA time zone, so the UI can show the observer's
+*local* civil time at the observing site regardless of who is viewing it.
+"""
+from __future__ import annotations
+
+import math
+from datetime import UTC, datetime
+
+# Bump when the parsing logic changes so stored records can be re-derived.
+PARSE_VERSION = 1
+
+
+class ParseError(ValueError):
+ """Payload could not be parsed; recorded on the raw row, raw kept intact."""
+
+
+def _num(value):
+ if value is None:
+ return None
+ try:
+ f = float(value)
+ except (TypeError, ValueError):
+ return None
+ return f if math.isfinite(f) else None
+
+
+def _angle(value, lo, hi, name):
+ f = _num(value)
+ if f is None:
+ return None
+ if not (lo <= f <= hi):
+ raise ParseError(f"{name} out of range [{lo}, {hi}]: {f}")
+ return f
+
+
+def _parse_time(value):
+ if value in (None, ""):
+ return None
+ if isinstance(value, int | float):
+ # epoch seconds, or milliseconds when the value is implausibly large
+ secs = value / 1000.0 if value > 1e11 else float(value)
+ return datetime.fromtimestamp(secs, tz=UTC)
+ try:
+ dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise ParseError(f"unparseable eventTimestamp: {value!r}") from exc
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ return dt.astimezone(UTC)
+
+
+def parse_payload(payload: dict) -> dict:
+ """Extract and validate the render fields from a raw payload.
+
+ Raises ParseError on malformed data or a missing trail endpoint.
+ """
+ if not isinstance(payload, dict):
+ raise ParseError("payload is not an object")
+ start = payload.get("startPoint") or {}
+ end = payload.get("endPoint") or {}
+ site = payload.get("site") or {}
+
+ out = {
+ "event_time": _parse_time(payload.get("eventTimestamp")),
+ "start_alt": _angle(start.get("alt"), -90, 90, "start.alt"),
+ "start_az": _angle(start.get("az"), 0, 360, "start.az"),
+ "end_alt": _angle(end.get("alt"), -90, 90, "end.alt"),
+ "end_az": _angle(end.get("az"), 0, 360, "end.az"),
+ "lat": _angle(site.get("lat"), -90, 90, "site.lat"),
+ "lon": _angle(site.get("lon"), -180, 180, "site.lon"),
+ "accuracy": _num(site.get("accuracy")),
+ "quality": _num(payload.get("quality")),
+ }
+ # A trail needs both aim points; without them there is nothing to render.
+ for key in ("start_alt", "start_az", "end_alt", "end_az"):
+ if out[key] is None:
+ raise ParseError(f"missing required field: {key}")
+ return out
+
+
+def _julian_date(dt: datetime) -> float:
+ """Julian Date from a (tz-aware) datetime via the civil-calendar formula."""
+ dt = dt.astimezone(UTC)
+ year, month = dt.year, dt.month
+ if month <= 2:
+ year -= 1
+ month += 12
+ a = year // 100
+ b = 2 - a + a // 4
+ day = dt.day + (dt.hour + (dt.minute + (dt.second + dt.microsecond / 1e6) / 60) / 60) / 24
+ return math.floor(365.25 * (year + 4716)) + math.floor(30.6001 * (month + 1)) + day + b - 1524.5
+
+
+def altaz_to_radec(alt_deg, az_deg, lat_deg, lon_deg, when):
+ """Closed-form horizontal -> equatorial conversion, RA/Dec in degrees.
+
+ Azimuth is measured from North, increasing eastward (compass convention).
+ Uses mean sidereal time (IAU 1982); arc-minute accurate, which comfortably
+ exceeds the phone-orientation accuracy of the inputs. Returns ``(ra, dec)``
+ or ``None`` when location or time are unavailable.
+ """
+ if None in (alt_deg, az_deg, lat_deg, lon_deg) or when is None:
+ return None
+ alt = math.radians(alt_deg)
+ az = math.radians(az_deg)
+ lat = math.radians(lat_deg)
+
+ sin_dec = math.sin(alt) * math.sin(lat) + math.cos(alt) * math.cos(lat) * math.cos(az)
+ sin_dec = max(-1.0, min(1.0, sin_dec))
+ dec = math.asin(sin_dec)
+
+ # Hour angle via atan2 with both terms scaled by (cos dec * cos lat) >= 0,
+ # so there is no division and no singularity at the poles.
+ num = -math.sin(az) * math.cos(alt) * math.cos(lat)
+ den = math.sin(alt) - math.sin(lat) * sin_dec
+ hour_angle = math.degrees(math.atan2(num, den))
+
+ d = _julian_date(when) - 2451545.0
+ gmst = (280.46061837 + 360.98564736629 * d) % 360.0 # Greenwich mean sidereal time
+ lst = (gmst + lon_deg) % 360.0 # local sidereal time
+ ra = (lst - hour_angle) % 360.0
+ return ra, math.degrees(dec)
+
+
+def site_timezone(lat, lon):
+ """IANA time-zone name for a GPS location, or None if unavailable.
+
+ Uses timezonefinder (offline polygon lookup); imported lazily so the pure
+ parsing/maths above stay dependency-free and unit-testable.
+ """
+ if lat is None or lon is None:
+ return None
+ finder = _tz_finder()
+ return finder.timezone_at(lat=lat, lng=lon) if finder else None
+
+
+_TZF = None
+
+
+def _tz_finder():
+ global _TZF
+ if _TZF is None:
+ try:
+ from timezonefinder import TimezoneFinder
+ except ImportError:
+ return None
+ _TZF = TimezoneFinder() # loads bundled boundary data once
+ return _TZF
+
+
+def parse_and_store(raw):
+ """Parse a RawIngest, persist a ParsedMeasurement, and update raw.status.
+
+ On ParseError the raw row is marked failed (with the reason) and the error
+ re-raised; the raw payload is left untouched.
+ """
+ from django.utils import timezone as djtz
+
+ from .models import ParsedMeasurement, RawIngest
+
+ try:
+ fields = parse_payload(raw.payload or {})
+ except ParseError as exc:
+ raw.status = RawIngest.STATUS_FAILED
+ raw.error = str(exc)
+ raw.attempts = (raw.attempts or 0) + 1
+ raw.processed_at = djtz.now()
+ raw.save(update_fields=["status", "error", "attempts", "processed_at"])
+ raise
+
+ loc = (fields["lat"], fields["lon"], fields["event_time"])
+ start = altaz_to_radec(fields["start_alt"], fields["start_az"], *loc)
+ end = altaz_to_radec(fields["end_alt"], fields["end_az"], *loc)
+ data = dict(fields)
+ data["start_ra"], data["start_dec"] = start or (None, None)
+ data["end_ra"], data["end_dec"] = end or (None, None)
+ # UTC is already absolute (epoch ms); resolve the site's civil time zone so
+ # the observer's local time can be shown alongside it.
+ data["event_tz"] = site_timezone(fields["lat"], fields["lon"]) or ""
+
+ parsed, _ = ParsedMeasurement.objects.update_or_create(
+ raw=raw, defaults={**data, "parse_version": PARSE_VERSION}
+ )
+ raw.status = RawIngest.STATUS_PROCESSED
+ raw.error = ""
+ raw.attempts = (raw.attempts or 0) + 1
+ raw.processed_at = djtz.now()
+ raw.save(update_fields=["status", "error", "attempts", "processed_at"])
+ return parsed
+
+
+def ensure_parsed(raw):
+ """Return an up-to-date ParsedMeasurement, parsing lazily if needed.
+
+ Returns None if the payload cannot be parsed (raw is marked failed).
+ """
+ parsed = getattr(raw, "parsed", None)
+ if parsed is not None and parsed.parse_version == PARSE_VERSION:
+ return parsed
+ try:
+ return parse_and_store(raw)
+ except ParseError:
+ return None
diff --git a/backend/apps/web/api.py b/backend/apps/web/api.py
index 6fb05fb..fc423ed 100644
--- a/backend/apps/web/api.py
+++ b/backend/apps/web/api.py
@@ -40,6 +40,11 @@ class PollOut(Schema):
class MeOut(Schema):
device_id: str
label: str
+ language: str
+
+
+class SettingsIn(Schema):
+ language: str
class ReportRow(Schema):
@@ -57,6 +62,28 @@ class ReportRow(Schema):
accuracy: float | None = None
+class TrailPoint(Schema):
+ alt: float | None = None
+ az: float | None = None
+ ra: float | None = None # equatorial, degrees (for the star map)
+ dec: float | None = None
+
+
+class ReportDetail(Schema):
+ client_key: str
+ status: str
+ received_at: str
+ event_utc: str | None = None # absolute instant
+ event_local: str | None = None # civil time at the observing site
+ event_tz: str | None = None # IANA zone resolved from the GPS site
+ quality: float | None = None
+ lat: float | None = None
+ lon: float | None = None
+ accuracy: float | None = None
+ start: TrailPoint = TrailPoint()
+ end: TrailPoint = TrailPoint()
+
+
# ---- device flow ----
@router.post("/device-code", response=DeviceCodeOut)
@@ -138,10 +165,32 @@ def poll(request, payload: PollIn, response: HttpResponse):
# ---- authenticated web session ----
+def _me_payload(device):
+ return {
+ "device_id": str(device.id),
+ "label": device.label,
+ "language": device.fe_language or "cs",
+ }
+
+
@router.get("/me", auth=web_auth, response=MeOut)
def me(request):
+ return _me_payload(request.web_device)
+
+
+# Supported web-frontend languages; anything else falls back to Czech.
+WEB_LANGUAGES = {"cs", "en"}
+
+
+@router.post("/settings", auth=web_auth, response=MeOut)
+def update_settings(request, payload: SettingsIn):
+ """Persist this device's web-frontend language preference."""
device = request.web_device
- return {"device_id": str(device.id), "label": device.label}
+ lang = payload.language if payload.language in WEB_LANGUAGES else "cs"
+ if lang != device.fe_language:
+ device.fe_language = lang
+ device.save(update_fields=["fe_language"])
+ return _me_payload(device)
@router.get("/reports", auth=web_auth, response=list[ReportRow])
@@ -173,6 +222,57 @@ def reports(request):
return out
+@router.get("/reports/{client_key}", auth=web_auth, response={200: ReportDetail, 404: dict})
+def report_detail(request, client_key: str):
+ """A single measurement, parsed into render-ready fields for the sky view.
+
+ Parses lazily on first view (parse-later) so it works without a worker; the
+ result is cached in ParsedMeasurement and reused thereafter.
+ """
+ from apps.ingest.parser import ensure_parsed
+
+ raw = request.web_device.raw_ingests.filter(client_key=client_key).first()
+ if raw is None:
+ return 404, {"detail": "Measurement not found"}
+
+ parsed = ensure_parsed(raw)
+ base = {
+ "client_key": raw.client_key,
+ "status": raw.status,
+ "received_at": raw.received_at.isoformat(),
+ }
+ if parsed is None:
+ return 200, base # unparseable payload: status only, no render body
+
+ event_local = None
+ if parsed.event_time and parsed.event_tz:
+ try:
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+ event_local = parsed.event_time.astimezone(ZoneInfo(parsed.event_tz)).isoformat()
+ except (ZoneInfoNotFoundError, ValueError):
+ event_local = None
+
+ return 200, {
+ **base,
+ "event_utc": parsed.event_time.isoformat() if parsed.event_time else None,
+ "event_local": event_local,
+ "event_tz": parsed.event_tz or None,
+ "quality": parsed.quality,
+ "lat": parsed.lat,
+ "lon": parsed.lon,
+ "accuracy": parsed.accuracy,
+ "start": {
+ "alt": parsed.start_alt, "az": parsed.start_az,
+ "ra": parsed.start_ra, "dec": parsed.start_dec,
+ },
+ "end": {
+ "alt": parsed.end_alt, "az": parsed.end_az,
+ "ra": parsed.end_ra, "dec": parsed.end_dec,
+ },
+ }
+
+
@router.post("/logout", auth=web_auth, response={200: dict})
def logout(request, response: HttpResponse):
request.web_session.revoked_at = timezone.now()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 0b0908e..c6707d8 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -22,6 +22,10 @@ python-dotenv==1.0.1
# QR codes for web sign-in (pure Python, no deps)
segno==1.6.1
+# Offline lat/lon -> IANA time zone, to show the observer's local event time
+# (MIT licensed; alt/az->RA/Dec maths in the parser are pure-Python, no deps)
+timezonefinder==6.5.7
+
# Dev / test
pytest==8.3.4
pytest-django==4.9.0
diff --git a/backend/tests/test_parser.py b/backend/tests/test_parser.py
new file mode 100644
index 0000000..d8b505b
--- /dev/null
+++ b/backend/tests/test_parser.py
@@ -0,0 +1,122 @@
+"""Measurement parser: payload validation, alt/az -> RA/Dec, lazy detail endpoint."""
+import json
+from datetime import UTC, datetime
+
+import pytest
+from django.test import Client
+
+from apps.devices.models import Device
+from apps.ingest import parser
+from apps.ingest.models import RawIngest
+from tests.test_auth_sync import _register, _token
+
+pytestmark = pytest.mark.django_db
+
+try:
+ import timezonefinder # noqa: F401
+
+ _HAS_TZF = True
+except ImportError:
+ _HAS_TZF = False
+
+
+# eventTimestamp is Date.now() epoch ms (absolute UTC); a Czech site.
+PAYLOAD = {
+ "eventTimestamp": 1781455673730,
+ "startPoint": {"alt": 40.0, "az": 90.0},
+ "endPoint": {"alt": 45.0, "az": 95.0},
+ "site": {"lat": 48.8128, "lon": 14.6458, "accuracy": 100},
+ "quality": 0.8,
+}
+
+
+# ---- pure functions ----
+
+def test_parse_payload_extracts_and_validates():
+ out = parser.parse_payload(PAYLOAD)
+ assert out["start_alt"] == 40.0 and out["end_az"] == 95.0
+ assert out["lat"] == pytest.approx(48.8128)
+ # epoch ms decodes to an absolute UTC instant (not a local wall-clock time)
+ assert out["event_time"] == datetime.fromtimestamp(1781455673.730, tz=UTC)
+
+
+def test_parse_payload_rejects_bad_data():
+ with pytest.raises(parser.ParseError):
+ parser.parse_payload({"startPoint": {"alt": 40, "az": 90}}) # no endPoint
+ with pytest.raises(parser.ParseError):
+ parser.parse_payload({**PAYLOAD, "startPoint": {"alt": 40, "az": 999}}) # az range
+
+
+def test_altaz_to_radec_zenith_equals_latitude():
+ when = datetime(2026, 6, 14, 22, 0, tzinfo=UTC)
+ ra, dec = parser.altaz_to_radec(90.0, 0.0, 48.0, 14.0, when)
+ assert dec == pytest.approx(48.0, abs=0.3) # the zenith's dec is the observer latitude
+ assert 0.0 <= ra < 360.0
+
+
+def test_altaz_to_radec_without_location_is_none():
+ when = datetime(2026, 6, 14, 22, 0, tzinfo=UTC)
+ assert parser.altaz_to_radec(40, 90, None, None, when) is None
+
+
+# ---- persistence + endpoint ----
+
+def _device(client):
+ device_id, priv = _register(client)
+ return Device.objects.get(id=device_id), device_id, priv
+
+
+def _login_fe(fe, mobile, device_id, priv):
+ token = _token(mobile, device_id, priv)
+ auth = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
+ data = fe.post("/v1/web/device-code").json()
+ mobile.post(
+ "/v1/web/approve",
+ data=json.dumps({"user_code": data["user_code"]}),
+ content_type="application/json",
+ **auth,
+ )
+ fe.post(
+ "/v1/web/poll",
+ data=json.dumps({"device_code": data["device_code"]}),
+ content_type="application/json",
+ )
+
+
+def test_parse_and_store_creates_record():
+ device, _, _ = _device(Client())
+ raw = RawIngest.objects.create(device=device, client_key="k1", payload=PAYLOAD)
+
+ parsed = parser.parse_and_store(raw)
+
+ assert parsed.start_ra is not None and parsed.end_dec is not None
+ assert parsed.event_time is not None and parsed.parse_version == parser.PARSE_VERSION
+ raw.refresh_from_db()
+ assert raw.status == RawIngest.STATUS_PROCESSED
+ if _HAS_TZF:
+ assert parsed.event_tz == "Europe/Prague"
+
+
+def test_report_detail_endpoint_returns_render_payload():
+ fe, mobile = Client(), Client()
+ device, device_id, priv = _device(mobile)
+ RawIngest.objects.create(device=device, client_key="k1", payload=PAYLOAD)
+ _login_fe(fe, mobile, device_id, priv)
+
+ res = fe.get("/v1/web/reports/k1")
+ assert res.status_code == 200, res.content
+ body = res.json()
+ assert body["status"] == "processed"
+ assert body["start"]["alt"] == 40.0 and body["start"]["ra"] is not None
+ assert body["end"]["az"] == 95.0
+ assert body["event_utc"] is not None
+ if _HAS_TZF:
+ assert body["event_tz"] == "Europe/Prague" and body["event_local"] is not None
+
+
+def test_report_detail_unknown_key_is_404():
+ fe, mobile = Client(), Client()
+ device, device_id, priv = _device(mobile)
+ _login_fe(fe, mobile, device_id, priv)
+
+ assert fe.get("/v1/web/reports/does-not-exist").status_code == 404
diff --git a/frontend/app.js b/frontend/app.js
index 58e2c4c..1cf2a37 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -4,6 +4,7 @@
// the proxy serves the API under :443/api/ and strips the /api prefix before
// forwarding to the container. Dev (FE on :8080) overrides it to the API host.
const API = (window.API_BASE ?? '/api');
+const t = (k, v) => window.MPI18n.t(k, v);
const $ = (id) => document.getElementById(id);
@@ -20,18 +21,32 @@ function fmt(v, digits = 1) {
return v === null || v === undefined ? '—' : Number(v).toFixed(digits);
}
+// Site-local wall-clock time from an ISO string with offset (shown as-is,
+// independent of the viewer's own time zone).
+function fmtLocal(iso) {
+ if (!iso) return '—';
+ const m = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
+ return m ? `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}` : iso;
+}
+
+let meData = null; // { device_id, count }
+let selectedDetail = null; // last loaded measurement detail (for re-render)
+
async function loadGrid() {
const me = await api('/v1/web/me');
if (!me.ok) return startLogin();
- const meData = await me.json();
- $('deviceId').textContent = meData.device_id.slice(0, 8);
+ const m = await me.json();
+ if (window.MPI18n.supported(m.language)) {
+ window.MPI18n.setLang(m.language);
+ localStorage.setItem('mp_lang', m.language);
+ }
const res = await api('/v1/web/reports');
const rows = res.ok ? await res.json() : [];
- $('count').textContent = rows.length;
+ meData = { device_id: m.device_id.slice(0, 8), count: rows.length };
$('rows').innerHTML = rows
.map(
- (r) => `
+ (r) => `
| ${new Date(r.received_at).toLocaleString()} |
${r.status} |
${fmt(r.start_alt)}° / ${fmt(r.start_az)}° |
@@ -42,18 +57,50 @@ async function loadGrid() {
`,
)
.join('');
+ refreshGridIntro();
show('app');
}
+function refreshGridIntro() {
+ if (meData) $('gridIntro').textContent = t('grid.intro', { device: meData.device_id, n: meData.count });
+}
+
+// Click a row -> fetch the parsed detail -> draw the trail on the sky dome.
+async function selectReport(key, tr) {
+ document.querySelectorAll('#rows tr.selected').forEach((el) => el.classList.remove('selected'));
+ if (tr) tr.classList.add('selected');
+ $('skyCaption').textContent = t('sky.loading');
+ const res = await api('/v1/web/reports/' + key);
+ if (!res.ok) {
+ selectedDetail = null;
+ $('skyCaption').textContent = t('sky.loadFail');
+ return;
+ }
+ selectedDetail = await res.json();
+ showSkyCaption(selectedDetail);
+}
+
+function showSkyCaption(d) {
+ const cap = $('skyCaption');
+ const rendered = d ? MeteorSky.render(d) : false;
+ if (!d) { cap.textContent = t('sky.pick'); return; }
+ if (!rendered) { cap.textContent = t('sky.noCoords'); return; }
+ const tz = d.event_tz ? ` (${d.event_tz})` : '';
+ const s = d.start, e = d.end;
+ cap.innerHTML =
+ `${fmtLocal(d.event_local || d.event_utc)}${tz}
` +
+ `${t('sky.startLabel')} ALT/AZ ${fmt(s.alt)}° / ${fmt(s.az)}° · RA/Dek ${fmt(s.ra, 1)}° / ${fmt(s.dec, 1)}°
` +
+ `${t('sky.endLabel')} ALT/AZ ${fmt(e.alt)}° / ${fmt(e.az)}° · RA/Dek ${fmt(e.ra, 1)}° / ${fmt(e.dec, 1)}°`;
+}
+
let pollTimer = null;
async function startLogin() {
show('login');
- $('loginStatus').textContent = 'Čekám na potvrzení v aplikaci…';
+ $('loginStatus').textContent = t('login.waiting');
const res = await api('/v1/web/device-code', { method: 'POST' });
const { user_code, device_code, interval } = await res.json();
$('userCode').textContent = user_code;
- // QR encodes the plain user code; the in-app scanner reads and approves it.
$('qr').src = `${API}/v1/web/qr?data=${encodeURIComponent(user_code)}`;
clearInterval(pollTimer);
@@ -69,15 +116,63 @@ async function startLogin() {
loadGrid();
} else if (status === 'expired') {
clearInterval(pollTimer);
- startLogin(); // restart with a fresh code
+ startLogin();
}
}, (interval || 2) * 1000);
}
+// Re-apply language-dependent dynamic content whenever the language changes.
+window.onI18nApplied = function () {
+ refreshGridIntro();
+ if (selectedDetail) showSkyCaption(selectedDetail);
+ else $('skyCaption').textContent = t('sky.pick');
+ if (window.MeteorSky) MeteorSky.redraw();
+};
+
+// Language switcher: update UI now, persist on the device (if signed in).
+document.querySelectorAll('[data-lang]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const lang = btn.getAttribute('data-lang');
+ window.MPI18n.setLang(lang);
+ localStorage.setItem('mp_lang', lang);
+ if (meData) {
+ await api('/v1/web/settings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ language: lang }),
+ });
+ }
+ });
+});
+
+// Row selection (event delegation).
+$('rows').addEventListener('click', (e) => {
+ const tr = e.target.closest('tr');
+ if (tr && tr.dataset.key) selectReport(tr.dataset.key, tr);
+});
+
+// About / licenses modal.
+$('about').addEventListener('click', () => $('aboutModal').classList.remove('hidden'));
+$('aboutClose').addEventListener('click', () => $('aboutModal').classList.add('hidden'));
+$('aboutModal').addEventListener('click', (e) => {
+ if (e.target.id === 'aboutModal') $('aboutModal').classList.add('hidden');
+});
+
$('logout').addEventListener('click', async () => {
await api('/v1/web/logout', { method: 'POST' });
+ meData = null;
+ selectedDetail = null;
startLogin();
});
+// Provisional language before we know the device setting: stored choice, else
+// the browser language, else Czech.
+(function initLang() {
+ const stored = localStorage.getItem('mp_lang');
+ const nav = (navigator.language || 'cs').slice(0, 2);
+ const lang = window.MPI18n.supported(stored) ? stored : (window.MPI18n.supported(nav) ? nav : 'cs');
+ window.MPI18n.setLang(lang);
+})();
+
// Start: if already logged in, show the grid; otherwise begin the login flow.
loadGrid();
diff --git a/frontend/i18n.js b/frontend/i18n.js
new file mode 100644
index 0000000..dd6ad1e
--- /dev/null
+++ b/frontend/i18n.js
@@ -0,0 +1,126 @@
+// Tiny i18n for the web frontend. The active language is a per-device setting
+// stored in the API (loaded from /me, saved via /settings); this module only
+// holds the strings and applies them to the DOM.
+(function (global) {
+ 'use strict';
+
+ var DICT = {
+ cs: {
+ about: 'O aplikaci', logout: 'Odhlásit',
+ login: {
+ heading: 'Přihlášení přes mobilní aplikaci',
+ howto: 'V aplikaci klepni Autorizovat web, pak Skenovat QR a namiř na kód níže — nebo kód opiš ručně:',
+ waiting: 'Čekám na potvrzení v aplikaci…',
+ },
+ grid: {
+ intro: 'Přihlášen jako zařízení {device} — {n} měření. Klepni na řádek pro zobrazení stopy na obloze.',
+ received: 'Přijato', status: 'Stav', startAltAz: 'Start ALT/AZ', endAltAz: 'Konec ALT/AZ',
+ quality: 'Kvalita', gps: 'GPS', acc: '±m',
+ },
+ sky: {
+ head: 'Obloha v čase a místě měření',
+ start: 'začátek stopy', end: 'konec stopy',
+ pick: 'Vyber měření vlevo pro zobrazení stopy meteoru.',
+ loading: 'Načítám…',
+ loadFail: 'Detail měření se nepodařilo načíst.',
+ noCoords: 'Měření nemá GPS souřadnice nebo čas — stopu na obloze nelze vykreslit.',
+ startLabel: 'Start', endLabel: 'Konec',
+ hint: 'Kolečkem přiblížíš, tažením posuneš, dvojklikem resetuješ pohled.',
+ },
+ dir: { N: 'S', NE: 'SV', E: 'V', SE: 'JV', S: 'J', SW: 'JZ', W: 'Z', NW: 'SZ' },
+ about_box: {
+ tagline: 'Síť pro pozorování meteorů Bolidozor. Web pro nekomerční a výzkumné účely. Naměřená data jsou poskytována pod licencí CC0 1.0 (volné dílo).',
+ compH: 'Použité komponenty a licence',
+ compIntro: 'Tato aplikace používá následující open-source software a data; uvádíme je zde, abychom dostáli jejich licenčním podmínkám (zachování autorství / atribuce).',
+ coordsH: 'Souřadnice a čas',
+ coords: 'Stopa meteoru je zaznamenána jako dva směry (výška/azimut) v okamžiku měření. Rovníkové souřadnice (RA/Dek) pro vykreslení na obloze počítá server uzavřeným převodem. Čas události je absolutní UTC; lokální čas v místě pozorování se odvozuje z GPS polohy a časového pásma.',
+ close: 'Zavřít',
+ liStars: 'Data hvězd a souhvězdí — Hipparcos (ESA) a názvy/čáry dle IAU, ve formě datové sady projektu d3-celestial (© 2015 Olaf Frohn, licence BSD-3-Clause). Používáme pouze tato data, nikoli samotnou knihovnu.',
+ liSegno: 'segno — generování QR kódů (server). Licence BSD.',
+ liTz: 'timezonefinder — určení časového pásma místa (server). Licence MIT; hranice pásem © přispěvatelé OpenStreetMap, licence ODbL.',
+ liDjango: 'Django (BSD) & django-ninja (MIT) — serverový framework API.',
+ },
+ },
+ en: {
+ about: 'About', logout: 'Sign out',
+ login: {
+ heading: 'Sign in with the mobile app',
+ howto: 'In the app tap Authorize web login, then Scan QR and aim at the code below — or type the code in manually:',
+ waiting: 'Waiting for approval in the app…',
+ },
+ grid: {
+ intro: 'Signed in as device {device} — {n} measurement(s). Click a row to show the trail on the sky.',
+ received: 'Received', status: 'Status', startAltAz: 'Start ALT/AZ', endAltAz: 'End ALT/AZ',
+ quality: 'Quality', gps: 'GPS', acc: '±m',
+ },
+ sky: {
+ head: 'Sky at the time & place of the measurement',
+ start: 'trail start', end: 'trail end',
+ pick: 'Select a measurement on the left to show the meteor trail.',
+ loading: 'Loading…',
+ loadFail: 'Could not load the measurement detail.',
+ noCoords: 'This measurement has no GPS location or time — the trail cannot be drawn.',
+ startLabel: 'Start', endLabel: 'End',
+ hint: 'Scroll to zoom, drag to pan, double-click to reset the view.',
+ },
+ dir: { N: 'N', NE: 'NE', E: 'E', SE: 'SE', S: 'S', SW: 'SW', W: 'W', NW: 'NW' },
+ about_box: {
+ tagline: 'The Bolidozor meteor-observation network. Web for non-commercial and research use. Measurement data is released under CC0 1.0 (public domain).',
+ compH: 'Components and licenses',
+ compIntro: 'This application uses the following open-source software and data; they are listed here so we meet their license terms (attribution / authorship notices).',
+ coordsH: 'Coordinates and time',
+ coords: 'A meteor trail is recorded as two directions (altitude/azimuth) at the moment of measurement. The equatorial coordinates (RA/Dec) for plotting are computed on the server by a closed-form conversion. The event time is absolute UTC; the local civil time at the observing site is derived from the GPS location and time zone.',
+ close: 'Close',
+ liStars: 'Star & constellation data — Hipparcos (ESA) and IAU names/lines, as the dataset shipped by the d3-celestial project (© 2015 Olaf Frohn, BSD-3-Clause). We use only this data, not the library itself.',
+ liSegno: 'segno — QR code generation (server). BSD license.',
+ liTz: 'timezonefinder — site time-zone lookup (server). MIT license; zone boundaries © OpenStreetMap contributors, ODbL.',
+ liDjango: 'Django (BSD) & django-ninja (MIT) — server API framework.',
+ },
+ },
+ };
+
+ var LANG = 'cs';
+
+ function lookup(lang, key) {
+ var node = DICT[lang] || DICT.cs;
+ var parts = key.split('.');
+ for (var i = 0; i < parts.length; i++) {
+ if (node == null) return undefined;
+ node = node[parts[i]];
+ }
+ return node;
+ }
+
+ function t(key, vars) {
+ var s = lookup(LANG, key);
+ if (s == null) s = lookup('cs', key);
+ if (s == null) return key;
+ if (vars) s = s.replace(/\{(\w+)\}/g, function (m, k) { return vars[k] != null ? vars[k] : m; });
+ return s;
+ }
+
+ function apply() {
+ document.documentElement.lang = LANG;
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
+ el.textContent = t(el.getAttribute('data-i18n'));
+ });
+ document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
+ el.innerHTML = t(el.getAttribute('data-i18n-html'));
+ });
+ document.querySelectorAll('[data-lang]').forEach(function (el) {
+ el.classList.toggle('active', el.getAttribute('data-lang') === LANG);
+ });
+ if (typeof global.onI18nApplied === 'function') global.onI18nApplied();
+ }
+
+ function setLang(lang) {
+ LANG = (lang === 'en') ? 'en' : 'cs';
+ apply();
+ }
+
+ global.MPI18n = {
+ t: t, apply: apply, setLang: setLang,
+ get lang() { return LANG; },
+ supported: function (l) { return l === 'cs' || l === 'en'; },
+ };
+})(window);
diff --git a/frontend/index.html b/frontend/index.html
index e31ef63..5408ba5 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -7,76 +7,163 @@
METEORPOINTER
-
+
+
- Přihlášení přes mobilní aplikaci
- V aplikaci klepni Autorizovat web, pak Skenovat QR a namiř na kód níže — nebo kód opiš ručně:
-
+ Přihlášení přes mobilní aplikaci
+
+
····-····
- Čekám na potvrzení v aplikaci…
+
-
-
- Přihlášen jako zařízení — měření
-
-
-
-
- | Přijato | Stav |
- Start ALT/AZ | Konec ALT/AZ |
- Kvalita | GPS | ±m |
-
-
-
-
+
+
+
+
+
+
+
+
+ | Přijato |
+ Stav |
+ Start ALT/AZ |
+ Konec ALT/AZ |
+ Kvalita |
+ GPS |
+ ±m |
+
+
+
+
+
-
+
+
Obloha v čase a místě měření
+
+
+
+
+
+
+
+
+
+
+
+
+
METEORPOINTER
+
+
+
+
+
+
+
+
+
+
+
+