diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 982d94b0..d6a6ecf3 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -14,94 +14,18 @@ *= require_self */ -.emoji-icon { - font-size: 36px; /* Adjust size as needed */ - text-align: center; - line-height: 36px; /* Same as font-size for perfect centering */ -} - -.timeline-box { - overflow: visible !important; -} - -/* Style for the settings panel */ -.leaflet-settings-panel { - background-color: white; - padding: 10px; - border: 1px solid #ccc; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); -} - -.leaflet-settings-panel label { - display: block; - margin-bottom: 5px; -} - -.leaflet-settings-panel input { +/* Leaflet map container styles */ +[data-controller="visits-map"] { + height: 100%; width: 100%; - margin-bottom: 10px; - padding: 5px; - border: 1px solid #ccc; - border-radius: 3px; } -.leaflet-settings-panel button { - padding: 5px 10px; - background-color: #007bff; - color: white; - border: none; - border-radius: 3px; - cursor: pointer; -} - -.leaflet-settings-panel button:hover { - background-color: #0056b3; -} - -.photo-marker { - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - border-radius: 50%; -} - -.photo-marker img { - border-radius: 50%; - width: 48px; - height: 48px; -} - -.leaflet-loading-control { - padding: 5px; - border-radius: 4px; - box-shadow: 0 1px 5px rgba(0,0,0,0.2); - margin: 10px; - width: 32px; - height: 32px; - background: white; -} - -.loading-spinner { - display: flex; - align-items: center; - gap: 8px; - font-size: 18px; - color: gray; -} - -.loading-spinner::before { - content: '🔵'; - font-size: 18px; - animation: spinner 1s linear infinite; -} - -.loading-spinner.done::before { - content: '✅'; - animation: none; +[data-visits-map-target="container"] { + height: 100%; + width: 100%; } +/* Loading spinner animation */ @keyframes spinner { to { transform: rotate(360deg); diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 48e213d2..8b0dac9b 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -20,3 +20,58 @@ transition: opacity 150ms ease-in-out; } } + +/* Leaflet Panel Styles */ +.leaflet-right-panel { + margin-top: 80px; /* Give space for controls above */ + margin-right: 10px; + transform: none; + transition: right 0.3s ease-in-out; + z-index: 400; + background: white; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.leaflet-right-panel.controls-shifted { + right: 310px; +} + +.leaflet-control-button { + background-color: white !important; + color: #374151 !important; +} + +.leaflet-control-button:hover { + background-color: #f3f4f6 !important; +} + +/* Drawer Panel Styles */ +.leaflet-drawer { + position: absolute; + top: 0; + right: 0; + width: 300px; + height: 100%; + background: rgba(255, 255, 255, 0.5); + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + z-index: 450; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); +} + +.leaflet-drawer.open { + transform: translateX(0); +} + +/* Controls transition */ +.leaflet-control-layers, +.leaflet-control-button, +.toggle-panel-button { + transition: right 0.3s ease-in-out; + z-index: 500; +} + +.controls-shifted { + right: 300px !important; +} diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 6a6b51fb..3c5a773e 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -1,6 +1,32 @@ # frozen_string_literal: true class Api::V1::VisitsController < ApiController + def index + start_time = begin + Time.zone.parse(params[:start_at]) + rescue StandardError + Time.zone.now.beginning_of_day + end + end_time = begin + Time.zone.parse(params[:end_at]) + rescue StandardError + Time.zone.now.end_of_day + end + + visits = + Visit + .includes(:place) + .where(user: current_api_user) + .where('started_at >= ? AND ended_at <= ?', start_time, end_time) + .order(started_at: :desc) + + serialized_visits = visits.map do |visit| + Api::VisitSerializer.new(visit).call + end + + render json: serialized_visits + end + def update visit = current_api_user.visits.find(params[:id]) visit = update_visit(visit) @@ -11,7 +37,7 @@ class Api::V1::VisitsController < ApiController private def visit_params - params.require(:visit).permit(:name, :place_id) + params.require(:visit).permit(:name, :place_id, :status) end def update_visit(visit) diff --git a/app/controllers/visits_controller.rb b/app/controllers/visits_controller.rb index a8469831..0ce00b10 100644 --- a/app/controllers/visits_controller.rb +++ b/app/controllers/visits_controller.rb @@ -11,11 +11,10 @@ class VisitsController < ApplicationController visits = current_user .visits .where(status:) - .includes(%i[suggested_places area]) + .includes(%i[suggested_places area points]) .order(started_at: order_by) @suggested_visits_count = current_user.visits.suggested.count - @visits = visits.page(params[:page]).per(10) end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 9868368a..6b2de7fd 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -29,6 +29,8 @@ export default class extends BaseController { layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; + drawerOpen = false; + visitCircles = L.layerGroup(); connect() { super.connect(); @@ -248,6 +250,12 @@ export default class extends BaseController { // Start monitoring this.tileMonitor.startMonitoring(); + + // Add the drawer button + this.addDrawerButton(); + + // Fetch and display visits when map loads + this.fetchAndDisplayVisits(); } disconnect() { @@ -1322,11 +1330,294 @@ export default class extends BaseController { formatDuration(seconds) { const days = Math.floor(seconds / (24 * 60 * 60)); const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); + const minutes = Math.floor((seconds % (60 * 60)) / 60); - if (days > 0) { - return `${days}d ${hours}h`; + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 && days === 0) parts.push(`${minutes}m`); // Only show minutes if less than a day + + return parts.join(' ') || '< 1m'; + } + + addDrawerButton() { + const DrawerControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button'); + button.innerHTML = '⬅️'; // Left arrow icon + button.style.width = '32px'; + button.style.height = '32px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '32px'; + button.style.textAlign = 'center'; + + L.DomEvent.disableClickPropagation(button); + L.DomEvent.on(button, 'click', () => { + this.toggleDrawer(); + }); + + return button; + } + }); + + this.map.addControl(new DrawerControl({ position: 'topright' })); + } + + toggleDrawer() { + this.drawerOpen = !this.drawerOpen; + + let drawer = document.querySelector('.leaflet-drawer'); + if (!drawer) { + drawer = this.createDrawer(); } - return `${hours}h`; + + drawer.classList.toggle('open'); + + const drawerButton = document.querySelector('.drawer-button'); + if (drawerButton) { + drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️'; + } + + const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel'); + controls.forEach(control => { + control.classList.toggle('controls-shifted'); + }); + + // Update the drawer content if it's being opened + if (this.drawerOpen) { + this.fetchAndDisplayVisits(); + } + } + + createDrawer() { + const drawer = document.createElement('div'); + drawer.className = 'leaflet-drawer'; + + // Add styles to make the drawer scrollable + drawer.style.overflowY = 'auto'; + drawer.style.maxHeight = '100vh'; + + drawer.innerHTML = ` +
Loading visits...
+Error loading visits
'; + } + } + } + + displayVisits(visits) { + const container = document.getElementById('visits-list'); + if (!container) return; + + if (!visits || visits.length === 0) { + container.innerHTML = 'No visits found in selected timeframe
'; + return; + } + + // Clear existing circles + this.visitCircles.clearLayers(); + + // Draw circles only for confirmed visits + visits + .filter(visit => visit.status === 'confirmed') + .forEach(visit => { + if (visit.place?.latitude && visit.place?.longitude) { + const circle = L.circle([visit.place.latitude, visit.place.longitude], { + color: '#4A90E2', + fillColor: '#4A90E2', + fillOpacity: 0.2, + radius: 100, + weight: 2 + }); + this.visitCircles.addLayer(circle); + } + }); + + const html = visits + // Filter out declined visits + .filter(visit => visit.status !== 'declined') + .map(visit => { + const startDate = new Date(visit.started_at); + const endDate = new Date(visit.ended_at); + const isSameDay = startDate.toDateString() === endDate.toDateString(); + + let timeDisplay; + if (isSameDay) { + timeDisplay = ` + ${startDate.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} + `; + } else { + timeDisplay = ` + ${startDate.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })}, + ${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} + `; + } + + const durationText = this.formatDuration(visit.duration * 60); + + // Add opacity class for suggested visits + const bgClass = visit.status === 'suggested' ? 'bg-neutral border-dashed border-2 border-red-500' : 'bg-base-200'; + + return ` +- Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page! -
++ Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page! +
+