dailyForecast;
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java b/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java
new file mode 100644
index 0000000..52b6696
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java
@@ -0,0 +1,23 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.model.enums.UnitSystem;
+
+import java.util.UUID;
+
+/**
+ * One client's live-dashboard subscription: which user, in which unit
+ * system, viewing which sorted/paginated slice of their saved locations.
+ * Mirrors the parameters {@code HomeController#home} takes from the query
+ * string, so the scheduler can recompute exactly the same page.
+ *
+ * @param sessionId the STOMP session that registered this subscription
+ * @param userId owner of the dashboard
+ * @param username owner's username (email) - the STOMP user-destination principal name
+ * @param units owner's preferred display units, applied to pushed weather
+ * @param sort dashboard sort key ({@code date}, {@code nameAsc}, {@code nameDesc}, {@code favoriteFirst}, {@code favoritesOnly})
+ * @param page 0-based dashboard page number
+ */
+public record DashboardSubscription(String sessionId, UUID userId,
+ String username, UnitSystem units,
+ String sort, int page) {
+}
diff --git a/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java b/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java
new file mode 100644
index 0000000..1d1ff90
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java
@@ -0,0 +1,23 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.model.enums.UnitSystem;
+
+import java.util.UUID;
+
+/**
+ * One client's live-forecast subscription: which user, in which unit
+ * system, watching the hourly/daily forecast for a single coordinate pair.
+ * Mirrors the parameters {@code ForecastController#getForecast} takes from
+ * the query string.
+ *
+ * @param sessionId the STOMP session that registered this subscription
+ * @param userId owner of the forecast page (used to re-verify the location is still theirs)
+ * @param username owner's username (email) - the STOMP user-destination principal name
+ * @param units owner's preferred display units, applied to pushed weather
+ * @param latitude location latitude
+ * @param longitude location longitude
+ */
+public record ForecastSubscription(String sessionId, UUID userId,
+ String username, UnitSystem units,
+ double latitude, double longitude) {
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java b/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java
new file mode 100644
index 0000000..493fa7b
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java
@@ -0,0 +1,150 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.dto.LocationDto;
+import com.weatherviewer.dto.WeatherDto;
+import com.weatherviewer.dto.ws.DashboardLocationWeather;
+import com.weatherviewer.dto.ws.DashboardUpdateMessage;
+import com.weatherviewer.dto.ws.ForecastUpdateMessage;
+import com.weatherviewer.exception.notfound.LocationNotFoundException;
+import com.weatherviewer.service.LocationService;
+import com.weatherviewer.service.WeatherApiService;
+import com.weatherviewer.service.helper.UnitConverter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Drives every connected client's live weather feed.
+ *
+ * On a fixed schedule, this re-derives exactly what each active
+ * {@link DashboardSubscription}/{@link ForecastSubscription} should
+ * currently be showing (same lookups {@code HomeController} and
+ * {@code ForecastController} do for a full page load) and pushes it to
+ * that user's private STOMP queue. Weather fetches for the individual
+ * subscriptions are composed as a single {@link Flux} with bounded
+ * concurrency, so one broadcast tick can't fan out an unbounded burst of
+ * calls into {@link WeatherApiService} (which mostly resolves from the
+ * Redis-backed cache anyway, but a cache stampede past the TTL boundary is
+ * still worth capping). Each subscription's fetch/push is isolated with
+ * {@code onErrorResume}, so one user's failing/slow location can't stall or
+ * cancel the whole tick.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherLiveUpdateScheduler {
+
+ private final WeatherSubscriptionRegistry registry;
+ private final LocationService locationService;
+ private final WeatherApiService weatherApiService;
+ private final UnitConverter unitConverter;
+ private final SimpMessagingTemplate messagingTemplate;
+ private final ExecutorService weatherFetchExecutor;
+
+ @Value("${location.dashboard.page-size:12}")
+ private int dashboardPageSize;
+
+ @Value("${weather.live.max-concurrent-fetches:16}")
+ private int maxConcurrentFetches;
+
+ /**
+ * Runs one broadcast tick. {@code fixedDelayString} (not fixed-rate) so
+ * that if a tick ever runs long - a slow OpenWeatherMap response, a
+ * burst of subscribers - the next tick is scheduled relative to when
+ * this one actually finished, instead of piling up overlapping ticks.
+ */
+ @Scheduled(fixedDelayString = "${weather.live.push-interval-ms:60000}")
+ public void broadcast() {
+ Scheduler fetchScheduler = Schedulers.fromExecutor(weatherFetchExecutor);
+
+ Flux dashboardTicks = Flux.fromIterable(registry.dashboardSubscriptions())
+ .flatMap(subscription -> pushDashboardUpdate(subscription, fetchScheduler), maxConcurrentFetches);
+
+ Flux forecastTicks = Flux.fromIterable(registry.forecastSubscriptions())
+ .flatMap(subscription -> pushForecastUpdate(subscription, fetchScheduler), maxConcurrentFetches);
+
+ Flux.merge(dashboardTicks, forecastTicks).then().block();
+ }
+
+ private Mono pushDashboardUpdate(DashboardSubscription subscription, Scheduler fetchScheduler) {
+ return Mono.fromRunnable(() -> doPushDashboardUpdate(subscription))
+ .subscribeOn(fetchScheduler)
+ .then()
+ .onErrorResume(ex -> {
+ log.warn("Live dashboard push failed for user={}: {}", subscription.username(), ex.getMessage());
+ return Mono.empty();
+ });
+ }
+
+ private void doPushDashboardUpdate(DashboardSubscription subscription) {
+ Page locationPage = locationService.getByUserIdSorted(
+ subscription.userId(), subscription.sort(), PageRequest.of(subscription.page(), dashboardPageSize));
+
+ List updates = new ArrayList<>();
+ List unavailable = new ArrayList<>();
+
+ for (LocationDto location : locationPage.getContent()) {
+ try {
+ WeatherDto weather = unitConverter.toDisplayUnits(
+ weatherApiService.getWeatherByLocation(location), subscription.units());
+ updates.add(new DashboardLocationWeather()
+ .setLocationId(location.getId())
+ .setLocationName(location.getName())
+ .setWeather(weather));
+ } catch (RuntimeException ex) {
+ log.debug("Live weather fetch failed for location '{}' (user={}): {}",
+ location.getName(), subscription.username(), ex.getMessage());
+ unavailable.add(location.getName());
+ }
+ }
+
+ if (updates.isEmpty() && unavailable.isEmpty()) {
+ return;
+ }
+
+ messagingTemplate.convertAndSendToUser(subscription.username(), "/queue/dashboard",
+ new DashboardUpdateMessage().setLocations(updates).setUnavailableLocationNames(unavailable));
+ }
+
+ private Mono pushForecastUpdate(ForecastSubscription subscription, Scheduler fetchScheduler) {
+ return Mono.fromRunnable(() -> doPushForecastUpdate(subscription))
+ .subscribeOn(fetchScheduler)
+ .then()
+ .onErrorResume(ex -> {
+ log.warn("Live forecast push failed for user={}: {}", subscription.username(), ex.getMessage());
+ return Mono.empty();
+ });
+ }
+
+ private void doPushForecastUpdate(ForecastSubscription subscription) {
+ try {
+ locationService.getByCoordinatesAndUserId(subscription.latitude(), subscription.longitude(), subscription.userId());
+ } catch (LocationNotFoundException ex) {
+ log.debug("Live forecast subscription for user={} no longer owns lat={}, lon={}; skipping tick",
+ subscription.username(), subscription.latitude(), subscription.longitude());
+ return;
+ }
+
+ List hourly = unitConverter.toDisplayUnits(
+ weatherApiService.getHourlyForecastByCoordinates(subscription.latitude(), subscription.longitude()), subscription.units());
+ List daily = unitConverter.toDisplayUnits(
+ weatherApiService.getDailyForecastByCoordinates(subscription.latitude(), subscription.longitude()), subscription.units());
+
+ messagingTemplate.convertAndSendToUser(subscription.username(), "/queue/forecast",
+ new ForecastUpdateMessage().setHourlyForecast(hourly).setDailyForecast(daily));
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java b/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java
new file mode 100644
index 0000000..a0aa8f7
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java
@@ -0,0 +1,73 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.dto.ws.DashboardSubscribeRequest;
+import com.weatherviewer.dto.ws.ForecastSubscribeRequest;
+import com.weatherviewer.security.SecUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.messaging.handler.annotation.MessageMapping;
+import org.springframework.messaging.handler.annotation.Payload;
+import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Controller;
+
+import java.security.Principal;
+import java.util.Set;
+
+/**
+ * STOMP message handlers for {@code /app/**} destinations. These don't
+ * return a value (no {@code @SendTo}) - registering a subscription here
+ * just updates {@link WeatherSubscriptionRegistry}; the actual weather
+ * pushes are sent later, out-of-band, by {@link WeatherLiveUpdateScheduler}.
+ */
+@Controller
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherSocketController {
+
+ private static final Set VALID_SORTS = Set.of("date", "nameAsc", "nameDesc", "favoriteFirst", "favoritesOnly");
+
+ private final WeatherSubscriptionRegistry registry;
+
+ /** Registers (or replaces) this session's live dashboard subscription for the given sort/page. */
+ @MessageMapping("/dashboard.subscribe")
+ public void subscribeDashboard(@Payload DashboardSubscribeRequest request, Principal principal,
+ SimpMessageHeaderAccessor headerAccessor) {
+ SecUser user = extractUser(principal);
+ String sessionId = headerAccessor.getSessionId();
+ if (user == null || sessionId == null) {
+ log.warn("Ignoring dashboard.subscribe with no authenticated principal or session id");
+ return;
+ }
+
+ String sort = request.getSort() != null && VALID_SORTS.contains(request.getSort()) ? request.getSort() : "date";
+ int page = request.getPage() != null && request.getPage() > 0 ? request.getPage() : 0;
+
+ registry.registerDashboard(new DashboardSubscription(sessionId, user.getId(), user.getUsername(), user.getUnits(), sort, page));
+ log.debug("Live dashboard subscription registered: user={}, sort={}, page={}", user.getUsername(), sort, page);
+ }
+
+ /** Registers (or replaces) this session's live forecast subscription for the given coordinates. */
+ @MessageMapping("/forecast.subscribe")
+ public void subscribeForecast(@Payload ForecastSubscribeRequest request, Principal principal,
+ SimpMessageHeaderAccessor headerAccessor) {
+ SecUser user = extractUser(principal);
+ String sessionId = headerAccessor.getSessionId();
+ if (user == null || sessionId == null || request.getLat() == null || request.getLon() == null) {
+ log.warn("Ignoring forecast.subscribe with missing principal, session id, or coordinates");
+ return;
+ }
+
+ registry.registerForecast(new ForecastSubscription(sessionId, user.getId(), user.getUsername(), user.getUnits(),
+ request.getLat(), request.getLon()));
+ log.debug("Live forecast subscription registered: user={}, lat={}, lon={}", user.getUsername(), request.getLat(), request.getLon());
+ }
+
+ private SecUser extractUser(Principal principal) {
+ if (principal instanceof Authentication authentication && authentication.getPrincipal() instanceof SecUser secUser) {
+ return secUser;
+ }
+ return null;
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java b/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java
new file mode 100644
index 0000000..f9dc958
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java
@@ -0,0 +1,28 @@
+package com.weatherviewer.websocket;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.messaging.SessionDisconnectEvent;
+
+/**
+ * Removes a session's live-update subscription (dashboard or forecast) as
+ * soon as its WebSocket connection closes - tab closed, page navigated
+ * away, network drop, etc. - so {@link WeatherLiveUpdateScheduler} never
+ * wastes a fetch/push on a client that's no longer listening.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherSocketEventListener {
+
+ private final WeatherSubscriptionRegistry registry;
+
+ @EventListener
+ public void onSessionDisconnect(SessionDisconnectEvent event) {
+ registry.remove(event.getSessionId());
+ log.debug("WebSocket session {} disconnected; live-update subscription removed", event.getSessionId());
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java b/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java
new file mode 100644
index 0000000..b85c321
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java
@@ -0,0 +1,57 @@
+package com.weatherviewer.websocket;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Tracks which connected WebSocket sessions want live weather pushes, and
+ * for what (a paginated/sorted dashboard view, or a single forecast-page
+ * location).
+ *
+ * Keyed by STOMP session ID rather than user ID: a user could have the
+ * dashboard open in one tab and a forecast page in another, each getting
+ * its own independent live feed. A session holds at most one subscription
+ * of each kind at a time - registering a new one for a session replaces
+ * whatever that session was previously subscribed to.
+ *
+ * Entries are removed by {@link WeatherSocketEventListener} when the
+ * underlying WebSocket session disconnects, so this never accumulates
+ * subscriptions for clients that have navigated away or closed the tab.
+ */
+@Component
+public class WeatherSubscriptionRegistry {
+
+ private final Map dashboardSubscriptions = new ConcurrentHashMap<>();
+ private final Map forecastSubscriptions = new ConcurrentHashMap<>();
+
+ public void registerDashboard(DashboardSubscription subscription) {
+ forecastSubscriptions.remove(subscription.sessionId());
+ dashboardSubscriptions.put(subscription.sessionId(), subscription);
+ }
+
+ public void registerForecast(ForecastSubscription subscription) {
+ dashboardSubscriptions.remove(subscription.sessionId());
+ forecastSubscriptions.put(subscription.sessionId(), subscription);
+ }
+
+ /** Removes any subscription (dashboard or forecast) held by this session, e.g. on disconnect. */
+ public void remove(String sessionId) {
+ if (sessionId == null) {
+ return;
+ }
+ dashboardSubscriptions.remove(sessionId);
+ forecastSubscriptions.remove(sessionId);
+ }
+
+ public Collection dashboardSubscriptions() {
+ return dashboardSubscriptions.values();
+ }
+
+ public Collection forecastSubscriptions() {
+ return forecastSubscriptions.values();
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 88b1f3f..e09d24b 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -61,6 +61,10 @@ scalar.path=/scalar
location.max-per-user=${LOCATION_MAX_PER_USER:100}
location.dashboard.page-size=${LOCATION_DASHBOARD_PAGE_SIZE:4}
+# --- Live weather updates (WebSocket/STOMP) ---
+weather.live.push-interval-ms=${WEATHER_LIVE_PUSH_INTERVAL_MS:60000}
+weather.live.max-concurrent-fetches=${WEATHER_LIVE_MAX_CONCURRENT_FETCHES:16}
+
# --- Weather API (OpenWeatherMap) ---
weather.dashboard.fetch-pool-size=${WEATHER_DASHBOARD_FETCH_POOL_SIZE:20}
weather.dashboard.fetch-timeout-ms=${WEATHER_DASHBOARD_FETCH_TIMEOUT_MS:4000}
diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css
index 7f3bdec..0bcc569 100644
--- a/src/main/resources/static/css/theme.css
+++ b/src/main/resources/static/css/theme.css
@@ -97,16 +97,15 @@ body {
color: var(--bs-link-color) !important;
}
-/* The base OSM tile layer is a plain raster image with no dark variant of
- its own, so it stays light regardless of data-bs-theme by default. This
- inverts it into a legible dark basemap in dark mode using CSS alone
- (reacts live when the theme toggles, no JS re-fetch needed). Scoped to
- .base-map-tiles (set via the tileLayer's className option in
- map-init.js) so it only touches the base layer -- NOT the weather
- overlay tiles (precipitation/clouds/temperature/wind), which would
- otherwise have their data-carrying colors inverted into nonsense, and
- NOT the location markers, which live in a separate Leaflet pane and stay
- their normal color in both themes for legibility. */
[data-bs-theme="dark"] .base-map-tiles {
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
+}
+
+.weather-card, .card {
+ transition: box-shadow 0.4s ease-out, border-color 0.4s ease-out;
+}
+
+.live-updated {
+ box-shadow: 0 0 0 2px var(--bs-primary) !important;
+ border-color: var(--bs-primary) !important;
}
\ No newline at end of file
diff --git a/src/main/resources/static/js/life-forecast.js b/src/main/resources/static/js/life-forecast.js
new file mode 100644
index 0000000..42d33a0
--- /dev/null
+++ b/src/main/resources/static/js/life-forecast.js
@@ -0,0 +1,92 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const root = document.getElementById('forecastLiveRoot');
+ if (!root || typeof StompJs === 'undefined') {
+ return;
+ }
+
+ const lat = parseFloat(root.dataset.lat);
+ const lon = parseFloat(root.dataset.lon);
+ const tempSymbol = root.dataset.tempSymbol;
+ const locale = root.dataset.locale || undefined;
+
+ /** enum name (e.g. "RAIN") -> localized weather-condition.* label, from ForecastController. */
+ const conditionLabels = JSON.parse(document.getElementById('forecast-condition-labels').textContent || '{}');
+
+ const dayDateFormatter = new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'long' });
+ const dayNameFormatter = new Intl.DateTimeFormat(locale, { weekday: 'long' });
+ const hourFormatter = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12: false });
+
+ function set(card, field, text) {
+ const el = card.querySelector(`[data-field="${field}"]`);
+ if (el) {
+ el.textContent = text;
+ }
+ }
+
+ function setIcon(card, weather) {
+ const icon = card.querySelector('[data-field="icon"]');
+ if (icon) {
+ icon.src = `/images/${weather.weatherCondition}-${weather.timeOfDay}.svg`;
+ icon.alt = `${weather.weatherCondition} ${weather.timeOfDay}`;
+ }
+ }
+
+ function setDescription(card, weather) {
+ const el = card.querySelector('[data-field="description"]');
+ if (el) {
+ el.textContent = conditionLabels[weather.weatherCondition] || weather.weatherCondition;
+ el.dataset.condition = weather.weatherCondition;
+ }
+ }
+
+ function flash(card) {
+ card.classList.add('live-updated');
+ setTimeout(() => card.classList.remove('live-updated'), 1500);
+ }
+
+ function patchHourlyCard(card, weather) {
+ set(card, 'time', hourFormatter.format(new Date(weather.date)));
+ set(card, 'temperature', `${weather.temperature}${tempSymbol}`);
+ setIcon(card, weather);
+ setDescription(card, weather);
+ flash(card);
+ }
+
+ function patchDailyCard(card, weather) {
+ const date = new Date(weather.date);
+ set(card, 'dayDate', dayDateFormatter.format(date));
+ set(card, 'dayName', dayNameFormatter.format(date));
+ set(card, 'temperature', `${weather.temperature}${tempSymbol}`);
+ set(card, 'temperatureMinimum', `${weather.temperatureMinimum}${tempSymbol}`);
+ setIcon(card, weather);
+ setDescription(card, weather);
+ flash(card);
+ }
+
+ function patchForecast(update) {
+ (update.hourlyForecast || []).slice(0, 5).forEach((weather, index) => {
+ const card = root.querySelector(`[data-hourly-index="${index}"]`);
+ if (card) {
+ patchHourlyCard(card, weather);
+ }
+ });
+
+ (update.dailyForecast || []).forEach((weather, index) => {
+ const card = root.querySelector(`[data-daily-index="${index}"]`);
+ if (card) {
+ patchDailyCard(card, weather);
+ }
+ });
+ }
+
+ LiveWeather.connect(client => {
+ client.subscribe('/user/queue/forecast', message => {
+ patchForecast(JSON.parse(message.body));
+ });
+
+ client.publish({
+ destination: '/app/forecast.subscribe',
+ body: JSON.stringify({ lat, lon }),
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/main/resources/static/js/live-dashboard.js b/src/main/resources/static/js/live-dashboard.js
new file mode 100644
index 0000000..66f4cbb
--- /dev/null
+++ b/src/main/resources/static/js/live-dashboard.js
@@ -0,0 +1,76 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const root = document.getElementById('dashboardLiveRoot');
+ if (!root || typeof StompJs === 'undefined') {
+ return;
+ }
+
+ const sort = root.dataset.sort;
+ const page = parseInt(root.dataset.page, 10) || 0;
+ const tempSymbol = root.dataset.tempSymbol;
+ const windUnit = root.dataset.windUnit;
+ const i18n = {
+ feelsLike: root.dataset.i18nFeelsLike,
+ min: root.dataset.i18nMin,
+ max: root.dataset.i18nMax,
+ clouds: root.dataset.i18nClouds,
+ wind: root.dataset.i18nWind,
+ humidity: root.dataset.i18nHumidity,
+ pressure: root.dataset.i18nPressure,
+ };
+
+ /** Minimal {0}/{1}/{2}-style substitution, matching the java.text.MessageFormat patterns from messages.properties. */
+ function formatMessage(pattern, ...args) {
+ return pattern.replace(/\{(\d+)}/g, (_, index) => args[index]);
+ }
+
+ function formatTime(epochMillis) {
+ const date = new Date(epochMillis);
+ const pad = n => String(n).padStart(2, '0');
+ return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
+ }
+
+ function patchCard(card, weather) {
+ const set = (field, text) => {
+ const el = card.querySelector(`[data-field="${field}"]`);
+ if (el) {
+ el.textContent = text;
+ }
+ };
+
+ set('time', formatTime(weather.date));
+ set('temperature', `${weather.temperature}${tempSymbol}`);
+ set('feelsLike', formatMessage(i18n.feelsLike, weather.temperatureFeelsLike, tempSymbol));
+ set('description', weather.description.charAt(0).toUpperCase() + weather.description.slice(1));
+ set('min', formatMessage(i18n.min, weather.temperatureMinimum, tempSymbol));
+ set('clouds', formatMessage(i18n.clouds, weather.cloudiness));
+ set('wind', formatMessage(i18n.wind, weather.windDirection, weather.windSpeed, windUnit));
+ set('max', formatMessage(i18n.max, weather.temperatureMaximum, tempSymbol));
+ set('humidity', formatMessage(i18n.humidity, weather.humidity));
+ set('pressure', formatMessage(i18n.pressure, weather.pressure));
+
+ const icon = card.querySelector('[data-field="icon"]');
+ if (icon) {
+ icon.src = `/images/${weather.weatherCondition}-${weather.timeOfDay}.svg`;
+ }
+
+ card.classList.add('live-updated');
+ setTimeout(() => card.classList.remove('live-updated'), 1500);
+ }
+
+ LiveWeather.connect(client => {
+ client.subscribe('/user/queue/dashboard', message => {
+ const update = JSON.parse(message.body);
+ (update.locations || []).forEach(location => {
+ const card = root.querySelector(`[data-location-id="${location.locationId}"]`);
+ if (card && location.weather) {
+ patchCard(card, location.weather);
+ }
+ });
+ });
+
+ client.publish({
+ destination: '/app/dashboard.subscribe',
+ body: JSON.stringify({ sort, page }),
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/main/resources/static/js/live-weather.js b/src/main/resources/static/js/live-weather.js
new file mode 100644
index 0000000..dcd394d
--- /dev/null
+++ b/src/main/resources/static/js/live-weather.js
@@ -0,0 +1,35 @@
+/**
+ * Thin wrapper around StompJs.Client for this app's live-weather feeds.
+ *
+ * One WebSocket connection per page (native ws/wss, no SockJS - see
+ * WebSocketConfig for why). Reconnects automatically with backoff and
+ * re-runs onConnect (which callers use to re-send their subscribe frame)
+ * every time, since the server-side subscription registry is in-memory and
+ * doesn't survive a dropped connection.
+ */
+const LiveWeather = (() => {
+
+ function connect(onConnectCallback) {
+ const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const brokerUrl = `${protocol}://${window.location.host}/ws`;
+
+ const client = new StompJs.Client({
+ brokerURL: brokerUrl,
+ reconnectDelay: 5000,
+ heartbeatIncoming: 10000,
+ heartbeatOutgoing: 10000,
+ });
+
+ client.onConnect = () => onConnectCallback(client);
+
+ client.onStompError = frame => {
+ console.warn('Live weather STOMP error:', frame.headers && frame.headers.message);
+ };
+
+ client.activate();
+ window.addEventListener('beforeunload', () => client.deactivate());
+ return client;
+ }
+
+ return { connect };
+})();
\ No newline at end of file
diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html
index b03839f..ffbe419 100644
--- a/src/main/resources/templates/forecast.html
+++ b/src/main/resources/templates/forecast.html
@@ -10,19 +10,23 @@
-
+
+
Hourly Forecast
-
-
+
+
-
-
-
![]()
-
+
+
+
![]()
+
@@ -32,18 +36,19 @@
Daily Forecast
-
-
+
+
-
-
+
+
-
-
-
![]()
-
+
+
+
![]()
+
@@ -57,7 +62,10 @@
+
+
+