diff --git a/events/2025-08-19-aw-telefonplan.json b/events/2025-08-19-aw-telefonplan.json
new file mode 100644
index 0000000..c182047
--- /dev/null
+++ b/events/2025-08-19-aw-telefonplan.json
@@ -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"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/js/rsvp-tracker.js b/js/rsvp-tracker.js
index a9f63d6..c36ba28 100644
--- a/js/rsvp-tracker.js
+++ b/js/rsvp-tracker.js
@@ -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 = `
-
-
📋 Anmälningsstatus
-
Skicka meddelande över LoRa-meshen: "${this.eventLabel} - [Kommer/Kanske/Kommer inte] "
-
-
-
-
-
-
- ${this.generateAttendeeList(summary.kommer)}
-
+ // 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 '
Inga svar än
';
+ }
+
+ return attendees.map(attendee => `
+
+
+ ${attendee.shortName.substring(0, 4)}
+
+
+
+
+ ${new Date(attendee.timestamp).toLocaleString('sv-SE', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+
+
+
+ `).join('');
+}
+
+function generateRSVPHTML(summary) {
+ return `
+
+
+
+
+
-
-
-
-
-
-
- ${this.generateAttendeeList(summary.kanske)}
-
-
-
-
-
-
-
-
- ${this.generateAttendeeList(summary['kommer inte'])}
-
+
+ ${generateAttendeeList(summary.yes)}
-
-
- Totalt ${summary.total} svar •
- Senast uppdaterad: ${new Date().toLocaleString('sv-SE')}
-
-
-
- `;
-
- return html;
- }
-
- /**
- * Generate attendee list HTML
- */
- generateAttendeeList(attendees) {
- if (attendees.length === 0) {
- return '
Inga svar än
';
- }
-
- return attendees.map(attendee => `
-
-
- ${attendee.shortName.substring(0, 4)}
-
-
-
-
- ${attendee.longName}
-
+
+
+
+
+ ${generateAttendeeList(summary.maybe)}
+
-
- ${new Date(attendee.timestamp).toLocaleString('sv-SE', {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- })}
+
+
+
+
+
+
+ ${generateAttendeeList(summary.no)}
+
- `).join('');
- }
-
- /**
- * Get node color based on ID
- */
- getNodeColour(nodeId) {
- return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
- }
+
+
+
+ Totalt ${summary.yes.length + summary.maybe.length + summary.no.length} svar •
+ Senast uppdaterad: ${new Date().toLocaleString('sv-SE')}
+
+
+
+ `;
}
-/**
- * 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 = '
';
- // 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 = `
Kunde inte ladda anmälningar: ${error.message}
`;
@@ -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;
\ No newline at end of file
diff --git a/meetups/index.html b/meetups/index.html
index c0a2d9a..2a58583 100644
--- a/meetups/index.html
+++ b/meetups/index.html
@@ -5,12 +5,12 @@
⏰ Tid: 17:00 (baren öppnar 15:00) Nu är det dags för en Meshtastic AW i Stockholm igen! Denna gång hoppas vi på bra väder och träffas på baren i Svandammsparken. Kom och träffa likasinnade, snacka LoRa och bygg ut nätverket i Stockholm!">
Kommande Meetups 🍻 Meshtastic AW i Stockholm! 🍻 Träffa likasinnade, snacka LoRa och bygg ut nätverket i Stockholm!
📍 Plats: Midsommarköket, Svandammsparken (T) Midsommarkransen 📅 Datum: Tisdag 19 augusti ⏰ Tid: 17:00 (baren öppnar 15:00)
Nu är det dags för en Meshtastic AW i Stockholm igen! Denna gång hoppas vi på bra väder och träffas på baren i Svandammsparken. Kom och träffa likasinnade, snacka LoRa och bygg ut nätverket i Stockholm!
Ta gärna med din nod, eller visa upp det senaste bygget.
Om du inte kan komma exakt 17:00 är det helt okej att dyka upp senare. Skriv gärna ett meddelande på meshen eller Discord om du kommer!
Tidigare meetups:
Datum: 2025-04-08Tid: 17:00 - 22:00Plats: The Bishops Arms, SundbybergEvent: Facebook
Datum: 2024-09-04Tid: 17:00 - 22:00Plats: The Bishops Arms, SundbybergEvent: Facebook
Datum: 2024-05-05Tid: 17:00Plats: Takpark by Urban Deli, Sveavägen 44