Convert JS hooks to TypeScript and update esbuild to 0.25.4

Convert app.js, info_map.js, error_boundary.js, and time_ago_hook.js
to TypeScript with proper typing for Phoenix hooks, Leaflet, DOM
interactions, and timer management. Update esbuild hex package to
~> 0.10 and binary to 0.25.4. Entry point changed to app.ts.
This commit is contained in:
Graham McIntire 2026-02-20 12:26:46 -06:00
parent b562c23589
commit 13bc58e650
No known key found for this signature in database
10 changed files with 453 additions and 411 deletions

View file

@ -1,51 +1,59 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html";
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
declare global {
interface Window {
topbar: {
config: (opts: { barColors: Record<number, string>; shadowColor: string }) => void;
show: (delay?: number) => void;
hide: () => void;
};
mapBundleLoaded?: boolean;
chartBundleLoaded?: boolean;
Chart?: unknown;
VendorLoader?: {
mapBundleUrl: string;
loadCharts: () => void;
};
liveSocket: any;
Hooks: Record<string, any>;
}
}
// topbar is loaded globally from vendor bundle
const topbar = window.topbar;
let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
const csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
if (!csrfToken) {
console.error("CSRF token not found in meta tags");
}
// Import minimal APRS map hook
// Import hooks
import MapAPRSMap from "./map";
// Import error boundary hook
import ErrorBoundary from "./hooks/error_boundary";
// Import info map hook
import { InfoMap } from "./hooks/info_map";
// Import time ago hook
import TimeAgoHook from "./hooks/time_ago_hook";
// APRS MapAPRSMap Hook
let Hooks = {};
interface HookDef {
mounted?: () => void;
updated?: () => void;
destroyed?: () => void;
[key: string]: unknown;
}
const Hooks: Record<string, HookDef> = {};
// Singleton map bundle loader — ensures the script is only appended once
let mapBundleCallbacks = [];
let mapBundleCallbacks: Array<() => void> = [];
let mapBundleLoading = false;
function loadMapBundle(callback) {
function loadMapBundle(callback: () => void) {
if (window.mapBundleLoaded) {
callback();
return;
@ -54,19 +62,19 @@ function loadMapBundle(callback) {
mapBundleCallbacks.push(callback);
if (mapBundleLoading) {
return; // Already loading, callback will fire when ready
return;
}
if (window.VendorLoader) {
mapBundleLoading = true;
const script = document.createElement('script');
const script = document.createElement("script");
script.src = window.VendorLoader.mapBundleUrl;
script.onload = () => {
window.mapBundleLoaded = true;
mapBundleLoading = false;
const cbs = mapBundleCallbacks;
mapBundleCallbacks = [];
cbs.forEach(cb => cb());
cbs.forEach((cb) => cb());
};
script.onerror = () => {
console.error("Failed to load map bundle");
@ -90,7 +98,7 @@ Hooks.APRSMap = {
originalMapMounted.call(self);
}
});
}
},
};
Hooks.InfoMap = {
@ -102,21 +110,19 @@ Hooks.InfoMap = {
InfoMap.mounted.call(self);
}
});
}
},
};
// Chart hooks - load chart bundle when needed
import { WeatherChartHooks } from "./features/weather_charts";
Object.keys(WeatherChartHooks).forEach(hookName => {
const originalHook = WeatherChartHooks[hookName];
Object.keys(WeatherChartHooks).forEach((hookName) => {
const originalHook = (WeatherChartHooks as Record<string, HookDef>)[hookName];
Hooks[hookName] = {
...originalHook,
mounted() {
const self = this;
if (window.VendorLoader && !window.chartBundleLoaded) {
// Load chart bundle and wait for it to complete
window.VendorLoader.loadCharts();
// Wait a bit for charts to load, then call mounted
const checkChartLoaded = () => {
if (window.Chart) {
if (originalHook.mounted) {
@ -128,12 +134,11 @@ Object.keys(WeatherChartHooks).forEach(hookName => {
};
setTimeout(checkChartLoaded, 100);
} else {
// Chart bundle already loaded or loading, call mounted
if (originalHook.mounted) {
originalHook.mounted.call(this);
}
}
}
},
};
});
@ -142,12 +147,15 @@ Hooks.ErrorBoundary = ErrorBoundary;
Hooks.TimeAgoHook = TimeAgoHook;
// Theme management
const applyTheme = (theme) => {
const applyTheme = (theme: string | null) => {
const element = document.documentElement;
if (!element) return;
if (theme === "auto") {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
if (theme === "auto" || !theme) {
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
element.setAttribute("data-theme", "dark");
} else {
element.setAttribute("data-theme", "light");
@ -161,52 +169,56 @@ const applyTheme = (theme) => {
applyTheme(localStorage.getItem("theme") || "auto");
// Handle theme changes dispatched from LiveView via JS.dispatch
window.addEventListener("phx:set-theme", (e) => {
window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => {
const theme = e.detail.theme;
applyTheme(theme);
localStorage.setItem("theme", theme);
window.dispatchEvent(new CustomEvent("themeChanged"));
});
}) as EventListener);
// Listen for system theme changes when auto is selected
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (localStorage.getItem("theme") === "auto") {
applyTheme("auto");
window.dispatchEvent(new CustomEvent("themeChanged"));
}
});
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (localStorage.getItem("theme") === "auto") {
applyTheme("auto");
window.dispatchEvent(new CustomEvent("themeChanged"));
}
});
let liveSocket = new LiveSocket("/live", Socket, {
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken, viewport_width: window.innerWidth },
hooks: Hooks,
timeout: 60000, // 60 second timeout for slow initial loads
timeout: 60000,
});
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
topbar.config({
barColors: { 0: "#29d" },
shadowColor: "rgba(0, 0, 0, .3)",
});
window.addEventListener("phx:page-loading-start", (_info) => topbar.show(100));
window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide());
// Handle connection draining reconnect events
window.addEventListener("phx:reconnect", (e) => {
window.addEventListener("phx:reconnect", ((e: CustomEvent<{ delay?: number }>) => {
const delay = e.detail.delay || 1000;
setTimeout(() => {
// Disconnect and reconnect to potentially land on a different server
liveSocket.disconnect();
setTimeout(() => {
liveSocket.connect();
}, 100);
}, delay);
});
}) as EventListener);
// connect if there are any LiveViews on the page
liveSocket.connect();
// Workaround for Phoenix LiveView bug where fallback timer isn't cleared
// after successful WebSocket connection
window.addEventListener("phx:live_socket:connect", (info) => {
const socket = liveSocket.socket;
window.addEventListener("phx:live_socket:connect", (_info) => {
const socket = (liveSocket as any).socket;
if (socket && socket.fallbackTimer) {
clearTimeout(socket.fallbackTimer);
socket.fallbackTimer = null;
@ -215,7 +227,7 @@ window.addEventListener("phx:live_socket:connect", (info) => {
// Also check periodically in case the event doesn't fire
setTimeout(() => {
const socket = liveSocket.socket;
const socket = (liveSocket as any).socket;
if (socket && socket.isConnected() && socket.fallbackTimer) {
clearTimeout(socket.fallbackTimer);
socket.fallbackTimer = null;

View file

@ -1,111 +0,0 @@
// Error Boundary Hook for Phoenix LiveView
// Catches JavaScript errors and displays fallback content
const ErrorBoundary = {
mounted() {
// Get content and fallback elements
this.content = this.el.querySelector('.error-boundary-content');
this.fallback = this.el.querySelector('.error-boundary-fallback');
// Create bound error handlers
this.errorHandler = this.handleGlobalError.bind(this);
this.unhandledRejectionHandler = this.handleUnhandledRejection.bind(this);
// Set up error handlers for this component
this.setupErrorHandlers();
},
setupErrorHandlers() {
// Use event listeners instead of overriding global handlers
window.addEventListener('error', this.errorHandler, true);
window.addEventListener('unhandledrejection', this.unhandledRejectionHandler, true);
},
handleGlobalError(event) {
// Extract error from ErrorEvent
const error = event.error || new Error(event.message);
if (this.isErrorInComponent(error)) {
this.handleError(error);
event.preventDefault();
event.stopPropagation();
}
},
handleUnhandledRejection(event) {
if (this.isErrorInComponent(event.reason)) {
this.handleError(event.reason);
event.preventDefault();
event.stopPropagation();
}
},
isErrorInComponent(error) {
// Check if the error occurred within this component's DOM tree
if (!error) return false;
// If the error has a target element, check if it's within our component
if (error.target && this.el.contains(error.target)) {
return true;
}
// Check if the error stack trace contains references to our component's hooks or elements
if (error.stack) {
// Look for our component's ID in the stack trace
const componentId = this.el.id;
if (componentId && error.stack.includes(componentId)) {
return true;
}
// Check if the error originated from a hook within this component
const hooks = this.el.querySelectorAll('[phx-hook]');
for (let hook of hooks) {
const hookName = hook.getAttribute('phx-hook');
if (hookName && error.stack.includes(hookName)) {
return true;
}
}
}
// For now, we'll be conservative and only handle errors we're sure about
// In a more sophisticated implementation, you might analyze the error source
return false;
},
handleError(error) {
console.error('Error caught by boundary:', error);
// Hide content and show fallback
if (this.content && this.content.style) {
this.content.style.display = 'none';
}
if (this.fallback && this.fallback.classList) {
this.fallback.classList.remove('hidden');
}
// Log error to server if pushEvent is available
if (this.pushEvent && typeof this.pushEvent === 'function') {
try {
this.pushEvent('error_boundary_triggered', {
message: error?.message || 'Unknown error',
stack: error?.stack || 'No stack trace',
component_id: this.el?.id || 'unknown'
});
} catch (e) {
console.error('Failed to send error to server:', e);
}
}
},
destroyed() {
// Remove event listeners
if (this.errorHandler) {
window.removeEventListener('error', this.errorHandler, true);
}
if (this.unhandledRejectionHandler) {
window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler, true);
}
}
};
export default ErrorBoundary;

View file

@ -0,0 +1,129 @@
// Error Boundary Hook for Phoenix LiveView
// Catches JavaScript errors and displays fallback content
interface ErrorBoundaryContext {
el: HTMLElement;
content: HTMLElement | null;
fallback: HTMLElement | null;
errorHandler: (event: ErrorEvent) => void;
unhandledRejectionHandler: (event: PromiseRejectionEvent) => void;
pushEvent?: (event: string, payload: Record<string, unknown>) => void;
}
interface ErrorLike {
message?: string;
stack?: string;
target?: EventTarget | null;
}
const ErrorBoundary = {
mounted(this: ErrorBoundaryContext) {
this.content = this.el.querySelector(".error-boundary-content");
this.fallback = this.el.querySelector(".error-boundary-fallback");
this.errorHandler = handleGlobalError.bind(this);
this.unhandledRejectionHandler = handleUnhandledRejection.bind(this);
window.addEventListener("error", this.errorHandler, true);
window.addEventListener(
"unhandledrejection",
this.unhandledRejectionHandler,
true,
);
},
destroyed(this: ErrorBoundaryContext) {
if (this.errorHandler) {
window.removeEventListener("error", this.errorHandler, true);
}
if (this.unhandledRejectionHandler) {
window.removeEventListener(
"unhandledrejection",
this.unhandledRejectionHandler,
true,
);
}
},
};
function handleGlobalError(this: ErrorBoundaryContext, event: ErrorEvent) {
const error: ErrorLike = event.error || { message: event.message };
if (isErrorInComponent.call(this, error)) {
handleError.call(this, error);
event.preventDefault();
event.stopPropagation();
}
}
function handleUnhandledRejection(
this: ErrorBoundaryContext,
event: PromiseRejectionEvent,
) {
const error = event.reason as ErrorLike | undefined;
if (isErrorInComponent.call(this, error)) {
handleError.call(this, error);
event.preventDefault();
event.stopPropagation();
}
}
function isErrorInComponent(
this: ErrorBoundaryContext,
error: ErrorLike | undefined,
): boolean {
if (!error) return false;
if (
error.target &&
error.target instanceof Node &&
this.el.contains(error.target)
) {
return true;
}
if (error.stack) {
const componentId = this.el.id;
if (componentId && error.stack.includes(componentId)) {
return true;
}
const hooks = this.el.querySelectorAll("[phx-hook]");
for (const hook of hooks) {
const hookName = hook.getAttribute("phx-hook");
if (hookName && error.stack.includes(hookName)) {
return true;
}
}
}
return false;
}
function handleError(
this: ErrorBoundaryContext,
error: ErrorLike | undefined,
) {
console.error("Error caught by boundary:", error);
if (this.content) {
this.content.style.display = "none";
}
if (this.fallback) {
this.fallback.classList.remove("hidden");
}
if (this.pushEvent && typeof this.pushEvent === "function") {
try {
this.pushEvent("error_boundary_triggered", {
message: error?.message || "Unknown error",
stack: error?.stack || "No stack trace",
component_id: this.el?.id || "unknown",
});
} catch (e) {
console.error("Failed to send error to server:", e);
}
}
}
export default ErrorBoundary;

View file

@ -1,168 +0,0 @@
// Simple map hook for displaying a single station on the info page
export const InfoMap = {
mounted() {
this.initializeMap();
},
updated() {
// When the element updates, check if we need to update the marker
const lat = parseFloat(this.el.dataset.lat);
const lon = parseFloat(this.el.dataset.lon);
const symbolHtml = this.el.dataset.symbolHtml;
// Validate coordinates
const callsign = this.el.dataset.callsign;
if (isNaN(lat) || isNaN(lon)) {
console.warn(`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`);
return;
}
// If map doesn't exist yet, initialize it
if (!this.map || !this.map._container || !this.map._container.parentNode) {
// Map was destroyed or removed from DOM, reinitialize
this.initializeMap();
return;
}
// Update marker position if it changed
if (this.marker) {
const currentPos = this.marker.getLatLng();
if (currentPos.lat !== lat || currentPos.lng !== lon) {
// Animate the marker to the new position
this.marker.setLatLng([lat, lon]);
// Update the popup content
const callsign = this.el.dataset.callsign;
this.marker.setPopupContent(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`);
// Optionally pan the map to the new position with animation
this.map.panTo([lat, lon], { animate: true, duration: 1 });
}
// Update marker icon if symbol changed
if (symbolHtml !== this.lastSymbolHtml) {
const markerIcon = this.createMarkerIcon(symbolHtml);
this.marker.setIcon(markerIcon);
this.lastSymbolHtml = symbolHtml;
}
}
},
initializeMap() {
// Prevent multiple simultaneous initializations
if (this.initializing) {
return;
}
// Check if Leaflet is available
if (typeof L === "undefined") {
console.error("Leaflet not loaded for InfoMap");
return;
}
this.initializing = true;
// Get data from element attributes
const lat = parseFloat(this.el.dataset.lat);
const lon = parseFloat(this.el.dataset.lon);
const zoom = parseInt(this.el.dataset.zoom) || 13;
const callsign = this.el.dataset.callsign;
const symbolHtml = this.el.dataset.symbolHtml;
// Validate coordinates
if (isNaN(lat) || isNaN(lon)) {
console.warn(`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`);
return;
}
// Hide loading spinner
const loadingEl = this.el.querySelector(`#${this.el.id}-loading`);
if (loadingEl) {
loadingEl.style.display = 'none';
}
// Initialize the map
try {
this.map = L.map(this.el, {
center: [lat, lon],
zoom: zoom,
scrollWheelZoom: false, // Disable scroll zoom for embedded maps
zoomControl: true,
attributionControl: true
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(this.map);
// Create marker icon
const markerIcon = this.createMarkerIcon(symbolHtml);
this.lastSymbolHtml = symbolHtml;
// Add marker for the station
this.marker = L.marker([lat, lon], { icon: markerIcon })
.addTo(this.map)
.bindPopup(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`);
// Invalidate size after a short delay to ensure proper rendering
this.resizeTimer = setTimeout(() => {
if (this.map) {
this.map.invalidateSize();
}
this.resizeTimer = null;
}, 250);
// Mark initialization as complete
this.initializing = false;
} catch (error) {
console.error("Error initializing InfoMap:", error);
this.initializing = false;
}
},
createMarkerIcon(symbolHtml) {
if (symbolHtml) {
// Use the APRS symbol if provided
return L.divIcon({
html: symbolHtml,
className: 'aprs-info-marker',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
} else {
// Default marker
return L.divIcon({
html: `<div style="
width: 24px;
height: 24px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
"></div>`,
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
},
destroyed() {
// Cancel pending resize timer
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
// Clean up map and reset state
if (this.map) {
this.map.remove();
this.map = null;
}
this.marker = null;
this.lastSymbolHtml = null;
this.initializing = false;
}
};

174
assets/js/hooks/info_map.ts Normal file
View file

@ -0,0 +1,174 @@
// Simple map hook for displaying a single station on the info page
declare const L: typeof import("leaflet");
interface InfoMapContext {
el: HTMLElement;
map: import("leaflet").Map | null;
marker: import("leaflet").Marker | null;
lastSymbolHtml: string | null;
initializing: boolean;
resizeTimer: ReturnType<typeof setTimeout> | null;
}
export const InfoMap = {
mounted(this: InfoMapContext) {
this.map = null;
this.marker = null;
this.lastSymbolHtml = null;
this.initializing = false;
this.resizeTimer = null;
initializeMap.call(this);
},
updated(this: InfoMapContext) {
const lat = parseFloat(this.el.dataset.lat || "");
const lon = parseFloat(this.el.dataset.lon || "");
const symbolHtml = this.el.dataset.symbolHtml || null;
const callsign = this.el.dataset.callsign || "unknown";
if (isNaN(lat) || isNaN(lon)) {
console.warn(
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`,
);
return;
}
if (
!this.map ||
!(this.map as any)._container ||
!(this.map as any)._container.parentNode
) {
initializeMap.call(this);
return;
}
if (this.marker) {
const currentPos = this.marker.getLatLng();
if (currentPos.lat !== lat || currentPos.lng !== lon) {
this.marker.setLatLng([lat, lon]);
this.marker.setPopupContent(
`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`,
);
this.map!.panTo([lat, lon], { animate: true, duration: 1 });
}
if (symbolHtml !== this.lastSymbolHtml) {
const markerIcon = createMarkerIcon(symbolHtml);
this.marker.setIcon(markerIcon);
this.lastSymbolHtml = symbolHtml;
}
}
},
destroyed(this: InfoMapContext) {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
if (this.map) {
this.map.remove();
this.map = null;
}
this.marker = null;
this.lastSymbolHtml = null;
this.initializing = false;
},
};
function initializeMap(this: InfoMapContext) {
if (this.initializing) return;
if (typeof L === "undefined") {
console.error("Leaflet not loaded for InfoMap");
return;
}
this.initializing = true;
const lat = parseFloat(this.el.dataset.lat || "");
const lon = parseFloat(this.el.dataset.lon || "");
const zoom = parseInt(this.el.dataset.zoom || "13") || 13;
const callsign = this.el.dataset.callsign || "unknown";
const symbolHtml = this.el.dataset.symbolHtml || null;
if (isNaN(lat) || isNaN(lon)) {
console.warn(
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`,
);
this.initializing = false;
return;
}
const loadingEl = this.el.querySelector(
`#${this.el.id}-loading`,
) as HTMLElement | null;
if (loadingEl) {
loadingEl.style.display = "none";
}
try {
this.map = L.map(this.el, {
center: [lat, lon],
zoom: zoom,
scrollWheelZoom: false,
zoomControl: true,
attributionControl: true,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(this.map);
const markerIcon = createMarkerIcon(symbolHtml);
this.lastSymbolHtml = symbolHtml;
this.marker = L.marker([lat, lon], { icon: markerIcon })
.addTo(this.map)
.bindPopup(
`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`,
);
const map = this.map;
this.resizeTimer = setTimeout(() => {
if (map) {
map.invalidateSize();
}
this.resizeTimer = null;
}, 250);
this.initializing = false;
} catch (error) {
console.error("Error initializing InfoMap:", error);
this.initializing = false;
}
}
function createMarkerIcon(symbolHtml: string | null): import("leaflet").DivIcon {
if (symbolHtml) {
return L.divIcon({
html: symbolHtml,
className: "aprs-info-marker",
iconSize: [32, 32],
iconAnchor: [16, 16],
});
} else {
return L.divIcon({
html: `<div style="
width: 24px;
height: 24px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
"></div>`,
className: "",
iconSize: [24, 24],
iconAnchor: [12, 12],
});
}
}

View file

@ -1,69 +0,0 @@
// Hook to automatically update time ago displays
export default {
mounted() {
this.startTimer();
},
destroyed() {
this.stopTimer();
},
updated() {
// Restart timer when the element updates
this.stopTimer();
this.startTimer();
},
startTimer() {
const updateTimeAgo = () => {
const timestampStr = this.el.dataset.timestamp;
if (!timestampStr) return;
const timestamp = new Date(timestampStr);
const now = new Date();
const diffMs = now - timestamp;
const diffSeconds = Math.floor(diffMs / 1000);
if (diffSeconds < 60) {
this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`;
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
this.el.textContent = `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
} else if (diffSeconds < 86400) {
const hours = Math.floor(diffSeconds / 3600);
this.el.textContent = `${hours} hour${hours !== 1 ? 's' : ''} ago`;
} else {
const days = Math.floor(diffSeconds / 86400);
this.el.textContent = `${days} day${days !== 1 ? 's' : ''} ago`;
}
};
// Update immediately
updateTimeAgo();
// Update every second for recent timestamps, less frequently for older ones
const timestampStr = this.el.dataset.timestamp;
if (timestampStr) {
const timestamp = new Date(timestampStr);
const age = Date.now() - timestamp;
let interval;
if (age < 60000) { // Less than 1 minute old
interval = 1000; // Update every second
} else if (age < 3600000) { // Less than 1 hour old
interval = 60000; // Update every minute
} else {
interval = 300000; // Update every 5 minutes
}
this.timer = setInterval(updateTimeAgo, interval);
}
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
};

View file

@ -0,0 +1,75 @@
// Hook to automatically update time ago displays
interface TimeAgoHookContext {
el: HTMLElement;
timer: ReturnType<typeof setInterval> | null;
}
const TimeAgoHook = {
mounted(this: TimeAgoHookContext) {
this.timer = null;
startTimer.call(this);
},
destroyed(this: TimeAgoHookContext) {
stopTimer.call(this);
},
updated(this: TimeAgoHookContext) {
stopTimer.call(this);
startTimer.call(this);
},
};
function startTimer(this: TimeAgoHookContext) {
const updateTimeAgo = () => {
const timestampStr = this.el.dataset.timestamp;
if (!timestampStr) return;
const timestamp = new Date(timestampStr);
const now = new Date();
const diffMs = now.getTime() - timestamp.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
if (diffSeconds < 60) {
this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? "s" : ""} ago`;
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
this.el.textContent = `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
} else if (diffSeconds < 86400) {
const hours = Math.floor(diffSeconds / 3600);
this.el.textContent = `${hours} hour${hours !== 1 ? "s" : ""} ago`;
} else {
const days = Math.floor(diffSeconds / 86400);
this.el.textContent = `${days} day${days !== 1 ? "s" : ""} ago`;
}
};
updateTimeAgo();
const timestampStr = this.el.dataset.timestamp;
if (timestampStr) {
const timestamp = new Date(timestampStr);
const age = Date.now() - timestamp.getTime();
let interval: number;
if (age < 60000) {
interval = 1000;
} else if (age < 3600000) {
interval = 60000;
} else {
interval = 300000;
}
this.timer = setInterval(updateTimeAgo, interval);
}
}
function stopTimer(this: TimeAgoHookContext) {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
export default TimeAgoHook;

View file

@ -81,10 +81,10 @@ config :error_tracker,
# Configure esbuild (the version is required)
config :esbuild,
version: "0.24.2",
version: "0.25.4",
default: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=css --loader:.png=file --loader:.svg=file),
~w(js/app.ts --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=css --loader:.png=file --loader:.svg=file),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
],

View file

@ -16,10 +16,10 @@ config :aprsme, AprsmeWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: 4000]
config :esbuild,
version: "0.24.2",
version: "0.25.4",
default: [
args:
~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --loader:.ts=ts --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*),
~w(js/app.ts --bundle --target=es2020 --outdir=../priv/static/assets --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]

View file

@ -81,7 +81,7 @@ defmodule Aprsme.MixProject do
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
aprs_dep(),
{:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.4.0", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons", tag: "v2.1.1", sparse: "optimized", app: false, compile: false, depth: 1},