///
/**
* Location Track Module - GPS coordinate tracking over time
*
* A composable pattern that can be used standalone or embedded in containers
* like Record. Captures GPS coordinates with timestamps, storing them as an
* array for track/breadcrumb logging.
*
* TODO: Add confirmation dialog before "Clear All" to prevent accidental data loss
* TODO: Add allowMultiple: true to MODULE_METADATA for multiple tracks per record
* TODO: Consider increasing remove button tap target to 44x44px for mobile accessibility (currently 32x32px)
* TODO: Add export functionality (GPX, GeoJSON, CSV)
* TODO: Add virtualization for large point lists (100+ points)
* TODO: Expose continuous tracking mode from ct-location
* TODO: Add schema fields for altitudeAccuracy, heading, speed
* TODO: Consider adding to "place" template or creating "trip" template
*/
import {
computed,
type Default,
handler,
ifElse,
NAME,
pattern,
UI,
Writable,
} from "commontools";
import type { ModuleMetadata } from "./container-protocol.ts";
// ===== Types =====
/**
* Single GPS point with metadata
*/
export interface LocationPoint {
/** Unique ID for this point */
id: string;
/** Latitude in decimal degrees */
latitude: number;
/** Longitude in decimal degrees */
longitude: number;
/** Accuracy in meters */
accuracy: number;
/** Altitude in meters (if available) */
altitude?: number;
/** Altitude accuracy in meters (if available) */
altitudeAccuracy?: number;
/** Heading in degrees 0-360 (if available) */
heading?: number;
/** Speed in m/s (if available) */
speed?: number;
/** Unix timestamp in milliseconds */
timestamp: number;
}
// ===== Self-Describing Metadata =====
export const MODULE_METADATA: ModuleMetadata = {
type: "location-track",
label: "Location Track",
icon: "\u{1F310}", // globe emoji
schema: {
locations: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
latitude: { type: "number" },
longitude: { type: "number" },
accuracy: { type: "number" },
altitude: { type: "number" },
timestamp: { type: "number" },
},
},
description: "Array of GPS location points with timestamps",
},
label: { type: "string", description: "Label for this track" },
},
fieldMapping: ["locations", "label"],
};
// ===== Module Input Type =====
export interface LocationTrackModuleInput {
/** Array of captured location points */
locations: Default;
/** Optional label for this track (e.g., "Morning run", "Commute") */
label: Default;
}
// ===== Handlers =====
/**
* Handler for when ct-location emits a new location
* Appends the location to the array
*/
const handleLocationUpdate = handler<
{ detail: { location: LocationPoint } },
{ locations: Writable }
>(({ detail }, { locations }) => {
const newPoint = detail.location;
// Validate required fields exist
if (
newPoint &&
typeof newPoint.latitude === "number" &&
typeof newPoint.longitude === "number" &&
typeof newPoint.timestamp === "number"
) {
locations.push(newPoint);
}
});
/**
* Handler to clear all locations
*/
const clearLocations = handler<
unknown,
{ locations: Writable }
>((_event, { locations }) => {
locations.set([]);
});
/**
* Handler to remove a specific location by index
*/
const removeLocation = handler<
unknown,
{ locations: Writable; index: number }
>((_event, { locations, index }) => {
const current = locations.get() || [];
locations.set(current.toSpliced(index, 1));
});
// ===== Helper Functions =====
function formatCoords(lat: number, lng: number): string {
if (
typeof lat !== "number" || typeof lng !== "number" || isNaN(lat) ||
isNaN(lng)
) {
return "Invalid coordinates";
}
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
}
function formatTimestamp(ts: number): string {
if (typeof ts !== "number" || isNaN(ts)) {
return "Invalid time";
}
const date = new Date(ts);
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatAccuracy(accuracy: number): string {
if (typeof accuracy !== "number" || isNaN(accuracy)) {
return "";
}
if (accuracy < 100) {
return `\u00B1${accuracy.toFixed(0)}m`;
}
return `\u00B1${(accuracy / 1000).toFixed(1)}km`;
}
// ===== The Pattern =====
export const LocationTrackModule = pattern<
LocationTrackModuleInput,
LocationTrackModuleInput
>(({ locations, label }) => {
// Local writable cell for ct-location binding (not stored)
const currentCapture = Writable.of(null);
// Computed display text
const displayText = computed(() => {
const count = locations.length || 0;
const labelText = label ? `${label}: ` : "";
return count > 0
? `${labelText}${count} point${count !== 1 ? "s" : ""}`
: `${labelText}No points`;
});
const hasPoints = computed(() => locations.length > 0);
const hasMultiplePoints = computed(() => locations.length > 1);
// Pre-compute filtered locations with indices for the list
// IMPORTANT: We pre-compute index here because closures over index in .map() callbacks
// don't work correctly with the reactive system
const validLocationsWithIndex = computed(() => {
return locations
.map((point, index) => ({ point, index }))
.filter(({ point }) => point && typeof point.latitude === "number");
});
return {
[NAME]: computed(() => `${MODULE_METADATA.icon} Track: ${displayText}`),
[UI]: (
{/* Label input */}
{/* Location capture button */}
{/* Points summary */}
{computed(() => {
const count = locations.length || 0;
return `${count} point${count !== 1 ? "s" : ""} captured`;
})}
{ifElse(
hasPoints,
Clear All
,
null,
)}
{/* Points list (collapsible for many points) */}
{ifElse(
hasMultiplePoints,
View all points
{validLocationsWithIndex.map(({ point, index }) => (
{/* Point number - badge style */}
{index + 1}
{/* Main content: coords + metadata stacked */}
{/* Coordinates */}
{formatCoords(point.latitude, point.longitude)}
{/* Timestamp + Accuracy on same line */}
{formatTimestamp(point.timestamp)}{" "}
·{" "}
{formatAccuracy(point.accuracy)}
{/* Delete button - more visible */}
))}
,
null,
)}
),
locations,
label,
};
});
export default LocationTrackModule;