This commit is contained in:
Roslund
2025-08-22 18:09:56 +00:00
parent 4dd3793324
commit 997fc77ddb
4 changed files with 329 additions and 284 deletions
+119
View File
@@ -0,0 +1,119 @@
{
"messagePattern": "AW 19/8",
"archived": true,
"exportedAt": "2025-08-20T19:11:59.376Z",
"description": "AW Telefonplan meetup",
"attendees": {
"yes": [
{
"nodeId": "364861217",
"shortName": "KAKd",
"longName": "Kladdkakd",
"response": "yes",
"timestamp": "2025-08-18T21:55:44.225Z"
},
{
"nodeId": "910104769",
"shortName": "TXD0",
"longName": "Wibbe",
"response": "yes",
"timestamp": "2025-08-18T20:01:19.763Z"
},
{
"nodeId": "2745076674",
"shortName": "TWK5",
"longName": "TWK-Mobil T114",
"response": "yes",
"timestamp": "2025-08-18T18:13:36.385Z"
},
{
"nodeId": "1258726557",
"shortName": "JwKC",
"longName": "JwK Car",
"response": "yes",
"timestamp": "2025-08-18T11:51:05.986Z"
},
{
"nodeId": "3681979732",
"shortName": "Ros",
"longName": "Ros Mobil",
"response": "yes",
"timestamp": "2025-08-15T05:19:51.846Z"
},
{
"nodeId": "762864438",
"shortName": "TUFx",
"longName": "Lasse",
"response": "yes",
"timestamp": "2025-08-15T04:15:40.832Z"
},
{
"nodeId": "2718571204",
"shortName": "DXD3",
"longName": "DXD3 Ruggen",
"response": "yes",
"timestamp": "2025-08-14T19:33:19.153Z"
},
{
"nodeId": "1422344156",
"shortName": "TELE",
"longName": "MDG Telefonplan",
"response": "yes",
"timestamp": "2025-08-14T19:19:56.688Z"
},
{
"nodeId": "2152997845",
"shortName": "MDG5",
"longName": "MDG5 /R1",
"response": "yes",
"timestamp": "2025-08-14T19:16:29.975Z"
}
],
"maybe": [
{
"nodeId": "220489446",
"shortName": "SolS",
"longName": "SolarStation",
"response": "maybe",
"timestamp": "2025-08-17T07:26:51.406Z"
},
{
"nodeId": "2892741592",
"shortName": "bbd8",
"longName": "Meshtastic bbd8",
"response": "maybe",
"timestamp": "2025-08-16T11:48:12.544Z"
},
{
"nodeId": "4102046320",
"shortName": "DLTA",
"longName": "Delta Flyer",
"response": "maybe",
"timestamp": "2025-08-14T21:23:49.942Z"
},
{
"nodeId": "2879616005",
"shortName": "JlyT",
"longName": "Jelly Test",
"response": "maybe",
"timestamp": "2025-08-14T20:48:10.687Z"
}
],
"no": [
{
"nodeId": "2956846808",
"shortName": "LSD",
"longName": "R∆dioW∆ve🇸🇪",
"response": "no",
"timestamp": "2025-08-19T11:39:03.262Z"
},
{
"nodeId": "2947306466",
"shortName": "CVK",
"longName": "SA0CVK Primary",
"response": "no",
"timestamp": "2025-08-18T12:51:47.594Z"
}
]
}
}
+206 -280
View File
@@ -1,295 +1,170 @@
/**
* RSVP Tracker for Meshtastic Events
* Tracks attendance responses from LoRa mesh messages
*/
// RSVP response patterns (order matters - check longer phrases first!)
const RESPONSE_PATTERNS = {
'no': ['kommer inte', 'no', 'nej', 'not attending', 'kan inte', 'cannot attend'],
'maybe': ['kanske', 'maybe', 'unsure', 'oklart', 'tvekar'],
'yes': ['kommer', 'yes', 'ja', 'attending', 'deltar']
};
class RSVPTracker {
constructor(eventLabel) {
this.eventLabel = eventLabel;
this.state = {
messages: [],
nodesById: {},
rsvpResponses: new Map() // nodeId -> latest response
};
function parseRSVPMessage(messageText, messagePattern) {
const text = messageText.trim().toLowerCase();
const pattern = messagePattern.toLowerCase();
if (!text.includes(pattern)) return null;
// Find response type by checking patterns
for (const [responseType, patterns] of Object.entries(RESPONSE_PATTERNS)) {
for (const p of patterns) {
if (text.includes(p)) {
return { type: responseType };
}
}
}
return null;
}
async function fetchMessages() {
const response = await fetch('https://map.sthlm-mesh.se/api/v1/text-messages?order=desc&count=500');
const data = await response.json();
return data.text_messages;
}
function parseRSVPResponses(messages, messagePattern) {
const responses = new Map();
for (const message of messages) {
const rsvp = parseRSVPMessage(message.text, messagePattern);
if (!rsvp) continue;
// RSVP response patterns (order matters - check longer phrases first!)
this.responsePatterns = {
'kommer inte': ['kommer inte', 'no', 'nej', 'not attending', 'kan inte', 'cannot attend'],
'kanske': ['kanske', 'maybe', 'unsure', 'oklart', 'tvekar'],
'kommer': ['kommer', 'yes', 'ja', 'attending', 'deltar']
};
}
/**
* Parse RSVP message format: "EventLabel - Response"
* Example: "AW 21/8 - Kommer"
*/
parseRSVPMessage(messageText) {
const text = messageText.trim().toLowerCase();
// Check if message matches our event pattern
const eventPattern = this.eventLabel.toLowerCase();
if (!text.includes(eventPattern)) {
return null;
}
// Extract the response part after the separator
const separatorIndex = text.indexOf(' - ');
if (separatorIndex === -1) {
// Try alternative separators
const altSeparators = [' -', '- ', '-', ':'];
let response = null;
for (const sep of altSeparators) {
const sepIndex = text.indexOf(sep);
if (sepIndex !== -1 && text.substring(0, sepIndex).includes(eventPattern)) {
response = text.substring(sepIndex + sep.length).trim();
break;
}
}
if (!response) return null;
} else {
const response = text.substring(separatorIndex + 3).trim();
}
// Determine response type
for (const [responseType, patterns] of Object.entries(this.responsePatterns)) {
for (const pattern of patterns) {
if (text.includes(pattern)) {
return {
type: responseType,
originalText: messageText,
isValid: true
};
}
}
}
return null;
}
/**
* Fetch messages from the API and parse RSVP responses
*/
async fetchRSVPData() {
try {
const response = await fetch('https://map.sthlm-mesh.se/api/v1/text-messages?order=desc&count=500');
const data = await response.json();
// Filter duplicate messages and sort by timestamp
this.state.messages = Array.from(
new Map(data.text_messages.map(msg => [msg.packet_id, msg])).values()
).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
// Parse RSVP responses
this.parseRSVPResponses();
// Fetch node information for all RSVP responders
await this.fetchNodeInfoForRSVPs();
return this.getRSVPSummary();
} catch (error) {
console.error('Error fetching RSVP data:', error);
throw error;
const existing = responses.get(message.from);
if (!existing || new Date(message.created_at) > new Date(existing.timestamp)) {
responses.set(message.from, {
nodeId: message.from,
response: rsvp.type,
timestamp: message.created_at
});
}
}
return responses;
}
/**
* Parse all messages for RSVP responses
*/
parseRSVPResponses() {
this.state.rsvpResponses.clear();
for (const message of this.state.messages) {
const rsvp = this.parseRSVPMessage(message.text);
if (rsvp) {
// Only keep the latest response from each node
const existingResponse = this.state.rsvpResponses.get(message.from);
if (!existingResponse || new Date(message.created_at) > new Date(existingResponse.timestamp)) {
this.state.rsvpResponses.set(message.from, {
nodeId: message.from,
response: rsvp.type,
originalText: rsvp.originalText,
timestamp: message.created_at,
messageId: message.id
});
}
}
}
}
function findNodeById(id) {
return nodes.find(node => node.node_id.toString() === id.toString()) ?? null;
}
/**
* Fetch node information for all RSVP responders
*/
async fetchNodeInfoForRSVPs() {
const nodeIds = Array.from(this.state.rsvpResponses.keys());
for (const nodeId of nodeIds) {
if (!this.state.nodesById[nodeId]) {
try {
const response = await fetch(`https://map.sthlm-mesh.se/api/v1/nodes/${nodeId}`);
const data = await response.json();
if (data.node) {
this.state.nodesById[nodeId] = data.node;
}
} catch (error) {
console.warn(`Failed to fetch node info for ${nodeId}:`, error);
}
}
}
}
function createRSVPSummary(responses) {
const summary = { yes: [], maybe: [], no: [] };
/**
* Get summary of RSVP responses
*/
getRSVPSummary() {
const summary = {
kommer: [],
kanske: [],
'kommer inte': [],
total: 0
};
for (const [nodeId, rsvp] of this.state.rsvpResponses) {
const node = this.state.nodesById[nodeId];
const attendee = {
nodeId: nodeId,
shortName: node?.short_name || '?',
longName: node?.long_name || `!${parseInt(nodeId).toString(16)}`,
response: rsvp.response,
timestamp: rsvp.timestamp,
originalText: rsvp.originalText
};
if (summary[rsvp.response]) {
summary[rsvp.response].push(attendee);
}
summary.total++;
}
// Sort each category by timestamp (most recent first)
Object.keys(summary).forEach(key => {
if (Array.isArray(summary[key])) {
summary[key].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
}
for (const [nodeId, rsvp] of responses) {
const node = findNodeById(nodeId);
summary[rsvp.response]?.push({
nodeId,
shortName: node?.short_name || '?',
longName: node?.long_name || `!${parseInt(nodeId).toString(16)}`,
response: rsvp.response,
timestamp: rsvp.timestamp
});
return summary;
}
/**
* Generate HTML for displaying RSVP results
*/
generateRSVPHTML(summary) {
const html = `
<div class="rsvp-tracker mt-4">
<h4>📋 Anmälningsstatus</h4>
<p class="text-muted">Skicka meddelande över LoRa-meshen: "<strong>${this.eventLabel} - [Kommer/Kanske/Kommer inte]</strong>"</p>
<div class="row">
<div class="col-md-4">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h6 class="mb-0">✅ Kommer (${summary.kommer.length})</h6>
</div>
<div class="card-body">
${this.generateAttendeeList(summary.kommer)}
</div>
// Sort by timestamp (most recent first)
Object.values(summary).forEach(arr =>
arr.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
);
return summary;
}
function getNodeColour(nodeId) {
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
}
function generateAttendeeList(attendees) {
if (attendees.length === 0) {
return '<p class="text-muted small">Inga svar än</p>';
}
return attendees.map(attendee => `
<div class="d-flex align-items-center mb-2">
<div class="rounded-circle d-flex justify-content-center align-items-center me-2"
style="width: 32px; height: 32px; background-color: ${getNodeColour(attendee.nodeId)}; color: white; font-size: 12px;">
${attendee.shortName.substring(0, 4)}
</div>
<div>
<div class="small">
<a href="https://map.sthlm-mesh.se/?node_id=${attendee.nodeId}"
target="_blank" class="text-decoration-none">
${attendee.longName}
</a>
</div>
<div class="small text-muted">
${new Date(attendee.timestamp).toLocaleString('sv-SE', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
</div>
`).join('');
}
function generateRSVPHTML(summary) {
return `
<div class="rsvp-tracker mt-4">
<div class="row">
<div class="col-md-4 mb-3">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h6 class="mb-0">✅ Kommer (${summary.yes.length})</h6>
</div>
</div>
<div class="col-md-4">
<div class="card border-warning">
<div class="card-header bg-warning text-white">
<h6 class="mb-0">❓ Kanske (${summary.kanske.length})</h6>
</div>
<div class="card-body">
${this.generateAttendeeList(summary.kanske)}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0">❌ Kommer inte (${summary['kommer inte'].length})</h6>
</div>
<div class="card-body">
${this.generateAttendeeList(summary['kommer inte'])}
</div>
<div class="card-body">
${generateAttendeeList(summary.yes)}
</div>
</div>
</div>
<div class="mt-3 text-center">
<small class="text-muted">
Totalt ${summary.total} svar •
Senast uppdaterad: ${new Date().toLocaleString('sv-SE')}
</small>
</div>
</div>
`;
return html;
}
/**
* Generate attendee list HTML
*/
generateAttendeeList(attendees) {
if (attendees.length === 0) {
return '<p class="text-muted small">Inga svar än</p>';
}
return attendees.map(attendee => `
<div class="d-flex align-items-center mb-2">
<div class="rounded-circle d-flex justify-content-center align-items-center me-2"
style="width: 32px; height: 32px; background-color: ${this.getNodeColour(attendee.nodeId)}; color: white; font-size: 12px;">
${attendee.shortName.substring(0, 4)}
</div>
<div>
<div class="small">
<a href="https://map.sthlm-mesh.se/?node_id=${attendee.nodeId}"
target="_blank" class="text-decoration-none">
${attendee.longName}
</a>
<div class="col-md-4 mb-3">
<div class="card border-warning">
<div class="card-header bg-warning text-white">
<h6 class="mb-0">❓ Kanske (${summary.maybe.length})</h6>
</div>
<div class="card-body">
${generateAttendeeList(summary.maybe)}
</div>
</div>
<div class="small text-muted">
${new Date(attendee.timestamp).toLocaleString('sv-SE', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div class="col-md-4 mb-3">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0">❌ Kommer inte (${summary.no.length})</h6>
</div>
<div class="card-body">
${generateAttendeeList(summary.no)}
</div>
</div>
</div>
</div>
`).join('');
}
/**
* Get node color based on ID
*/
getNodeColour(nodeId) {
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
}
<div class="text-center">
<small class="text-muted">
Totalt ${summary.yes.length + summary.maybe.length + summary.no.length} svar •
Senast uppdaterad: ${new Date().toLocaleString('sv-SE')}
</small>
</div>
</div>
`;
}
/**
* Generate container ID from event label
*/
function generateContainerId(eventLabel) {
return 'rsvp-tracker-' + eventLabel.toLowerCase().replace(/[^a-z0-9]/g, '-');
}
/**
* Initialize RSVP tracking for a specific event
* Supports both active events (parse messages + JSON) and archived events (JSON only)
*/
async function initRSVPTracker(eventLabel) {
const tracker = new RSVPTracker(eventLabel);
const containerId = generateContainerId(eventLabel);
async function initRSVPTracker(eventId) {
const containerId = 'rsvp-tracker-' + eventId;
const container = document.getElementById(containerId);
if (!container) {
@@ -301,19 +176,30 @@ async function initRSVPTracker(eventLabel) {
// Show loading state
container.innerHTML = '<div class="text-center"><div class="spinner-border" role="status"></div><p>Laddar anmälningar...</p></div>';
// Fetch and display RSVP data
const summary = await tracker.fetchRSVPData();
container.innerHTML = tracker.generateRSVPHTML(summary);
const eventData = await loadEventDataFromJSON(eventId);
// Set up auto-refresh every 60 seconds
setInterval(async () => {
try {
const summary = await tracker.fetchRSVPData();
container.innerHTML = tracker.generateRSVPHTML(summary);
} catch (error) {
console.error('Error refreshing RSVP data:', error);
}
}, 60000);
if (!eventData) {
throw new Error(`Event data file not found: /events/${eventId}.json`);
}
let summary;
if (eventData.archived) {
summary = eventData.attendees;
} else {
await fetchNodes();
const messages = await fetchMessages();
const responses = parseRSVPResponses(messages, eventData.messagePattern);
summary = createRSVPSummary(responses);
// Add manual attendees
Object.entries(eventData.attendees).forEach(([type, attendees]) => {
summary[type].push(...attendees);
});
}
// Display results
container.innerHTML = generateRSVPHTML(summary);
} catch (error) {
container.innerHTML = `<div class="alert alert-danger">Kunde inte ladda anmälningar: ${error.message}</div>`;
@@ -321,6 +207,46 @@ async function initRSVPTracker(eventLabel) {
}
}
/**
* Export current RSVP data as JSON (for manual browser use)
* Call this function in browser console: exportRSVPAsJSON('event-id', 'Message Pattern')
*/
async function exportRSVPAsJSON(eventId, messagePattern) {
try {
console.log('Fetching RSVP data for export...');
await fetchNodes(); // Load nodes cache
const messages = await fetchMessages();
const responses = parseRSVPResponses(messages, messagePattern);
const summary = createRSVPSummary(responses);
const exportData = {
messagePattern,
archived: true,
exportedAt: new Date().toISOString(),
attendees: summary
};
console.log(JSON.stringify(exportData, null, 2));
return exportData;
} catch (error) {
console.error('Error exporting RSVP data:', error);
throw error;
}
}
async function loadEventDataFromJSON(eventId) {
try {
const response = await fetch(`/events/${eventId}.json`);
if (!response.ok) throw new Error(`Event data not found: ${response.status}`);
return await response.json();
} catch (error) {
console.warn(`Could not load event data for ${eventId}:`, error.message);
return null;
}
}
// Export for use in other scripts
window.RSVPTracker = RSVPTracker;
window.initRSVPTracker = initRSVPTracker;
window.exportRSVPAsJSON = exportRSVPAsJSON;
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"><url><loc>https://sthlm-mesh.se/docs/settings/</loc><lastmod>2025-02-26T18:39:36+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/device_role/</loc><lastmod>2025-02-26T18:42:15+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/position/</loc><lastmod>2025-02-22T09:11:26+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/mqtt/</loc><lastmod>2025-03-31T19:16:34+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/neighbor_info/</loc><lastmod>2025-02-23T20:48:06+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/kartor/</loc><lastmod>2025-03-08T20:11:28+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/hardware/</loc><lastmod>2025-04-21T12:00:04+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/solar_nodes/</loc><lastmod>2025-04-21T01:54:22+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/communities/</loc><lastmod>2025-02-23T20:48:06+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/categories/</loc></url><url><loc>https://sthlm-mesh.se/docs/</loc><lastmod>2025-03-03T22:48:04+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/messages/</loc><lastmod>2025-07-13T10:01:31+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/meetups/</loc><lastmod>2025-08-14T20:42:21+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/about/</loc><lastmod>2025-02-23T18:02:23+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/status/</loc><lastmod>2025-08-10T11:53:58+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/</loc><lastmod>2025-06-10T01:31:45+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/tags/</loc></url></urlset>
<?xml version="1.0" encoding="utf-8" standalone="yes"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"><url><loc>https://sthlm-mesh.se/docs/settings/</loc><lastmod>2025-02-26T18:39:36+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/device_role/</loc><lastmod>2025-02-26T18:42:15+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/position/</loc><lastmod>2025-02-22T09:11:26+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/mqtt/</loc><lastmod>2025-03-31T19:16:34+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/neighbor_info/</loc><lastmod>2025-02-23T20:48:06+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/kartor/</loc><lastmod>2025-03-08T20:11:28+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/hardware/</loc><lastmod>2025-04-21T12:00:04+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/solar_nodes/</loc><lastmod>2025-04-21T01:54:22+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/docs/communities/</loc><lastmod>2025-02-23T20:48:06+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/categories/</loc></url><url><loc>https://sthlm-mesh.se/docs/</loc><lastmod>2025-03-03T22:48:04+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/messages/</loc><lastmod>2025-07-13T10:01:31+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/meetups/</loc><lastmod>2025-08-22T20:09:05+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/about/</loc><lastmod>2025-02-23T18:02:23+01:00</lastmod></url><url><loc>https://sthlm-mesh.se/status/</loc><lastmod>2025-08-10T11:53:58+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/</loc><lastmod>2025-06-10T01:31:45+02:00</lastmod></url><url><loc>https://sthlm-mesh.se/tags/</loc></url></urlset>