This example comes from my own home dashboard. I get a weekly email from a service called OutdoorActive (I use their mobile app for walks and bike rides) that contains 3 recommended walks each week from the local area (within 10-20mi I think).
I have a node-red flow that extracts the data from the emails (using a function node and an IMAP library) and puts the data into JSON. That JSON is sent to the front end when needed and is converted into an HTML table.
I use UIBUILDER's front-end router library to create a home dashboard Single Page App (SPA) and all the front-end code for the walks tab is in a single file. This WAS the output prior to me implementing a more sophisticated version.
Now, I've added links to Google Maps, improved the details display and added both a column sort and a filter feature:
No extra libraries were needed for this. Indeed, I tried several of them and it was harder to make them work as I wanted than it was to simply add the code - especially with the help of one of the more advanced GitHub Copilot models (Claude Sonnet 4). I get an allowance for advanced models each month and I'd only used a small % this month despite working on some other complex changes. Even now, I've only used 14%.
Anyway, here is the HTML fragment that is the route file for this particular page.
<style>
#walkTbl {
width: 100%;
}
/* Custom search input styling */
.search-container {
position: relative;
display: inline-block;
margin-bottom: 1rem;
}
#walkSearch {
padding: 0.5rem;
padding-right: 2.5rem;
border: 1px solid hsl(0, 0%, 80%);
border-radius: 0.25rem;
width: 300px;
}
.clear-search-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
color: hsl(0, 0%, 60%);
padding: 0;
display: none;
}
.clear-search-btn:hover {
color: hsl(0, 0%, 30%);
}
.clear-search-btn.visible {
display: block;
}
/* Sortable column headers */
.sortable-header {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 1.5rem;
}
.sortable-header:hover {
background-color: hsl(0, 0%, 90%);
}
.sort-indicator {
position: absolute;
right: 0.25rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.75em;
color: hsl(0, 0%, 60%);
}
.sort-indicator.asc::after {
content: '▲';
color: hsl(210, 100%, 50%);
}
.sort-indicator.desc::after {
content: '▼';
color: hsl(210, 100%, 50%);
}
.sort-indicator.none::after {
content: '▲▼';
opacity: 0.3;
}
/* Table cell alignment */
#walkTbl td {
text-align: right;
}
#walkTbl td:first-child {
text-align: left;
}
#walkTbl td:last-child {
text-align: center;
}
td.walk-description {
text-align: left !important;
}
</style>
<template id="walk-template">
<tr>
<td>
<details name="walk-details">
<summary>
<a href="{{routeLink}}" data-route="{{route}}" target="_blank">{{route}}</a>
</summary>
</details>
</td>
<td>{{fromme}}</td>
<td title="Length of the walk (mi)">{{distance}}</td>
<td title="How long will the walk take?">{{time}}</td>
<td title="How difficult is the walk?">{{difficulty}}</td>
</tr>
<tr class="details-row" style="display: none;">
<td colspan="5" class="walk-description">{{description}}</td>
</tr>
</template>
<div class="search-container">
<input type="text" id="walkSearch" placeholder="Search walks..." />
<button type="button" class="clear-search-btn" id="clearSearch" title="Clear search">❌</button>
</div>
<table id="walkTbl">
<thead>
<tr>
<th class="sortable-header" data-column="walk">
Walk
<span class="sort-indicator none"></span>
</th>
<th class="sortable-header" data-column="fromMe" title="Straight-line distance from me to start of walk (mi)">
🎯
<span class="sort-indicator none"></span>
</th>
<th class="sortable-header" data-column="distance" title="How long is the walk? (mi)">
➡️
<span class="sort-indicator none"></span>
</th>
<th class="sortable-header" data-column="time" title="How long does the walk take? h:m">
⌛
<span class="sort-indicator none"></span>
</th>
<th class="sortable-header" data-column="difficulty" title="How difficult is the walk?">
🚶➡️
<span class="sort-indicator none"></span>
</th>
</tr>
</thead>
<tbody id="walks">
</tbody>
</table>
<script>
'use strict'
// Only ever run this once per page load
if (!window.walkSetup) {
console.log('Running initial setup for walks route - wont be run again until page reload')
const walkSetup = window.walkSetup = {}
// Store table data and current sort state
walkSetup.tableData = []
walkSetup.currentSort = { column: null, direction: 'none', }
/** Sort table data by column
* @param {string} column - Column name to sort by
* @param {string} direction - Sort direction: 'asc', 'desc', or 'none'
*/
walkSetup.sortTable = function(column, direction) {
if (direction === 'none') {
walkSetup.renderTable(walkSetup.originalData)
return
}
const sortedData = [...walkSetup.tableData].sort((a, b) => {
let aVal, bVal
switch (column) {
case 'walk':
aVal = a.walk.title.toLowerCase()
bVal = b.walk.title.toLowerCase()
break
case 'fromMe':
aVal = a.fromMe === 'N/A' ? Infinity : parseFloat(a.fromMe)
bVal = b.fromMe === 'N/A' ? Infinity : parseFloat(b.fromMe)
break
case 'distance':
aVal = a.distance === 'N/A' ? Infinity : parseFloat(a.distance)
bVal = b.distance === 'N/A' ? Infinity : parseFloat(b.distance)
break
case 'time':
// Parse time strings like "2:30" or "1:15"
aVal = a.time === 'N/A' ? Infinity : walkSetup.parseTimeToMinutes(a.time)
bVal = b.time === 'N/A' ? Infinity : walkSetup.parseTimeToMinutes(b.time)
break
case 'difficulty':
aVal = a.difficulty === 'N/A' ? '' : a.difficulty.toLowerCase()
bVal = b.difficulty === 'N/A' ? '' : b.difficulty.toLowerCase()
break
default:
return 0
}
if (typeof aVal === 'string') {
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
}
return direction === 'asc' ? aVal - bVal : bVal - aVal
})
walkSetup.renderTable(sortedData)
}
/** Parse time string to minutes for sorting
* @param {string} timeStr - Time string like "2:30" or "1:15"
* @returns {number} Total minutes
*/
walkSetup.parseTimeToMinutes = function(timeStr) {
if (!timeStr || timeStr === 'N/A') return Infinity
const parts = timeStr.split(':')
if (parts.length !== 2) return Infinity
const hours = parseInt(parts[0]) || 0
const minutes = parseInt(parts[1]) || 0
return hours * 60 + minutes
}
/** Render table with given data
* @param {Array} data - Array of route data objects
*/
walkSetup.renderTable = function(data) {
const outEl = document.getElementById('walks')
if (!outEl) throw new Error('Walks table body not found')
outEl.innerHTML = ''
const template = document.getElementById('walk-template')
if (!template) throw new Error('Template not found')
data.forEach((routeItem) => {
// Create a new row for each route by cloning the template content
const clone = template.content.cloneNode(true)
// replace the placeholders in the template with actual data
const link = clone.querySelector('a')
link.setAttribute('href', routeItem.walk.link)
link.textContent = routeItem.walk.title
link.dataset.route = routeItem.walk.route
// Set up the description in the details row
const descriptionDiv = clone.querySelector('.walk-description')
descriptionDiv.textContent = routeItem.walk.description
// add a link to the Google Maps location and make the link text the distance
if (routeItem.lat && routeItem.lon) {
clone.querySelector('td:nth-child(2)').innerHTML = `<a href="https://www.google.com/maps/search/?q=${routeItem.lat}%2C${routeItem.lon}" target="_blank" title="Miles from me. Click to see on Google Maps">${routeItem.fromMe}</a>`
} else {
clone.querySelector('td:nth-child(2)').textContent = routeItem.fromMe
}
clone.querySelector('td:nth-child(3)').textContent = routeItem.distance
clone.querySelector('td:nth-child(4)').textContent = routeItem.time
clone.querySelector('td:nth-child(5)').textContent = routeItem.difficulty
// Add click handler for details toggle
const details = clone.querySelector('details')
const detailsRow = clone.querySelector('.details-row')
const firstCell = clone.querySelector('td:first-child')
// Function to toggle details
const toggleDetails = () => {
if (details.open) {
details.open = false
detailsRow.style.display = 'none'
} else {
details.open = true
detailsRow.style.display = 'table-row'
}
}
// Handle details element toggle event
details.addEventListener('toggle', function() {
if (this.open) {
detailsRow.style.display = 'table-row'
} else {
detailsRow.style.display = 'none'
}
})
// Make the entire first cell clickable for toggling details
firstCell.style.cursor = 'pointer'
firstCell.addEventListener('click', function(event) {
// Don't toggle if the link was clicked
if (event.target === link || link.contains(event.target)) {
return
}
event.preventDefault()
toggleDetails()
})
// Prevent the cell click when clicking the link
link.addEventListener('click', function(event) {
event.stopPropagation()
})
// Set initial state (details collapsed by default)
details.open = false
detailsRow.style.display = 'none'
// Append the cloned rows to the table body
outEl.appendChild(clone)
})
}
/** Initialize sorting event listeners */
walkSetup.initSorting = function() {
const headers = document.querySelectorAll('.sortable-header')
headers.forEach((header) => {
header.addEventListener('click', function() {
const column = this.getAttribute('data-column')
const indicator = this.querySelector('.sort-indicator')
// Clear other indicators
headers.forEach((h) => {
if (h !== this) {
h.querySelector('.sort-indicator').className = 'sort-indicator none'
}
})
// Cycle through sort states: none -> asc -> desc -> none
let newDirection
if (walkSetup.currentSort.column === column) {
switch (walkSetup.currentSort.direction) {
case 'none':
newDirection = 'asc'
break
case 'asc':
newDirection = 'desc'
break
case 'desc':
newDirection = 'none'
break
}
} else {
newDirection = 'asc'
}
walkSetup.currentSort = { column, direction: newDirection, }
indicator.className = `sort-indicator ${newDirection}`
walkSetup.sortTable(column, newDirection)
})
})
}
/** Initialize search/filter functionality */
walkSetup.initSearch = function() {
const searchInput = document.getElementById('walkSearch')
const clearBtn = document.getElementById('clearSearch')
if (!searchInput || !clearBtn) return
/** Toggle clear button visibility based on input content
* @param {string} value - The current input value
*/
const toggleClearButton = (value) => {
if (value.trim()) {
clearBtn.classList.add('visible')
} else {
clearBtn.classList.remove('visible')
}
}
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim()
toggleClearButton(this.value)
walkSetup.filterTable(searchTerm)
})
clearBtn.addEventListener('click', function() {
searchInput.value = ''
searchInput.focus()
toggleClearButton('')
walkSetup.filterTable('')
})
// Initialize clear button state
toggleClearButton(searchInput.value)
}
/** Filter table based on search term
* @param {string} searchTerm - The search term to filter by
*/
walkSetup.filterTable = function(searchTerm) {
if (!searchTerm) {
// If no search term, show all data (with current sort applied)
const dataToShow = walkSetup.currentSort.direction === 'none'
? walkSetup.originalData
: walkSetup.tableData
walkSetup.renderTable(dataToShow)
return
}
// Filter data based on search term
const filteredData = walkSetup.tableData.filter((item) => {
// Search in walk title
if (item.walk.title.toLowerCase().includes(searchTerm)) return true
// Search in walk description
if (item.walk.description.toLowerCase().includes(searchTerm)) return true
// Search in fromMe (distance from me)
if (item.fromMe.toString().toLowerCase()
.includes(searchTerm)) return true
// Search in distance
if (item.distance.toString().toLowerCase()
.includes(searchTerm)) return true
// Search in time
if (item.time.toString().toLowerCase()
.includes(searchTerm)) return true
// Search in difficulty
if (item.difficulty.toString().toLowerCase()
.includes(searchTerm)) return true
return false
})
walkSetup.renderTable(filteredData)
}
// Initialize sorting and search when DOM is ready
const initializeFeatures = () => {
walkSetup.initSorting()
walkSetup.initSearch()
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeFeatures)
} else {
initializeFeatures()
}
/** Calculate the distance between two geographic coordinates using the Haversine formula
* @param {number} lat1 - Latitude of the first point in degrees
* @param {number} lon1 - Longitude of the first point in degrees
* @param {number} lat2 - Latitude of the second point in degrees
* @param {number} lon2 - Longitude of the second point in degrees
* @param {string} [unit] - Unit of measurement ('km' for kilometers [default], 'mi' for miles, 'm' for meters)
* @returns {number} Distance between the two points in the specified unit
* @throws {Error} If coordinates are invalid
*
* @example
* // Calculate distance between London and Paris
* const distance = calculateDistance(51.5074, -0.1278, 48.8566, 2.3522)
* console.log(`Distance: ${distance.toFixed(2)} km`)
*/
walkSetup.calculateDistance = function calculateDistance(lat1, lon1, lat2, lon2, unit = 'km') {
lat1 = parseFloat(lat1)
lon1 = parseFloat(lon1)
lat2 = parseFloat(lat2)
lon2 = parseFloat(lon2)
// Validate input parameters
if (typeof lat1 !== 'number' || typeof lon1 !== 'number'
|| typeof lat2 !== 'number' || typeof lon2 !== 'number') {
throw new Error('All coordinates must be numbers')
}
if (lat1 < -90 || lat1 > 90 || lat2 < -90 || lat2 > 90) {
throw new Error('Latitude must be between -90 and 90 degrees')
}
if (lon1 < -180 || lon1 > 180 || lon2 < -180 || lon2 > 180) {
throw new Error('Longitude must be between -180 and 180 degrees')
}
// Earth's radius in kilometers
const earthRadiusKm = 6371
// Convert degrees to radians
const lat1Rad = lat1 * Math.PI / 180
const lon1Rad = lon1 * Math.PI / 180
const lat2Rad = lat2 * Math.PI / 180
const lon2Rad = lon2 * Math.PI / 180
// Calculate differences
const deltaLat = lat2Rad - lat1Rad
const deltaLon = lon2Rad - lon1Rad
// Haversine formula
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
+ Math.cos(lat1Rad) * Math.cos(lat2Rad)
* Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
// Distance in kilometers
const distanceKm = earthRadiusKm * c
// Convert to requested unit
switch (unit.toLowerCase()) {
case 'km':
return distanceKm
case 'mi':
return distanceKm * 0.621371
case 'm':
return distanceKm * 1000
default:
throw new Error('Unit must be "km", "mi", or "m"')
}
}
/** Listener function to handle the 'sendRoutes' message from uibuilder
* @param {object} msg - The message object containing route data
*/
walkSetup.rcvRoutes = uibuilder.onTopic('sendRoutes', (msg) => {
console.log('New Routes:', msg.payload)
// Amended slightly for sharing 😊
const mylat = 53
const mylon = -1
const routes = walkSetup.routes = msg.payload
const tableData = []
Object.keys(routes).forEach((route) => {
const routeData = routes[route]
if ('text' in routeData.title) routeData.title.text = routeData.title.text.trim()
// Entries with no title text are regions rather than routes so ignore them
if (!routeData.title.text) return
const routeLink = routeData.title.href ?? '#'
const routeTitle = routeData.title.text
const time = (routeData.details.time ?? 'N/A').replace(/ h$/, '')
// How far from me?
let fromMe
let lat, lon
if (routeData.details.positions) {
// Calculate distance if positions are provided
[lat, lon] = routeData.details.positions[1].split(', ') // lat, lon
fromMe = Number(routeData.details.distance = walkSetup.calculateDistance(
mylat, mylon, lat, lon, 'mi'
).toFixed(1))
} else {
fromMe = 'N/A'
}
const distance = routeData.details.distance
? Number((parseFloat(routeData.details.distance) * 0.621371).toFixed(1)) // converted from km to mi
: 'N/A'
// Add row data for rendering
tableData.push({
walk: {
title: routeTitle,
link: routeLink,
route: route,
description: routeData.details.description || 'No description provided',
},
fromMe: fromMe,
distance: distance,
time: time,
difficulty: routeData.details.difficulty || 'N/A',
lat: lat,
lon: lon,
})
})
// Store data for sorting
walkSetup.tableData = tableData
walkSetup.originalData = [...tableData]
// Render the table
walkSetup.renderTable(tableData)
})
}
</script>