143 add sse endpoint (#147)

This commit is contained in:
Daniel Graf
2025-07-25 14:10:16 +02:00
committed by GitHub
parent 2bb02d6480
commit a7e15080df
8 changed files with 316 additions and 39 deletions

View File

@@ -47,7 +47,7 @@ public class UserSseEmitterService implements SmartLifecycle {
for (SseEmitter emitter : new CopyOnWriteArraySet<>(emitters)) {
try {
emitter.send(SseEmitter.event().data(eventData));
log.info("Sent event to user: {}", userId);
log.debug("Sent event to user: {}", userId);
} catch (IOException e) {
log.error("Error sending event to user {}: {}", userId, e.getMessage());
emitter.completeWithError(e);
@@ -55,7 +55,7 @@ public class UserSseEmitterService implements SmartLifecycle {
}
}
} else {
System.out.println("No active SSE emitters for user: " + userId);
log.debug("No active SSE emitters for user: {}", userId);
}
}
@@ -81,28 +81,13 @@ public class UserSseEmitterService implements SmartLifecycle {
}
}
public void sendEventToAllUsers(Object eventData) {
userEmitters.forEach((userId, emitters) -> {
for (SseEmitter emitter : new CopyOnWriteArraySet<>(emitters)) {
try {
emitter.send(SseEmitter.event().data(eventData));
} catch (IOException e) {
emitter.completeWithError(e);
removeEmitter(userId, emitter);
}
}
});
}
@Override
public void start() {
}
@Override
public void stop() {
userEmitters.values().forEach(sseEmitters -> sseEmitters.forEach(SseEmitter::complete));
}
@Override

View File

@@ -393,3 +393,5 @@ month.12=December
# SSE Events
sse.error.connection-lost=Connection to server lost! Try reconnecting ...
# Map
map.auto-update.latest-location=Latest location

View File

@@ -327,6 +327,12 @@ login.password=Passwort
login.remember.me=Angemeldet bleiben
login.button=Anmelden
# SSE Events
sse.error.connection-lost=Verbindung zum Server verloren! Versuchen erneut zu verbinden ...
# Map
map.auto-update.latest-location=Neuster Standort
# Additional messages
message.success.geocode.created=Geokodierungsdienst erfolgreich erstellt
message.error.geocode.creation=Fehler beim Erstellen des Geokodierungsdienstes: {0}

View File

@@ -385,3 +385,11 @@ data.remove.all.button=Poista kaikki tiedot
data.remove.all.confirm=Oletko varma, ett\u00E4 haluat poistaa KAIKKI tiedot paitsi merkitt\u00E4v\u00E4t paikat? T\u00E4t\u00E4 toimintoa ei voi peruuttaa.
data.remove.all.success=Kaikki tiedot paitsi merkitt\u00E4v\u00E4t paikat on poistettu onnistuneesti
data.remove.all.error=Virhe poistettaessa tietoja: {0}
# SSE Events
sse.error.connection-lost=Yhteys palvelimeen katkesi! Yrit\u00E4 muodostaa yhteys uudelleen...
# Map
map.auto-update.latest-location=Viimeisin sijainti

View File

@@ -394,3 +394,9 @@ data.remove.all.button=Supprimer toutes les donn\u00E9es
data.remove.all.confirm=\u00CAtes-vous s\u00FBr de vouloir supprimer TOUTES les donn\u00E9es sauf les lieux significatifs ? Cette action ne peut pas \u00EAtre annul\u00E9e.
data.remove.all.success=Toutes les donn\u00E9es sauf les lieux significatifs ont \u00E9t\u00E9 supprim\u00E9es avec succ\u00E8s
data.remove.all.error=Erreur lors de la suppression des donn\u00E9es : {0}
# SSE Events
sse.error.connection-lost=Connexion au serveur perdue ! Reconnexion en cours...
# Map
map.auto-update.latest-location=Derni\u00E8re position

View File

