Merge pull request #450 from Freika/feature/trips

Trips
This commit is contained in:
Evgenii Burmakin 2024-11-28 18:00:11 +01:00 committed by GitHub
commit d0c66b68ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1463 additions and 157 deletions

View file

@ -1 +1 @@
0.17.2
0.18.0

View file

@ -5,7 +5,25 @@ 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.17.1 - 2024-11-27
# 0.18.0 - 2024-11-28
## The Trips release
You can now create, edit and delete trips. To create a trip, click on the "New Trip" button on the Trips page. Provide a name, date and time for start and end of the trip. You can add your own notes to the trip as well.
If you have points tracked during provided timeframe, they will be automatically added to the trip and will be shown on the trip map.
Also, if you have Immich integrated, you will see photos from the trip on the trip page, along with a link to look at them on Immich.
### Added
- The Trips feature. Read above for more details.
### Changed
- Maps are now not so rough on the edges.
# 0.17.2 - 2024-11-27
### Fixed

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,45 @@
/*
* Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and
* the trix-editor content (whether displayed or under editing). Feel free to incorporate this
* inclusion directly in any other asset bundle and remove this file.
*
*= require trix
*/
/*
* We need to override trix.csss image gallery styles to accommodate the
* <action-text-attachment> element we wrap around attachments. Otherwise,
* images in galleries will be squished by the max-width: 33%; rule.
*/
.trix-content .attachment-gallery > action-text-attachment,
.trix-content .attachment-gallery > .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%;
}
.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
flex-basis: 50%;
max-width: 50%;
}
.trix-content action-text-attachment .attachment {
padding: 0 !important;
max-width: 100% !important;
}
/* Hide both attach files and attach images buttons in trix editor*/
.trix-button-group.trix-button-group--file-tools {
display:none;
}
/* Color buttons in white */
.trix-button-row button {
background-color: white !important;
}
.trix-content {
min-height: 10rem;
}

View file

@ -12,3 +12,4 @@
}
*/
@import 'actiontext.css';

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
class TripsController < ApplicationController
before_action :authenticate_user!
before_action :set_trip, only: %i[show edit update destroy]
before_action :set_coordinates, only: %i[show edit]
def index
@trips = current_user.trips.order(started_at: :desc).page(params[:page]).per(6)
end
def show
@coordinates = @trip.points.pluck(
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
@photos = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
@trip.photos
end
end
def new
@trip = Trip.new
@coordinates = []
end
def edit; end
def create
@trip = current_user.trips.build(trip_params)
if @trip.save
redirect_to @trip, notice: 'Trip was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
def update
if @trip.update(trip_params)
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@trip.destroy!
redirect_to trips_url, notice: 'Trip was successfully destroyed.', status: :see_other
end
private
def set_trip
@trip = current_user.trips.find(params[:id])
end
def set_coordinates
@coordinates = @trip.points.pluck(
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
end
def trip_params
params.require(:trip).permit(:name, :started_at, :ended_at, :notes)
end
end

View file

@ -106,4 +106,18 @@ module ApplicationHelper
'text-blue-600'
end
def human_date(date)
date.strftime('%e %B %Y')
end
def immich_search_url(base_url, start_date, end_date)
query = {
takenAfter: "#{start_date.to_date}T00:00:00.000Z",
takenBefore: "#{end_date.to_date}T23:59:59.999Z"
}
encoded_query = URI.encode_www_form_component(query.to_json)
"#{base_url}/search?query=#{encoded_query}"
end
end

View file

@ -9,3 +9,6 @@ import "leaflet-providers"
import "chartkick"
import "Chart.bundle"
import "./channels"
import "trix"
import "@rails/actiontext"

View file

@ -0,0 +1,69 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["startedAt", "endedAt", "apiKey"]
static values = { tripsId: String }
connect() {
console.log("Datetime controller connected")
this.debounceTimer = null;
}
async updateCoordinates(event) {
// Clear any existing timeout
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Set new timeout
this.debounceTimer = setTimeout(async () => {
const startedAt = this.startedAtTarget.value
const endedAt = this.endedAtTarget.value
const apiKey = this.apiKeyTarget.value
if (startedAt && endedAt) {
try {
const params = new URLSearchParams({
start_at: startedAt,
end_at: endedAt,
api_key: apiKey,
slim: true
})
let allPoints = [];
let currentPage = 1;
const perPage = 1000;
do {
const paginatedParams = `${params}&page=${currentPage}&per_page=${perPage}`;
const response = await fetch(`/api/v1/points?${paginatedParams}`);
const data = await response.json();
allPoints = [...allPoints, ...data];
const totalPages = parseInt(response.headers.get('X-Total-Pages'));
currentPage++;
if (!totalPages || currentPage > totalPages) {
break;
}
} while (true);
const event = new CustomEvent('coordinates-updated', {
detail: { coordinates: allPoints },
bubbles: true,
composed: true
})
const tripsElement = document.querySelector('[data-controller="trips"]')
if (tripsElement) {
tripsElement.dispatchEvent(event)
} else {
console.error('Trips controller element not found')
}
} catch (error) {
console.error('Error:', error)
}
}
}, 500);
}
}

View file

@ -12,6 +12,7 @@ import { fetchAndDrawAreas } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas";
import { showFlashMessage } from "../maps/helpers";
import { fetchAndDisplayPhotos } from '../maps/helpers';
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
@ -83,14 +84,13 @@ export default class extends Controller {
Photos: this.photoMarkers
};
L.control
.scale({
position: "bottomright",
metric: true,
imperial: true,
maxWidth: 120,
})
.addTo(this.map);
// Add scale control to bottom right
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
maxWidth: 120
}).addTo(this.map)
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
@ -132,16 +132,22 @@ export default class extends Controller {
this.initializeDrawControl();
// Add event listeners to toggle draw controls
this.map.on('overlayadd', (e) => {
this.map.on('overlayadd', async (e) => {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
if (e.name === 'Photos') {
// Extract dates from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
this.fetchAndDisplayPhotos(startDate, endDate);
await fetchAndDisplayPhotos({
map: this.map,
photoMarkers: this.photoMarkers,
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
});
}
});
@ -782,88 +788,6 @@ export default class extends Controller {
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}
async fetchAndDisplayPhotos(startDate, endDate, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000; // 3 seconds
// Create loading control
const LoadingControl = L.Control.extend({
onAdd: (map) => {
const container = L.DomUtil.create('div', 'leaflet-loading-control');
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
});
const loadingControl = new LoadingControl({ position: 'topleft' });
this.map.addControl(loadingControl);
try {
const params = new URLSearchParams({
api_key: this.apiKey,
start_date: startDate,
end_date: endDate
});
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const photos = await response.json();
this.photoMarkers.clearLayers();
// Create a promise for each photo to track when it's fully loaded
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;
img.onload = () => {
this.createPhotoMarker(photo);
resolve();
};
img.onerror = () => {
console.error(`Failed to load photo ${photo.id}`);
resolve(); // Resolve anyway to not block other photos
};
img.src = thumbnailUrl;
});
});
// Wait for all photos to be loaded and rendered
await Promise.all(photoLoadPromises);
if (!this.map.hasLayer(this.photoMarkers)) {
this.photoMarkers.addTo(this.map);
}
// Show checkmark for 1 second before removing
const loadingSpinner = document.querySelector('.loading-spinner');
loadingSpinner.classList.add('done');
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('Error fetching photos:', error);
showFlashMessage('error', 'Failed to fetch photos');
if (retryCount < MAX_RETRIES) {
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => {
this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
// Remove loading control after the delay
this.map.removeControl(loadingControl);
}
}
createPhotoMarker(photo) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;

View file

@ -0,0 +1,60 @@
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
export default class extends Controller {
static values = {
tripId: Number,
coordinates: Array,
apiKey: String,
userSettings: Object,
timezone: String,
distanceUnit: String
}
connect() {
setTimeout(() => {
this.initializeMap()
}, 100)
}
initializeMap() {
// Initialize map with basic configuration
this.map = L.map(this.element, {
zoomControl: false,
dragging: false,
scrollWheelZoom: false,
attributionControl: true // Disable default attribution control
})
// Add the tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
}).addTo(this.map)
// If we have coordinates, show the route
if (this.hasCoordinatesValue && this.coordinatesValue.length > 0) {
this.showRoute()
}
}
showRoute() {
const points = this.coordinatesValue.map(coord => [coord[0], coord[1]])
const polyline = L.polyline(points, {
color: 'blue',
weight: 3,
opacity: 0.8
}).addTo(this.map)
this.map.fitBounds(polyline.getBounds(), {
padding: [20, 20]
})
}
disconnect() {
if (this.map) {
this.map.remove()
}
}
}

