Merge pull request #367 from Freika/feature/websockets

Feature/websockets
This commit is contained in:
Evgenii Burmakin 2024-11-07 19:18:27 +01:00 committed by GitHub
commit 202be783c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 546 additions and 46 deletions

View file

@ -1 +1 @@
0.15.13
0.16.0

View file

@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.16.0 - 2024-11-03
## The Websockets release
### Added
- New notifications are now being indicated with a blue-ish dot in the top right corner of the screen. Hovering over the bell icon will show you last 10 notifications.
- New points on the map will now be shown in real-time. No need to reload the map to see new points.
- User can now enable or disable Live Mode in the map controls. When Live Mode is enabled, the map will automatically scroll to the new points as they are being added to the map.
### Changed
- Scale on the map now shows the distance both in kilometers and miles.
# 0.15.13 - 2024-11-01
### Added

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,21 @@
# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if (verified_user = env['warden'].user)
verified_user
else
reject_unauthorized_connection
end
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ImportsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class PointsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:preferred_map_layer, :points_rendering_mode
:preferred_map_layer, :points_rendering_mode, :live_map_enabled
)
end
end

View file

@ -8,3 +8,4 @@ import "leaflet"
import "leaflet-providers"
import "chartkick"
import "Chart.bundle"
import "./channels"

View file

@ -0,0 +1,6 @@
// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
import { createConsumer } from "@rails/actioncable"
export default createConsumer()

View file

@ -0,0 +1,15 @@
import consumer from "./consumer"
consumer.subscriptions.create("ImportsChannel", {
connected() {
// console.log("Connected to the imports channel!");
},
disconnected() {
// Called when the subscription has been terminated by the server
},
received(data) {
// Called when there's incoming data on the websocket for this channel
}
});

View file

@ -0,0 +1,4 @@
// Import all the channels to be used by Action Cable
import "notifications_channel"
import "points_channel"
import "imports_channel"

View file

@ -0,0 +1,15 @@
import consumer from "./consumer"
consumer.subscriptions.create("NotificationsChannel", {
connected() {
// console.log("Connected to the notifications channel!");
},
disconnected() {
// Called when the subscription has been terminated by the server
},
received(data) {
// Called when there's incoming data on the websocket for this channel
}
});

View file

@ -0,0 +1,15 @@
import consumer from "./consumer"
consumer.subscriptions.create("PointsChannel", {
connected() {
// Called when the subscription is ready for use on the server
},
disconnected() {
// Called when the subscription has been terminated by the server
},
received(data) {
// Called when there's incoming data on the websocket for this channel
}
});

View file

@ -0,0 +1,56 @@
import { Controller } from "@hotwired/stimulus";
import consumer from "../channels/consumer";
export default class extends Controller {
static targets = ["index"];
connect() {
if (!this.hasIndexTarget) {
console.log("No index target found, skipping subscription")
return
}
// console.log("Imports controller connected", {
// hasIndexTarget: this.hasIndexTarget,
// element: this.element,
// userId: this.element.dataset.userId
// });
this.setupSubscription();
}
setupSubscription() {
const userId = this.element.dataset.userId;
// console.log("Setting up subscription with userId:", userId);
this.channel = consumer.subscriptions.create(
{ channel: "ImportsChannel" },
{
connected: () => {
// console.log("Successfully connected to ImportsChannel");
// Test that we can receive messages
// console.log("Subscription object:", this.channel);
},
disconnected: () => {
// console.log("Disconnected from ImportsChannel");
},
received: (data) => {
// console.log("Received data:", data);
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
if (row) {
const pointsCell = row.querySelector('[data-points-count]');
if (pointsCell) {
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
}
}
}
}
);
}
disconnect() {
if (this.channel) {
this.channel.unsubscribe();
}
}
}

View file

