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]"

- -
-
-
-
-
✅ Kommer (${summary.kommer.length})
-
-
- ${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 ` +
+
+
+
+
+
✅ Kommer (${summary.yes.length})
-
- -
-
-
-
❓ Kanske (${summary.kanske.length})
-
-
- ${this.generateAttendeeList(summary.kanske)} -
-
-
- -
-
-
-
❌ Kommer inte (${summary['kommer inte'].length})
-
-
- ${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} - +
+
+
+
❓ Kanske (${summary.maybe.length})
+
+
+ ${generateAttendeeList(summary.maybe)} +
-
- ${new Date(attendee.timestamp).toLocaleString('sv-SE', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })} +
+ +
+
+
+
❌ Kommer inte (${summary.no.length})
+
+
+ ${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 = '

Laddar anmälningar...

'; - // 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-08
  • Tid: 17:00 - 22:00
  • Plats: The Bishops Arms, Sundbyberg
  • Event: Facebook

  • Datum: 2024-09-04
  • Tid: 17:00 - 22:00
  • Plats: The Bishops Arms, Sundbyberg
  • Event: Facebook

  • Datum: 2024-05-05
  • Tid: 17:00
  • Plats: Takpark by Urban Deli, Sveavägen 44