View file

@ -0,0 +1,182 @@
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
import { osmMapLayer } from "../maps/layers"
import { createPopupContent } from "../maps/popups"
import { osmHotMapLayer } from "../maps/layers"
import { OPNVMapLayer } from "../maps/layers"
import { openTopoMapLayer } from "../maps/layers"
import { cyclOsmMapLayer } from "../maps/layers"
import { esriWorldStreetMapLayer } from "../maps/layers"
import { esriWorldTopoMapLayer } from "../maps/layers"
import { esriWorldImageryMapLayer } from "../maps/layers"
import { esriWorldGrayCanvasMapLayer } from "../maps/layers"
import { fetchAndDisplayPhotos } from '../maps/helpers';
export default class extends Controller {
static targets = ["container", "startedAt", "endedAt"]
static values = { }
connect() {
if (!this.hasContainerTarget) {
return;
}
console.log("Trips controller connected")
this.coordinates = JSON.parse(this.containerTarget.dataset.coordinates)
this.apiKey = this.containerTarget.dataset.api_key
this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings)
this.timezone = this.containerTarget.dataset.timezone
this.distanceUnit = this.containerTarget.dataset.distance_unit
// Initialize map and layers
this.initializeMap()
// Add event listener for coordinates updates
this.element.addEventListener('coordinates-updated', (event) => {
console.log("Coordinates updated:", event.detail.coordinates)
this.updateMapWithCoordinates(event.detail.coordinates)
})
}
// Move map initialization to separate method
initializeMap() {
// Initialize layer groups
this.markersLayer = L.layerGroup()
this.polylinesLayer = L.layerGroup()
this.photoMarkers = L.layerGroup()
// Set default center and zoom for world view
const hasValidCoordinates = this.coordinates && Array.isArray(this.coordinates) && this.coordinates.length > 0
const center = hasValidCoordinates
? [this.coordinates[0][0], this.coordinates[0][1]]
: [20, 0] // Roughly centers the world map
const zoom = hasValidCoordinates ? 14 : 2
// Initialize map
this.map = L.map(this.containerTarget).setView(center, zoom)
// Add base map layer
osmMapLayer(this.map, "OpenStreetMap")
// Add scale control to bottom right
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
maxWidth: 120
}).addTo(this.map)
const overlayMaps = {
"Points": this.markersLayer,
"Route": this.polylinesLayer,
"Photos": this.photoMarkers
}
// Add layer control
L.control.layers(this.baseMaps(), overlayMaps).addTo(this.map)
// Add event listener for layer changes
this.map.on('overlayadd', (e) => {
if (e.name === 'Photos' && this.coordinates?.length > 0) {
const firstCoord = this.coordinates[0];
const lastCoord = this.coordinates[this.coordinates.length - 1];
const startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0];
const endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0];
fetchAndDisplayPhotos({
map: this.map,
photoMarkers: this.photoMarkers,
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
});
}
});
// Add markers and route
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}
}
disconnect() {
if (this.map) {
this.map.remove()
}
}
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
return {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
};
}
addMarkers() {
this.coordinates.forEach(coord => {
const marker = L.circleMarker([coord[0], coord[1]], {radius: 4})
const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit)
marker.bindPopup(popupContent)
// Add to markers layer instead of directly to map
marker.addTo(this.markersLayer)
})
}
addPolyline() {
const points = this.coordinates.map(coord => [coord[0], coord[1]])
const polyline = L.polyline(points, {
color: 'blue',
weight: 3,
opacity: 0.8
})
// Add to polylines layer instead of directly to map
this.polylinesLayer.addTo(this.map)
polyline.addTo(this.polylinesLayer)
}
fitMapToBounds() {
const bounds = L.latLngBounds(
this.coordinates.map(coord => [coord[0], coord[1]])
)
this.map.fitBounds(bounds, { padding: [50, 50] })
}
// Add this new method to update coordinates and refresh the map
updateMapWithCoordinates(newCoordinates) {
// Transform the coordinates to match the expected format
this.coordinates = newCoordinates.map(point => [
parseFloat(point.latitude),
parseFloat(point.longitude),
point.id,
null, // This is so we can use the same order and position of elements in the coordinates object as in the api/v1/points response
(point.timestamp).toString()
]).sort((a, b) => a[4] - b[4]);
// Clear existing layers
this.markersLayer.clearLayers()
this.polylinesLayer.clearLayers()
this.photoMarkers.clearLayers()
// Add new markers and route if coordinates exist
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}
}
}

