Initial commit: Breadcrumb GPS trip viewer

Go server receiving Colota location data into SQLite, with a trip-segmentation
API and embedded web frontend for browsing trips on a map.
This commit is contained in:
Astra 2026-06-03 19:34:08 +01:00
commit c41a2c5411
6 changed files with 1268 additions and 0 deletions

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM golang:1.22-alpine AS builder
# gcc is required for go-sqlite3 (CGO)
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o breadcrumb .
# ── Runtime image ─────────────────────────────────────────────────────────────
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/breadcrumb .
# Data directory for the SQLite file (mount a volume here)
RUN mkdir -p /data
EXPOSE 8080
ENTRYPOINT ["./breadcrumb"]
CMD ["-port", "8080", "-db", "/data/locations.db"]

147
README.md Normal file
View file

@ -0,0 +1,147 @@
# Breadcrumb
A self-contained Go server that receives GPS location data from the
[Colota Android app](https://colota.app), stores it in an SQLite3 database,
and serves a web frontend for browsing trips on a map.
## Quick Start
```bash
# Build
go build -o breadcrumb .
# Run (no auth, default port 8080, db = ./locations.db)
./breadcrumb
# Run with Bearer token auth on port 443
./breadcrumb -port 443 -token mysecrettoken -db /var/data/locations.db
```
### Flags
| Flag | Default | Description |
|----------|------------------|------------------------------------------------------|
| `-port` | `8080` | HTTP listen port |
| `-db` | `locations.db` | Path to the SQLite3 database file |
| `-token` | *(empty)* | Bearer token for auth. Empty = authentication off. |
---
## API
### `POST /api/location` ← Colota app sends here
**Headers:** `Content-Type: application/json`
**Body** (Colota default payload):
```json
{
"lat": 51.495065,
"lon": -0.043945,
"acc": 12,
"alt": 519,
"vel": 0,
"batt": 85,
"bs": 2,
"tst": 1704067200,
"bear": 180.5
}
```
| Field | Type | Required | Description |
|--------|---------|----------|--------------------------------------------------------|
| `lat` | float | ✓ | Latitude (decimal degrees) |
| `lon` | float | ✓ | Longitude (decimal degrees) |
| `acc` | float | ✓ | Accuracy (metres) |
| `tst` | int | ✓ | Unix timestamp (seconds). Falls back to server time. |
| `alt` | float | | Altitude (metres) |
| `vel` | float | | Speed (m/s) |
| `bear` | float | | Bearing (degrees, 0360) |
| `batt` | int | | Battery level (0100 %) |
| `bs` | int | | Battery state: 0=unknown 1=unplugged 2=charging 3=full |
**GET** variant is also supported (query parameters with the same field names).
**Returns:** `200 OK` on success.
---
### `GET /api/locations` ← Query stored locations
| Param | Default | Description |
|---------|---------|------------------------------------------|
| `limit` | `100` | Max records to return (up to 500 000) |
| `page` | `1` | Page number (1-based) |
| `from` | | Filter: Unix timestamp lower bound |
| `to` | | Filter: Unix timestamp upper bound |
**Response:**
```json
{
"total": 42,
"page": 1,
"limit": 100,
"records": [ { "id": 1, "lat": 51.495065, "lon": -0.043945, ... } ]
}
```
---
### `GET /api/trips`
Returns location points grouped into trips (gaps > 30 min = new trip), newest first.
---
### `GET /health`
Returns server status and total location count.
---
## Colota App Configuration
1. Open Colota → **Settings → API Settings**
2. Select template: **Custom**
3. Set **Endpoint URL** to `http(s)://your-server:8080/api/location`
4. **HTTP Method:** POST
5. If using a token: set **Authentication → Bearer Token** to your `-token` value
6. Leave field mapping at defaults
7. Tap **Test Connection** — you should see `200 OK`
---
## Docker
```bash
docker build -t breadcrumb .
docker run -d \
--name breadcrumb \
-p 8080:8080 \
-v breadcrumb-data:/data \
breadcrumb -token "$TOKEN"
```
---
## Database Schema
```sql
CREATE TABLE locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lon REAL NOT NULL,
acc REAL NOT NULL,
alt REAL,
vel REAL,
batt INTEGER,
bs INTEGER,
tst INTEGER NOT NULL, -- Unix timestamp
bear REAL,
received_at TEXT NOT NULL -- ISO-8601 UTC
);
CREATE INDEX idx_locations_tst ON locations(tst);
```

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/breadcrumb
go 1.22
require github.com/mattn/go-sqlite3 v1.14.22

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

396
main.go Normal file
View file

@ -0,0 +1,396 @@
package main
import (
"database/sql"
"embed"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed static
var staticFiles embed.FS
// LocationPayload matches the Colota app's default POST JSON body.
// alt, vel, and bear are optional (Colota may omit them).
type LocationPayload struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Acc float64 `json:"acc"`
Alt *float64 `json:"alt,omitempty"`
Vel *float64 `json:"vel,omitempty"`
Batt *int `json:"batt,omitempty"`
Bs *int `json:"bs,omitempty"` // battery state: 0=unknown,1=unplugged,2=charging,3=full
Tst int64 `json:"tst"` // Unix timestamp (seconds)
Bear *float64 `json:"bear,omitempty"` // bearing in degrees
}
// LocationRecord is what we store and return via the query API.
type LocationRecord struct {
ID int64 `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Acc float64 `json:"acc"`
Alt *float64 `json:"alt,omitempty"`
Vel *float64 `json:"vel,omitempty"`
Batt *int `json:"batt,omitempty"`
Bs *int `json:"bs,omitempty"`
Tst int64 `json:"tst"`
Bear *float64 `json:"bear,omitempty"`
ReceivedAt string `json:"received_at"`
}
type Server struct {
db *sql.DB
token string // optional Bearer token; empty = no auth
logger *log.Logger
}
func main() {
port := flag.Int("port", 8080, "HTTP listen port")
dbPath := flag.String("db", "locations.db", "SQLite3 database file path")
token := flag.String("token", "", "Optional Bearer token for authentication (empty = disabled)")
flag.Parse()
logger := log.New(os.Stdout, "[breadcrumb] ", log.LstdFlags)
db, err := sql.Open("sqlite3", *dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
logger.Fatalf("failed to open database: %v", err)
}
defer db.Close()
if err := initDB(db); err != nil {
logger.Fatalf("failed to initialise database: %v", err)
}
srv := &Server{db: db, token: *token, logger: logger}
sub, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatalf("failed to create static sub-fs: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/location", srv.handleLocation) // POST receive from Colota app
mux.HandleFunc("/api/locations", srv.handleLocations) // GET query stored locations
mux.HandleFunc("/api/trips", srv.handleTrips) // GET trip list
mux.HandleFunc("/health", srv.handleHealth) // GET health check
mux.Handle("/", http.FileServer(http.FS(sub))) // static frontend
addr := fmt.Sprintf(":%d", *port)
logger.Printf("listening on %s db=%s auth=%v", addr, *dbPath, *token != "")
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Fatalf("server error: %v", err)
}
}
// initDB creates the locations table if it doesn't already exist.
func initDB(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lon REAL NOT NULL,
acc REAL NOT NULL,
alt REAL,
vel REAL,
batt INTEGER,
bs INTEGER,
tst INTEGER NOT NULL,
bear REAL,
received_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_locations_tst ON locations(tst);
`)
return err
}
// ── Middleware ────────────────────────────────────────────────────────────────
func (s *Server) authenticate(r *http.Request) bool {
if s.token == "" {
return true // auth disabled
}
auth := r.Header.Get("Authorization")
return auth == "Bearer "+s.token
}
// ── Handlers ──────────────────────────────────────────────────────────────────
// handleLocation accepts the Colota app's POST JSON payload (or GET query params).
func (s *Server) handleLocation(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var p LocationPayload
switch r.Method {
case http.MethodPost:
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
s.logger.Printf("bad JSON: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
case http.MethodGet:
q := r.URL.Query()
p.Lat = parseFloat(q.Get("lat"))
p.Lon = parseFloat(q.Get("lon"))
p.Acc = parseFloat(q.Get("acc"))
p.Tst = parseInt64(q.Get("tst"))
if v := q.Get("alt"); v != "" { f := parseFloat(v); p.Alt = &f }
if v := q.Get("vel"); v != "" { f := parseFloat(v); p.Vel = &f }
if v := q.Get("bear"); v != "" { f := parseFloat(v); p.Bear = &f }
if v := q.Get("batt"); v != "" { i := int(parseFloat(v)); p.Batt = &i }
if v := q.Get("bs"); v != "" { i := int(parseFloat(v)); p.Bs = &i }
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if p.Lat == 0 && p.Lon == 0 {
http.Error(w, "lat/lon required", http.StatusBadRequest)
return
}
if p.Tst == 0 {
p.Tst = time.Now().Unix()
}
receivedAt := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(`
INSERT INTO locations (lat, lon, acc, alt, vel, batt, bs, tst, bear, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Lat, p.Lon, p.Acc, p.Alt, p.Vel, p.Batt, p.Bs, p.Tst, p.Bear, receivedAt,
)
if err != nil {
s.logger.Printf("db insert error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
s.logger.Printf("saved lat=%.6f lon=%.6f acc=%.1f tst=%d", p.Lat, p.Lon, p.Acc, p.Tst)
w.WriteHeader(http.StatusOK)
}
// handleLocations returns stored location records as JSON.
// Query params: limit (default 100), from / to (Unix timestamps), page (1-based).
func (s *Server) handleLocations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
q := r.URL.Query()
limit := 100
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500000 {
limit = n
}
}
page := 1
if v := q.Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * limit
// Optional time-range filter
args := []any{}
where := "WHERE 1=1"
if v := q.Get("from"); v != "" {
where += " AND tst >= ?"
args = append(args, parseInt64(v))
}
if v := q.Get("to"); v != "" {
where += " AND tst <= ?"
args = append(args, parseInt64(v))
}
countArgs := make([]any, len(args))
copy(countArgs, args)
var total int
row := s.db.QueryRow("SELECT COUNT(*) FROM locations "+where, countArgs...)
if err := row.Scan(&total); err != nil {
s.logger.Printf("count error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
args = append(args, limit, offset)
rows, err := s.db.Query(
"SELECT id, lat, lon, acc, alt, vel, batt, bs, tst, bear, received_at "+
"FROM locations "+where+" ORDER BY tst DESC LIMIT ? OFFSET ?",
args...,
)
if err != nil {
s.logger.Printf("query error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
records := []LocationRecord{}
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(
&rec.ID, &rec.Lat, &rec.Lon, &rec.Acc,
&rec.Alt, &rec.Vel, &rec.Batt, &rec.Bs,
&rec.Tst, &rec.Bear, &rec.ReceivedAt,
); err != nil {
s.logger.Printf("scan error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
records = append(records, rec)
}
type response struct {
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Records []LocationRecord `json:"records"`
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response{
Total: total,
Page: page,
Limit: limit,
Records: records,
})
}
// handleHealth returns 200 OK with a JSON status blob.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
var count int
s.db.QueryRow("SELECT COUNT(*) FROM locations").Scan(&count)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
"total_locations": count,
})
}
// ── Trips ─────────────────────────────────────────────────────────────────────
type TripSummary struct {
StartTst int64 `json:"start_tst"`
EndTst int64 `json:"end_tst"`
PointCount int `json:"point_count"`
DistanceKm float64 `json:"distance_km"`
}
// handleTrips groups all location points into trips separated by gaps > 30 min.
func (s *Server) handleTrips(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
rows, err := s.db.Query(`SELECT tst, lat, lon FROM locations ORDER BY tst ASC`)
if err != nil {
s.logger.Printf("trips query error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
type point struct{ tst int64; lat, lon float64 }
var pts []point
for rows.Next() {
var p point
if err := rows.Scan(&p.tst, &p.lat, &p.lon); err != nil {
continue
}
pts = append(pts, p)
}
const gapSec = 30 * 60 // 30 minutes
var trips []TripSummary
if len(pts) > 0 {
cur := TripSummary{StartTst: pts[0].tst, EndTst: pts[0].tst, PointCount: 1}
prevLat, prevLon := pts[0].lat, pts[0].lon
distKm := 0.0
for i := 1; i < len(pts); i++ {
p := pts[i]
if p.tst-pts[i-1].tst > gapSec {
cur.DistanceKm = math.Round(distKm*10) / 10
trips = append(trips, cur)
cur = TripSummary{StartTst: p.tst, EndTst: p.tst, PointCount: 1}
distKm = 0
} else {
cur.EndTst = p.tst
cur.PointCount++
distKm += haversineKm(prevLat, prevLon, p.lat, p.lon)
}
prevLat, prevLon = p.lat, p.lon
}
cur.DistanceKm = math.Round(distKm*10) / 10
trips = append(trips, cur)
}
// Return newest first
for i, j := 0, len(trips)-1; i < j; i, j = i+1, j-1 {
trips[i], trips[j] = trips[j], trips[i]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(trips)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
math.Sin(dLon/2)*math.Sin(dLon/2)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
func parseFloat(s string) float64 {
if s == "" {
return 0
}
v, _ := strconv.ParseFloat(s, 64)
return v
}
func parseInt64(s string) int64 {
if s == "" {
return 0
}
v, _ := strconv.ParseInt(s, 10, 64)
return v
}

692
static/index.html Normal file
View file

@ -0,0 +1,692 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Breadcrumb</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--page-bg: #1b1b1b;
--section-bg: #2b2b2b;
--header-bg: #3c3c3c;
--input-bg: #111111;
--border: #4a4a4a;
--text: #f0f0f0;
--text-dim: #a0a0a0;
--link: #b4c7d9;
--link-vis: #9b8fd4;
--accent: #f2ac08;
--danger: #c0392b;
}
html, body { height: 100%; overflow: hidden; }
body {
display: flex;
flex-direction: column;
background: var(--page-bg);
color: var(--text);
font-family: 'Open Sans', Verdana, sans-serif;
font-size: 12px;
line-height: 1.5;
}
a { color: var(--link); text-decoration: none; }
a:visited { color: var(--link-vis); }
a:hover { color: var(--accent); text-decoration: underline; }
/* ── Header ── */
#header {
background: #111;
border-bottom: 2px solid #000;
flex-shrink: 0;
}
#header-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 12px;
}
#logo {
font-size: 22px;
font-weight: 700;
color: var(--accent);
letter-spacing: -1px;
}
#logo em {
font-style: normal;
font-size: 11px;
color: var(--text-dim);
font-weight: 400;
letter-spacing: 0;
margin-left: 5px;
}
#header-count {
font-size: 11px;
color: var(--text-dim);
}
/* ── Layout: desktop side-by-side, mobile stacked ── */
.layout {
display: flex;
flex: 1;
min-height: 0;
}
/* ── Sidebar ── */
aside {
width: 230px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--section-bg);
border-right: 1px solid var(--border);
overflow: hidden;
}
/* Date navigator */
#date-nav {
display: flex;
align-items: center;
background: var(--header-bg);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
user-select: none;
}
#date-nav button {
background: none;
border: none;
color: var(--text-dim);
font-size: 16px;
padding: 4px 10px;
cursor: pointer;
line-height: 1;
flex-shrink: 0;
}
#date-nav button:hover { color: var(--accent); }
#date-nav button:disabled { color: #555; cursor: default; }
#date-label {
flex: 1;
text-align: center;
font-size: 11px;
font-weight: 700;
color: var(--text);
cursor: pointer;
padding: 5px 0;
}
#date-label:hover { color: var(--accent); }
#date-input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
}
/* Section header */
.section-header {
padding: 4px 8px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-dim);
background: var(--header-bg);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#trip-list {
flex: 1;
overflow-y: auto;
}
#trip-list::-webkit-scrollbar { width: 5px; }
#trip-list::-webkit-scrollbar-track { background: transparent; }
#trip-list::-webkit-scrollbar-thumb { background: var(--border); }
.trip-entry {
padding: 6px 8px;
cursor: pointer;
border-bottom: 1px solid #333;
}
.trip-entry:hover { background: #323232; }
.trip-entry.active {
background: #1a1a2e;
border-left: 3px solid var(--accent);
padding-left: 5px;
}
.trip-entry .trip-date { color: var(--accent); font-weight: 700; font-size: 11px; }
.trip-entry .trip-time { color: var(--text-dim); font-size: 11px; }
.trip-entry .trip-tags {
margin-top: 3px;
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.ttag { font-size: 10px; white-space: nowrap; }
.ttag-dur { color: #b4c7d9; }
.ttag-dist { color: #00aa00; }
.ttag-pts { color: #ffffff; }
.ttag::before { content: attr(data-count) '\00a0'; color: var(--text-dim); }
.empty, .error {
padding: 16px 8px;
color: var(--text-dim);
font-size: 11px;
text-align: center;
}
.error { color: var(--danger); }
/* ── Map ── */
.map-wrap {
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
}
#map { position: absolute; inset: 0; }
.map-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17,17,17,.8);
z-index: 1000;
font-size: 12px;
color: var(--text-dim);
pointer-events: none;
}
/* ── Token overlay ── */
#token-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
#token-box {
width: min(340px, 92vw);
background: var(--section-bg);
border: 1px solid var(--border);
}
#token-box > header {
background: var(--header-bg);
padding: 5px 8px;
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
border-bottom: 1px solid var(--border);
}
#token-box-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
#token-box p { font-size: 11px; color: var(--text-dim); }
#token-box input {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 6px 8px;
width: 100%;
outline: none;
}
#token-box input:focus { border-color: var(--accent); }
#token-box button {
align-self: flex-start;
background: var(--header-bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 12px;
padding: 5px 16px;
cursor: pointer;
}
#token-box button:hover { background: var(--accent); color: #000; border-color: var(--accent); }
#token-err { color: var(--danger); font-size: 11px; display: none; }
/* ── Leaflet overrides ── */
.leaflet-container { background: #111; font-family: 'Open Sans', Verdana, sans-serif; }
.leaflet-control-zoom a { background: var(--section-bg) !important; color: var(--text) !important; border-color: var(--border) !important; }
.leaflet-control-zoom a:hover { background: var(--header-bg) !important; color: var(--accent) !important; }
.leaflet-control-attribution { background: rgba(17,17,17,.75) !important; color: var(--text-dim) !important; }
.leaflet-control-attribution a { color: var(--link) !important; }
.leaflet-popup-content-wrapper {
background: var(--section-bg); color: var(--text);
border: 1px solid var(--border); border-radius: 0;
box-shadow: 0 2px 8px rgba(0,0,0,.6);
font-size: 11px; font-family: 'Open Sans', Verdana, sans-serif;
}
.leaflet-popup-tip { background: var(--section-bg); }
.leaflet-popup-content { margin: 8px 10px; }
.leaflet-popup-content b { color: var(--accent); }
/* ── Points toggle ── */
#pts-toggle {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 900;
background: var(--section-bg);
border: 1px solid var(--border);
color: var(--text-dim);
font-family: 'Open Sans', Verdana, sans-serif;
font-size: 11px;
line-height: 1.3;
padding: 4px 6px;
cursor: pointer;
display: none;
white-space: nowrap;
}
#pts-toggle:hover { color: var(--accent); border-color: var(--accent); }
#pts-toggle.on { color: var(--accent); border-color: var(--accent); background: #1a1a00; }
/* ── Mobile ── */
@media (max-width: 640px) {
html, body { overflow: hidden; }
.layout { flex-direction: column; }
aside {
width: 100%;
flex-shrink: 0;
max-height: 44vh;
border-right: none;
border-bottom: 2px solid var(--border);
}
/* horizontal scroll trip list on mobile */
#trip-list {
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
gap: 0;
}
#trip-list::-webkit-scrollbar { height: 4px; }
.trip-entry {
flex-shrink: 0;
width: 160px;
border-bottom: none;
border-right: 1px solid #333;
padding: 8px 10px;
}
.trip-entry.active {
border-left: none;
border-top: 3px solid var(--accent);
padding-left: 10px;
padding-top: 5px;
}
.map-wrap { flex: 1; min-height: 0; }
#logo font-size { font-size: 18px; }
}
</style>
</head>
<body>
<div id="header">
<div id="header-inner">
<div id="logo">breadcrumb <em>/ trips</em></div>
<span id="header-count">loading…</span>
</div>
</div>
<div id="token-overlay" style="display:none">
<div id="token-box">
<header>Authentication Required</header>
<div id="token-box-body">
<p>Enter your Bearer token to access this Colota instance.</p>
<input id="token-input" type="password" placeholder="bearer token" autocomplete="current-password">
<span id="token-err">» Invalid token — please try again.</span>
<button id="token-submit">Login</button>
</div>
</div>
</div>
<div class="layout">
<aside>
<div id="date-nav">
<button id="date-prev" title="Previous day">&#8249;</button>
<div id="date-label"></div>
<button id="date-next" title="Next day" disabled>&#8250;</button>
<input type="date" id="date-input">
</div>
<div class="section-header">Trips</div>
<div id="trip-list"><div class="empty">Loading…</div></div>
</aside>
<div class="map-wrap">
<div id="map"></div>
<div class="map-loading" id="map-overlay" style="display:none">Loading track…</div>
<button id="pts-toggle" title="Toggle GPS points">Show Points</button>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(function () {
const STORAGE_KEY = 'colota_token';
let token = localStorage.getItem(STORAGE_KEY) || '';
class AuthError extends Error {}
async function apiFetch(url) {
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
const res = await fetch(url, { headers });
if (res.status === 401) throw new AuthError();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function showTokenOverlay(invalid = false) {
document.getElementById('token-overlay').style.display = 'flex';
document.getElementById('token-err').style.display = invalid ? 'block' : 'none';
setTimeout(() => document.getElementById('token-input').focus(), 50);
}
function hideTokenOverlay() {
document.getElementById('token-overlay').style.display = 'none';
}
document.getElementById('token-submit').addEventListener('click', async () => {
const val = document.getElementById('token-input').value.trim();
const headers = val ? { 'Authorization': 'Bearer ' + val } : {};
const res = await fetch('/api/trips', { headers });
if (res.status === 401) { showTokenOverlay(true); return; }
token = val;
localStorage.setItem(STORAGE_KEY, token);
hideTokenOverlay();
loadTrips();
});
document.getElementById('token-input').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('token-submit').click();
});
// ── Date navigation ───────────────────────────────────────────────────────────
let allTrips = [];
let availableDates = []; // sorted array of 'YYYY-MM-DD' strings
let selectedDateIdx = 0;
function localDateStr(tst) {
const d = new Date(tst * 1000);
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function prettyDate(ymd) {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric'
});
}
function updateDateNav() {
const ymd = availableDates[selectedDateIdx];
document.getElementById('date-label').textContent = prettyDate(ymd);
document.getElementById('date-prev').disabled = selectedDateIdx >= availableDates.length - 1;
document.getElementById('date-next').disabled = selectedDateIdx <= 0;
}
document.getElementById('date-prev').addEventListener('click', () => {
if (selectedDateIdx < availableDates.length - 1) {
selectedDateIdx++;
updateDateNav();
renderTripList();
}
});
document.getElementById('date-next').addEventListener('click', () => {
if (selectedDateIdx > 0) {
selectedDateIdx--;
updateDateNav();
renderTripList();
}
});
// clicking the date label opens a native date picker
document.getElementById('date-label').addEventListener('click', () => {
const inp = document.getElementById('date-input');
inp.value = availableDates[selectedDateIdx];
inp.style.position = 'absolute';
inp.style.opacity = '0';
inp.style.pointerEvents = 'auto';
inp.focus();
inp.showPicker?.();
});
document.getElementById('date-input').addEventListener('change', (e) => {
const val = e.target.value;
const idx = availableDates.indexOf(val);
if (idx !== -1) {
selectedDateIdx = idx;
updateDateNav();
renderTripList();
}
e.target.style.pointerEvents = 'none';
});
// ── Map ───────────────────────────────────────────────────────────────────────
const map = L.map('map', { zoomControl: true }).setView([20, 0], 2);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19
}).addTo(map);
let trackLayer = null, markerStart = null, markerEnd = null, pointsLayer = null;
let showPoints = false;
let lastSorted = [];
const ptsToggleBtn = document.getElementById('pts-toggle');
function renderPoints() {
if (pointsLayer) { map.removeLayer(pointsLayer); pointsLayer = null; }
if (!showPoints || lastSorted.length === 0) return;
pointsLayer = L.layerGroup(
lastSorted.map(r => L.circleMarker([r.lat, r.lon], {
radius: 3,
color: '#f2ac08',
fillColor: '#f2ac08',
fillOpacity: 0.8,
weight: 0,
}).bindPopup(`${fmtTime(r.tst)}${r.acc != null ? '<br><span style="color:var(--text-dim)">acc ' + Math.round(r.acc) + 'm</span>' : ''}`))
).addTo(map);
}
ptsToggleBtn.addEventListener('click', () => {
showPoints = !showPoints;
ptsToggleBtn.classList.toggle('on', showPoints);
renderPoints();
});
function fmtTime(tst) {
return new Date(tst * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
function fmtDuration(s, e) {
const sec = Math.round(e - s);
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
return h > 0 ? `${h}h_${m}m` : `${m}m`;
}
function fmtDist(km) {
return km < 1 ? `${Math.round(km * 1000)}m` : `${km.toFixed(1)}km`;
}
// ── Load all trips, then group by date ───────────────────────────────────────
async function loadTrips() {
const list = document.getElementById('trip-list');
const headerCount = document.getElementById('header-count');
list.innerHTML = '<div class="empty">Loading…</div>';
try {
allTrips = await apiFetch('/api/trips');
} catch (e) {
if (e instanceof AuthError) { showTokenOverlay(!!token); return; }
list.innerHTML = `<div class="error">Error: ${e.message}</div>`;
return;
}
if (!allTrips || allTrips.length === 0) {
list.innerHTML = '<div class="empty">No trips recorded yet.</div>';
headerCount.textContent = '0 trips';
document.getElementById('date-label').textContent = 'No data';
return;
}
headerCount.textContent = `${allTrips.length} trip${allTrips.length === 1 ? '' : 's'}`;
// Build sorted unique date list (newest first)
const dateSet = new Set(allTrips.map(t => localDateStr(t.start_tst)));
availableDates = [...dateSet].sort((a, b) => b.localeCompare(a));
selectedDateIdx = 0;
updateDateNav();
renderTripList();
}
function renderTripList() {
const list = document.getElementById('trip-list');
const ymd = availableDates[selectedDateIdx];
const filtered = allTrips.filter(t => localDateStr(t.start_tst) === ymd).reverse();
list.innerHTML = '';
if (filtered.length === 0) {
list.innerHTML = '<div class="empty">No trips on this date.</div>';
return;
}
filtered.forEach((trip) => {
const el = document.createElement('div');
el.className = 'trip-entry';
el.innerHTML = `
<div class="trip-time">${fmtTime(trip.start_tst)} &ndash; ${fmtTime(trip.end_tst)}</div>
<div class="trip-tags">
<span class="ttag ttag-dur" data-count="${fmtDuration(trip.start_tst, trip.end_tst)}">duration</span>
<span class="ttag ttag-dist" data-count="${fmtDist(trip.distance_km)}">distance</span>
<span class="ttag ttag-pts" data-count="${trip.point_count}">points</span>
</div>`;
el.addEventListener('click', () => selectTrip(trip, el));
list.appendChild(el);
});
selectTrip(filtered[0], list.firstElementChild);
}
// ── Draw trip ─────────────────────────────────────────────────────────────────
async function selectTrip(trip, el) {
document.querySelectorAll('.trip-entry').forEach(e => e.classList.remove('active'));
el.classList.add('active');
const overlay = document.getElementById('map-overlay');
overlay.style.display = 'flex';
if (trackLayer) { map.removeLayer(trackLayer); trackLayer = null; }
if (markerStart) { map.removeLayer(markerStart); markerStart = null; }
if (markerEnd) { map.removeLayer(markerEnd); markerEnd = null; }
if (pointsLayer) { map.removeLayer(pointsLayer); pointsLayer = null; }
lastSorted = [];
ptsToggleBtn.style.display = 'none';
let allRecords = [];
try {
const pageSize = 50000;
let page = 1, total = Infinity;
while (allRecords.length < total) {
const data = await apiFetch(
`/api/locations?from=${trip.start_tst}&to=${trip.end_tst}&limit=${pageSize}&page=${page}`
);
total = data.total;
allRecords.push(...(data.records || []));
if (allRecords.length >= total) break;
page++;
}
} catch (e) {
overlay.style.display = 'none';
if (e instanceof AuthError) { showTokenOverlay(true); return; }
alert(`Failed to load track: ${e.message}`);
return;
}
overlay.style.display = 'none';
const sorted = allRecords.slice().sort((a, b) => a.tst - b.tst);
lastSorted = sorted;
const pts = sorted.map(r => [r.lat, r.lon]);
if (pts.length === 0) return;
ptsToggleBtn.style.display = 'block';
renderPoints();
trackLayer = L.polyline(pts, {
color: '#f2ac08', weight: 3, opacity: 0.9,
lineJoin: 'round', lineCap: 'round'
}).addTo(map);
const dotIcon = (color) => L.divIcon({
className: '',
html: `<div style="width:10px;height:10px;border-radius:50%;background:${color};border:2px solid #111;box-shadow:0 1px 4px rgba(0,0,0,.7)"></div>`,
iconSize: [10, 10], iconAnchor: [5, 5]
});
const first = sorted[0], last = sorted[sorted.length - 1];
markerStart = L.marker([first.lat, first.lon], { icon: dotIcon('#00aa00') })
.addTo(map).bindPopup(`<b>start</b><br>${fmtTime(first.tst)}`);
markerEnd = L.marker([last.lat, last.lon], { icon: dotIcon('#c0392b') })
.addTo(map).bindPopup(`<b>end</b><br>${fmtTime(last.tst)}`);
map.fitBounds(trackLayer.getBounds(), { padding: [48, 48] });
}
if (!token) {
showTokenOverlay(false);
} else {
loadTrips();
}
})();
</script>
</body>
</html>