@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
import consumer from "../channels/consumer";
import { createMarkersArray } from "../maps/markers";
@ -42,6 +43,7 @@ export default class extends Controller {
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.distanceUnit = this.element.dataset.distance_unit || "km";
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
this.liveMapEnabled = this.userSettings.live_map_enabled || false;
this.countryCodesMap = countryCodesMap();
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@ -82,7 +84,7 @@ export default class extends Controller {
.scale({
position: "bottomright",
metric: true,
imperial: false,
imperial: true,
maxWidth: 120,
})
.addTo(this.map);
@ -138,12 +140,75 @@ export default class extends Controller {
this.map.removeControl(this.drawControl);
}
});
if (this.liveMapEnabled) {
this.setupSubscription();
}
}
disconnect() {
this.map.remove();
}
setupSubscription() {
consumer.subscriptions.create("PointsChannel", {
received: (data) => {
// TODO:
// Only append the point if its timestamp is within current
// timespan
if (this.map && this.map._loaded) {
this.appendPoint(data);
}
}
});
}
appendPoint(data) {
// Parse the received point data
const newPoint = data;
// Add the new point to the markers array
this.markers.push(newPoint);
const newMarker = L.marker([newPoint[0], newPoint[1]])
this.markersArray.push(newMarker);
// Update the markers layer
this.markersLayer.clearLayers();
this.markersLayer.addLayer(L.layerGroup(this.markersArray));
// Update heatmap
this.heatmapMarkers.push([newPoint[0], newPoint[1], 0.2]);
this.heatmapLayer.setLatLngs(this.heatmapMarkers);
// Update polylines
this.polylinesLayer.clearLayers();
this.polylinesLayer = createPolylinesLayer(
this.markers,
this.map,
this.timezone,
this.routeOpacity,
this.userSettings
);
// Pan map to new location
this.map.setView([newPoint[0], newPoint[1]], 16);
// Update fog of war if enabled
if (this.map.hasLayer(this.fogOverlay)) {
this.updateFog(this.markers, this.clearFogRadius);
}
// Update the last marker
this.map.eachLayer((layer) => {
if (layer instanceof L.Marker && !layer._popup) {
this.map.removeLayer(layer);
}
});
this.addLastMarker(this.map, this.markers);
}
async setupScratchLayer(countryCodesMap) {
this.scratchLayer = L.geoJSON(null, {
style: {
@ -183,7 +248,6 @@ export default class extends Controller {
}
}
getVisitedCountries(countryCodesMap) {
if (!this.markers) return [];
@ -491,6 +555,12 @@ export default class extends Controller {
Simplified
</label>
<label for="live_map_enabled">
Live Map
<label for="live_map_enabled_info" class="btn-xs join-item inline">?</label>
<input type="checkbox" id="live_map_enabled" name="live_map_enabled" class='w-4' style="width: 20px;" value="false" ${this.liveMapEnabledChecked(true)} />
</label>
<button type="submit">Update</button>
</form>
`;
@ -523,6 +593,14 @@ export default class extends Controller {
}
}
liveMapEnabledChecked(value) {
if (value === this.liveMapEnabled) {
return 'checked';
} else {
return '';
}
}
updateSettings(event) {
event.preventDefault();
@ -537,7 +615,8 @@ export default class extends Controller {
minutes_between_routes: event.target.minutes_between_routes.value,
time_threshold_minutes: event.target.time_threshold_minutes.value,
merge_threshold_minutes: event.target.merge_threshold_minutes.value,
points_rendering_mode: event.target.points_rendering_mode.value
points_rendering_mode: event.target.points_rendering_mode.value,
live_map_enabled: event.target.live_map_enabled.checked
},
}),
})
@ -546,6 +625,10 @@ export default class extends Controller {
if (data.status === 'success') {
showFlashMessage('notice', data.message);
this.updateMapWithNewSettings(data.settings);
if (data.settings.live_map_enabled) {
this.setupSubscription();
}
} else {
showFlashMessage('error', data.message);
}

View file

@ -0,0 +1,78 @@
import { Controller } from "@hotwired/stimulus"
import consumer from "../channels/consumer"
export default class extends Controller {
static targets = ["badge", "list"]
static values = { userId: Number }
initialize() {
this.subscription = null
}
connect() {
// Clean up any existing subscription
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
this.createSubscription()
}
disconnect() {
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
}
createSubscription() {
this.subscription = consumer.subscriptions.create("NotificationsChannel", {
connected: () => {
// console.log("[WebSocket] Connected to NotificationsChannel")
},
disconnected: () => {
// console.log("[WebSocket] Disconnected from NotificationsChannel")
},
received: (data) => {
// console.log("[WebSocket] Received notification:", data)
this.prependNotification(data)
}
})
}
prependNotification(notification) {
const existingNotification = this.listTarget.querySelector(`a[href="/notifications/${notification.id}"]`)
if (existingNotification) {
return
}
const li = this.createNotificationListItem(notification)
const divider = this.listTarget.querySelector(".divider")
if (divider) {
divider.parentNode.insertBefore(li, divider.nextSibling)
} else {
this.listTarget.prepend(li)
}
this.updateBadge()
}
createNotificationListItem(notification) {
const li = document.createElement("li")
li.className = "notification-item"
li.innerHTML = `
<a href="/notifications/${notification.id}">
${notification.title}
<div class="badge badge-xs justify-self-end badge-${notification.kind}"></div>
</a>
`
return li
}
updateBadge() {
const badgeCount = this.listTarget.querySelectorAll(".notification-item").length
this.badgeTarget.textContent = badgeCount
this.badgeTarget.classList.toggle("hidden", badgeCount === 0)
}
}

View file

@ -1,15 +1,29 @@
# frozen_string_literal: true
class Notification < ApplicationRecord
after_create_commit :broadcast_notification
belongs_to :user
validates :title, :content, :kind, presence: true
enum :kind, { info: 0, warning: 1, error: 2 }
scope :unread, -> { where(read_at: nil) }
scope :unread, -> { where(read_at: nil).order(created_at: :desc) }
def read?
read_at.present?
end
private
def broadcast_notification
NotificationsChannel.broadcast_to(
user, {
title: title,
id: id,
kind: kind
}
)
end
end

View file

@ -23,6 +23,7 @@ class Point < ApplicationRecord
scope :not_visited, -> { where(visit_id: nil) }
after_create :async_reverse_geocode
after_create_commit :broadcast_coordinates
def self.without_raw_data
select(column_names - ['raw_data'])
@ -37,4 +38,22 @@ class Point < ApplicationRecord
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
private
def broadcast_coordinates
PointsChannel.broadcast_to(
user,
[
latitude.to_f,
longitude.to_f,
battery.to_s,
altitude.to_s,
timestamp.to_s,
velocity.to_s,
id.to_s,
country.to_s
]
)
end
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Geojson::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id
def initialize(import, user_id)
@ -12,10 +14,12 @@ class Geojson::ImportParser
def call
data = Geojson::Params.new(json).call
data.each do |point|
data.each.with_index(1) do |point, index|
next if point_exists?(point, user_id)
Point.create!(point.merge(user_id:, import_id: import.id))
broadcast_import_progress(import, index)
end
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class GoogleMaps::PhoneTakeoutParser
include Imports::Broadcaster
attr_reader :import, :user_id
def initialize(import, user_id)
@ -11,7 +13,7 @@ class GoogleMaps::PhoneTakeoutParser
def call
points_data = parse_json
points_data.compact.each do |point_data|
points_data.compact.each.with_index(1) do |point_data, index|
next if Point.exists?(
timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
@ -32,6 +34,8 @@ class GoogleMaps::PhoneTakeoutParser
tracker_id: 'google-maps-phone-timeline-export',
user_id:
)
broadcast_import_progress(import, index)
end
end

View file

@ -35,7 +35,7 @@ class GoogleMaps::RecordsParser
{
latitude: json['latitudeE7'].to_f / 10**7,
longitude: json['longitudeE7'].to_f / 10**7,
timestamp: Timestamps::parse_timestamp(json['timestamp'] || json['timestampMs']),
timestamp: Timestamps.parse_timestamp(json['timestamp'] || json['timestampMs']),
altitude: json['altitude'],
velocity: json['velocity'],
raw_data: json

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class GoogleMaps::SemanticHistoryParser
include Imports::Broadcaster
attr_reader :import, :user_id
def initialize(import, user_id)
@ -11,7 +13,7 @@ class GoogleMaps::SemanticHistoryParser
def call
points_data = parse_json
points_data.each do |point_data|
points_data.each.with_index(1) do |point_data, index|
next if Point.exists?(
timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
@ -29,6 +31,8 @@ class GoogleMaps::SemanticHistoryParser
import_id: import.id,
user_id:
)
broadcast_import_progress(import, index)
end
end
@ -44,7 +48,7 @@ class GoogleMaps::SemanticHistoryParser
{
latitude: waypoint['latE7'].to_f / 10**7,
longitude: waypoint['lngE7'].to_f / 10**7,
timestamp: Timestamps::parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
raw_data: timeline_object
}
end
@ -52,7 +56,7 @@ class GoogleMaps::SemanticHistoryParser
{
latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['activitySegment']['startLocation']['longitudeE7'].to_f / 10**7,
timestamp: Timestamps::parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
raw_data: timeline_object
}
end
@ -62,7 +66,7 @@ class GoogleMaps::SemanticHistoryParser
{
latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['placeVisit']['location']['longitudeE7'].to_f / 10**7,
timestamp: Timestamps::parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
raw_data: timeline_object
}
elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any?
@ -73,7 +77,7 @@ class GoogleMaps::SemanticHistoryParser
{
latitude: point['latitudeE7'].to_f / 10**7,
longitude: point['longitudeE7'].to_f / 10**7,
timestamp: Timestamps::parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
raw_data: timeline_object
}
else

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Gpx::TrackParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id
def initialize(import, user_id)
@ -13,7 +15,9 @@ class Gpx::TrackParser
tracks = json['gpx']['trk']
tracks_arr = tracks.is_a?(Array) ? tracks : [tracks]
tracks_arr.map { parse_track(_1) }.flatten
tracks_arr.map { parse_track(_1) }.flatten.each.with_index(1) do |point, index|
create_point(point, index)
end
end
private
@ -22,12 +26,10 @@ class Gpx::TrackParser
segments = track['trkseg']
segments_array = segments.is_a?(Array) ? segments : [segments]
segments_array.map do |segment|
segment['trkpt'].each { create_point(_1) }
end
segments_array.map { |segment| segment['trkpt'] }
end
def create_point(point)
def create_point(point, index)
return if point['lat'].blank? || point['lon'].blank? || point['time'].blank?
return if point_exists?(point)
@ -40,6 +42,8 @@ class Gpx::TrackParser
raw_data: point,
user_id:
)
broadcast_import_progress(import, index)
end
def point_exists?(point)

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Immich::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id
def initialize(import, user_id)
@ -10,10 +12,10 @@ class Immich::ImportParser
end
def call
json.each { |point| create_point(point) }
json.each.with_index(1) { |point, index| create_point(point, index) }
end
def create_point(point)
def create_point(point, index)
return 0 if point['latitude'].blank? || point['longitude'].blank? || point['timestamp'].blank?
return 0 if point_exists?(point, point['timestamp'])
@ -26,7 +28,7 @@ class Immich::ImportParser
user_id:
)
1
broadcast_import_progress(import, index)
end
def point_exists?(point, timestamp)

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Imports::Broadcaster
def broadcast_import_progress(import, index)
ImportsChannel.broadcast_to(
import.user,
{
action: 'update',
import: {
id: import.id,
points_count: index
}
}
)
end
end

View file

@ -25,7 +25,6 @@ class Imports::Create
# Bad classes naming by the way, they are not parsers, they are point creators
case source
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_records' then GoogleMaps::RecordsParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class OwnTracks::ExportParser
include Imports::Broadcaster
attr_reader :import, :data, :user_id
def initialize(import, user_id)
@ -12,7 +14,7 @@ class OwnTracks::ExportParser
def call
points_data = data.map { |point| OwnTracks::Params.new(point).call }
points_data.each do |point_data|
points_data.each.with_index(1) do |point_data, index|
next if Point.exists?(
timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
@ -26,6 +28,8 @@ class OwnTracks::ExportParser
end
point.save
broadcast_import_progress(import, index)
end
end
end

View file

@ -39,18 +39,23 @@
<th>Created at</th>
</tr>
</thead>
<tbody>
<tbody
data-controller="imports"
data-imports-target="index"
data-user-id="<%= current_user.id %>"
>
<% @imports.each do |import| %>
<tr>
<tr data-import-id="<%= import.id %>" id="import-<%= import.id %>">
<td>
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
&nbsp
<%= link_to import.name, import, class: 'underline hover:no-underline' %>
(<%= import.source %>)
&nbsp;
<%= link_to '🗺️', map_path(import_id: import.id) %>
&nbsp
&nbsp;
<%= link_to '📋', points_path(import_id: import.id) %>
</td>
<td>
<%= "#{number_with_delimiter import.points_count}" %>
<td data-points-count>
<%= number_with_delimiter import.points_count %>
</td>
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
</tr>

View file

@ -3,10 +3,11 @@
<head>
<title><%= full_title(yield(:title)) %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= action_cable_meta_tag %>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.css" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>

View file

@ -41,8 +41,10 @@
<% end %>
<div
id='map'
class="w-full"
data-controller="maps"
data-controller="maps points"
data-points-target="map"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings=<%= current_user.settings.to_json %>

View file

@ -53,7 +53,9 @@
<div class="navbar-end">
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %>
<div class="dropdown dropdown-end dropdown-bottom">
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover z-[10000]"
data-controller="notifications"
data-notifications-user-id-value="<%= current_user.id %>">
<div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost'>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -68,16 +70,16 @@
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<% if @unread_notifications.present? %>
<span class="badge badge-xs badge-primary"></span>
<span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge">
<%= @unread_notifications.size %>
</span>
<% end %>
</div>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-box min-w-52">
<% if @unread_notifications.any? %>
<li><%= link_to 'See all', notifications_path %></li>
<div class="divider p-0 m-0"></div>
<% end %>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-box min-w-52" data-notifications-target="list">
<li><%= link_to 'See all', notifications_path %></li>
<div class="divider p-0 m-0"></div>
<% @unread_notifications.first(10).each do |notification| %>
<li>
<li class='notification-item'>
<%= link_to notification do %>
<%= notification.title %>
<div class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></div>

View file

@ -16,4 +16,9 @@ pin 'leaflet-providers' # @2.0.0
pin 'chartkick', to: 'chartkick.js'
pin 'Chart.bundle', to: 'Chart.bundle.js'
pin 'leaflet.heat' # @0.2.0
pin "leaflet-draw" # @1.0.4
pin 'leaflet-draw' # @1.0.4
pin '@rails/actioncable', to: 'actioncable.esm.js'
pin_all_from 'app/javascript/channels', under: 'channels'
pin 'notifications_channel', to: 'channels/notifications_channel.js'
pin 'points_channel', to: 'channels/points_channel.js'
pin 'imports_channel', to: 'channels/imports_channel.js'

View file

@ -3,6 +3,7 @@
require 'sidekiq/web'
Rails.application.routes.draw do
mount ActionCable.server => '/cable'
mount Rswag::Api::Engine => '/api-docs'
mount Rswag::Ui::Engine => '/api-docs'
authenticate :user, ->(u) { u.admin? } do

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class AddLiveMapEnabledToSettings < ActiveRecord::Migration[7.2]
def up
User.find_each do |user|
user.settings = user.settings.merge(live_map_enabled: false)
user.save!
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20240730130922)
DataMigrate::Data.define(version: 20241107112451)

5
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_08_22_092405) do
ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -53,6 +53,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_22_092405) do
t.index ["user_id"], name: "index_areas_on_user_id"
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table "exports", force: :cascade do |t|
t.string "name", null: false
t.string "url"

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ImportsChannel, type: :channel do
let(:user) { create(:user) }
before do
stub_connection(current_user: user)
end
it 'subscribes to a stream for the current user' do
subscribe
expect(subscription).to be_confirmed
expect(subscription).to have_stream_for(user)
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe NotificationsChannel, type: :channel do
let(:user) { create(:user) }
before do
stub_connection(current_user: user)
end
it 'subscribes to a stream for the current user' do
subscribe
expect(subscription).to be_confirmed
expect(subscription).to have_stream_for(user)
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PointsChannel, type: :channel do
let(:user) { create(:user) }
before do
stub_connection(current_user: user)
end
it 'subscribes to a stream for the current user' do
subscribe
expect(subscription).to be_confirmed
expect(subscription).to have_stream_for(user)
end
end

View file

@ -16,6 +16,12 @@ RSpec.describe Gpx::TrackParser do
it 'creates points' do
expect { parser }.to change { Point.count }.by(301)
end
it 'broadcasts importing progress' do
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(301).times
parser
end
end
context 'when file has multiple segments' do
@ -24,6 +30,12 @@ RSpec.describe Gpx::TrackParser do
it 'creates points' do
expect { parser }.to change { Point.count }.by(558)
end
it 'broadcasts importing progress' do
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(558).times
parser
end
end
end
@ -33,6 +45,12 @@ RSpec.describe Gpx::TrackParser do
it 'creates points' do
expect { parser }.to change { Point.count }.by(407)
end
it 'broadcasts importing progress' do
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(407).times
parser
end
end
end
end