View file

@ -136,3 +136,131 @@ function classesForFlash(type) {
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate, userSettings }, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000; // 3 seconds
// Create loading control
const LoadingControl = L.Control.extend({
onAdd: (map) => {
const container = L.DomUtil.create('div', 'leaflet-loading-control');
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
});
const loadingControl = new LoadingControl({ position: 'topleft' });
map.addControl(loadingControl);
try {
const params = new URLSearchParams({
api_key: apiKey,
start_date: startDate,
end_date: endDate
});
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const photos = await response.json();
photoMarkers.clearLayers();
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
img.onload = () => {
createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey);
resolve();
};
img.onerror = () => {
console.error(`Failed to load photo ${photo.id}`);
resolve(); // Resolve anyway to not block other photos
};
img.src = thumbnailUrl;
});
});
await Promise.all(photoLoadPromises);
if (!map.hasLayer(photoMarkers)) {
photoMarkers.addTo(map);
}
// Show checkmark for 1 second before removing
const loadingSpinner = document.querySelector('.loading-spinner');
loadingSpinner.classList.add('done');
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('Error fetching photos:', error);
showFlashMessage('error', 'Failed to fetch photos');
if (retryCount < MAX_RETRIES) {
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => {
fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate }, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
map.removeControl(loadingControl);
}
}
export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
const icon = L.divIcon({
className: 'photo-marker',
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
iconSize: [48, 48]
});
const marker = L.marker(
[photo.exifInfo.latitude, photo.exifInfo.longitude],
{ icon }
);
const startOfDay = new Date(photo.localDateTime);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(photo.localDateTime);
endOfDay.setHours(23, 59, 59, 999);
const queryParams = {
takenAfter: startOfDay.toISOString(),
takenBefore: endOfDay.toISOString()
};
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
const immich_photo_link = `${immichUrl}/search?query=${encodedQuery}`;
const popupContent = `
<div class="max-w-xs">
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}"
class="mb-2 rounded"
style="transition: box-shadow 0.3s ease;"
alt="${photo.originalFileName}">
</a>
<h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
</div>
`;
marker.bindPopup(popupContent);
photoMarkers.addLayer(marker);
}

55
app/models/trip.rb Normal file
View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class Trip < ApplicationRecord
has_rich_text :notes
belongs_to :user
validates :name, :started_at, :ended_at, presence: true
before_save :calculate_distance
def points
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
end
def countries
points.pluck(:country).uniq.compact
end
def photos
immich_photos = Immich::RequestPhotos.new(
user,
start_date: started_at.to_date.to_s,
end_date: ended_at.to_date.to_s
).call.reject { |asset| asset['type'].downcase == 'video' }
# let's count what photos are more: vertical or horizontal and select the ones that are more
vertical_photos = immich_photos.select { _1['exifInfo']['orientation'] == '6' }
horizontal_photos = immich_photos.select { _1['exifInfo']['orientation'] == '3' }
# this is ridiculous, but I couldn't find my way around frontend
# to show all photos in the same height
photos = vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
photos.sample(12).sort_by { _1['localDateTime'] }.map do |asset|
{ url: "/api/v1/photos/#{asset['id']}/thumbnail.jpg?api_key=#{user.api_key}" }
end
end
private
def calculate_distance
distance = 0
points.each_cons(2) do |point1, point2|
distance_between = Geocoder::Calculations.distance_between(
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
)
distance += distance_between
end
self.distance = distance.round
end
end

View file

@ -15,6 +15,7 @@ class User < ApplicationRecord
has_many :visits, dependent: :destroy
has_many :points, through: :imports
has_many :places, through: :visits
has_many :trips, dependent: :destroy
after_create :create_api_key

View file

@ -27,6 +27,7 @@ class Immich::RequestPhotos
data = []
max_pages = 10_000 # Prevent infinite loop
# TODO: Handle pagination using nextPage
while page <= max_pages
response = JSON.parse(
HTTParty.post(
@ -38,6 +39,8 @@ class Immich::RequestPhotos
if items.blank?
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
Rails.logger.debug("START_DATE: #{start_date}")
Rails.logger.debug("END_DATE: #{end_date}")
Rails.logger.debug(response)
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')

View file

@ -0,0 +1,14 @@
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
<% if blob.representable? %>
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
<% end %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure>

View file

@ -1,7 +1,7 @@
<% content_for :title, "Exports" %>
<div class="w-full">
<div class="flex justify-center my-5">
<div class="w-full my-5">
<div class="flex justify-center">
<h1 class="font-bold text-4xl">Exports</h1>
</div>

View file

@ -1,4 +1,4 @@
<div class="w-full mx-auto">
<div class="w-full mx-auto my-5">
<div class="flex justify-between items-center mt-5 mb-5">
<div class="hero h-fit bg-base-200 py-20" style="background-image: url(<%= '/images/bg-image.jpg' %>);">
<div class="hero-content text-center">

View file

@ -1,7 +1,7 @@
<% content_for :title, 'Imports' %>
<div class="w-full">
<div class="flex justify-between items-center mb-3">
<div class="w-full my-5">
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Imports</h1>
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>

View file

@ -0,0 +1,3 @@
<div class="trix-content">
<%= yield %>
</div>

View file

@ -1,40 +1,42 @@
<% content_for :title, 'Map' %>
<div class="flex flex-col lg:flex-row lg:space-x-4 mt-8 w-full">
<div class="flex flex-col lg:flex-row lg:space-x-4 my-5 w-full">
<div class='w-full lg:w-5/6'>
<div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
</div>
</div>
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-3/12">
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12">
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
<%= link_to "Yesterday",
map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]),
class: "btn btn-neutral hover:btn-ghost" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
</div>
</div>
</div>
@ -50,7 +52,7 @@
data-user_settings=<%= current_user.settings.to_json %>
data-coordinates="<%= @coordinates %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-maps-target="container" class="h-[25rem] w-full min-h-screen">
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen">
<div id="fog" class="fog"></div>
</div>
</div>

View file

@ -1,7 +1,7 @@
<% content_for :title, "Places" %>
<div class="w-full">
<div class="flex justify-center my-5">
<div class="w-full my-5">
<div class="flex justify-center">
<h1 class="font-bold text-4xl">Places</h1>
</div>

View file

@ -1,6 +1,6 @@
<% content_for :title, 'Points' %>
<div class="w-full">
<div class="w-full my-5">
<%= form_with url: points_path(import_id: params[:import_id]), data: { turbo_method: :get }, method: :get do |f| %>
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
<div class="w-full md:w-2/12">

View file

@ -1,6 +1,6 @@
<% content_for :title, "Background jobs" %>
<div class="min-h-content w-full">
<div class="min-h-content w-full my-5">
<%= render 'settings/navigation' %>
<div class="flex justify-between items-center mt-5">

View file

@ -1,6 +1,6 @@
<% content_for :title, 'Settings' %>
<div class="min-h-content w-full">
<div class="min-h-content w-full my-5">
<%= render 'settings/navigation' %>
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">

View file

@ -8,8 +8,9 @@
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Places<sup>β</sup>'.html_safe, places_url, class: "#{active_class?(places_url)}" %></li>
<li><%= link_to 'Visits<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Places<sup>α</sup>'.html_safe, places_url, class: "#{active_class?(places_url)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
@ -41,13 +42,14 @@
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Places<sup>β</sup>'.html_safe, places_url, class: "#{active_class?(places_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "mx-1 #{active_class?(visits_url)}" %></li>
<li><%= link_to 'Places<sup>α</sup>'.html_safe, places_url, class: "mx-1 #{active_class?(places_url)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "mx-1 #{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "mx-1 #{active_class?(exports_url)}" %></li>
</ul>
</div>
<div class="navbar-end">

View file

@ -2,7 +2,7 @@
<div id='years-nav'>
<div class="dropdown">
<div tabindex="0" role="button" class="btn m-1">Select year</div>
<div tabindex="0" role="button" class="btn">Select year</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<% current_user.stats.years.each do |year| %>
<li><%= link_to year, map_url(year_timespan(year).merge(year: year, import_id: params[:import_id])) %></li>

View file

@ -1,6 +1,6 @@
<% content_for :title, 'Statistics' %>
<div class="w-full">
<div class="w-full my-5">
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
<div class="stat text-center">
<div class="stat-value text-primary">

View file

@ -1,5 +1,5 @@
<% content_for :title, "Statistics for #{@year} year" %>
<div class="w-full">
<div class="w-full my-5">
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
</div>

View file

@ -0,0 +1,73 @@
<%= form_with(model: trip, class: "contents") do |form| %>
<% if trip.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(trip.errors.count, "error") %> prohibited this trip from being saved:</h2>
<ul>
<% trip.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="flex flex-col lg:flex-row gap-4 my-4" data-controller="trips">
<div class="w-full lg:w-1/2">
<div
id='map trips-container'
class="w-full h-full rounded-lg"
data-trips-target="container"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings="<%= current_user.settings.to_json %>"
data-coordinates="<%= @coordinates.to_json %>"
data-timezone="<%= Rails.configuration.time_zone %>">
</div>
</div>
<div
class="w-full lg:w-1/2 space-y-4"
data-controller="datetime">
<div class="form-control">
<%= form.label :name %>
<%= form.text_field :name, class: 'input input-bordered w-full' %>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<div class="form-control w-full">
<input type="hidden" data-datetime-target="apiKey" value="<%= current_user.api_key %>">
<%= form.label :started_at %>
<%= form.datetime_field :started_at,
include_seconds: false,
class: 'input input-bordered w-full',
value: trip.started_at,
data: {
datetime_target: "startedAt",
action: "change->datetime#updateCoordinates"
} %>
</div>
<div class="form-control w-full">
<%= form.label :ended_at %>
<%= form.datetime_field :ended_at,
include_seconds: false,
class: 'input input-bordered w-full',
value: trip.ended_at,
data: {
datetime_target: "endedAt",
action: "change->datetime#updateCoordinates"
} %>
</div>
</div>
<div class="form-control">
<%= form.label :notes %>
<%= form.rich_text_area :notes %>
</div>
<div>
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,24 @@
<%= link_to trip, class: "block hover:shadow-lg rounded-lg" do %>
<div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow duration-200" data-trip-id="<%= trip.id %>" id="trip-<%= trip.id %>">
<div class="card-body">
<h2 class="card-title justify-center">
<span class="hover:underline"><%= trip.name %></span>
</h2>
<p class="text-sm text-gray-600 text-center">
<%= "#{human_date(trip.started_at)} #{human_date(trip.ended_at)}, #{trip.distance} #{DISTANCE_UNIT}" %>
</p>
<div style="width: 100%; aspect-ratio: 1/1;"
id="map-<%= trip.id %>"
class="rounded-lg"
data-controller="trip-map"
data-trip-map-trip-id-value="<%= trip.id %>"
data-trip-map-coordinates-value="<%= trip.points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country).to_json %>"
data-trip-map-api-key-value="<%= current_user.api_key %>"
data-trip-map-user-settings-value="<%= current_user.settings.to_json %>"
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>"
data-trip-map-distance-unit-value="<%= DISTANCE_UNIT %>">
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,13 @@
<div class="mx-auto md:w-2/3 w-full my-5">
<h1 class="font-bold text-4xl">Editing trip</h1>
<%= render "form", trip: @trip %>
<!-- Action Buttons Section -->
<div class="bg-base-100 items-center mt-8 mb-4">
<div class="flex flex-wrap gap-2 justify-center">
<%= link_to "Show this trip", @trip, class: "btn" %>
<%= link_to "Back to trips", trips_path, class: "btn" %>
</div>
</div>
</div>

View file

@ -0,0 +1,35 @@
<% content_for :title, 'Trips' %>
<div class="w-full my-5">
<div id="trips" class="min-w-full">
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Trips</h1>
<%= link_to 'New trip', new_trip_path, class: 'btn btn-primary' %>
</div>
<% if @trips.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<p class="py-6">
Here you'll find your trips, but now there are none. Let's <%= link_to 'create one', new_trip_path, class: 'link' %>!
</p>
</div>
</div>
</div>
<% else %>
<div class="flex justify-center my-5">
<div class='flex'>
<%= paginate @trips %>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 my-4">
<% @trips.each do |trip| %>
<%= render 'trip', trip: trip %>
<% end %>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,9 @@
<% content_for :title, 'New trip' %>
<div class="mx-auto md:w-2/3 w-full my-5">
<h1 class="font-bold text-4xl">New trip</h1>
<%= render "form", trip: @trip %>
<%= link_to "Back to trips", trips_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View file

@ -0,0 +1,76 @@
<% content_for :title, @trip.name %>
<div class="container mx-auto px-4 max-w-4xl my-5">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2"><%= @trip.name %></h1>
<p class="text-md text-base-content/60">
<%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %>
</p>
<% if @trip.countries.any? %>
<p class="text-lg text-base-content/60">
<%= "#{@trip.countries.join(', ')} (#{@trip.distance} #{DISTANCE_UNIT})" %>
</p>
<% end %>
</div>
<div class="bg-base-100 my-8 p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="w-full">
<div
id='map'
class="w-full h-full rounded-lg"
data-controller="trips"
data-trips-target="container"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings="<%= current_user.settings.to_json %>"
data-coordinates="<%= @coordinates.to_json %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
</div>
</div>
</div>
<div class="w-full">
<div>
<%= @trip.notes.body %>
</div>
<!-- Photos Grid Section -->
<% if @photos.any? %>
<% @photos.each_slice(4) do |slice| %>
<div class="h-32 flex gap-4 mt-4 justify-center">
<% slice.each do |photo| %>
<div class="flex-1 h-full overflow-hidden rounded-lg transition-transform duration-300 hover:scale-105 hover:shadow-lg">
<img
src="<%= photo[:url] %>"
loading='lazy'
class="h-full w-full object-cover"
>
</div>
<% end %>
</div>
<% end %>
<% end %>
<div class="text-center mt-6">
<%= link_to "More photos on Immich", immich_search_url(current_user.settings['immich_url'], @trip.started_at, @trip.ended_at), class: "btn btn-primary", target: '_blank' %>
</div>
</div>
</div>
</div>
<!-- Action Buttons Section -->
<div class="bg-base-100 items-center">
<div class="flex flex-wrap gap-2 justify-center">
<%= link_to "Edit this trip", edit_trip_path(@trip), class: "btn" %>
<%= link_to "Destroy this trip",
trip_path(@trip),
data: {
turbo_confirm: "Are you sure?",
turbo_method: :delete
},
class: "btn" %>
<%= link_to "Back to trips", trips_path, class: "btn" %>
</div>
</div>
</div>

View file

@ -1,7 +1,8 @@
<div class="w-full">
<% content_for :title, "Visits" %>
<div class="flex justify-between my-5">
<div class="w-full my-5">
<div class="flex justify-between">
<h1 class="font-bold text-4xl">Visits</h1>
<div role="tablist" class="tabs tabs-boxed">
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',

View file

@ -22,3 +22,5 @@ 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'
pin "trix"
pin "@rails/actiontext", to: "actiontext.esm.js"

View file

@ -30,6 +30,7 @@ Rails.application.routes.draw do
resources :visits, only: %i[index update]
resources :places, only: %i[index destroy]
resources :exports, only: %i[index create destroy]
resources :trips
resources :points, only: %i[index] do
collection do
delete :bulk_destroy

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateTrips < ActiveRecord::Migration[7.2]
def change
create_table :trips do |t|
t.string :name, null: false
t.datetime :started_at, null: false
t.datetime :ended_at, null: false
t.integer :distance
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end

View file

@ -0,0 +1,26 @@
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :action_text_rich_texts, id: primary_key_type do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.timestamps
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

24
db/schema.rb generated
View file

@ -10,10 +10,20 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
ActiveRecord::Schema[7.2].define(version: 2024_11_28_095325) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
create_table "action_text_rich_texts", force: :cascade do |t|
t.string "name", null: false
t.text "body"
t.string "record_type", null: false
t.bigint "record_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@ -175,6 +185,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
t.index ["year"], name: "index_stats_on_year"
end
create_table "trips", force: :cascade do |t|
t.string "name", null: false
t.datetime "started_at", null: false
t.datetime "ended_at", null: false
t.integer "distance"
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_trips_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@ -216,6 +237,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
add_foreign_key "points", "users"
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "trips", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "places"
add_foreign_key "visits", "users"

59
package-lock.json generated
View file

@ -6,7 +6,9 @@
"": {
"dependencies": {
"@hotwired/turbo-rails": "^7.3.0",
"leaflet": "^1.9.4"
"@rails/actiontext": "^8.0.0",
"leaflet": "^1.9.4",
"trix": "^2.1.8"
},
"devDependencies": {
"daisyui": "^4.7.3"
@ -34,6 +36,25 @@
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
"integrity": "sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA=="
},
"node_modules/@rails/actiontext": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.0.0.tgz",
"integrity": "sha512-8pvXDEHqlVHptzfYDUXmBpstHsfHAVacYxO47cWDRjRmp1zdVXusLcom8UvqkRdTcAPXpte+LkjcfpD9S4DSSQ==",
"dependencies": {
"@rails/activestorage": ">= 8.0.0-alpha"
},
"peerDependencies": {
"trix": "^2.0.0"
}
},
"node_modules/@rails/activestorage": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0.tgz",
"integrity": "sha512-qoA7U1gMcWXhDnImwDIyRQDXkQKzThT2lu2Xpim8CnTOCEeAgkQ5Co2kzodpAI2grF1JSDvwXSPYNWwVAswndA==",
"dependencies": {
"spark-md5": "^3.0.1"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -186,6 +207,16 @@
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/spark-md5": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="
},
"node_modules/trix": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/trix/-/trix-2.1.8.tgz",
"integrity": "sha512-y1h5mKQcjMsZDsUOqOgyIUfw+Z31u4Fe9JqXtKGUzIC7FM9cTpxZFFWxQggwXBo18ccIKYx1Fn9toVO5mCpn9g=="
}
},
"dependencies": {
@ -208,6 +239,22 @@
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
"integrity": "sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA=="
},
"@rails/actiontext": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.0.0.tgz",
"integrity": "sha512-8pvXDEHqlVHptzfYDUXmBpstHsfHAVacYxO47cWDRjRmp1zdVXusLcom8UvqkRdTcAPXpte+LkjcfpD9S4DSSQ==",
"requires": {
"@rails/activestorage": ">= 8.0.0-alpha"
}
},
"@rails/activestorage": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0.tgz",
"integrity": "sha512-qoA7U1gMcWXhDnImwDIyRQDXkQKzThT2lu2Xpim8CnTOCEeAgkQ5Co2kzodpAI2grF1JSDvwXSPYNWwVAswndA==",
"requires": {
"spark-md5": "^3.0.1"
}
},
"camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -299,6 +346,16 @@
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true,
"peer": true
},
"spark-md5": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="
},
"trix": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/trix/-/trix-2.1.8.tgz",
"integrity": "sha512-y1h5mKQcjMsZDsUOqOgyIUfw+Z31u4Fe9JqXtKGUzIC7FM9cTpxZFFWxQggwXBo18ccIKYx1Fn9toVO5mCpn9g=="
}
}
}

View file

@ -1,7 +1,10 @@
{
"dependencies": {
"@hotwired/turbo-rails": "^7.3.0",
"leaflet": "^1.9.4"
"@rails/actiontext": "^8.0.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.49",
"trix": "^2.1.8"
},
"devDependencies": {
"daisyui": "^4.7.3"

View file

@ -54,6 +54,11 @@ FactoryBot.define do
}
}
end
trait :reverse_geocoded do
country { FFaker::Address.country }
city { FFaker::Address.city }
end
end
end
end

21
spec/factories/trips.rb Normal file
View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
FactoryBot.define do
factory :trip do
user
name { FFaker::Lorem.word }
started_at { DateTime.new(2024, 11, 27, 17, 16, 21) }
ended_at { DateTime.new(2024, 11, 29, 17, 16, 21) }
notes { FFaker::Lorem.sentence }
trait :with_points do
after(:build) do |trip|
create_list(
:point, 25,
user: trip.user,
timestamp: trip.started_at + (1..1000).to_a.sample.minutes
)
end
end
end
end

96
spec/models/trip_spec.rb Normal file
View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Trip, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:started_at) }
it { is_expected.to validate_presence_of(:ended_at) }
end
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
describe 'callbacks' do
let(:user) { create(:user) }
let(:trip) { create(:trip, :with_points, user:) }
let(:calculated_distance) { trip.send(:calculate_distance) }
it 'sets the distance' do
expect(trip.distance).to eq(calculated_distance)
end
end
describe '#countries' do
let(:user) { create(:user) }
let(:trip) { create(:trip, user:) }
let(:points) do
create_list(
:point,
25,
:reverse_geocoded,
user:,
timestamp: (trip.started_at.to_i..trip.ended_at.to_i).to_a.sample
)
end
it 'returns the unique countries of the points' do
expect(trip.countries).to eq(trip.points.pluck(:country).uniq.compact)
end
end
describe '#photos' do
let(:photo_data) do
[
{
'id' => '123',
'latitude' => 35.6762,
'longitude' => 139.6503,
'localDateTime' => '2024-01-01T03:00:00.000Z',
'type' => 'photo',
'exifInfo' => {
'orientation' => '3'
}
},
{
'id' => '456',
'latitude' => 40.7128,
'longitude' => -74.0060,
'localDateTime' => '2024-01-02T01:00:00.000Z',
'type' => 'photo',
'exifInfo' => {
'orientation' => '6'
}
},
{
'id' => '789',
'latitude' => 40.7128,
'longitude' => -74.0060,
'localDateTime' => '2024-01-02T02:00:00.000Z',
'type' => 'photo',
'exifInfo' => {
'orientation' => '6'
}
}
]
end
let(:user) { create(:user) }
let(:trip) { create(:trip, user:) }
let(:expected_photos) do
[
{ url: "/api/v1/photos/456/thumbnail.jpg?api_key=#{user.api_key}" },
{ url: "/api/v1/photos/789/thumbnail.jpg?api_key=#{user.api_key}" }
]
end
before do
allow_any_instance_of(Immich::RequestPhotos).to receive(:call).and_return(photo_data)
end
it 'returns the photos' do
expect(trip.photos).to eq(expected_photos)
end
end
end

View file

@ -13,6 +13,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:areas).dependent(:destroy) }
it { is_expected.to have_many(:visits).dependent(:destroy) }
it { is_expected.to have_many(:places).through(:visits) }
it { is_expected.to have_many(:trips).dependent(:destroy) }
end
describe 'callbacks' do

View file

@ -12,14 +12,14 @@ RSpec.describe 'Api::V1::Photos', type: :request do
'id' => '123',
'latitude' => 35.6762,
'longitude' => 139.6503,
'createdAt' => '2024-01-01T00:00:00.000Z',
'localDateTime' => '2024-01-01T00:00:00.000Z',
'type' => 'photo'
},
{
'id' => '456',
'latitude' => 40.7128,
'longitude' => -74.0060,
'createdAt' => '2024-01-02T00:00:00.000Z',
'localDateTime' => '2024-01-02T00:00:00.000Z',
'type' => 'photo'
}
]

148
spec/requests/trips_spec.rb Normal file
View file

@ -0,0 +1,148 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/trips', type: :request do
let(:valid_attributes) do
{
name: 'Summer Vacation 2024',
started_at: Date.tomorrow,
ended_at: Date.tomorrow + 7.days,
notes: 'A wonderful week-long trip'
}
end
let(:invalid_attributes) do
{
name: '', # name can't be blank
start_date: nil, # dates are required
end_date: Date.yesterday # end date can't be before start date
}
end
let(:user) { create(:user) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
allow_any_instance_of(Trip).to receive(:photos).and_return([])
sign_in user
end
describe 'GET /index' do
it 'renders a successful response' do
get trips_url
expect(response).to be_successful
end
end
describe 'GET /show' do
let(:trip) { create(:trip, :with_points, user:) }
it 'renders a successful response' do
get trip_url(trip)
expect(response).to be_successful
end
end
describe 'GET /new' do
it 'renders a successful response' do
get new_trip_url
expect(response).to be_successful
end
end
describe 'GET /edit' do
let(:trip) { create(:trip, :with_points, user:) }
it 'renders a successful response' do
get edit_trip_url(trip)
expect(response).to be_successful
end
end
describe 'POST /create' do
context 'with valid parameters' do
it 'creates a new Trip' do
expect do
post trips_url, params: { trip: valid_attributes }
end.to change(Trip, :count).by(1)
end
it 'redirects to the created trip' do
post trips_url, params: { trip: valid_attributes }
expect(response).to redirect_to(trip_url(Trip.last))
end
end
context 'with invalid parameters' do
it 'does not create a new Trip' do
expect do
post trips_url, params: { trip: invalid_attributes }
end.to change(Trip, :count).by(0)
end
it "renders a response with 422 status (i.e. to display the 'new' template)" do
post trips_url, params: { trip: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /update' do
context 'with valid parameters' do
let(:new_attributes) do
{
name: 'Updated Trip Name',
notes: 'Changed trip notes'
}
end
let(:trip) { create(:trip, :with_points, user:) }
it 'updates the requested trip' do
patch trip_url(trip), params: { trip: new_attributes }
trip.reload
expect(trip.name).to eq('Updated Trip Name')
expect(trip.notes.body.to_plain_text).to eq('Changed trip notes')
expect(trip.notes).to be_an(ActionText::RichText)
end
it 'redirects to the trip' do
patch trip_url(trip), params: { trip: new_attributes }
trip.reload
expect(response).to redirect_to(trip_url(trip))
end
end
context 'with invalid parameters' do
let(:trip) { create(:trip, :with_points, user:) }
it 'renders a response with 422 status' do
patch trip_url(trip), params: { trip: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /destroy' do
let!(:trip) { create(:trip, :with_points, user:) }
it 'destroys the requested trip' do
expect do
delete trip_url(trip)
end.to change(Trip, :count).by(-1)
end
it 'redirects to the trips list' do
delete trip_url(trip)
expect(response).to redirect_to(trips_url)
end
end
end

View file

@ -18,8 +18,8 @@ RSpec.describe Immich::RequestPhotos do
"facets": []
},
"assets": {
"total": 1000,
"count": 1000,
"total": 2,
"count": 2,
"items": [
{
"id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
@ -71,8 +71,60 @@ RSpec.describe Immich::RequestPhotos do
"hasMetadata": true,
"duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
"resized": true
},
{
"id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c2',
"deviceAssetId": 'IMG_9913.jpeg-1168914',
"ownerId": 'f579f328-c355-438c-a82c-fe3390bd5f08',
"deviceId": 'CLI',
"libraryId": nil,
"type": 'VIDEO',
"originalPath": 'upload/library/admin/2023/2023-06-08/IMG_9913.jpeg',
"originalFileName": 'IMG_9913.jpeg',
"originalMimeType": 'image/jpeg',
"thumbhash": '4RgONQaZqYaH93g3h3p3d6RfPPrG',
"fileCreatedAt": '2023-06-08T07:58:45.637Z',
"fileModifiedAt": '2023-06-08T09:58:45.000Z',
"localDateTime": '2023-06-08T09:58:45.637Z',
"updatedAt": '2024-08-24T18:20:47.965Z',
"isFavorite": false,
"isArchived": false,
"isTrashed": false,
"duration": '0:00:00.00000',
"exifInfo": {
"make": 'Apple',
"model": 'iPhone 12 Pro',
"exifImageWidth": 4032,
"exifImageHeight": 3024,
"fileSizeInByte": 1_168_914,
"orientation": '6',
"dateTimeOriginal": '2023-06-08T07:58:45.637Z',
"modifyDate": '2023-06-08T07:58:45.000Z',
"timeZone": 'Europe/Berlin',
"lensModel": 'iPhone 12 Pro back triple camera 4.2mm f/1.6',
"fNumber": 1.6,
"focalLength": 4.2,
"iso": 320,
"exposureTime": '1/60',
"latitude": 52.11,
"longitude": 13.22,
"city": 'Johannisthal',
"state": 'Berlin',
"country": 'Germany',
"description": '',
"projectionType": nil,
"rating": nil
},
"livePhotoVideoId": nil,
"people": [],
"checksum": 'aL1edPVg4ZpEnS6xCRWNUY0pUS8=',
"isOffline": false,
"hasMetadata": true,
"duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
"resized": true
}
]
],
nextPage: nil
}
}.to_json
end
@ -84,6 +136,10 @@ RSpec.describe Immich::RequestPhotos do
'http://immich.app/api/search/metadata'
).to_return(status: 200, body: immich_data, headers: {})
end
it 'returns images and videos' do
expect(service.map { _1['type'] }.uniq).to eq(['IMAGE', 'VIDEO'])
end
end
context 'when user has no immich_url' do

View file

@ -20,6 +20,20 @@
resolved "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz"
integrity sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA==
"@rails/actiontext@^8.0.0":
version "8.0.0"
resolved "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.0.0.tgz"
integrity sha512-8pvXDEHqlVHptzfYDUXmBpstHsfHAVacYxO47cWDRjRmp1zdVXusLcom8UvqkRdTcAPXpte+LkjcfpD9S4DSSQ==
dependencies:
"@rails/activestorage" ">= 8.0.0-alpha"
"@rails/activestorage@>= 8.0.0-alpha":
version "8.0.0"
resolved "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0.tgz"
integrity sha512-qoA7U1gMcWXhDnImwDIyRQDXkQKzThT2lu2Xpim8CnTOCEeAgkQ5Co2kzodpAI2grF1JSDvwXSPYNWwVAswndA==
dependencies:
spark-md5 "^3.0.1"
camelcase-css@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
@ -64,15 +78,20 @@ leaflet@^1.9.4:
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
version "3.3.8"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
picocolors@^1, picocolors@^1.0.0:
picocolors@^1:
version "1.0.0"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
postcss-js@^4:
version "4.0.1"
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
@ -80,16 +99,26 @@ postcss-js@^4:
dependencies:
camelcase-css "^2.0.1"
postcss@^8.4.21:
version "8.4.35"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz"
integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
postcss@^8.4.49:
version "8.4.49"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
dependencies:
nanoid "^3.3.7"
picocolors "^1.0.0"
source-map-js "^1.0.2"
picocolors "^1.1.1"
source-map-js "^1.2.1"
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
spark-md5@^3.0.1:
version "3.0.2"
resolved "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz"
integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
trix@^2.1.8:
version "2.1.8"
resolved "https://registry.npmjs.org/trix/-/trix-2.1.8.tgz#b9383af8cd9c1a0a0818d6b4e0c9e771bf7fd564"
integrity sha512-y1h5mKQcjMsZDsUOqOgyIUfw+Z31u4Fe9JqXtKGUzIC7FM9cTpxZFFWxQggwXBo18ccIKYx1Fn9toVO5mCpn9g==