@@ -1071,6 +1071,44 @@ button:disabled {
color: white;
}
.pulsating-marker {
animation: pulse 1s infinite;
z-index: 2000;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.css-icon {
}
.gps_ring {
border: 3px solid #ff0000;
-webkit-border-radius: 30px;
height: 36px;
width: 36px;
-webkit-animation: pulsate 1.5s ease-out;
-webkit-animation-iteration-count: infinite;
}
@-webkit-keyframes pulsate {
0% {-webkit-transform: scale(0.1, 0.1); opacity: 0.0;}
50% {opacity: 1.0;}
100% {-webkit-transform: scale(1.2, 1.2); opacity: 0.0;}
}
.sr-only {
display: none;
}

View File

@@ -130,7 +130,7 @@ class HorizontalDatePicker {
this._isManualSelection = true;
// Force selection of the clicked date
this.selectDate(dateItem, true);
this.selectDateItem(dateItem, true);
// Reset the manual selection flag after a delay
setTimeout(() => {
@@ -232,7 +232,7 @@ class HorizontalDatePicker {
this._isManualSelection = true;
// Force selection of the tapped date
this.selectDate(dateItem, true);
this.selectDateItem(dateItem, true);
// Reset the manual selection flag after a delay
setTimeout(() => {
@@ -424,8 +424,8 @@ class HorizontalDatePicker {
return dateItem;
}
selectDate(dateItem, isManualSelection = false) {
selectDateItem(dateItem, isManualSelection = false) {
// Check if date is within min/max range, but only if they are set
const dateToSelect = this.parseDate(dateItem.dataset.date);
@@ -447,6 +447,14 @@ class HorizontalDatePicker {
if (this.selectedElement === dateItem && !isManualSelection) {
return;
}
// If this is a manual selection and auto-update mode is active, disable it
if (isManualSelection && window.autoUpdateMode) {
console.log('Manual date selection detected, disabling auto-update mode');
if (typeof window.disableAutoUpdate === 'function') {
window.disableAutoUpdate();
}
}
// Clear any existing selection
if (this.selectedElement) {
@@ -498,7 +506,7 @@ class HorizontalDatePicker {
// For manual selections, call the callback immediately
if (isManualSelection) {
if (typeof this.options.onDateSelect === 'function') {
this.options.onDateSelect(dateToSelect, dateItem.dataset.date);
this.options.onDateSelect(dateToSelect, dateItem.dataset.date, true);
}
// Dispatch custom event
@@ -824,6 +832,14 @@ class HorizontalDatePicker {
// Select a month
selectMonth(year, month) {
// If auto-update mode is active, disable it for manual month selection
if (window.autoUpdateMode) {
console.log('Manual month selection detected, disabling auto-update mode');
if (typeof window.disableAutoUpdate === 'function') {
window.disableAutoUpdate();
}
}
// Get the current day from the selected date
const currentDay = this.options.selectedDate.getDate();
@@ -862,7 +878,7 @@ class HorizontalDatePicker {
for (const item of dateItems) {
if (item.dataset.date === formattedExactDate) {
this.selectDate(item, true);
this.selectDateItem(item, true);
break;
}
}
@@ -874,7 +890,7 @@ class HorizontalDatePicker {
// Call onDateSelect callback if provided
const formattedDate = this.formatDate(exactSelectedDate);
if (typeof this.options.onDateSelect === 'function') {
this.options.onDateSelect(exactSelectedDate, formattedDate);
this.options.onDateSelect(exactSelectedDate, formattedDate, false);
}
// Dispatch custom event
@@ -889,6 +905,14 @@ class HorizontalDatePicker {
// Select a year
selectYear(year) {
// If auto-update mode is active, disable it for manual year selection
if (window.autoUpdateMode) {
console.log('Manual year selection detected, disabling auto-update mode');
if (typeof window.disableAutoUpdate === 'function') {
window.disableAutoUpdate();
}
}
// Get the current month and day from the selected date
const currentDate = new Date(this.options.selectedDate);
@@ -930,7 +954,7 @@ class HorizontalDatePicker {
for (const item of dateItems) {
if (item.dataset.date === formattedExactDate) {
this.selectDate(item, true);
this.selectDateItem(item, true);
break;
}
}
@@ -942,7 +966,7 @@ class HorizontalDatePicker {
// Call onDateSelect callback if provided
const formattedDate = this.formatDate(exactSelectedDate);
if (typeof this.options.onDateSelect === 'function') {
this.options.onDateSelect(exactSelectedDate, formattedDate);
this.options.onDateSelect(exactSelectedDate, formattedDate, false);
}
// Dispatch custom event
@@ -1044,7 +1068,8 @@ class HorizontalDatePicker {
if (this.options.showMonthRow) {
this.highlightSelectedMonth();
}
// Find and mark the selected date element
setTimeout(() => {
const dateItems = this.dateContainer.querySelectorAll('.date-item');
@@ -1060,9 +1085,20 @@ class HorizontalDatePicker {
break;
}
}
if (typeof this.options.onDateSelect === 'function') {
this.options.onDateSelect(today, formattedDate, false);
}
// Center the selected date
this.scrollToSelectedDate(false);
const event = new CustomEvent('dateSelected', {
detail: {
date: newDate,
formattedDate: formattedDate
}
});
this.element.dispatchEvent(event);
}, 0);
}
@@ -1084,7 +1120,7 @@ class HorizontalDatePicker {
// Call onDateSelect callback
const formattedDate = this.formatDate(today);
if (typeof this.options.onDateSelect === 'function') {
this.options.onDateSelect(today, formattedDate);
this.options.onDateSelect(today, formattedDate, true);
}
// Dispatch custom event

View File

@@ -31,6 +31,7 @@
<span><img class="logo" th:src="@{/img/logo.png}" alt="reitti logo" title="reitti" src="/img/logo.png"></span>
<a href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
<a href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
<a type="button" class="nav-link" id="auto-update-btn" onclick="toggleAutoUpdate()" title="Auto Update"><i class="lni lni-play"></i></a>
<a type="button" class="nav-link" onclick="toggleFullscreen()" title="Toggle Fullscreen"><i class="lni lni-arrow-all-direction"></i></a>
<form th:action="@{/logout}" method="post" >
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i
@@ -79,11 +80,23 @@
],
sse: {
error: /*[[#{sse.error.connection-lost}]]*/ 'Connection to server lost! Will reconnect ...',
},
autoupdate: {
latestLocation: /*[[#{map.auto-update.latest-location}]]*/ 'Latest location',
}
};
window.userSettings = /*[[${userSettings}]]*/ {}
const messagesDiv = document.getElementById('sse-message'); let autoUpdateMode = false;
let autoUpdateTimer = null;
let eventSource = null;
let reloadTimeoutId = null;
let pendingEvents = [];
// Initialize the map
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
function getSelectedDate() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('date')) {
@@ -129,6 +142,8 @@
zoomSnap: 0.1
};
let pulsatingMarkers = [];
document.addEventListener('DOMContentLoaded', function () {
// Check if date is in URL parameters
const urlParams = new URLSearchParams(window.location.search);
@@ -158,9 +173,6 @@
}
// Initialize the map
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
const tilesUrl = window.userSettings.tiles.service;
const tilesAttribution = window.userSettings.tiles.attribution;
@@ -183,6 +195,8 @@
function loadTimelineData(date) {
// Load photos for the selected date
photoClient.updatePhotosForDate(date, getUserTimezone());
// Remove pulsating markers when loading new data
removePulsatingMarkers();
// Get raw location points URL from timeline container
const timelineContainer = document.querySelectorAll('.user-timeline-section');
for (const path of rawPointPaths) {
@@ -230,6 +244,12 @@
rawPointsPath.setLatLngs(rawPointsCoords);
rawPointsPath.addTo(map);
rawPointPaths.push(rawPointsPath)
// Add pulsating marker for the latest point if in auto-update mode and today is selected
if (autoUpdateMode && isSelectedDateToday() && rawPointsData.points.length > 0) {
const latestPoint = rawPointsData.points[rawPointsData.points.length - 1];
addPulsatingMarker(latestPoint.latitude, latestPoint.longitude, color);
}
}
}
@@ -445,7 +465,7 @@
}
// Initialize horizontal date picker
new HorizontalDatePicker({
window.horizontalDatePicker = new HorizontalDatePicker({
container: document.getElementById('horizontal-date-picker-container'),
selectedDate: dateToUse,
showNavButtons: false, // Show navigation buttons
@@ -456,20 +476,196 @@
allowFutureDates: false, // Disable selection of future dates
showTodayButton: true, // Show the Today button
// No min/max date for infinite scrolling
onDateSelect: (date, formattedDate) => {
onDateSelect: (date, formattedDate, isManualSelected) => {
// Update URL
updateUrlWithDate(formattedDate);
// Trigger HTMX reload of timeline
document.body.dispatchEvent(new CustomEvent('dateChanged'));
},
onDateDeselect: () => {
// Clear photos when no date is selected
photoClient.clearPhotos();
if (isManualSelected) {
disableAutoUpdate();
}
}
});
});
// Fullscreen functionality
function toggleAutoUpdate() {
const btn = document.getElementById('auto-update-btn');
const icon = btn.querySelector('i');
if (!autoUpdateMode) {
// Enable auto-update mode
autoUpdateMode = true;
icon.className = 'lni lni-pause';
btn.title = 'Pause Auto Update';
// Update the date picker to today
if (window.horizontalDatePicker) {
window.horizontalDatePicker.setDate(new Date());
}
// Start the timer to check for date changes every 30 seconds
startAutoUpdateTimer();
eventSource = new EventSource('/events'); // Connect to your SSE endpoint
eventSource.onopen = function() {
console.log('SSE connection opened.');
messagesDiv.classList.remove('active')
};
// Listen for events with the name "message"
eventSource.addEventListener('message', function(event) {
console.log('Received message event:', event.data);
});
eventSource.onerror = function(error) {
console.error('EventSource failed:', error);
messagesDiv.innerHTML = `<p><strong>${window.locale.sse.error}</strong></p>`;
messagesDiv.classList.add('active')
};
eventSource.onmessage = function(event) {
console.log('Received generic event:', event.data);
// Parse the event data
try {
const eventData = JSON.parse(event.data);
// Check if the event has a date field and it matches today
if (eventData.date) {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const eventDate = eventData.date;
// If event date matches today and we're in auto-update mode, schedule reload
if (eventDate === today && autoUpdateMode) {
console.log('Auto-update: Scheduling timeline reload due to SSE event for today');
scheduleTimelineReload(eventData);
}
}
} catch (error) {
console.warn('Could not parse SSE event data:', error);
}
};
} else {
// Disable auto-update mode
disableAutoUpdate();
}
}
function disableAutoUpdate() {
autoUpdateMode = false;
// Clear the timer
if (autoUpdateTimer) {
clearInterval(autoUpdateTimer);
autoUpdateTimer = null;
}
// Clear any pending reload timeout
if (reloadTimeoutId) {
clearTimeout(reloadTimeoutId);
reloadTimeoutId = null;
}
// Clear pending events
pendingEvents = [];
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Remove pulsating markers when auto-update is disabled
removePulsatingMarkers();
const btn = document.getElementById('auto-update-btn');
const icon = btn.querySelector('i');
icon.className = 'lni lni-play';
btn.title = 'Auto Update';
}
function startAutoUpdateTimer() {
// Check every 30 seconds if we need to switch to today's date
autoUpdateTimer = setInterval(() => {
if (!autoUpdateMode) {
return; // Exit if auto-update mode was disabled
}
const today = new Date();
const todayString = today.toISOString().split('T')[0]; // YYYY-MM-DD format
const currentSelectedDate = getSelectedDate();
// If the selected date is not today, switch to today
if (currentSelectedDate !== todayString) {
console.log('Auto-update: Switching to today\'s date');
if (window.horizontalDatePicker) {
window.horizontalDatePicker.setDate(today);
}
}
}, 30000); // 30 seconds
}
function isSelectedDateToday() {
const today = new Date().toISOString().split('T')[0];
const selectedDate = getSelectedDate();
return selectedDate === today;
}
function addPulsatingMarker(lat, lng, color) {
// Create new pulsating marker
const cssIcon = L.divIcon({
// Specify a class name we can refer to in CSS.
className: 'css-icon',
html: `<div style="border-color: ${color || '#606060'}" class="gps_ring"></div>`
,iconSize: [22,22]
});
const pulsatingMarker = L.marker([lat, lng], {icon: cssIcon}).addTo(map);
// Add tooltip
pulsatingMarker.bindTooltip(window.locale.autoupdate.latestLocation, {
permanent: false,
direction: 'top'
});
// Store the marker for cleanup later
pulsatingMarkers.push(pulsatingMarker);
}
function removePulsatingMarkers() {
pulsatingMarkers.forEach(marker => {
if (marker) {
map.removeLayer(marker);
}
});
pulsatingMarkers = [];
}
function scheduleTimelineReload(eventData) {
// Add event to pending events
pendingEvents.push(eventData);
// Clear existing timeout if any
if (reloadTimeoutId) {
clearTimeout(reloadTimeoutId);
}
// Schedule reload after 5 seconds of idle time
reloadTimeoutId = setTimeout(() => {
debugger
if (autoUpdateMode && pendingEvents.length > 0) {
console.log(`Auto-update: Reloading timeline data after ${pendingEvents.length} accumulated events`);
document.body.dispatchEvent(new CustomEvent('dateChanged'));
// Clear pending events
pendingEvents = [];
}
reloadTimeoutId = null;
}, 5000); // 5 seconds
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {