* feat: initialize Spring Boot project with core application structure

* docs: update README with project description and setup instructions

* Remove unused Location model and TimelineController

The Location model and TimelineController, along with related dependencies (Lombok and placeholder data), were removed as they are no longer used. Updated the project to Java 21 and Spring Boot 3.3.2 for compatibility and modern features. Adjusted `.gitignore` and cleaned up the Maven configuration accordingly.

* feat: add docker-compose and database initialization for Reitti infrastructure

* chore: Add comment explaining reitti user creation in init.sql

* fix: enhance database user privileges in init.sql

* Remove unused pgAdmin service and redundant SQL grants

The pgAdmin service definition has been removed from docker-compose.yml as it is not used. Additionally, redundant schema privileges in init.sql were cleaned up to avoid duplication and ensure clarity.

* feat: add core model classes, repositories, and controllers for Reitti application

* fixed code. removed lombok finally and fixed db initialization

* - Replace H2 database with PostgreSQL and add Flyway dependencies
- Introduce Flyway migrations for initial database schema
- Update JPA configuration: set `ddl-auto` to `validate` and remove unnecessary logging properties

* - Update Java version to 24 in `pom.xml` and `README.md`
- Add Spring Boot Actuator dependency to `pom.xml`

* feat: Add Thymeleaf support with web and timeline controllers

* - Replace external MapLibre style with custom inline raster style in `index.html`
- Update Spring Boot version to 3.5.0 in `pom.xml`
- Remove deprecated Hibernate dialect property from `application.properties`

* feat: implement API token authentication and location data ingestion

* - Remove `LocationDataController` and `TimelineController` implementations.
- Relocate `ApiTokenController` and `LocationDataApiController` to `controller.api` package.
- Update `.gitignore` to exclude `/target/` and `/.idea/` directories.

* feat: add location data API controller and request DTO

* - Remove `userId` field from `LocationDataRequest` class.
- Simplify `LocationDataRequest` handling by removing associated getters, setters, and validations for `userId`.
- Remove unused endpoint for processing single location points (`/location-point`).
- Clean up unused code and redundant logic in `LocationDataApiController`.

* feat: implement event-driven architecture for location data processing

* feat: add RabbitMQ configuration to application.properties

* feat: add Google Takeout location import endpoint with streaming processing

* - Add initial test structure for `LocationDataApiController`
- Remove unnecessary imports and `@Transactional` annotation from `LocationDataProcessingService`
- Update RabbitMQ credentials in `application.properties`

* feat: Migrate location data processing to RabbitMQ asynchronous messaging

This commit implements the following changes:
- Replace ApplicationEventPublisher with RabbitMQ for location data processing
- Add RabbitMQ configuration and dependencies
- Modify LocationDataApiController to publish events to RabbitMQ
- Create LocationDataProcessingService to consume RabbitMQ messages
- Update event handling to use RabbitMQ message listener
- Improve scalability and resilience of location data processing

* feat: add method to find RawLocationPoint by user and timestamp

* feat: prevent duplicate location points by timestamp check

* feat: Add geospatial support and visit tracking to SignificantPlace model

This commit enhances the SignificantPlace model with:
- PostGIS Point geometry column for geospatial queries
- Visit count tracking
- Total duration tracking for visits
- Methods to increment visit count and add duration
- Additional constructor to support Point geometry

The changes will improve geospatial analysis and provide more insights into place visitation patterns.

* - Remove `@Transactional` annotation from `processLocationData` to simplify method behavior.
- Add `jts-core` dependency to `pom.xml` for geometry data support.
- Create database migration `V3__updated_significant_places.sql` to add `geom`, `total_duration_seconds`, and `visit_count` columns to `significant_places` table.
- Refactor `LocationDataApiController` to remove unused `LocationDataService` dependency.

* feat: add method to calculate average visit duration for significant places

* Based on the changes and the context of the location processing pipeline, here's a concise commit message:

feat: Implement location processing pipeline with stay point detection and significant place tracking

* feat: add GeoCodingService to SignificantPlaceService constructor

* feat: implement event-driven reverse geocoding for significant places

* feat: add raw location points display with map toggle

* feat: load raw location points from repository for timeline view

* - Add initial JSON sample data for Google Takeout location records
- Refactor `StayPointDetectionService` to improve null safety and reduce distance threshold
- Remove redundant raw-points layer cleanup in map update function
- Adjust imports and clean up `SignificantPlaceService` for efficiency

* fix: correct stay point detection logic by changing distance condition

* feat: implement cluster-based stay point detection algorithm

* - Adjust `PLACE_MERGE_DISTANCE` to use degrees instead of meters.
- Replace `save` with `saveAndFlush` for immediate database synchronization.
- Add `Optional` check when updating existing significant places.
- Remove unnecessary `@Transactional` annotations.

* - Remove redundant map layer and source clearing logic
- Simplify handling of raw points and map updates

* feat: implement trip detection between significant places

* feat: add visit processing configuration and migration for processed visits

* feat: implement visit merging with processed visits table

* refactor: improve visit merging to handle single-place visits chronologically

* feat: add trip detection service and controller for identifying trips between consecutive visits

This commit introduces a comprehensive trip detection system that:
- Identifies trips between consecutive processed visits
- Calculates estimated distance using Haversine formula
- Infers transport mode based on speed
- Provides REST endpoints for manual trip detection
- Integrates with visit merging process to automatically detect trips

Key changes:
- Created TripDetectionService to handle trip detection logic
- Added TripDetectionController for manual trip detection
- Updated VisitMergingRunner to optionally run trip detection after visit merging
- Implemented distance and transport mode inference methods

* feat: enable processing visits on startup

* feat: add trip detection configuration to application properties

* feat: add travelled distance tracking for trips

* refactor: Remove unused code and simplify TripDetectionService

* feat: Add travelled distance calculation for trip detection

* feat: replace mock timeline implementation with actual database data

* refactor: rename origin/destination place methods in timeline view controller

* refactor: Replace Visit with ProcessedVisit in TimelineViewController

* feat: Prevent duplicate ProcessedVisit entries during visit processing

This commit adds logic to check for existing ProcessedVisit entries and prevent duplicates when processing visits. Key changes include:

- Add method to find overlapping ProcessedVisits by user, place, and time range
- Check for existing ProcessedVisits before processing to avoid redundant work
- Update existing ProcessedVisit entries instead of creating duplicates
- Extend time ranges and merge original visit IDs when overlapping visits are found

* feat: implement trip merging service to handle duplicate trips

This commit adds functionality to merge duplicate trips for users by:
- Grouping trips by user, start/end places, and time range
- Merging trips with the earliest start and latest end times
- Recalculating distances based on raw location points
- Determining the most common transport mode
- Adding API endpoints to trigger trip merging for all users or a specific user

* feat: Add processed flag to Visit repository to filter unprocessed visits

* refactor: Remove unused SignificantPlaceRepository and simplify visit processing logic

* feat: Add processed flag to Visit and mark visits as processed after merging

* - Add `processed` column to `visits` table.
- Refactor `calculateHaversineDistance` to `GeoUtils` utility class and update usage across services.
- Enhance `LocationProcessingPipeline` with trip and visit merging logic.
- Modify processed visit indexing and detect trips within specific time ranges.
- Update Google Takeout import API to handle larger file uploads (5GB limit).
- Improve logic and format consistency in database migration and UI templates.
- Adjust trip grouping key and reduce redundant trip detection steps.

* feat: add settings page with navigation link and configuration options

* refactor: simplify index.html by removing complex map and timeline features

* feat: Add full-screen map with transparent navbar and timeline panel

* feat: add timeline navigation and container to index page

* feat: add MapLibre GL JS map with interactive timeline markers and paths

* feat: Update timeline navigation and endpoint handling

* feat: Add TimelineApiController with JSON timeline endpoints

* refactor: replace HTMX timeline loading with pure JavaScript

* feat: Enhance timeline entries with improved styling and details

* feat: enhance timeline entry rendering with icons, details, and duration

* feat: migrate map library from MapLibre GL to Leaflet

* - **Remove settings.html**: Deleted the `settings.html` template as it is no longer used.
- **Update index.html**: Minor formatting and whitespace adjustments.
- **Enhance trip details API**: Added path data to Timeline API, including latitude, longitude, timestamp, and accuracy.
- **Refactor TimelineResponse**: Added `path` attribute to `TimelineEntry` and introduced `PointInfo` record for trip path details.
- **Adjust RabbitMQ Listener**: Added concurrency limit for `LocationDataProcessingService` and removed unused error handling comments.
- **Remove settings mapping**: Deleted unused `/settings` mapping in `WebViewController`.

* feat: add user authentication with login page and password storage

* refactor: replace ON CONFLICT with PL/pgSQL block for admin user migration

* - Rename migration file from `V2` to `V7`.
- Use `ON CONFLICT` to handle admin user creation/upsert logic.
- Ensure `password` column addition remains idempotent.

* feat: add settings page with API token, user management, and job status sections

* - Add `getTokensForUser` method to `ApiTokenService` to fetch user-specific tokens.
- Introduce `RabbitAdmin` bean configuration in `RabbitMQConfig`.
- Autowire `RabbitAdmin` in `QueueStatsService` constructor.
- Add `thymeleaf-extras-springsecurity6` dependency in `pom.xml`.
- Extend `ApiTokenRepository` with `findByUser` method.
- Remove `redirectToSettings` method from `WebViewController`.

* feat: add pagination and place management to settings page

This commit adds a new section to the settings page for managing significant places, including:
- Pagination of places (20 per page)
- Small map display for each place
- Ability to update place names
- Server-side support for place updates
- URL parameter handling for active tabs

The changes include updates to:
- SignificantPlaceRepository (added pagination method)
- PlaceService (added methods for retrieving and updating places)
- SettingsController (added place management endpoint)
- Settings page template (added places management section)

* feat: add places management section with pagination and map display

* - Refactored `QueueStatsService` by removing trip queue handling and updating queue names.
- Integrated Leaflet library into `settings.html` for enhanced map functionality.
- Removed outdated `<div>` and JavaScript trip queue elements in `settings.html`.
- Deleted unused methods and functionalities from `PlaceService`.

* - Fix typo in `LOCATION_QUEUE` constant string.
- Add a translucent circle to the map with a radius of 30 for enhanced visualization.

* feat: add form to create new users in settings page

* feat: add user creation endpoint and move form below users table

* feat: add createUser method to UserService

* feat: add BCryptPasswordEncoder to UserService for secure password hashing

* - Remove `TripMergingController` and its API endpoints.
- Transition trip processing to RabbitMQ messaging.
- Replace direct user trip/visit processing with message queue listeners.
- Simplify `QueueStatsService` and update client APIs for queue stats.
- Add new RabbitMQ queues in `RabbitMQConfig` for processing workflows.
- Refactored frontend template (`settings.html`) for dynamic job statistics display.
- General code cleanup: remove unused methods, adjust indentation, minor fixes.

* feat: add auto-refresh for queue stats on settings page

* feat: add date parameter to URL for sharing and navigation

* - Refactored trip merging logic with added flexibility for grouping by start or end time.
- Removed unused fields (`firstSeen`, `lastSeen`, `visitCount`, `totalDurationSeconds`) from `SignificantPlace` model and database.
- Adjusted significant place processing to simplify logic and remove redundant updates.
- Reduced logging verbosity, changed significant log levels to `debug` or `trace`.
- Updated queue stats refresh interval in settings template.
- Added `MERGE_TRIP_ROUTING_KEY` scheduling in `VisitMergingRunner`.
- Disabled Spring Security debug mode and updated `csrf` configuration.
- Removed unused import and properties.

* style: Refactor index.html with modern UI and improved UX design

* style: switch to 24h time format in timeline display

* - Add new `main.css` file with updated styles for UI enhancements
- Update `index.html` to improve structure and styles, including `timeline-header` and `map` elements placement
- Integrate `HumanizeDuration.js` library for handling time-related operations

* - Remove unused `/merge/{userId}` API endpoint.
- Refactor `VisitMergingService` and `TripDetectionService` to process events with time ranges.
- Introduce `MergeVisitEvent` to encapsulate processing time ranges.
- Replace `CommandLineRunner` in `VisitMergingRunner` with `@Scheduled` for periodic execution.
- Adjust timeline font to "Serif" in `index.html`.
- Increase location processing batch size from 1 to 100.
- Update RabbitMQ prefetch count from 2 to 10.

* - Remove unused imports in `MergeVisitEvent` class
- Optimize code readability

* - Replace `MergeVisitEvent` record with a class for extended functionality.
- Introduce time range filtering in trip and visit merging processes.
- Add configurable cron-based scheduling for visit and trip processing.
- Improve RabbitMQ event handling with detailed payloads.
- Add database index for `raw_location_points` to optimize queries.
- Remove `process-visits-on-startup` and introduce `process-visits-trips.schedule`.
- Increase RabbitMQ processing concurrency for location data queue.
- Simplify significant place repository save operation for efficiency.

* feat: replace date picker with text display of current day

* - Refactored timeline styles in `index.html` for improved layout and consistency.
- Adjusted trip merging logic to remove unused variables and streamline execution in `TripMergingService`.
- Cleaned up redundant visit merging logic in `VisitMergingRunner`.
- Removed obsolete `processVisitsOnStartup` flag from `VisitMergingRunner`.

* feat: add calendar view with date selection functionality

* feat: display multiple months in calendar view

* feat: enhance calendar to display 6 months with responsive layout

* style: update calendar layout and month display count

* feat: add horizontal date picker with responsive design and event handling

* feat: add horizontal date picker with dynamic date selection

* refactor: rename datePicker variable to datePicker2 in index.html

* feat: add auto-select on scroll for horizontal date picker

* feat: add navigation buttons and improve date picker navigation functionality

* refactor: disable navigation buttons in date picker

* feat: add min and max date configuration to horizontal date picker

* feat: enhance horizontal date picker with infinite scrolling and date centering

* feat: Enable infinite scrolling in horizontal date picker without min/max date constraints

* feat: implement infinite scrolling for horizontal date picker

* feat: improve horizontal date picker with centered selection and scroll handling

* feat: add month and year display for selected date element

* feat: add month and year display to selected date element

* feat: enhance date picker with smooth scrolling and animated selection

* refactor: prevent month name duplication during date selection and scrolling

* - Remove unnecessary animation delays and transitions in `horizontal-date-picker.js`
- Refine `.selected` style for date picker in `index.html`
- Comment out unused URL and date display update logic in `index.html`
- Configure RabbitMQ listener concurrency for trip detection service

* perf: optimize horizontal date picker scrolling performance

* refactor: simplify date picker onDateSelect callback

* refactor: remove calendar functionality and related HTML/CSS

* refactor: disable navigation buttons in date picker

* refactor: remove date display and related functionality from index.html

* style: remove date label CSS class

* refactor: remove date navigation and related JavaScript code

* fix: improve date picker selection and scrolling behavior

* fix: add null check for date picker input element

* fix: improve date selection behavior in horizontal date picker

* feat: add horizontal date picker with fixed bottom positioning

* feat: add month row feature with month selection and synchronization

* fix: sync month row with selected date and display 12 months

* feat: modify month picker to start from January for the selected year

* feat: prevent actions when clicking on active month or date

* feat: add year selection and scrolling to horizontal date picker

* style: enhance month item and date picker styling with color and font updates

* feat: add configurable year row with show/hide and years to display options

* feat: emit date selected event when selecting month or year

* style: update timeline and date picker styling with padding and color adjustments

* feat: add option to control selection of future dates

* feat: add today button option to horizontal date picker

* style: update CSS styles for date picker and map components

* feat: ensure date picker initializes with URL-provided date

* fix: adjust date comparison to include full current day

* feat: center horizontal date picker around selected date instead of today

* refactor: preserve current year and month when selecting date components

* fix: update timeline when selecting year or month

* fix: prevent date jumping when selecting month or year in date picker

* - Add concurrency configuration (1-16) to @RabbitListener annotation in ReverseGeocodingListener
This commit is contained in:
Daniel Graf
2025-05-29 23:01:29 +02:00
committed by GitHub
parent 81abf45e00
commit 2eabf69396
80 changed files with 19028 additions and 1 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
.aider*
/target/
/.idea/

View File

@@ -1 +1,38 @@
# reitti
# Reitti
Reitti is a Spring Boot application for tracking and visualizing location data over time.
## Features
- Store location data with timestamps
- View timeline of locations
- REST API for location data
## Getting Started
### Prerequisites
- Java 24 or higher
- Maven 3.6 or higher
- Docker and Docker Compose
### Running the Application
1. Clone the repository
2. Navigate to the project directory
3. Start the infrastructure services with `docker-compose up -d`
4. Run `mvn spring-boot:run`
5. Access the application at `http://localhost:8080`
## API Endpoints
- `GET /api/v1/timeline` - Get all timeline locations
## Technologies
- Spring Boot
- Spring Data JPA
- PostgreSQL with PostGIS and TimescaleDB extensions
- Redis for caching
- RabbitMQ for message queuing
- Lombok

57
docker-compose.yml Normal file
View File

@@ -0,0 +1,57 @@
services:
# PostgreSQL with PostGIS and TimescaleDB extensions
postgis:
image: timescale/timescaledb-ha:pg17
container_name: reitti-postgis
environment:
POSTGRES_USER: reitti
POSTGRES_PASSWORD: reitti
POSTGRES_DB: reittidb
ports:
- "5432:5432"
volumes:
- postgis-data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reitti -d reittidb"]
interval: 10s
timeout: 5s
retries: 5
# Redis for caching
redis:
image: redis:7-alpine
container_name: reitti-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# RabbitMQ for message queuing
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: reitti-rabbitmq
ports:
- "5672:5672" # AMQP protocol port
- "15672:15672" # Management UI port
environment:
RABBITMQ_DEFAULT_USER: reitti
RABBITMQ_DEFAULT_PASS: reitti
volumes:
- rabbitmq-data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 30s
timeout: 10s
retries: 5
volumes:
postgis-data:
redis-data:
rabbitmq-data:

90
generated-requests.http Normal file
View File

@@ -0,0 +1,90 @@
###
# @name Create new User Token
POST http://localhost:8080/api/v1/tokens
Content-Type: application/json
{
"name": "test-token",
"username": "daniel"
}
###
# @name Upload Whole Google Takeout Records.json
POST http://localhost:8080/api/v1/import/google-takeout
Content-Type: multipart/form-data; boundary=boundary
X-API-Token: d8a3e1ed-33ad-4e6c-b517-757b54d03833
--boundary
Content-Disposition: form-data; name="file"; filename="Records.json"
< /home/daniel/Downloads/takeout-20250422T152421Z-001/Takeout/Zeitachse/Records.json
###
# @name Upload Google Takeout Records.json
POST http://localhost:8080/api/v1/import/google-takeout
Content-Type: multipart/form-data; boundary=boundary
X-API-Token: d8a3e1ed-33ad-4e6c-b517-757b54d03833
--boundary
Content-Disposition: form-data; name="file"; filename="Records.json"
< src/test/resources/data/exports/google-takeout/Records-one-day.json
<> 2025-05-26T131205.202.json
<> 2025-05-26T130742.202.json
<> 2025-05-26T130708.202.json
<> 2025-05-26T130015.202.json
<> 2025-05-26T125856.202.json
<> 2025-05-26T123636.202.json
<> 2025-05-26T112900-2.202.json
<> 2025-05-26T112900-1.202.json
<> 2025-05-26T112900.202.json
<> 2025-05-26T112859-3.202.json
<> 2025-05-26T112859-2.202.json
<> 2025-05-26T112859-1.202.json
<> 2025-05-26T112859.202.json
<> 2025-05-26T112858-3.202.json
<> 2025-05-26T112858-2.202.json
<> 2025-05-26T112858-1.202.json
<> 2025-05-26T112858.202.json
<> 2025-05-26T112857-1.202.json
<> 2025-05-26T112857.202.json
<> 2025-05-26T112856-1.202.json
<> 2025-05-26T112856.202.json
<> 2025-05-26T112854.202.json
<> 2025-05-26T112841.202.json
<> 2025-05-26T112750.202.json
<> 2025-05-26T112750-1.202.json
<> 2025-05-26T112749-3.202.json
<> 2025-05-26T112749-2.202.json
<> 2025-05-26T112749-1.202.json
<> 2025-05-26T112749.202.json
<> 2025-05-26T112748-2.202.json
<> 2025-05-26T112748-1.202.json
<> 2025-05-26T112748.202.json
<> 2025-05-26T112747.202.json
<> 2025-05-26T112746-1.202.json
<> 2025-05-26T112746.202.json
<> 2025-05-26T112745-2.202.json
<> 2025-05-26T112745-1.202.json
<> 2025-05-26T112745.202.json
<> 2025-05-26T112744-4.202.json
<> 2025-05-26T112744-3.202.json
<> 2025-05-26T112744-2.202.json
<> 2025-05-26T112744-1.202.json
<> 2025-05-26T112744.202.json
<> 2025-05-26T112738.202.json
<> 2025-05-26T112657.202.json
<> 2025-05-26T112009.202.json
<> 2025-05-26T105346.200.json
<> 2025-05-26T105220.500.json
<> 2025-05-26T105116.400.json
<> 2025-05-26T105039.400.json
###
# @name Merge Trips
POST http://localhost:8080/api/v1/trips/merge/user/1

12
init-db/init.sql Normal file
View File

@@ -0,0 +1,12 @@
-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Create schema
CREATE SCHEMA IF NOT EXISTS reitti;
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE reittidb TO reitti;
ALTER USER reitti WITH SUPERUSER;

351
planning/plan.txt Normal file
View File

@@ -0,0 +1,351 @@
Overview
Okay, with the mobile data collection handled by another entity, we can focus on a robust backend to process and store that data, and a powerful frontend to visualize the timeline. Heres a recommended stack for that scenario:
Assumptions:
- You will receive location data (latitude, longitude, timestamp, accuracy, and potentially activity type, user ID) via an API or a data ingestion pipeline.
- The “other entity” handles user authentication for data submission, or you have a mechanism to associate incoming data with specific users.
------------------------------------------------------------------------------
I. Backend: Data Ingestion, Processing, Storage & API
The backend will be the core engine, responsible for turning raw location data into a meaningful timeline.
1. Data Ingestion API:
- Purpose: To receive location data from the external entity.
- Technology:
- RESTful API: Built with Python (Flask/Django-REST-framework), Node.js (Express.js/NestJS), or Java/Kotlin (Spring Boot). This is the most common and flexible approach.
- Endpoints:
- POST /v1/location-data: For batch uploads of location points (e.g., a JSON array of location objects).
- POST /v1/location-point: For individual location point submissions (less common for historical data but possible).
- Data Format: Expect well-defined JSON payloads (e.g., {"user_id": "string", "points": [{"latitude": float, "longitude": float, "timestamp": "ISO8601_string", "accuracy_meters": float, "activity": "string_optional"}, ...]}).
- Initial Validation: Basic validation of data types, required fields, and formats.
2. Data Processing Pipeline:
- Purpose: To transform raw location data into structured timeline information.
- Orchestration:
- Task Queues: RabbitMQ or Kafka are essential. When new data arrives via the API, its pushed to a queue for asynchronous processing. This prevents API timeouts and allows for scalable, resilient processing.
- Workflow Orchestration (Optional, for complex pipelines): Apache Airflow, Prefect, or Dagster if you have many dependent processing steps.
- Processing Steps (handled by worker services consuming from the queue):
- Detailed Validation & Cleaning: More thorough data checks, outlier removal.
- Geocoding & Reverse Geocoding:
- Services: Google Maps Platform (Geocoding API, Places API), Mapbox Search API, HERE Geocoding & Search, or self-hosted Nominatim (OpenStreetMap).
- Purpose: Convert coordinates to addresses (reverse geocoding) and identify nearby points of interest (POIs). This helps label places.
- Stay Point / Significant Place Detection:
- Algorithms: Time-based clustering (e.g., if a user stays within X meters for Y minutes) or density-based clustering (e.g., DBSCAN) on the location points for each user.
- Purpose: Identify significant locations like “Home,” “Work,” or frequently visited venues.
- Path Segmentation & Transportation Mode Inference:
- Algorithms: Analyze sequences of location points between stay points to define trips.
- Inference: If activity data isnt provided or is basic, you might infer transportation modes (walking, driving, cycling) based on speed, path shape, and comparison with road networks (requires more advanced GIS processing).
- Timeline Generation: Aggregate stay points and trips into a chronological sequence for each user.
- Data Enrichment (Optional): Add context like business names from POI lookups, categories of places, etc.
3. Database:
- Primary Storage (Geospatial & Time-Series):
- PostgreSQL with PostGIS extension: Highly recommended. PostGIS offers powerful geospatial functions and indexing (e.g., ST_DWithin, ST_ClusterDBSCAN).
- TimescaleDB (PostgreSQL extension): Excellent for handling the time-series nature of location data. Provides automatic partitioning by time, improving ingest and query performance.
- Data Models (Examples):
- users: user_id (PK), metadata
- raw_location_points: point_id (PK), user_id (FK), timestamp (indexed), latitude, longitude, accuracy_meters, activity_provided, geom (PostGIS geometry point, indexed)
- significant_places: place_id (PK), user_id (FK), name (e.g., "Home", "Office", or POI name), address, latitude_centroid, longitude_centroid, category, first_seen, last_seen, geom (PostGIS geometry polygon or point)
- visits: visit_id (PK), user_id (FK), place_id (FK), start_time, end_time, duration_seconds
- trips: trip_id (PK), user_id (FK), start_place_id (FK, nullable), end_place_id (FK, nullable), start_time, end_time, duration_seconds, estimated_distance_meters, transport_mode_inferred, path_geom (PostGIS linestring, optional)
- Caching: Redis or Memcached to cache frequently accessed timeline data, user profiles, or results of expensive queries.
4. Backend API for Frontend:
- Purpose: To serve processed timeline data to the web frontend.
- Technology: RESTful API or GraphQL using the same backend framework (Python, Node.js, Java/Kotlin).
- Endpoints (Examples):
- GET /v1/timeline/{user_id}?date={YYYY-MM-DD}
- GET /v1/timeline/{user_id}?start_date={...}&end_date={...}
- GET /v1/places/{user_id}
- GET /v1/trips/{trip_id}
- PUT /v1/places/{place_id} (for user edits, e.g., naming a place)
- DELETE /v1/timeline/entry/{entry_id}
5. Programming Language & Framework Choices:
- Python with Django/Flask (+ GeoDjango, Shapely, Pandas/GeoPandas):
- Pros: Rich ecosystem for data science and geospatial processing. Rapid development.
- Cons: Can be slower for highly concurrent I/O-bound tasks compared to Node.js or Go unless using async frameworks (FastAPI, Starlette).
- Node.js with Express.js/NestJS (+ Turf.js):
- Pros: Excellent for I/O-bound operations (many API calls, database interactions). JavaScript full-stack. Turf.js for geospatial analysis.
- Cons: CPU-intensive tasks might require worker threads or offloading to other services.
- Java/Kotlin with Spring Boot (+ JTS Topology Suite):
- Pros: Robust, highly scalable, strong typing, good for large teams and complex business logic. JTS for geospatial operations.
- Cons: Can be more verbose and have a steeper learning curve for smaller projects.
------------------------------------------------------------------------------
Okay, this is an interesting and very viable approach! Using HTMX with plain JavaScript for enhancements is a great way to build dynamic applications while keeping the frontend complexity lower than full-blown SPA frameworks.
Heres the adjusted frontend plan, focusing on plain JavaScript, HTMX, and server-rendered HTML fragments:
------------------------------------------------------------------------------
II. Frontend: Visualization & User Interface (Web Application with HTMX & Plain JavaScript)
The frontend will be driven by server-rendered HTML fragments, with HTMX managing AJAX interactions and partial page updates. Plain JavaScript will be used for initializing and controlling the interactive map, and for any other client-side enhancements HTMX doesnt directly cover.
1. Core Philosophy & Architecture:
- Server-Driven UI: The backend is responsible for rendering HTML. Most UI logic and state management resides on the server.
- HTMX for Interactivity: HTMX handles AJAX requests, DOM updates (swapping HTML fragments), and event triggers.
- Plain JavaScript for Enhancements: Primarily for map manipulation and specific dynamic behaviors that are better handled client-side.
- Progressive Enhancement: The core content should be accessible even if JavaScript fails (though map functionality would be lost).
2. HTML Structure & Templating (Server-Side):
- Backend Templating Engine: Your chosen backend language will need a templating engine to generate the HTML.
- Python: Jinja2 (common with Flask/Django), Mako
- Node.js: EJS, Handlebars, Nunjucks (Jinja2-like)
- Java: Thymeleaf, FreeMarker
- Kotlin: kotlinx.html
- Main Layout: A base HTML file (index.html) that includes:
- The HTMX library (<script src="https_unpkg_com_htmx_org@1_9_12.js"></script>).
- Your custom plain JavaScript file(s) (<script src="/js/main.js" defer></script>).
- CSS files.
- Placeholders (e.g., <div id="timeline-container"></div>, <div id="map"></div>, <div id="details-pane"></div>) that HTMX will target.
- Fragments: The backend will serve small, focused HTML fragments for specific parts of the UI (e.g., a single timeline entry, a list of entries for a day, place details).
3. HTMX Integration (hx-* attributes):
- Loading Initial Data: The main page might load with some initial timeline data, or an HTMX element can trigger a load on page ready.
- Example: <div hx-get="/timeline/today" hx-trigger="load" hx-target="#timeline-container">Loading timeline...</div>
- Navigation & Filtering:
- Date pickers (can be native <input type="date"> or a simple JS one) will have HTMX attributes to fetch data for the selected date.
- Example: <input type="date" id="date-picker" name="selected_date" hx-get="/timeline" hx-include="this" hx-target="#timeline-container" hx-indicator=".htmx-indicator">
- Buttons for next/previous day: <button hx-get="/timeline/next-day/{{current_date}}" hx-target="#timeline-container">Next Day</button>
- Displaying Details:
- Clicking a timeline item (which is itself an HTML fragment) can load more details.
- Example (inside a timeline entry fragment): <div hx-get="/places/{{place_id}}/details" hx-target="#details-pane">View Details</div>
- Infinite Scroll/Pagination:
- <div hx-get="/timeline?page={{next_page}}" hx-trigger="revealed" hx-swap="afterend">Load more...</div>
- User Edits (e.g., naming a place):
- Forms submitted via HTMX: <form hx-post="/places/{{place_id}}/edit-name" hx-target="#place-name-{{place_id}}" hx-swap="outerHTML"><input name="new_name" value="{{current_name}}"><button type="submit">Save</button></form>
- The server responds with the updated HTML fragment for that part of the UI.
4. Plain JavaScript for Map & Enhancements (main.js or similar):
- Mapping Library:
- Choices: MapLibre GL JS or Leaflet are excellent as they are JavaScript libraries with no framework dependencies.
- Initialization:
JavaScript
// Example with MapLibre GL JS
const map = new maplibregl.Map({
container: 'map', // ID of the map container div
style: 'https://demotiles.maplibre.org/style.json', // your map style
center: [0, 0], // Initial center
zoom: 1 // Initial zoom
});
map.on('load', () => {
// Map is ready, potentially add initial sources/layers
// Or wait for first data load from HTMX
});
- Updating Map with Data from HTMX: This is the crucial integration point.
- Method 1: Data Attributes in HTML Fragments: Embed necessary geo-data (coordinates, paths) in data-* attributes on the HTML fragments returned by the server.
HTML
<div class="timeline-entry"
data-lat="{{entry.latitude}}"
data-lng="{{entry.longitude}}"
data-type="{{entry.type}}"
data-path="{{entry.path_geojson_string_if_trip}}">
{{entry.description}}
</div>
Then, use an HTMX event listener in your JavaScript:
JavaScript
document.body.addEventListener('htmx:afterSwap', function(event) {
const swappedElement = event.detail.elt;
// Check if the swapped element or its children contain map data
// e.g., if #timeline-container was the target
if (event.detail.target.id === 'timeline-container') {
updateMapWithNewData(swappedElement);
}
});
function updateMapWithNewData(container) {
const entries = container.querySelectorAll('.timeline-entry');
// Clear existing map layers if necessary
// map.getSource('timeline-points').setData(...); // Or remove and re-add
const points = [];
const lines = [];
entries.forEach(entry => {
const lat = parseFloat(entry.dataset.lat);
const lng = parseFloat(entry.dataset.lng);
if (!isNaN(lat) && !isNaN(lng)) {
points.push({
'type': 'Feature',
'geometry': { 'type': 'Point', 'coordinates': [lng, lat] },
'properties': { 'description': entry.textContent.trim() }
});
}
if (entry.dataset.path) {
try {
lines.push(JSON.parse(entry.dataset.path));
} catch (e) { console.error("Error parsing path GeoJSON", e); }
}
});
// Update map sources and layers with 'points' and 'lines'
// e.g., map.getSource('pointsSource').setData({ type: 'FeatureCollection', features: points });
// map.getSource('linesSource').setData({ type: 'FeatureCollection', features: lines });
}
- Method 2: JSON in a <script> tag: The server can include a <script type="application/json" id="map-data-for-request">...</script> tag within the HTML response that HTMX processes. JavaScript can then parse this.
- Method 3: Separate JSON API call (less HTMX-idiomatic but possible): After an HTMX swap, JavaScript makes a separate Workspace call to a lightweight JSON endpoint to get only the geo-data for the map.
- Map Interactivity:
- Clicking on a map feature (e.g., a point representing a place) can trigger an HTMX request to load details into #details-pane.
JavaScript
map.on('click', 'places-layer', function (e) {
const placeId = e.features[0].properties.id;
// Manually trigger an HTMX request or set hx attributes on a hidden element and click it
const hiddenTrigger = document.getElementById('map-click-trigger'); // A hidden element
hiddenTrigger.setAttribute('hx-get', `/places/${placeId}/details`);
htmx.trigger(hiddenTrigger, 'click'); // Or directly use htmx.ajax()
});
- Synchronizing map view (pan/zoom) with selected timeline items.
- Other Enhancements:
- Custom date pickers (if native ones are not sufficient).
- Client-side input validation for a better UX before HTMX submits.
- Simple animations or UI effects.
5. State Management:
- Primarily Server-Side: The server maintains the canonical state.
- HTMX Manages DOM State: Reflects server state by swapping HTML.
- Plain JavaScript for UI State: Manages purely client-side state like:
- Current map view (zoom, center).
- Toggle states for UI elements not directly tied to server data.
- Store this in JavaScript variables, potentially scoped within modules or immediately-invoked function expressions (IIFEs) to avoid polluting the global namespace.
6. Styling:
- CSS: Standard CSS, potentially using a utility-first framework like Tailwind CSS (configured to scan your HTML templates), or a preprocessor like SASS.
- CSS from libraries like Bootstrap (just the CSS part) can also be used for grid and basic styling.
7. Build Process:
- Simpler: May not require complex bundlers like Webpack or Rollup if your JavaScript is minimal.
- Concatenation & Minification: Tools like esbuild, Parcel (in its simpler configurations), or even npm scripts with uglify-js and concat can be used to bundle and minify your JS and CSS for production.
- HTMX is typically included via CDN or as a single file.
Example Workflow (Date Change):
1. User picks a new date from <input type="date" hx-get="/timeline" ...>.
2. HTMX sends a GET request to /timeline?selected_date=YYYY-MM-DD to the backend.
3. Backend queries the database, generates an HTML fragment for the timeline entries of that day (including data-* attributes for map data), and sends it back.
4. HTMX receives the HTML and swaps it into #timeline-container.
5. The htmx:afterSwap event fires. Your main.js listener: a. Parses the data-* attributes from the new HTML in #timeline-container. b. Updates the MapLibre GL JS/Leaflet map with new points and paths. c. Possibly pans/zooms the map to fit the new data.
This approach gives you the dynamic feel of a SPA for many interactions, powered by the simplicity of server-rendered HTML and targeted JavaScript for the rich map component.
Links
# Creating Geographic Solutions with Maps in Frontend
IV. Scalability & Performance
- Backend:
- Horizontally scalable worker services for data processing.
- Stateless API services behind a load balancer.
- Database optimization (proper indexing, query tuning, connection pooling). Consider read replicas for PostgreSQL if read load is high.
- Frontend:
- Efficient data fetching (pagination, fetching only data needed for the current view).
- Code splitting to reduce initial load time.
- Optimized rendering of map elements (e.g., virtualizing long lists of timeline entries, simplifying geometries at far zoom levels).
- CDN for static assets.
------------------------------------------------------------------------------
Simplified Stack Example (Python Backend, React Frontend):
- Backend:
- Framework: Java with Spring Boot
- Data Processing Workers: Celery (with RabbitMQ or Redis as a broker).
- Geospatial: PostGIS extension for PostgreSQL.
- Database: PostgreSQL + PostGIS + TimescaleDB.
- Caching: Redis.
- Frontend:
- Framework: HTMX.
- Mapping: MapLibre GL JS.
- Styling: Plain CSS
This focused stack leverages powerful open-source technologies well-suited for building a sophisticated location timeline application. The key challenges will be in the efficiency and accuracy of your data processing pipeline and the performance of rendering potentially large amounts of data on the frontend map.

102
pom.xml Normal file
View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.dedicatedcode</groupId>
<artifactId>reitti</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>reitti</name>
<description>Reitti application</description>
<properties>
<java.version>24</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.20.0</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-spatial</artifactId>
<version>6.6.15.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,14 @@
package com.dedicatedcode.reitti;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class ReittiApplication {
public static void main(String[] args) {
SpringApplication.run(ReittiApplication.class, args);
}
}

View File

@@ -0,0 +1,101 @@
package com.dedicatedcode.reitti.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "reitti-exchange";
public static final String LOCATION_DATA_QUEUE = "location-data-queue";
public static final String LOCATION_DATA_ROUTING_KEY = "location.data";
public static final String SIGNIFICANT_PLACE_QUEUE = "significant-place-queue";
public static final String SIGNIFICANT_PLACE_ROUTING_KEY = "significant.place.created";
public static final String DETECT_TRIP_QUEUE = "detect-trip-queue";
public static final String DETECT_TRIP_ROUTING_KEY = "detect.trip.created";
public static final String MERGE_TRIP_QUEUE = "merge-trip-queue";
public static final String MERGE_TRIP_ROUTING_KEY = "merge.trip.created";
public static final String MERGE_VISIT_QUEUE = "merge-visit-queue";
public static final String MERGE_VISIT_ROUTING_KEY = "merge.visit.created";
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}
@Bean
public Queue locationDataQueue() {
return new Queue(LOCATION_DATA_QUEUE, true);
}
@Bean
public Queue detectTripQueue() {
return new Queue(DETECT_TRIP_QUEUE, true);
}
@Bean
public Queue mergeTripQueue() {
return new Queue(MERGE_TRIP_QUEUE, true);
}
@Bean
public Queue mergeVisitQueue() {
return new Queue(MERGE_VISIT_QUEUE, true);
}
@Bean
public Queue significantPlaceQueue() {
return new Queue(SIGNIFICANT_PLACE_QUEUE, true);
}
@Bean
public Binding locationDataBinding(Queue locationDataQueue, TopicExchange exchange) {
return BindingBuilder.bind(locationDataQueue).to(exchange).with(LOCATION_DATA_ROUTING_KEY);
}
@Bean
public Binding significantPlaceBinding(Queue significantPlaceQueue, TopicExchange exchange) {
return BindingBuilder.bind(significantPlaceQueue).to(exchange).with(SIGNIFICANT_PLACE_ROUTING_KEY);
}
@Bean
public Binding mergeVisitBinding(Queue mergeVisitQueue, TopicExchange exchange) {
return BindingBuilder.bind(mergeVisitQueue).to(exchange).with(MERGE_VISIT_ROUTING_KEY);
}
@Bean
public Binding detectTripBinding(Queue detectTripQueue, TopicExchange exchange) {
return BindingBuilder.bind(detectTripQueue).to(exchange).with(DETECT_TRIP_ROUTING_KEY);
}
@Bean
public Binding mergeTripBinding(Queue mergeTripQueue, TopicExchange exchange) {
return BindingBuilder.bind(mergeTripQueue).to(exchange).with(MERGE_TRIP_ROUTING_KEY);
}
@Bean
public Jackson2JsonMessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, Jackson2JsonMessageConverter messageConverter) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(messageConverter);
return rabbitTemplate;
}
}

View File

@@ -0,0 +1,49 @@
package com.dedicatedcode.reitti.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private TokenAuthenticationFilter bearerTokenAuthFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(bearerTokenAuthFilter, AuthorizationFilter.class)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", true)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,49 @@
package com.dedicatedcode.reitti.config;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.service.ApiTokenService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final ApiTokenService apiTokenService;
public TokenAuthenticationFilter(ApiTokenService apiTokenService) {
this.apiTokenService = apiTokenService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("X-API-Token");
if(authHeader != null) {
Optional<User> user = apiTokenService.getUserByToken(authHeader);
if (user.isPresent()) {
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
user.get().getUsername(),
user.get().getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,16 @@
package com.dedicatedcode.reitti.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.client.RestTemplate;
@Configuration
@EnableScheduling
public class WebConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@@ -0,0 +1,157 @@
package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.model.ApiToken;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.service.ApiTokenService;
import com.dedicatedcode.reitti.service.PlaceService;
import com.dedicatedcode.reitti.service.QueueStatsService;
import com.dedicatedcode.reitti.service.UserService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/settings")
public class SettingsController {
private final ApiTokenService apiTokenService;
private final UserService userService;
private final QueueStatsService queueStatsService;
private final PlaceService placeService;
public SettingsController(ApiTokenService apiTokenService, UserService userService,
QueueStatsService queueStatsService, PlaceService placeService) {
this.apiTokenService = apiTokenService;
this.userService = userService;
this.queueStatsService = queueStatsService;
this.placeService = placeService;
}
@GetMapping
public String settings(Authentication authentication, Model model,
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) String tab) {
User currentUser = userService.getUserByUsername(authentication.getName());
// Load API tokens
List<ApiToken> tokens = apiTokenService.getTokensForUser(currentUser);
model.addAttribute("tokens", tokens);
// Load users (for admin)
List<User> users = userService.getAllUsers();
model.addAttribute("users", users);
// Load significant places with pagination (20 per page)
Page<SignificantPlace> places = placeService.getPlacesForUser(currentUser, PageRequest.of(page, 20));
model.addAttribute("places", places);
// Load queue stats
model.addAttribute("queueStats", queueStatsService.getQueueStats());
model.addAttribute("username", authentication.getName());
// Set active tab if provided
if (tab != null) {
model.addAttribute("activeTab", tab);
}
return "settings";
}
@PostMapping("/tokens")
public String createToken(Authentication authentication, @RequestParam String name, RedirectAttributes redirectAttributes) {
User user = userService.getUserByUsername(authentication.getName());
try {
ApiToken token = apiTokenService.createToken(user, name);
redirectAttributes.addFlashAttribute("tokenMessage", "Token created successfully");
redirectAttributes.addFlashAttribute("tokenSuccess", true);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("tokenMessage", "Error creating token: " + e.getMessage());
redirectAttributes.addFlashAttribute("tokenSuccess", false);
}
return "redirect:/settings";
}
@PostMapping("/tokens/{tokenId}/delete")
public String deleteToken(@PathVariable Long tokenId, RedirectAttributes redirectAttributes) {
try {
apiTokenService.deleteToken(tokenId);
redirectAttributes.addFlashAttribute("tokenMessage", "Token deleted successfully");
redirectAttributes.addFlashAttribute("tokenSuccess", true);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("tokenMessage", "Error deleting token: " + e.getMessage());
redirectAttributes.addFlashAttribute("tokenSuccess", false);
}
return "redirect:/settings";
}
@PostMapping("/users/{userId}/delete")
public String deleteUser(@PathVariable Long userId, Authentication authentication, RedirectAttributes redirectAttributes) {
try {
// Prevent self-deletion
User currentUser = userService.getUserByUsername(authentication.getName());
if (currentUser.getId().equals(userId)) {
redirectAttributes.addFlashAttribute("userMessage", "You cannot delete your own account");
redirectAttributes.addFlashAttribute("userSuccess", false);
return "redirect:/settings";
}
userService.deleteUser(userId);
redirectAttributes.addFlashAttribute("userMessage", "User deleted successfully");
redirectAttributes.addFlashAttribute("userSuccess", true);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("userMessage", "Error deleting user: " + e.getMessage());
redirectAttributes.addFlashAttribute("userSuccess", false);
}
return "redirect:/settings";
}
@PostMapping("/places/{placeId}/update")
public String updatePlace(@PathVariable Long placeId,
@RequestParam String name,
Authentication authentication,
RedirectAttributes redirectAttributes,
@RequestParam(defaultValue = "0") int page) {
try {
User currentUser = userService.getUserByUsername(authentication.getName());
placeService.updatePlaceName(placeId, name, currentUser);
redirectAttributes.addFlashAttribute("placeMessage", "Place updated successfully");
redirectAttributes.addFlashAttribute("placeSuccess", true);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("placeMessage", "Error updating place: " + e.getMessage());
redirectAttributes.addFlashAttribute("placeSuccess", false);
}
return "redirect:/settings?tab=places-management&page=" + page;
}
@PostMapping("/users")
public String createUser(@RequestParam String username,
@RequestParam String displayName,
@RequestParam String password,
RedirectAttributes redirectAttributes) {
try {
userService.createUser(username, displayName, password);
redirectAttributes.addFlashAttribute("userMessage", "User created successfully");
redirectAttributes.addFlashAttribute("userSuccess", true);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("userMessage", "Error creating user: " + e.getMessage());
redirectAttributes.addFlashAttribute("userSuccess", false);
}
return "redirect:/settings?tab=user-management";
}
}

View File

@@ -0,0 +1,154 @@
package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.dto.TimelineResponse;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
public class TimelineViewController {
private final RawLocationPointRepository rawLocationPointRepository;
private final UserRepository userRepository;
private final ProcessedVisitRepository processedVisitRepository;
private final TripRepository tripRepository;
@Autowired
public TimelineViewController(RawLocationPointRepository rawLocationPointRepository,
UserRepository userRepository,
ProcessedVisitRepository processedVisitRepository,
TripRepository tripRepository) {
this.rawLocationPointRepository = rawLocationPointRepository;
this.userRepository = userRepository;
this.processedVisitRepository = processedVisitRepository;
this.tripRepository = tripRepository;
}
/**
* Format an Instant to a time string (HH:MM)
*/
private String formatTime(Instant instant) {
if (instant == null) {
return "N/A";
}
return instant.atZone(ZoneId.systemDefault())
.toLocalTime()
.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"));
}
@GetMapping("/timeline")
public String getTimeline(@RequestParam(required = false) LocalDate selectedDate,
@RequestParam(required = false, defaultValue = "1") Long userId,
Model model) {
// Default to today if no date is provided
LocalDate date = selectedDate != null ? selectedDate : LocalDate.now();
// Find the user
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
// Convert LocalDate to start and end Instant for the selected date
Instant startOfDay = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant endOfDay = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().minusMillis(1);
// Get processed visits and trips for the user and date range
List<ProcessedVisit> processedVisits = processedVisitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(
user, startOfDay, endOfDay);
List<Trip> trips = tripRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(
user, startOfDay, endOfDay);
// Convert to format expected by the frontend
List<Map<String, Object>> timelineEntries = new ArrayList<>();
// Add processed visits to timeline
for (ProcessedVisit visit : processedVisits) {
SignificantPlace place = visit.getPlace();
if (place != null) {
Map<String, Object> visitEntry = new HashMap<>();
visitEntry.put("id", "visit-" + visit.getId());
visitEntry.put("type", "place");
visitEntry.put("description", place.getName() != null ? place.getName() : "Unknown Place");
visitEntry.put("startTime", formatTime(visit.getStartTime()));
visitEntry.put("endTime", formatTime(visit.getEndTime()));
visitEntry.put("latitude", place.getLatitudeCentroid());
visitEntry.put("longitude", place.getLongitudeCentroid());
if (place.getCategory() != null) {
visitEntry.put("category", place.getCategory());
}
if (place.getAddress() != null) {
visitEntry.put("address", place.getAddress());
}
timelineEntries.add(visitEntry);
}
}
// Add trips to timeline
for (Trip trip : trips) {
Map<String, Object> tripEntry = new HashMap<>();
tripEntry.put("id", "trip-" + trip.getId());
tripEntry.put("type", "trip");
tripEntry.put("description", trip.getTransportModeInferred() != null ?
"Trip by " + trip.getTransportModeInferred() : "Trip");
tripEntry.put("startTime", formatTime(trip.getStartTime()));
tripEntry.put("endTime", formatTime(trip.getEndTime()));
// Get origin and destination coordinates if available
if (trip.getStartPlace() != null) {
tripEntry.put("startLatitude", trip.getStartPlace().getLatitudeCentroid());
tripEntry.put("startLongitude", trip.getStartPlace().getLongitudeCentroid());
}
if (trip.getEndPlace() != null) {
tripEntry.put("endLatitude", trip.getEndPlace().getLatitudeCentroid());
tripEntry.put("endLongitude", trip.getEndPlace().getLongitudeCentroid());
}
if (trip.getTransportModeInferred() != null) {
tripEntry.put("transportMode", trip.getTransportModeInferred().toLowerCase());
}
if (trip.getEstimatedDistanceMeters() != null) {
tripEntry.put("distanceMeters", trip.getEstimatedDistanceMeters());
}
timelineEntries.add(tripEntry);
}
// Sort timeline entries by start time
timelineEntries.sort((e1, e2) -> {
String time1 = (String) e1.get("startTime");
String time2 = (String) e2.get("startTime");
return time1.compareTo(time2);
});
model.addAttribute("date", date);
model.addAttribute("timelineEntries", timelineEntries);
return "fragments/timeline :: timeline";
}
}

View File

@@ -0,0 +1,24 @@
package com.dedicatedcode.reitti.controller;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class WebViewController {
@GetMapping("/")
public String index(Authentication authentication, Model model) {
if (authentication != null) {
model.addAttribute("username", authentication.getName());
}
return "index";
}
@GetMapping("/login")
public String login() {
return "login";
}
}

View File

@@ -0,0 +1,60 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.model.ApiToken;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.service.ApiTokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/tokens")
public class ApiTokenController {
private final ApiTokenService apiTokenService;
private final UserRepository userRepository;
@Autowired
public ApiTokenController(ApiTokenService apiTokenService, UserRepository userRepository) {
this.apiTokenService = apiTokenService;
this.userRepository = userRepository;
}
@PostMapping
public ResponseEntity<?> createToken(@RequestBody Map<String, String> request) {
String username = request.get("username");
String tokenName = request.get("name");
if (username == null || tokenName == null) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Username and token name are required"));
}
return userRepository.findByUsername(username)
.map(user -> {
ApiToken token = apiTokenService.createToken(user, tokenName);
return ResponseEntity.ok(Map.of(
"id", token.getId(),
"token", token.getToken(),
"name", token.getName(),
"createdAt", token.getCreatedAt().toString()
));
})
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found")));
}
@DeleteMapping("/{tokenId}")
public ResponseEntity<?> deleteToken(@PathVariable Long tokenId) {
try {
apiTokenService.deleteToken(tokenId);
return ResponseEntity.ok(Map.of("success", true));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to delete token: " + e.getMessage()));
}
}
}

View File

@@ -0,0 +1,247 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.service.ApiTokenService;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
@RequestMapping("/api/v1")
public class LocationDataApiController {
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
private static final int BATCH_SIZE = 100; // Process locations in batches of 100
private final ApiTokenService apiTokenService;
private final ObjectMapper objectMapper;
private final RabbitTemplate rabbitTemplate;
@Autowired
public LocationDataApiController(
ApiTokenService apiTokenService,
ObjectMapper objectMapper,
RabbitTemplate rabbitTemplate) {
this.apiTokenService = apiTokenService;
this.objectMapper = objectMapper;
this.rabbitTemplate = rabbitTemplate;
}
@PostMapping("/location-data")
public ResponseEntity<?> receiveLocationData(
@RequestHeader("X-API-Token") String apiToken,
@Valid @RequestBody LocationDataRequest request) {
// Authenticate using the API token
User user = apiTokenService.getUserByToken(apiToken)
.orElse(null);
if (user == null) {
logger.warn("Invalid API token used: {}", apiToken);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid API token"));
}
try {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getId(),
user.getUsername(),
request.getPoints()
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Successfully received and queued {} location points for user {}",
request.getPoints().size(), user.getUsername());
return ResponseEntity.accepted().body(Map.of(
"success", true,
"message", "Successfully queued " + request.getPoints().size() + " location points for processing",
"pointsReceived", request.getPoints().size()
));
} catch (Exception e) {
logger.error("Error processing location data", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Error processing location data: " + e.getMessage()));
}
}
@PostMapping("/import/google-takeout")
public ResponseEntity<?> importGoogleTakeout(
@RequestHeader("X-API-Token") String apiToken,
@RequestParam("file") MultipartFile file) {
// Authenticate using the API token
User user = apiTokenService.getUserByToken(apiToken)
.orElse(null);
if (user == null) {
logger.warn("Invalid API token used: {}", apiToken);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid API token"));
}
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "File is empty"));
}
if (!file.getOriginalFilename().endsWith(".json")) {
return ResponseEntity.badRequest().body(Map.of("error", "Only JSON files are supported"));
}
AtomicInteger processedCount = new AtomicInteger(0);
try (InputStream inputStream = file.getInputStream()) {
// Use Jackson's streaming API to process the file
JsonFactory factory = objectMapper.getFactory();
JsonParser parser = factory.createParser(inputStream);
// Find the "locations" array
while (parser.nextToken() != null) {
if (parser.getCurrentToken() == JsonToken.FIELD_NAME &&
"locations".equals(parser.getCurrentName())) {
// Move to the array
parser.nextToken(); // Should be START_ARRAY
if (parser.getCurrentToken() != JsonToken.START_ARRAY) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid format: 'locations' is not an array"));
}
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
// Process each location in the array
while (parser.nextToken() != JsonToken.END_ARRAY) {
if (parser.getCurrentToken() == JsonToken.START_OBJECT) {
// Parse the location object
JsonNode locationNode = objectMapper.readTree(parser);
try {
LocationDataRequest.LocationPoint point = convertGoogleTakeoutLocation(locationNode);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
// Process in batches to avoid memory issues
if (batch.size() >= BATCH_SIZE) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getId(),
user.getUsername(),
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued batch of {} locations for processing", batch.size());
batch.clear();
}
}
} catch (Exception e) {
logger.warn("Error processing location entry: {}", e.getMessage());
// Continue with next location
}
}
}
// Process any remaining locations
if (!batch.isEmpty()) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getId(),
user.getUsername(),
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued final batch of {} locations for processing", batch.size());
}
break; // We've processed the locations array, no need to continue
}
}
logger.info("Successfully imported and queued {} location points from Google Takeout for user {}",
processedCount.get(), user.getUsername());
return ResponseEntity.accepted().body(Map.of(
"success", true,
"message", "Successfully queued " + processedCount.get() + " location points for processing",
"pointsReceived", processedCount.get()
));
} catch (IOException e) {
logger.error("Error processing Google Takeout file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Error processing Google Takeout file: " + e.getMessage()));
}
}
/**
* Converts a Google Takeout location entry to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
// Check if we have the required fields
if (!locationNode.has("latitudeE7") ||
!locationNode.has("longitudeE7") ||
!locationNode.has("timestamp")) {
return null;
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// Convert latitudeE7 and longitudeE7 to standard decimal format
// Google stores these as integers with 7 decimal places of precision
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
point.setLatitude(latitude);
point.setLongitude(longitude);
point.setTimestamp(locationNode.get("timestamp").asText());
// Set accuracy if available
if (locationNode.has("accuracy")) {
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
} else {
point.setAccuracyMeters(100.0);
}
return point;
}
}

View File

@@ -0,0 +1,26 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.service.QueueStats;
import com.dedicatedcode.reitti.service.QueueStatsService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/queue-stats")
public class QueueStatsApiController {
private final QueueStatsService queueStatsService;
public QueueStatsApiController(QueueStatsService queueStatsService) {
this.queueStatsService = queueStatsService;
}
@GetMapping
public ResponseEntity<List<QueueStats>> getQueueStats() {
return ResponseEntity.ok(queueStatsService.getQueueStats());
}
}

View File

@@ -0,0 +1,164 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.dto.TimelineResponse;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/api/timeline")
public class TimelineApiController {
private final UserRepository userRepository;
private final ProcessedVisitRepository processedVisitRepository;
private final TripRepository tripRepository;
private final RawLocationPointRepository rawLocationPointRepository;
@Autowired
public TimelineApiController(UserRepository userRepository,
ProcessedVisitRepository processedVisitRepository,
TripRepository tripRepository, RawLocationPointRepository rawLocationPointRepository) {
this.userRepository = userRepository;
this.processedVisitRepository = processedVisitRepository;
this.tripRepository = tripRepository;
this.rawLocationPointRepository = rawLocationPointRepository;
}
@GetMapping
public ResponseEntity<TimelineResponse> getTimeline(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate selectedDate,
@RequestParam(required = false, defaultValue = "1") Long userId) {
// Default to today if no date is provided
LocalDate date = selectedDate != null ? selectedDate : LocalDate.now();
// Find the user
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
// Convert LocalDate to start and end Instant for the selected date
Instant startOfDay = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant endOfDay = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().minusMillis(1);
// Get processed visits for the user and date range
List<ProcessedVisit> processedVisits = processedVisitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(
user, startOfDay, endOfDay);
// Get trips for the user and date range
List<Trip> trips = tripRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(
user, startOfDay, endOfDay);
// Convert to timeline entries
List<TimelineResponse.TimelineEntry> entries = new ArrayList<>();
// Add visits to timeline
for (ProcessedVisit visit : processedVisits) {
SignificantPlace place = visit.getPlace();
if (place != null) {
TimelineResponse.PlaceInfo placeInfo = new TimelineResponse.PlaceInfo(
place.getId(),
place.getName() != null ? place.getName() : "Unknown Place",
place.getAddress(),
place.getCategory(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid()
);
entries.add(new TimelineResponse.TimelineEntry(
"VISIT",
visit.getId(),
visit.getStartTime(),
visit.getEndTime(),
visit.getDurationSeconds(),
placeInfo,
null,
null,
null,
null,
null
));
}
}
// Add trips to timeline
for (Trip trip : trips) {
TimelineResponse.PlaceInfo startPlace = null;
TimelineResponse.PlaceInfo endPlace = null;
if (trip.getStartPlace() != null) {
SignificantPlace start = trip.getStartPlace();
startPlace = new TimelineResponse.PlaceInfo(
start.getId(),
start.getName() != null ? start.getName() : "Unknown Place",
start.getAddress(),
start.getCategory(),
start.getLatitudeCentroid(),
start.getLongitudeCentroid()
);
}
if (trip.getEndPlace() != null) {
SignificantPlace end = trip.getEndPlace();
endPlace = new TimelineResponse.PlaceInfo(
end.getId(),
end.getName() != null ? end.getName() : "Unknown Place",
end.getAddress(),
end.getCategory(),
end.getLatitudeCentroid(),
end.getLongitudeCentroid()
);
}
List<RawLocationPoint> path = this.rawLocationPointRepository.findByUserAndTimestampBetweenOrderByTimestampAsc(trip.getUser(), trip.getStartTime(), trip.getEndTime());
entries.add(new TimelineResponse.TimelineEntry(
"TRIP",
trip.getId(),
trip.getStartTime(),
trip.getEndTime(),
trip.getDurationSeconds(),
null,
startPlace,
endPlace,
trip.getEstimatedDistanceMeters(),
trip.getTransportModeInferred(),
path.stream().map(p -> new TimelineResponse.PointInfo(p.getLatitude(), p.getLongitude(), p.getTimestamp(), p.getAccuracyMeters())).toList()
));
}
// Sort entries by start time
entries.sort((e1, e2) -> e1.getStartTime().compareTo(e2.getStartTime()));
return ResponseEntity.ok(new TimelineResponse(entries));
}
@GetMapping("/today")
public ResponseEntity<TimelineResponse> getToday(@RequestParam(required = false, defaultValue = "1") Long userId) {
return getTimeline(LocalDate.now(), userId);
}
@GetMapping("/prev-day")
public ResponseEntity<TimelineResponse> getPreviousDay(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate selectedDate,
@RequestParam(required = false, defaultValue = "1") Long userId) {
return getTimeline(selectedDate.minusDays(1), userId);
}
@GetMapping("/next-day")
public ResponseEntity<TimelineResponse> getNextDay(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate selectedDate,
@RequestParam(required = false, defaultValue = "1") Long userId) {
return getTimeline(selectedDate.plusDays(1), userId);
}
}

View File

@@ -0,0 +1,64 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.service.processing.TripDetectionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/trips/detection")
public class TripDetectionController {
private static final Logger logger = LoggerFactory.getLogger(TripDetectionController.class);
private final TripDetectionService tripDetectionService;
private final UserRepository userRepository;
public TripDetectionController(TripDetectionService tripDetectionService, UserRepository userRepository) {
this.tripDetectionService = tripDetectionService;
this.userRepository = userRepository;
}
@DeleteMapping("/clear-all")
public ResponseEntity<?> clearAllTrips() {
logger.info("Received request to clear all trips");
tripDetectionService.clearAllTrips();
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Cleared all trips");
return ResponseEntity.ok(response);
}
@DeleteMapping("/clear/{userId}")
public ResponseEntity<?> clearTripsForUser(@PathVariable Long userId) {
logger.info("Received request to clear trips for user ID: {}", userId);
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
tripDetectionService.clearTrips(userOpt.get());
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Cleared trips for user: " + userOpt.get().getUsername());
response.put("userId", userId);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,63 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.service.processing.VisitMergingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/visits/processing")
public class VisitProcessingController {
private static final Logger logger = LoggerFactory.getLogger(VisitProcessingController.class);
private final VisitMergingService visitMergingService;
private final UserRepository userRepository;
public VisitProcessingController(VisitMergingService visitMergingService, UserRepository userRepository) {
this.visitMergingService = visitMergingService;
this.userRepository = userRepository;
}
@DeleteMapping("/clear-all")
public ResponseEntity<?> clearAllProcessedVisits() {
logger.info("Received request to clear all processed visits");
visitMergingService.clearAllProcessedVisits();
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Cleared all processed visits");
return ResponseEntity.ok(response);
}
@DeleteMapping("/clear/{userId}")
public ResponseEntity<?> clearProcessedVisitsForUser(@PathVariable Long userId) {
logger.info("Received request to clear processed visits for user ID: {}", userId);
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
visitMergingService.clearProcessedVisits(userOpt.get());
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Cleared processed visits for user: " + userOpt.get().getUsername());
response.put("userId", userId);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,97 @@
package com.dedicatedcode.reitti.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Objects;
public class LocationDataRequest {
@NotEmpty
private List<@Valid LocationPoint> points;
public List<LocationPoint> getPoints() {
return points;
}
public void setPoints(List<LocationPoint> points) {
this.points = points;
}
@Override
public String toString() {
return "LocationDataRequest{" +
"points=" + points +
'}';
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
LocationDataRequest that = (LocationDataRequest) o;
return Objects.equals(points, that.points);
}
@Override
public int hashCode() {
return Objects.hash(points);
}
public static class LocationPoint {
@NotNull
private Double latitude;
@NotNull
private Double longitude;
@NotNull
private String timestamp; // ISO8601 format
@NotNull
private Double accuracyMeters;
private String activity; // Optional
public Double getLatitude() {
return latitude;
}
public void setLatitude(Double latitude) {
this.latitude = latitude;
}
public Double getLongitude() {
return longitude;
}
public void setLongitude(Double longitude) {
this.longitude = longitude;
}
public String getTimestamp() {
return timestamp;
}
public void setTimestamp(String timestamp) {
this.timestamp = timestamp;
}
public Double getAccuracyMeters() {
return accuracyMeters;
}
public void setAccuracyMeters(Double accuracyMeters) {
this.accuracyMeters = accuracyMeters;
}
public String getActivity() {
return activity;
}
public void setActivity(String activity) {
this.activity = activity;
}
}
}

View File

@@ -0,0 +1,101 @@
package com.dedicatedcode.reitti.dto;
import org.springframework.data.geo.Point;
import java.time.Instant;
import java.util.List;
public class TimelineResponse {
private final List<TimelineEntry> entries;
public TimelineResponse(List<TimelineEntry> entries) {
this.entries = entries;
}
public List<TimelineEntry> getEntries() {
return entries;
}
public static class TimelineEntry {
private final String type; // "VISIT" or "TRIP"
private final Long id;
private final Instant startTime;
private final Instant endTime;
private final Long durationSeconds;
// For visits
private final PlaceInfo place;
// For trips
private final PlaceInfo startPlace;
private final PlaceInfo endPlace;
private final Double distanceMeters;
private final String transportMode;
private final List<PointInfo> path;
public TimelineEntry(String type, Long id, Instant startTime, Instant endTime, Long durationSeconds, PlaceInfo place, PlaceInfo startPlace, PlaceInfo endPlace, Double distanceMeters, String transportMode, List<PointInfo> path) {
this.type = type;
this.id = id;
this.startTime = startTime;
this.endTime = endTime;
this.durationSeconds = durationSeconds;
this.place = place;
this.startPlace = startPlace;
this.endPlace = endPlace;
this.distanceMeters = distanceMeters;
this.transportMode = transportMode;
this.path = path;
}
public String getType() {
return type;
}
public Long getId() {
return id;
}
public Instant getStartTime() {
return startTime;
}
public Instant getEndTime() {
return endTime;
}
public Long getDurationSeconds() {
return durationSeconds;
}
public PlaceInfo getPlace() {
return place;
}
public PlaceInfo getStartPlace() {
return startPlace;
}
public PlaceInfo getEndPlace() {
return endPlace;
}
public Double getDistanceMeters() {
return distanceMeters;
}
public String getTransportMode() {
return transportMode;
}
public List<PointInfo> getPath() {
return path;
}
}
public record PointInfo(Double latitude, Double longitude, Instant timestamp, Double accuracy) {
}
public record PlaceInfo(Long id, String name, String address, String category, Double latitude, Double longitude) {
}
}

View File

@@ -0,0 +1,43 @@
package com.dedicatedcode.reitti.event;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
public class LocationDataEvent implements Serializable {
private final Long userId;
private final String username;
private final List<LocationDataRequest.LocationPoint> points;
private final Instant receivedAt;
@JsonCreator
public LocationDataEvent(
@JsonProperty("userId") Long userId,
@JsonProperty("username") String username,
@JsonProperty("points") List<LocationDataRequest.LocationPoint> points) {
this.userId = userId;
this.username = username;
this.points = points;
this.receivedAt = Instant.now();
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public List<LocationDataRequest.LocationPoint> getPoints() {
return points;
}
public Instant getReceivedAt() {
return receivedAt;
}
}

View File

@@ -0,0 +1,42 @@
package com.dedicatedcode.reitti.event;
public class MergeVisitEvent {
private Long userId;
private Long startTime;
private Long endTime;
// Default constructor for Jackson
public MergeVisitEvent() {
}
public MergeVisitEvent(Long userId, Long startTime, Long endTime) {
this.userId = userId;
this.startTime = startTime;
this.endTime = endTime;
}
// Getters and setters
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getStartTime() {
return startTime;
}
public void setStartTime(Long startTime) {
this.startTime = startTime;
}
public Long getEndTime() {
return endTime;
}
public void setEndTime(Long endTime) {
this.endTime = endTime;
}
}

View File

@@ -0,0 +1,27 @@
package com.dedicatedcode.reitti.event;
import java.io.Serializable;
public class SignificantPlaceCreatedEvent implements Serializable {
private final Long placeId;
private final Double latitude;
private final Double longitude;
public SignificantPlaceCreatedEvent(Long placeId, Double latitude, Double longitude) {
this.placeId = placeId;
this.latitude = latitude;
this.longitude = longitude;
}
public Long getPlaceId() {
return placeId;
}
public Double getLatitude() {
return latitude;
}
public Double getLongitude() {
return longitude;
}
}

View File

@@ -0,0 +1,89 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "api_tokens")
public class ApiToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Instant createdAt;
@Column
private Instant lastUsedAt;
@PrePersist
protected void onCreate() {
if (token == null) {
token = UUID.randomUUID().toString();
}
if (createdAt == null) {
createdAt = Instant.now();
}
}
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getLastUsedAt() {
return lastUsedAt;
}
public void setLastUsedAt(Instant lastUsedAt) {
this.lastUsedAt = lastUsedAt;
}
}

View File

@@ -0,0 +1,28 @@
package com.dedicatedcode.reitti.model;
public final class GeoUtils {
private GeoUtils() {
}
public static double calculateHaversineDistance(double lat1, double lon1, double lat2, double lon2) {
// Earth radius in meters
final double R = 6371000;
double latDistance = Math.toRadians(lat2 - lat1);
double lonDistance = Math.toRadians(lon2 - lon1);
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
public static double calculateHaversineDistance(RawLocationPoint p1, RawLocationPoint p2) {
return calculateHaversineDistance(
p1.getLatitude(), p1.getLongitude(),
p2.getLatitude(), p2.getLongitude());
}
}

View File

@@ -0,0 +1,136 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import org.locationtech.jts.geom.Point;
import java.time.Instant;
@Entity
@Table(name = "processed_visits")
public class ProcessedVisit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "place_id", nullable = false)
private SignificantPlace place;
@Column(name = "start_time", nullable = false)
private Instant startTime;
@Column(name = "end_time", nullable = false)
private Instant endTime;
@Column(name = "duration_seconds", nullable = false)
private Long durationSeconds;
@Column(name = "original_visit_ids")
private String originalVisitIds; // Comma-separated list of original visit IDs
@Column(name = "merged_count")
private Integer mergedCount;
@PrePersist
@PreUpdate
private void calculateDuration() {
if (startTime != null && endTime != null) {
durationSeconds = endTime.getEpochSecond() - startTime.getEpochSecond();
}
}
// Constructors
public ProcessedVisit() {
this.mergedCount = 1;
}
public ProcessedVisit(User user, SignificantPlace place, Instant startTime, Instant endTime) {
this.user = user;
this.place = place;
this.startTime = startTime;
this.endTime = endTime;
this.mergedCount = 1;
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public SignificantPlace getPlace() {
return place;
}
public void setPlace(SignificantPlace place) {
this.place = place;
}
public Instant getStartTime() {
return startTime;
}
public void setStartTime(Instant startTime) {
this.startTime = startTime;
}
public Instant getEndTime() {
return endTime;
}
public void setEndTime(Instant endTime) {
this.endTime = endTime;
}
public Long getDurationSeconds() {
return durationSeconds;
}
public void setDurationSeconds(Long durationSeconds) {
this.durationSeconds = durationSeconds;
}
public String getOriginalVisitIds() {
return originalVisitIds;
}
public void setOriginalVisitIds(String originalVisitIds) {
this.originalVisitIds = originalVisitIds;
}
public Integer getMergedCount() {
return mergedCount;
}
public void setMergedCount(Integer mergedCount) {
this.mergedCount = mergedCount;
}
public void incrementMergedCount() {
this.mergedCount++;
}
public void addOriginalVisitId(Long visitId) {
if (this.originalVisitIds == null || this.originalVisitIds.isEmpty()) {
this.originalVisitIds = visitId.toString();
} else {
this.originalVisitIds += "," + visitId;
}
}
}

View File

@@ -0,0 +1,99 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "raw_location_points")
public class RawLocationPoint {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false)
private Instant timestamp;
@Column(nullable = false)
private Double latitude;
@Column(nullable = false)
private Double longitude;
@Column(nullable = false)
private Double accuracyMeters;
@Column
private String activityProvided;
public RawLocationPoint() {
}
public RawLocationPoint(User user, Instant timestamp, Double latitude, Double longitude, Double accuracyMeters) {
this.user = user;
this.timestamp = timestamp;
this.latitude = latitude;
this.longitude = longitude;
this.accuracyMeters = accuracyMeters;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Instant getTimestamp() {
return timestamp;
}
public void setTimestamp(Instant timestamp) {
this.timestamp = timestamp;
}
public Double getLatitude() {
return latitude;
}
public void setLatitude(Double latitude) {
this.latitude = latitude;
}
public Double getLongitude() {
return longitude;
}
public void setLongitude(Double longitude) {
this.longitude = longitude;
}
public Double getAccuracyMeters() {
return accuracyMeters;
}
public void setAccuracyMeters(Double accuracyMeters) {
this.accuracyMeters = accuracyMeters;
}
public String getActivityProvided() {
return activityProvided;
}
public void setActivityProvided(String activityProvided) {
this.activityProvided = activityProvided;
}
}

View File

@@ -0,0 +1,132 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import org.locationtech.jts.geom.Point;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "significant_places")
public class SignificantPlace {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column
private String name;
@Column
private String address;
@Column(nullable = false)
private Double latitudeCentroid;
@Column(nullable = false)
private Double longitudeCentroid;
@Column(columnDefinition = "geometry(Point,4326)")
private Point geom;
@Column
private String category;
@OneToMany(mappedBy = "place", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Visit> visits = new ArrayList<>();
public SignificantPlace() {}
public SignificantPlace(User user,
String name,
String address,
Double latitudeCentroid,
Double longitudeCentroid,
Point geom,
String category) {
this.user = user;
this.name = name;
this.address = address;
this.latitudeCentroid = latitudeCentroid;
this.longitudeCentroid = longitudeCentroid;
this.geom = geom;
this.category = category;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public Double getLatitudeCentroid() {
return latitudeCentroid;
}
public void setLatitudeCentroid(Double latitudeCentroid) {
this.latitudeCentroid = latitudeCentroid;
}
public Double getLongitudeCentroid() {
return longitudeCentroid;
}
public void setLongitudeCentroid(Double longitudeCentroid) {
this.longitudeCentroid = longitudeCentroid;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public List<Visit> getVisits() {
return visits;
}
public void setVisits(List<Visit> visits) {
this.visits = visits;
}
public Point getGeom() {
return geom;
}
public void setGeom(Point geom) {
this.geom = geom;
}
}

View File

@@ -0,0 +1,147 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import java.time.Duration;
import java.time.Instant;
@Entity
@Table(name = "trips")
public class Trip {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "start_place_id")
private SignificantPlace startPlace;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "end_place_id")
private SignificantPlace endPlace;
@Column(nullable = false)
private Instant startTime;
@Column(nullable = false)
private Instant endTime;
@Column(nullable = false)
private Long durationSeconds;
@Column
private Double estimatedDistanceMeters;
@Column(name = "travelled_distance_meters")
private Double travelledDistanceMeters;
@Column
private String transportModeInferred;
public Trip() {}
public Trip(User user, SignificantPlace startPlace, SignificantPlace endPlace, Instant startTime, Instant endTime, Double estimatedDistanceMeters, String transportModeInferred) {
this.user = user;
this.startPlace = startPlace;
this.endPlace = endPlace;
this.startTime = startTime;
this.endTime = endTime;
this.estimatedDistanceMeters = estimatedDistanceMeters;
this.transportModeInferred = transportModeInferred;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public SignificantPlace getStartPlace() {
return startPlace;
}
public void setStartPlace(SignificantPlace startPlace) {
this.startPlace = startPlace;
}
public SignificantPlace getEndPlace() {
return endPlace;
}
public void setEndPlace(SignificantPlace endPlace) {
this.endPlace = endPlace;
}
public Instant getStartTime() {
return startTime;
}
public void setStartTime(Instant startTime) {
this.startTime = startTime;
}
public Instant getEndTime() {
return endTime;
}
public void setEndTime(Instant endTime) {
this.endTime = endTime;
}
public Long getDurationSeconds() {
return durationSeconds;
}
public void setDurationSeconds(Long durationSeconds) {
this.durationSeconds = durationSeconds;
}
public Double getEstimatedDistanceMeters() {
return estimatedDistanceMeters;
}
public void setEstimatedDistanceMeters(Double estimatedDistanceMeters) {
this.estimatedDistanceMeters = estimatedDistanceMeters;
}
public Double getTravelledDistanceMeters() {
return travelledDistanceMeters;
}
public void setTravelledDistanceMeters(Double travelledDistanceMeters) {
this.travelledDistanceMeters = travelledDistanceMeters;
}
public String getTransportModeInferred() {
return transportModeInferred;
}
public void setTransportModeInferred(String transportModeInferred) {
this.transportModeInferred = transportModeInferred;
}
@PrePersist
@PreUpdate
private void calculateDuration() {
if (startTime != null && endTime != null) {
durationSeconds = Duration.between(startTime, endTime).getSeconds();
}
}
}

View File

@@ -0,0 +1,106 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String displayName;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RawLocationPoint> locationPoints = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<SignificantPlace> significantPlaces = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Visit> visits = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Trip> trips = new ArrayList<>();
public User() {}
public User(String username, String displayName) {
this.username = username;
this.displayName = displayName;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public List<RawLocationPoint> getLocationPoints() {
return locationPoints;
}
public void setLocationPoints(List<RawLocationPoint> locationPoints) {
this.locationPoints = locationPoints;
}
public List<SignificantPlace> getSignificantPlaces() {
return significantPlaces;
}
public void setSignificantPlaces(List<SignificantPlace> significantPlaces) {
this.significantPlaces = significantPlaces;
}
public List<Visit> getVisits() {
return visits;
}
public void setVisits(List<Visit> visits) {
this.visits = visits;
}
public List<Trip> getTrips() {
return trips;
}
public void setTrips(List<Trip> trips) {
this.trips = trips;
}
}

View File

@@ -0,0 +1,107 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import java.time.Duration;
import java.time.Instant;
@Entity
@Table(name = "visits")
public class Visit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "place_id", nullable = false)
private SignificantPlace place;
@Column(nullable = false)
private Instant startTime;
@Column(nullable = false)
private Instant endTime;
@Column(nullable = false)
private Long durationSeconds;
@Column(nullable = false)
private boolean processed = false;
public Visit() {}
public Visit(User user, SignificantPlace place, Instant startTime, Instant endTime) {
this.user = user;
this.place = place;
this.startTime = startTime;
this.endTime = endTime;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public SignificantPlace getPlace() {
return place;
}
public void setPlace(SignificantPlace place) {
this.place = place;
}
public Instant getStartTime() {
return startTime;
}
public void setStartTime(Instant startTime) {
this.startTime = startTime;
}
public Instant getEndTime() {
return endTime;
}
public void setEndTime(Instant endTime) {
this.endTime = endTime;
}
public Long getDurationSeconds() {
return durationSeconds;
}
public void setDurationSeconds(Long durationSeconds) {
this.durationSeconds = durationSeconds;
}
public boolean isProcessed() {
return processed;
}
public void setProcessed(boolean processed) {
this.processed = processed;
}
@PrePersist
@PreUpdate
private void calculateDuration() {
if (startTime != null && endTime != null) {
durationSeconds = Duration.between(startTime, endTime).getSeconds();
}
}
}

View File

@@ -0,0 +1,16 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.ApiToken;
import com.dedicatedcode.reitti.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
Optional<ApiToken> findByToken(String token);
List<ApiToken> findByUser(User user);
}

View File

@@ -0,0 +1,44 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public interface ProcessedVisitRepository extends JpaRepository<ProcessedVisit, Long> {
List<ProcessedVisit> findByUser(User user);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = :user AND pv.place = :place " +
"AND ((pv.startTime <= :endTime AND pv.endTime >= :startTime) OR " +
"(pv.startTime >= :startTime AND pv.startTime <= :endTime) OR " +
"(pv.endTime >= :startTime AND pv.endTime <= :endTime))")
List<ProcessedVisit> findByUserAndPlaceAndTimeOverlap(
@Param("user") User user,
@Param("place") SignificantPlace place,
@Param("startTime") Instant startTime,
@Param("endTime") Instant endTime);
List<ProcessedVisit> findByUserAndStartTimeBetweenOrderByStartTimeAsc(
User user, Instant startTime, Instant endTime);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = ?1 AND pv.place = ?2 AND " +
"((pv.startTime <= ?3 AND pv.endTime >= ?3) OR " +
"(pv.startTime <= ?4 AND pv.endTime >= ?4) OR " +
"(pv.startTime >= ?3 AND pv.endTime <= ?4))")
List<ProcessedVisit> findOverlappingVisits(User user, SignificantPlace place,
Instant startTime, Instant endTime);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = ?1 AND pv.place = ?2 AND " +
"((pv.endTime >= ?3 AND pv.endTime <= ?4) OR " +
"(pv.startTime >= ?3 AND pv.startTime <= ?4))")
List<ProcessedVisit> findVisitsWithinTimeRange(User user, SignificantPlace place,
Instant startThreshold, Instant endThreshold);
}

View File

@@ -0,0 +1,24 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public interface RawLocationPointRepository extends JpaRepository<RawLocationPoint, Long> {
List<RawLocationPoint> findByUserOrderByTimestampAsc(User user);
List<RawLocationPoint> findByUserAndTimestampBetweenOrderByTimestampAsc(
User user, Instant startTime, Instant endTime);
@Query("SELECT r FROM RawLocationPoint r WHERE r.user = ?1 AND DATE(r.timestamp) = DATE(?2) ORDER BY r.timestamp ASC")
List<RawLocationPoint> findByUserAndDate(User user, Instant date);
Optional<RawLocationPoint> findByUserAndTimestamp(User user, Instant timestamp);
}

View File

@@ -0,0 +1,30 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import org.locationtech.jts.geom.Point;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface SignificantPlaceRepository extends JpaRepository<SignificantPlace, Long> {
List<SignificantPlace> findByUser(User user);
Page<SignificantPlace> findByUser(User user, Pageable pageable);
@Query(value = "SELECT sp.* FROM significant_places sp " +
"WHERE sp.user_id = :userId " +
"AND ST_DWithin(sp.geom, :point, :distance)",
nativeQuery = true)
List<SignificantPlace> findNearbyPlaces(
@Param("userId") Long userId,
@Param("point") Point point,
@Param("distance") double distanceInMeters);
}

View File

@@ -0,0 +1,20 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public interface TripRepository extends JpaRepository<Trip, Long> {
List<Trip> findByUser(User user);
List<Trip> findByUserAndStartTimeBetweenOrderByStartTimeAsc(
User user, Instant startTime, Instant endTime);
boolean existsByUserAndStartTimeAndEndTime(User user, Instant startTime, Instant endTime);
}

View File

@@ -0,0 +1,12 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

View File

@@ -0,0 +1,23 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.model.Visit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public interface VisitRepository extends JpaRepository<Visit, Long> {
List<Visit> findByUser(User user);
List<Visit> findByUserAndProcessedFalse(User user);
List<Visit> findByUserAndStartTimeBetweenOrderByStartTimeAsc(
User user, Instant startTime, Instant endTime);
List<Visit> findByPlace(SignificantPlace place);
}

View File

@@ -0,0 +1,52 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.ApiToken;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.ApiTokenRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service
public class ApiTokenService {
private final ApiTokenRepository apiTokenRepository;
@Autowired
public ApiTokenService(ApiTokenRepository apiTokenRepository) {
this.apiTokenRepository = apiTokenRepository;
}
@Transactional(readOnly = true)
public Optional<User> getUserByToken(String token) {
return apiTokenRepository.findByToken(token)
.map(this::updateLastUsed)
.map(ApiToken::getUser);
}
@Transactional
public ApiToken createToken(User user, String name) {
ApiToken token = new ApiToken();
token.setUser(user);
token.setName(name);
return apiTokenRepository.save(token);
}
@Transactional
public void deleteToken(Long tokenId) {
apiTokenRepository.deleteById(tokenId);
}
private ApiToken updateLastUsed(ApiToken token) {
token.setLastUsedAt(Instant.now());
return apiTokenRepository.save(token);
}
public List<ApiToken> getTokensForUser(User currentUser) {
return this.apiTokenRepository.findByUser(currentUser);
}
}

View File

@@ -0,0 +1,24 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class EventPublisherService {
private static final Logger logger = LoggerFactory.getLogger(EventPublisherService.class);
private final ApplicationEventPublisher eventPublisher;
public EventPublisherService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void publishLocationDataEvent(LocationDataEvent event) {
logger.info("Publishing location data event for user {} with {} points",
event.getUsername(), event.getPoints().size());
eventPublisher.publishEvent(event);
}
}

View File

@@ -0,0 +1,33 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.service.processing.LocationProcessingPipeline;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
@Service
public class LocationDataProcessingService {
private static final Logger logger = LoggerFactory.getLogger(LocationDataProcessingService.class);
private final LocationProcessingPipeline processingPipeline;
public LocationDataProcessingService(LocationProcessingPipeline processingPipeline) {
this.processingPipeline = processingPipeline;
}
@RabbitListener(queues = RabbitMQConfig.LOCATION_DATA_QUEUE, concurrency = "4-16")
public void handleLocationDataEvent(LocationDataEvent event) {
logger.info("Received location data event from RabbitMQ for user {} with {} points",
event.getUsername(), event.getPoints().size());
try {
processingPipeline.processLocationData(event);
} catch (Exception e) {
logger.error("Error processing location data event", e);
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,77 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class LocationDataService {
private static final Logger logger = LoggerFactory.getLogger(LocationDataService.class);
private final RawLocationPointRepository rawLocationPointRepository;
@Autowired
public LocationDataService(RawLocationPointRepository rawLocationPointRepository) {
this.rawLocationPointRepository = rawLocationPointRepository;
}
@Transactional
public List<RawLocationPoint> processLocationData(User user, List<LocationDataRequest.LocationPoint> points) {
List<RawLocationPoint> savedPoints = new ArrayList<>();
int duplicatesSkipped = 0;
for (LocationDataRequest.LocationPoint point : points) {
try {
Optional<RawLocationPoint> savedPoint = processSingleLocationPoint(user, point);
if (savedPoint.isPresent()) {
savedPoints.add(savedPoint.get());
} else {
duplicatesSkipped++;
}
} catch (Exception e) {
logger.warn("Error processing point at timestamp {}: {}", point.getTimestamp(), e.getMessage());
// Continue with next point
}
}
if (duplicatesSkipped > 0) {
logger.info("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
}
return savedPoints;
}
@Transactional
public Optional<RawLocationPoint> processSingleLocationPoint(User user, LocationDataRequest.LocationPoint point) {
Instant timestamp = Instant.parse(point.getTimestamp());
// Check if a point with this timestamp already exists for this user
Optional<RawLocationPoint> existingPoint = rawLocationPointRepository.findByUserAndTimestamp(user, timestamp);
if (existingPoint.isPresent()) {
logger.debug("Skipping duplicate point at timestamp {} for user {}", timestamp, user.getUsername());
return Optional.empty(); // Return empty to indicate no new point was saved
}
RawLocationPoint locationPoint = new RawLocationPoint();
locationPoint.setUser(user);
locationPoint.setLatitude(point.getLatitude());
locationPoint.setLongitude(point.getLongitude());
locationPoint.setTimestamp(timestamp);
locationPoint.setAccuracyMeters(point.getAccuracyMeters());
locationPoint.setActivityProvided(point.getActivity());
return Optional.of(rawLocationPointRepository.save(locationPoint));
}
}

View File

@@ -0,0 +1,46 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.SignificantPlaceRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
public class PlaceService {
private final SignificantPlaceRepository placeRepository;
public PlaceService(SignificantPlaceRepository placeRepository) {
this.placeRepository = placeRepository;
}
public Page<SignificantPlace> getPlacesForUser(User user, Pageable pageable) {
return placeRepository.findByUser(user, pageable);
}
@Transactional
public void updatePlaceName(Long placeId, String name, User currentUser) {
Optional<SignificantPlace> placeOpt = placeRepository.findById(placeId);
if (placeOpt.isEmpty()) {
throw new IllegalArgumentException("Place not found with ID: " + placeId);
}
SignificantPlace place = placeOpt.get();
// Security check: ensure the place belongs to the current user
if (!place.getUser().getId().equals(currentUser.getId())) {
throw new AccessDeniedException("You don't have permission to update this place");
}
place.setName(name);
placeRepository.save(place);
}
}

View File

@@ -0,0 +1,4 @@
package com.dedicatedcode.reitti.service;
public record QueueStats(String name, int count, String estimatedTime, int rate) {
}

View File

@@ -0,0 +1,66 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
@Service
public class QueueStatsService {
private final RabbitAdmin rabbitAdmin;
// Average processing times in milliseconds per item
private static final long AVG_LOCATION_PROCESSING_TIME = 2; // 500ms per location point
private final List<String> QUEUES = List.of(
RabbitMQConfig.LOCATION_DATA_QUEUE,
RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE,
RabbitMQConfig.MERGE_VISIT_QUEUE,
RabbitMQConfig.DETECT_TRIP_QUEUE);
@Autowired
public QueueStatsService(RabbitAdmin rabbitAdmin) {
this.rabbitAdmin = rabbitAdmin;
}
public List<QueueStats> getQueueStats() {
return QUEUES.stream().map(name -> {
int messageCount = getMessageCount(name);
return new QueueStats(name, messageCount, formatProcessingTime(messageCount * AVG_LOCATION_PROCESSING_TIME), calculateProgress(messageCount, 100));
}).toList();
}
private int getMessageCount(String queueName) {
Properties properties = rabbitAdmin.getQueueProperties(queueName);
if (properties.containsKey(RabbitAdmin.QUEUE_MESSAGE_COUNT)) {
return (int) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT);
}
return 0;
}
private String formatProcessingTime(long milliseconds) {
if (milliseconds < 60000) {
return (milliseconds / 1000) + " sec";
} else if (milliseconds < 3600000) {
return (milliseconds / 60000) + " min";
} else {
long hours = milliseconds / 3600000;
long minutes = (milliseconds % 3600000) / 60000;
return hours + " hr " + minutes + " min";
}
}
private int calculateProgress(int count, int maxExpected) {
if (count <= 0) return 0;
if (count >= maxExpected) return 100;
return (int) ((count / (double) maxExpected) * 100);
}
}

View File

@@ -0,0 +1,33 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
);
}
}

View File

@@ -0,0 +1,45 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User getUserByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
public List<User> getAllUsers() {
return userRepository.findAll();
}
@Transactional
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
@Transactional
public User createUser(String username, String displayName, String password) {
User user = new User();
user.setUsername(username);
user.setDisplayName(displayName);
user.setPassword(passwordEncoder.encode(password));
return userRepository.save(user);
}
}

View File

@@ -0,0 +1,89 @@
package com.dedicatedcode.reitti.service.geocoding;
import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.repository.SignificantPlaceRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import java.util.Optional;
@Component
public class ReverseGeocodingListener {
private static final Logger logger = LoggerFactory.getLogger(ReverseGeocodingListener.class);
private static final String NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse?format=geocodejson&lat=%s&lon=%s";
private final SignificantPlaceRepository significantPlaceRepository;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Autowired
public ReverseGeocodingListener(
SignificantPlaceRepository significantPlaceRepository,
RestTemplate restTemplate,
ObjectMapper objectMapper) {
this.significantPlaceRepository = significantPlaceRepository;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
@RabbitListener(queues = RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE, concurrency = "1-16")
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void handleSignificantPlaceCreated(SignificantPlaceCreatedEvent event) {
logger.info("Received SignificantPlaceCreatedEvent for place ID: {}", event.getPlaceId());
Optional<SignificantPlace> placeOptional = significantPlaceRepository.findById(event.getPlaceId());
if (placeOptional.isEmpty()) {
logger.error("Could not find SignificantPlace with ID: {}", event.getPlaceId());
return;
}
SignificantPlace place = placeOptional.get();
try {
String url = String.format(NOMINATIM_URL, place.getLatitudeCentroid(), place.getLongitudeCentroid());
String response = restTemplate.getForObject(url, String.class);
JsonNode root = objectMapper.readTree(response);
JsonNode features = root.path("features");
if (features.isArray() && !features.isEmpty()) {
JsonNode geocoding = features.get(0).path("properties").path("geocoding");
String label = geocoding.path("label").asText("");
String street = geocoding.path("street").asText("");
String city = geocoding.path("city").asText("");
String district = geocoding.path("district").asText("");
// Set the name to the street or district if available
if (!street.isEmpty()) {
place.setName(street);
} else if (!district.isEmpty()) {
place.setName(district);
} else if (!city.isEmpty()) {
place.setName(city);
}
// Set the address to the full label
place.setAddress(label);
// Save the updated place
significantPlaceRepository.saveAndFlush(place);
logger.info("Updated place ID: {} with geocoding data: {}", place.getId(), label);
} else {
logger.warn("No geocoding results found for place ID: {}", place.getId());
}
} catch (Exception e) {
logger.error("Error during reverse geocoding for place ID: {}", place.getId(), e);
}
}
}

View File

@@ -0,0 +1,93 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.service.LocationDataService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitMessageOperations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
@Service
public class LocationProcessingPipeline {
private static final Logger logger = LoggerFactory.getLogger(LocationProcessingPipeline.class);
private final UserRepository userRepository;
private final LocationDataService locationDataService;
private final StayPointDetectionService stayPointDetectionService;
private final SignificantPlaceService significantPlaceService;
private final RabbitMessageOperations rabbitTemplate;
private final int tripVisitMergeTimeRange;
@Autowired
public LocationProcessingPipeline(
UserRepository userRepository,
LocationDataService locationDataService,
StayPointDetectionService stayPointDetectionService,
SignificantPlaceService significantPlaceService,
RabbitMessageOperations rabbitTemplate,
@Value("${reitti.process-visits-trips.merge-time-range:1}") int tripVisitMergeTimeRange) {
this.userRepository = userRepository;
this.locationDataService = locationDataService;
this.stayPointDetectionService = stayPointDetectionService;
this.significantPlaceService = significantPlaceService;
this.rabbitTemplate = rabbitTemplate;
this.tripVisitMergeTimeRange = tripVisitMergeTimeRange;
}
public void processLocationData(LocationDataEvent event) {
logger.debug("Starting processing pipeline for user {} with {} points",
event.getUsername(), event.getPoints().size());
Optional<User> userOpt = userRepository.findById(event.getUserId());
if (userOpt.isEmpty()) {
logger.warn("User not found for ID: {}", event.getUserId());
return;
}
User user = userOpt.get();
// Step 1: Save raw location points (with duplicate checking)
List<RawLocationPoint> savedPoints = locationDataService.processLocationData(user, event.getPoints());
if (savedPoints.isEmpty()) {
logger.debug("No new points to process for user {}", user.getUsername());
return;
}
logger.info("Saved {} new location points for user {}", savedPoints.size(), user.getUsername());
// Step 2: Detect stay points from the new data
List<StayPoint> stayPoints = stayPointDetectionService.detectStayPoints(user, savedPoints);
if (!stayPoints.isEmpty()) {
logger.trace("Detected {} stay points", stayPoints.size());
// Step 3: Update significant places based on stay points
List<SignificantPlace> updatedPlaces = significantPlaceService.processStayPoints(user, stayPoints);
logger.trace("Updated {} significant places", updatedPlaces.size());
Instant startTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).min(Instant::compareTo).orElse(Instant.now());
Instant endTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).max(Instant::compareTo).orElse(Instant.now());
long searchStart = startTime.minus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
long searchEnd = endTime.plus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getId(), searchStart, searchEnd));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), searchStart, searchEnd));
}
logger.info("Completed processing pipeline for user {}", user.getUsername());
}
}

View File

@@ -0,0 +1,129 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.model.Visit;
import com.dedicatedcode.reitti.repository.SignificantPlaceRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.PrecisionModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class SignificantPlaceService {
private static final Logger logger = LoggerFactory.getLogger(SignificantPlaceService.class);
// Parameters for significant place detection
private static final double PLACE_MERGE_DISTANCE = 0.001; // degrees
private static final int SRID = 4326;
private final SignificantPlaceRepository significantPlaceRepository;
private final VisitRepository visitRepository;
private final GeometryFactory geometryFactory;
private final RabbitTemplate rabbitTemplate;
@Autowired
public SignificantPlaceService(
SignificantPlaceRepository significantPlaceRepository,
VisitRepository visitRepository,
RabbitTemplate rabbitTemplate) {
this.significantPlaceRepository = significantPlaceRepository;
this.visitRepository = visitRepository;
this.rabbitTemplate = rabbitTemplate;
this.geometryFactory = new GeometryFactory(new PrecisionModel(), SRID);
}
public List<SignificantPlace> processStayPoints(User user, List<StayPoint> stayPoints) {
logger.info("Processing {} stay points for user {}", stayPoints.size(), user.getUsername());
List<SignificantPlace> updatedPlaces = new ArrayList<>();
for (StayPoint stayPoint : stayPoints) {
// Find existing places near this stay point
List<SignificantPlace> nearbyPlaces = findNearbyPlaces(user, stayPoint.getLatitude(), stayPoint.getLongitude());
if (nearbyPlaces.isEmpty()) {
// Create a new significant place
SignificantPlace newPlace = createSignificantPlace(user, stayPoint);
significantPlaceRepository.save(newPlace);
// Create a visit for this place
Visit visit = createVisit(user, newPlace, stayPoint);
visitRepository.save(visit);
updatedPlaces.add(newPlace);
logger.info("Created new significant place at ({}, {})", stayPoint.getLatitude(), stayPoint.getLongitude());
// Publish event for the new place
publishSignificantPlaceCreatedEvent(newPlace);
} else {
// Update the first existing place
Optional<SignificantPlace> existingPlace = this.significantPlaceRepository.findById(nearbyPlaces.get(0).getId());
existingPlace.ifPresent(place -> {
// Create a visit for this place
Visit visit = createVisit(user, place, stayPoint);
visitRepository.save(visit);
updatedPlaces.add(place);
});
}
}
return updatedPlaces;
}
private List<SignificantPlace> findNearbyPlaces(User user, double latitude, double longitude) {
// Create a point geometry
Point point = geometryFactory.createPoint(new Coordinate(longitude, latitude));
// Find places within the merge distance
return significantPlaceRepository.findNearbyPlaces(user.getId(), point, PLACE_MERGE_DISTANCE);
}
private SignificantPlace createSignificantPlace(User user, StayPoint stayPoint) {
// Create a point geometry
Point point = geometryFactory.createPoint(new Coordinate(stayPoint.getLongitude(), stayPoint.getLatitude()));
return new SignificantPlace(
user,
null, // name will be set later through reverse geocoding or user input
null, // address will be set later through reverse geocoding
stayPoint.getLatitude(),
stayPoint.getLongitude(),
point,
null
);
}
private Visit createVisit(User user, SignificantPlace place, StayPoint stayPoint) {
Visit visit = new Visit();
visit.setUser(user);
visit.setPlace(place);
visit.setStartTime(stayPoint.getArrivalTime());
visit.setEndTime(stayPoint.getDepartureTime());
visit.setDurationSeconds(stayPoint.getDurationSeconds());
return visit;
}
private void publishSignificantPlaceCreatedEvent(SignificantPlace place) {
SignificantPlaceCreatedEvent event = new SignificantPlaceCreatedEvent(
place.getId(),
place.getLatitudeCentroid(),
place.getLongitudeCentroid()
);
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.SIGNIFICANT_PLACE_ROUTING_KEY, event);
logger.info("Published SignificantPlaceCreatedEvent for place ID: {}", place.getId());
}
}

View File

@@ -0,0 +1,47 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
public class StayPoint {
private final double latitude;
private final double longitude;
private final Instant arrivalTime;
private final Instant departureTime;
private final List<RawLocationPoint> points;
public StayPoint(double latitude, double longitude, Instant arrivalTime, Instant departureTime, List<RawLocationPoint> points) {
this.latitude = latitude;
this.longitude = longitude;
this.arrivalTime = arrivalTime;
this.departureTime = departureTime;
this.points = points;
}
public double getLatitude() {
return latitude;
}
public double getLongitude() {
return longitude;
}
public Instant getArrivalTime() {
return arrivalTime;
}
public Instant getDepartureTime() {
return departureTime;
}
public List<RawLocationPoint> getPoints() {
return points;
}
public long getDurationSeconds() {
return Duration.between(arrivalTime, departureTime).getSeconds();
}
}

View File

@@ -0,0 +1,175 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.model.GeoUtils;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class StayPointDetectionService {
private static final Logger logger = LoggerFactory.getLogger(StayPointDetectionService.class);
// Parameters for stay point detection
private static final double DISTANCE_THRESHOLD = 50; // meters
private static final long TIME_THRESHOLD = 20 * 60; // 20 minutes in seconds
private static final int MIN_POINTS_IN_CLUSTER = 3; // Minimum points to form a valid cluster
private final RawLocationPointRepository rawLocationPointRepository;
@Autowired
public StayPointDetectionService(RawLocationPointRepository rawLocationPointRepository) {
this.rawLocationPointRepository = rawLocationPointRepository;
}
@Transactional(readOnly = true)
public List<StayPoint> detectStayPoints(User user, List<RawLocationPoint> newPoints) {
logger.info("Detecting stay points for user {} with {} new points", user.getUsername(), newPoints.size());
// Get a window of points around the new points to ensure continuity
Optional<Instant> earliestNewPoint = newPoints.stream()
.map(RawLocationPoint::getTimestamp)
.min(Instant::compareTo);
Optional<Instant> latestNewPoint = newPoints.stream()
.map(RawLocationPoint::getTimestamp)
.max(Instant::compareTo);
if (earliestNewPoint.isPresent() && latestNewPoint.isPresent()) {
// Get points from 1 hour before the earliest new point
Instant windowStart = earliestNewPoint.get().minus(Duration.ofHours(1));
// Get points from 1 hour after the latest new point
Instant windowEnd = latestNewPoint.get().plus(Duration.ofHours(1));
List<RawLocationPoint> pointsInWindow = rawLocationPointRepository
.findByUserAndTimestampBetweenOrderByTimestampAsc(user, windowStart, windowEnd);
logger.info("Found {} points in the processing window", pointsInWindow.size());
// Apply the stay point detection algorithm
List<StayPoint> stayPoints = detectStayPointsFromTrajectory(pointsInWindow);
logger.info("Detected {} stay points", stayPoints.size());
return stayPoints;
}
return Collections.emptyList();
}
private List<StayPoint> detectStayPointsFromTrajectory(List<RawLocationPoint> points) {
if (points.size() < MIN_POINTS_IN_CLUSTER) {
return Collections.emptyList();
}
logger.info("Starting cluster-based stay point detection with {} points", points.size());
// Step 1: Create clusters based on spatial proximity
List<List<RawLocationPoint>> clusters = createSpatialClusters(points);
logger.info("Created {} initial spatial clusters", clusters.size());
// Step 2: Filter clusters based on time threshold
List<List<RawLocationPoint>> validClusters = filterClustersByTimeThreshold(clusters);
logger.info("Found {} valid clusters after time threshold filtering", validClusters.size());
// Step 3: Convert valid clusters to stay points
List<StayPoint> stayPoints = validClusters.stream()
.map(this::createStayPoint)
.collect(Collectors.toList());
return stayPoints;
}
private List<List<RawLocationPoint>> createSpatialClusters(List<RawLocationPoint> points) {
List<List<RawLocationPoint>> clusters = new ArrayList<>();
Set<RawLocationPoint> processedPoints = new HashSet<>();
for (RawLocationPoint point : points) {
if (processedPoints.contains(point)) {
continue;
}
// Start a new cluster with this point
List<RawLocationPoint> cluster = new ArrayList<>();
cluster.add(point);
processedPoints.add(point);
// Find all points within DISTANCE_THRESHOLD of this point
for (RawLocationPoint otherPoint : points) {
if (processedPoints.contains(otherPoint)) {
continue;
}
double distance = GeoUtils.calculateHaversineDistance(point, otherPoint);
if (distance <= DISTANCE_THRESHOLD) {
cluster.add(otherPoint);
processedPoints.add(otherPoint);
}
}
// Only add clusters with enough points
if (cluster.size() >= MIN_POINTS_IN_CLUSTER) {
// Sort the cluster by timestamp
cluster.sort(Comparator.comparing(RawLocationPoint::getTimestamp));
clusters.add(cluster);
}
}
return clusters;
}
private List<List<RawLocationPoint>> filterClustersByTimeThreshold(List<List<RawLocationPoint>> clusters) {
List<List<RawLocationPoint>> validClusters = new ArrayList<>();
for (List<RawLocationPoint> cluster : clusters) {
// Calculate the total time span of the cluster
Instant firstTimestamp = cluster.get(0).getTimestamp();
Instant lastTimestamp = cluster.get(cluster.size() - 1).getTimestamp();
long timeSpanSeconds = Duration.between(firstTimestamp, lastTimestamp).getSeconds();
if (timeSpanSeconds >= TIME_THRESHOLD) {
validClusters.add(cluster);
}
}
return validClusters;
}
private StayPoint createStayPoint(List<RawLocationPoint> clusterPoints) {
// Calculate the centroid of the cluster using weighted average based on accuracy
// Points with better accuracy (lower meters value) get higher weight
double weightSum = 0;
double weightedLatSum = 0;
double weightedLngSum = 0;
for (RawLocationPoint point : clusterPoints) {
// Use inverse of accuracy as weight (higher accuracy = higher weight)
double weight = point.getAccuracyMeters() != null && point.getAccuracyMeters() > 0
? 1.0 / point.getAccuracyMeters()
: 1.0; // default weight if accuracy is null or zero
weightSum += weight;
weightedLatSum += point.getLatitude() * weight;
weightedLngSum += point.getLongitude() * weight;
}
double latCentroid = weightedLatSum / weightSum;
double lngCentroid = weightedLngSum / weightSum;
// Get the time range
Instant arrivalTime = clusterPoints.get(0).getTimestamp();
Instant departureTime = clusterPoints.get(clusterPoints.size() - 1).getTimestamp();
return new StayPoint(latCentroid, lngCentroid, arrivalTime, departureTime, clusterPoints);
}
}

View File

@@ -0,0 +1,203 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Service
public class TripDetectionService {
private static final Logger logger = LoggerFactory.getLogger(TripDetectionService.class);
private final ProcessedVisitRepository processedVisitRepository;
private final RawLocationPointRepository rawLocationPointRepository;
private final TripRepository tripRepository;
private final UserRepository userRepository;
public TripDetectionService(ProcessedVisitRepository processedVisitRepository,
RawLocationPointRepository rawLocationPointRepository,
TripRepository tripRepository,
UserRepository userRepository) {
this.processedVisitRepository = processedVisitRepository;
this.rawLocationPointRepository = rawLocationPointRepository;
this.tripRepository = tripRepository;
this.userRepository = userRepository;
}
@Transactional
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1-16")
public void detectTripsForUser(MergeVisitEvent event) {
User user = this.userRepository.findById(event.getUserId()).orElseThrow(() -> new IllegalArgumentException("Unknown user id: " + event.getUserId()));
logger.info("Detecting trips for user: {}", user.getUsername());
// Get all processed visits for the user, sorted by start time
List<ProcessedVisit> visits;
if (event.getStartTime() == null || event.getEndTime() == null) {
visits = processedVisitRepository.findByUser(user);
} else {
visits = processedVisitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user, Instant.ofEpochMilli(event.getStartTime()), Instant.ofEpochMilli(event.getEndTime()));
}
findDetectedTrips(user, visits);
}
private List<Trip> findDetectedTrips(User user, List<ProcessedVisit> visits) {
visits.sort(Comparator.comparing(ProcessedVisit::getStartTime));
if (visits.size() < 2) {
logger.info("Not enough visits to detect trips for user: {}", user.getUsername());
return new ArrayList<>();
}
List<Trip> detectedTrips = new ArrayList<>();
// Iterate through consecutive visits to detect trips
for (int i = 0; i < visits.size() - 1; i++) {
ProcessedVisit startVisit = visits.get(i);
ProcessedVisit endVisit = visits.get(i + 1);
// Create a trip between these two visits
Trip trip = createTripBetweenVisits(user, startVisit, endVisit);
if (trip != null) {
detectedTrips.add(trip);
}
}
logger.info("Detected {} trips for user: {}", detectedTrips.size(), user.getUsername());
return detectedTrips;
}
private Trip createTripBetweenVisits(User user, ProcessedVisit startVisit, ProcessedVisit endVisit) {
// Trip starts when the first visit ends
Instant tripStartTime = startVisit.getEndTime();
// Trip ends when the second visit starts
Instant tripEndTime = endVisit.getStartTime();
// If end time is before or equal to start time, this is not a valid trip
if (tripEndTime.isBefore(tripStartTime) || tripEndTime.equals(tripStartTime)) {
logger.debug("Invalid trip time range detected for user {}: {} to {}",
user.getUsername(), tripStartTime, tripEndTime);
return null;
}
// Check if a trip already exists with the same start and end times
if (tripRepository.existsByUserAndStartTimeAndEndTime(user, tripStartTime, tripEndTime)) {
logger.debug("Trip already exists for user {} from {} to {}",
user.getUsername(), tripStartTime, tripEndTime);
return null;
}
// Get location points between the two visits
List<RawLocationPoint> tripPoints = rawLocationPointRepository
.findByUserAndTimestampBetweenOrderByTimestampAsc(
user, tripStartTime, tripEndTime);
// Create a new trip
Trip trip = new Trip();
trip.setUser(user);
trip.setStartTime(tripStartTime);
trip.setEndTime(tripEndTime);
// Set start and end places
trip.setStartPlace(startVisit.getPlace());
trip.setEndPlace(endVisit.getPlace());
// Calculate estimated distance (straight-line distance between places)
double distanceMeters = calculateDistanceBetweenPlaces(
startVisit.getPlace(), endVisit.getPlace());
trip.setEstimatedDistanceMeters(distanceMeters);
// Calculate travelled distance (sum of distances between consecutive points)
double travelledDistanceMeters = calculateTripDistance(tripPoints);
trip.setTravelledDistanceMeters(travelledDistanceMeters);
// Infer transport mode based on speed and distance
// Use travelled distance if available, otherwise use estimated distance
double distanceForSpeed = travelledDistanceMeters > 0 ? travelledDistanceMeters : distanceMeters;
String transportMode = inferTransportMode(distanceForSpeed, tripStartTime, tripEndTime);
trip.setTransportModeInferred(transportMode);
logger.debug("Created trip from {} to {}: estimated distance={}m, travelled distance={}m, mode={}",
startVisit.getPlace().getName(), endVisit.getPlace().getName(),
Math.round(distanceMeters), Math.round(travelledDistanceMeters), transportMode);
// Save and return the trip
return tripRepository.save(trip);
}
private double calculateDistanceBetweenPlaces(SignificantPlace place1, SignificantPlace place2) {
return GeoUtils.calculateHaversineDistance(
place1.getLatitudeCentroid(), place1.getLongitudeCentroid(),
place2.getLatitudeCentroid(), place2.getLongitudeCentroid());
}
private double calculateTripDistance(List<RawLocationPoint> points) {
if (points.size() < 2) {
return 0.0;
}
double totalDistance = 0.0;
for (int i = 0; i < points.size() - 1; i++) {
RawLocationPoint p1 = points.get(i);
RawLocationPoint p2 = points.get(i + 1);
totalDistance += GeoUtils.calculateHaversineDistance(p1, p2);
}
return totalDistance;
}
private String inferTransportMode(double distanceMeters, Instant startTime, Instant endTime) {
// Calculate duration in seconds
long durationSeconds = endTime.getEpochSecond() - startTime.getEpochSecond();
// Avoid division by zero
if (durationSeconds <= 0) {
return "UNKNOWN";
}
// Calculate speed in meters per second
double speedMps = distanceMeters / durationSeconds;
// Convert to km/h for easier interpretation
double speedKmh = speedMps * 3.6;
// Simple transport mode inference based on average speed
if (speedKmh < 7) {
return "WALKING";
} else if (speedKmh < 20) {
return "CYCLING";
} else if (speedKmh < 120) {
return "DRIVING";
} else {
return "TRANSIT"; // High-speed transit like train
}
}
@Transactional
public void clearTrips(User user) {
List<Trip> userTrips = tripRepository.findByUser(user);
tripRepository.deleteAll(userTrips);
logger.info("Cleared {} trips for user: {}", userTrips.size(), user.getUsername());
}
@Transactional
public void clearAllTrips() {
tripRepository.deleteAll();
logger.info("Cleared all trips");
}
}

View File

@@ -0,0 +1,204 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.GeoUtils;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class TripMergingService {
private static final Logger logger = LoggerFactory.getLogger(TripMergingService.class);
private final TripRepository tripRepository;
private final UserRepository userRepository;
private final RawLocationPointRepository rawLocationPointRepository;
@Autowired
public TripMergingService(TripRepository tripRepository,
UserRepository userRepository,
RawLocationPointRepository rawLocationPointRepository) {
this.tripRepository = tripRepository;
this.userRepository = userRepository;
this.rawLocationPointRepository = rawLocationPointRepository;
}
@Transactional
@RabbitListener(queues = RabbitMQConfig.MERGE_TRIP_QUEUE)
public void mergeDuplicateTripsForUser(MergeVisitEvent event) {
User user = this.userRepository.findById(event.getUserId()).orElseThrow(() -> new IllegalArgumentException("Unknown user id: " + event.getUserId()));
logger.info("Merging duplicate trips for user: {}", user.getUsername());
// Get all trips for the user
List<Trip> allTrips;
if (event.getStartTime() == null || event.getEndTime() == null) {
allTrips = tripRepository.findByUser(user);
} else {
allTrips = tripRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user, Instant.ofEpochMilli(event.getStartTime()), Instant.ofEpochMilli(event.getEndTime()));
}
mergeTrips(user, allTrips, true);
mergeTrips(user, allTrips, false);
}
private void mergeTrips(User user, List<Trip> allTrips, boolean withStart) {
if (allTrips.isEmpty()) {
logger.info("No trips found for user: {}", user.getUsername());
return;
}
// Group trips by start place, end place, and similar time range
Map<String, List<Trip>> tripGroups = groupSimilarTrips(allTrips, withStart);
// Process each group to merge duplicates
List<Trip> tripsToDelete = new ArrayList<>();
for (List<Trip> tripGroup : tripGroups.values()) {
if (tripGroup.size() > 1) {
mergeTrips(tripGroup, user);
tripsToDelete.addAll(tripGroup);
}
}
// Delete the original trips that were merged
if (!tripsToDelete.isEmpty()) {
tripRepository.deleteAll(tripsToDelete);
logger.info("Deleted {} duplicate trips for user: {}", tripsToDelete.size(), user.getUsername());
}
}
private Map<String, List<Trip>> groupSimilarTrips(List<Trip> trips, boolean withStart) {
Map<String, List<Trip>> tripGroups = new HashMap<>();
for (Trip trip : trips) {
// Create a key based on start place, end place, and approximate time
// We use minute precision for time to allow for small differences
String key = createTripGroupKey(trip, withStart);
tripGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(trip);
}
return tripGroups;
}
private String createTripGroupKey(Trip trip, boolean withStart) {
// Create a key that identifies similar trips
// Format: userId_startPlaceId_endPlaceId_startTimeMinute_endTimeMinute
long timeKey = withStart ? trip.getStartTime().getEpochSecond() / 60 : trip.getEndTime().getEpochSecond() / 60;
Long startPlaceId = trip.getStartPlace() != null ? trip.getStartPlace().getId() : 0;
Long endPlaceId = trip.getEndPlace() != null ? trip.getEndPlace().getId() : 0;
return String.format("%d_%d_%d_%d",
trip.getUser().getId(),
startPlaceId,
endPlaceId,
timeKey);
}
private Trip mergeTrips(List<Trip> trips, User user) {
// Use the first trip as a base
Trip baseTrip = trips.get(0);
// Find the earliest start time and latest end time
Instant earliestStart = baseTrip.getStartTime();
Instant latestEnd = baseTrip.getEndTime();
for (Trip trip : trips) {
if (trip.getStartTime().isBefore(earliestStart)) {
earliestStart = trip.getStartTime();
}
if (trip.getEndTime().isAfter(latestEnd)) {
latestEnd = trip.getEndTime();
}
}
// Create a new merged trip
Trip mergedTrip = new Trip();
mergedTrip.setUser(user);
mergedTrip.setStartPlace(baseTrip.getStartPlace());
mergedTrip.setEndPlace(baseTrip.getEndPlace());
mergedTrip.setStartTime(earliestStart);
mergedTrip.setEndTime(latestEnd);
// Recalculate distance based on raw location points
recalculateDistance(mergedTrip);
// Set transport mode (use the most common one from the trips)
mergedTrip.setTransportModeInferred(getMostCommonTransportMode(trips));
// Save the merged trip
return tripRepository.save(mergedTrip);
}
private void recalculateDistance(Trip trip) {
// Get all raw location points for this user within the trip's time range
List<RawLocationPoint> points = rawLocationPointRepository.findByUserAndTimestampBetweenOrderByTimestampAsc(
trip.getUser(), trip.getStartTime(), trip.getEndTime());
if (points.size() < 2) {
// Not enough points to calculate distance
trip.setTravelledDistanceMeters(0.0);
return;
}
// Calculate total distance
double totalDistance = 0.0;
for (int i = 0; i < points.size() - 1; i++) {
RawLocationPoint p1 = points.get(i);
RawLocationPoint p2 = points.get(i + 1);
double distance = GeoUtils.calculateHaversineDistance(
p1.getLatitude(), p1.getLongitude(),
p2.getLatitude(), p2.getLongitude());
totalDistance += distance;
}
trip.setTravelledDistanceMeters(totalDistance);
// Also update the estimated distance
if (trip.getStartPlace() != null && trip.getEndPlace() != null) {
double directDistance = GeoUtils.calculateHaversineDistance(
trip.getStartPlace().getLatitudeCentroid(), trip.getStartPlace().getLongitudeCentroid(),
trip.getEndPlace().getLatitudeCentroid(), trip.getEndPlace().getLongitudeCentroid());
trip.setEstimatedDistanceMeters(directDistance);
} else {
trip.setEstimatedDistanceMeters(totalDistance);
}
}
private String getMostCommonTransportMode(List<Trip> trips) {
Map<String, Integer> modeCounts = new HashMap<>();
for (Trip trip : trips) {
String mode = trip.getTransportModeInferred();
if (mode != null) {
modeCounts.put(mode, modeCounts.getOrDefault(mode, 0) + 1);
}
}
// Find the mode with the highest count
return modeCounts.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("UNKNOWN");
}
}

View File

@@ -0,0 +1,39 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.temporal.ChronoUnit;
@Component
public class VisitMergingRunner {
private static final Logger logger = LoggerFactory.getLogger(VisitMergingRunner.class);
private final UserService userService;
private final RabbitTemplate rabbitTemplate;
public VisitMergingRunner(UserService userService,
RabbitTemplate rabbitTemplate) {
this.userService = userService;
this.rabbitTemplate = rabbitTemplate;
}
@Scheduled(cron = "${reitti.process-visits-trips.schedule}")
public void run() {
userService.getAllUsers().forEach(user -> {
logger.info("Schedule visit merging process for user {}", user.getUsername());
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getId(), null, null));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), null, null));
});
}
}

View File

@@ -0,0 +1,214 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.model.Visit;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class VisitMergingService {
private static final Logger logger = LoggerFactory.getLogger(VisitMergingService.class);
private final VisitRepository visitRepository;
private final ProcessedVisitRepository processedVisitRepository;
private final UserRepository userRepository;
private final RabbitTemplate rabbitTemplate;
@Value("${reitti.visit.merge-threshold-seconds:300}")
private long mergeThresholdSeconds;
@Value("${reitti.detect-trips-after-merging:true}")
private boolean detectTripsAfterMerging;
@Autowired
public VisitMergingService(VisitRepository visitRepository,
ProcessedVisitRepository processedVisitRepository,
UserRepository userRepository, RabbitTemplate rabbitTemplate) {
this.visitRepository = visitRepository;
this.processedVisitRepository = processedVisitRepository;
this.userRepository = userRepository;
this.rabbitTemplate = rabbitTemplate;
}
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
@Transactional
public void mergeVisits(MergeVisitEvent event) {
processAndMergeVisits(userRepository.findById(event.getUserId()).orElseThrow(), event.getStartTime(), event.getEndTime());
}
private List<ProcessedVisit> processAndMergeVisits(User user, Long startTime, Long endTime) {
logger.info("Processing and merging visits for user: {}", user.getUsername());
List<Visit> allVisits;
// Get all unprocessed visits for the user
if (startTime == null || endTime == null) {
allVisits = this.visitRepository.findByUserAndProcessedFalse(user);
} else {
allVisits = this.visitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user, Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime));
}
if (allVisits.isEmpty()) {
logger.info("No visits found for user: {}", user.getUsername());
return Collections.emptyList();
}
// Sort all visits chronologically
allVisits.sort(Comparator.comparing(Visit::getStartTime));
// Process all visits chronologically to avoid overlaps
List<ProcessedVisit> processedVisits = mergeVisitsChronologically(user, allVisits);
// Mark all visits as processed
if (!allVisits.isEmpty()) {
allVisits.forEach(visit -> visit.setProcessed(true));
visitRepository.saveAll(allVisits);
logger.info("Marked {} visits as processed for user: {}", allVisits.size(), user.getUsername());
}
logger.info("Processed {} visits into {} merged visits for user: {}",
allVisits.size(), processedVisits.size(), user.getUsername());
if (!processedVisits.isEmpty() && detectTripsAfterMerging) {
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), startTime, endTime));
}
return processedVisits;
}
private List<ProcessedVisit> mergeVisitsChronologically(User user, List<Visit> visits) {
List<ProcessedVisit> result = new ArrayList<>();
if (visits.isEmpty()) {
return result;
}
// Start with the first visit
Visit currentVisit = visits.get(0);
SignificantPlace currentPlace = currentVisit.getPlace();
Instant currentStartTime = currentVisit.getStartTime();
Instant currentEndTime = currentVisit.getEndTime();
Set<Long> mergedVisitIds = new HashSet<>();
mergedVisitIds.add(currentVisit.getId());
for (int i = 1; i < visits.size(); i++) {
Visit nextVisit = visits.get(i);
SignificantPlace nextPlace = nextVisit.getPlace();
// Case 1: Same place and within merge threshold
if (nextPlace.getId().equals(currentPlace.getId()) &&
Duration.between(currentEndTime, nextVisit.getStartTime()).getSeconds() <= mergeThresholdSeconds) {
// Merge this visit with the current one
currentEndTime = nextVisit.getEndTime();
mergedVisitIds.add(nextVisit.getId());
}
// Case 2: Different place or gap too large
else {
// Handle overlapping visits - if next visit starts before current ends
if (nextVisit.getStartTime().isBefore(currentEndTime)) {
// Adjust current end time to when the next visit starts
currentEndTime = nextVisit.getStartTime();
}
// Create a processed visit from the current merged set
ProcessedVisit processedVisit = createProcessedVisit(user, currentPlace, currentStartTime,
currentEndTime, mergedVisitIds);
result.add(processedVisit);
// Start a new merged set with this visit
currentPlace = nextPlace;
currentStartTime = nextVisit.getStartTime();
currentEndTime = nextVisit.getEndTime();
mergedVisitIds = new HashSet<>();
mergedVisitIds.add(nextVisit.getId());
}
}
// Add the last merged set
ProcessedVisit processedVisit = createProcessedVisit(user, currentPlace, currentStartTime,
currentEndTime, mergedVisitIds);
result.add(processedVisit);
return result;
}
private ProcessedVisit createProcessedVisit(User user, SignificantPlace place,
Instant startTime, Instant endTime,
Set<Long> originalVisitIds) {
// Check if a processed visit already exists for this time range and place
List<ProcessedVisit> existingVisits = processedVisitRepository.findByUserAndPlaceAndTimeOverlap(
user, place, startTime, endTime);
if (!existingVisits.isEmpty()) {
// Use the existing processed visit
ProcessedVisit existingVisit = existingVisits.get(0);
logger.debug("Found existing processed visit for place ID {}", place.getId());
// Update the existing visit if needed (e.g., extend time range)
if (startTime.isBefore(existingVisit.getStartTime())) {
existingVisit.setStartTime(startTime);
}
if (endTime.isAfter(existingVisit.getEndTime())) {
existingVisit.setEndTime(endTime);
}
// Add original visit IDs to the existing one
String existingIds = existingVisit.getOriginalVisitIds();
String newIds = originalVisitIds.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
if (existingIds == null || existingIds.isEmpty()) {
existingVisit.setOriginalVisitIds(newIds);
} else {
existingVisit.setOriginalVisitIds(existingIds + "," + newIds);
}
existingVisit.setMergedCount(existingVisit.getMergedCount() + originalVisitIds.size());
return processedVisitRepository.save(existingVisit);
} else {
// Create a new processed visit
ProcessedVisit processedVisit = new ProcessedVisit(user, place, startTime, endTime);
processedVisit.setMergedCount(originalVisitIds.size());
// Store original visit IDs as comma-separated string
String visitIdsStr = originalVisitIds.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
processedVisit.setOriginalVisitIds(visitIdsStr);
return processedVisitRepository.save(processedVisit);
}
}
@Transactional
public void clearProcessedVisits(User user) {
List<ProcessedVisit> userVisits = processedVisitRepository.findByUser(user);
processedVisitRepository.deleteAll(userVisits);
logger.info("Cleared {} processed visits for user: {}", userVisits.size(), user.getUsername());
}
@Transactional
public void clearAllProcessedVisits() {
processedVisitRepository.deleteAll();
logger.info("Cleared all processed visits");
}
}

View File

@@ -0,0 +1,32 @@
# Server configuration
server.port=8080
# PostgreSQL configuration (commented out for now, uncomment for production)
spring.datasource.url=jdbc:postgresql://localhost:5432/reittidb
spring.datasource.username=reitti
spring.datasource.password=reitti
spring.thymeleaf.cache=false
# JPA/Hibernate properties
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
# RabbitMQ Configuration
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=reitti
spring.rabbitmq.password=reitti
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.initial-interval=1000ms
spring.rabbitmq.listener.simple.retry.max-attempts=3
spring.rabbitmq.listener.simple.retry.multiplier=1.5
spring.rabbitmq.listener.simple.prefetch=10
# Visit Processing Configuration
reitti.visit.merge-threshold-seconds=300
reitti.process-visits-trips.merge-time-range=1
reitti.process-visits-trips.schedule=0 */10 * * * *
spring.servlet.multipart.max-file-size=5GB
spring.servlet.multipart.max-request-size=5GB

View File

@@ -0,0 +1,86 @@
create table raw_location_points
(
id bigint generated by default as identity,
accuracy_meters float(53) not null,
activity_provided varchar(255),
latitude float(53) not null,
longitude float(53) not null,
timestamp timestamp(6) with time zone not null,
user_id bigint not null,
primary key (id)
);
create table significant_places
(
id bigint generated by default as identity,
address varchar(255),
category varchar(255),
first_seen timestamp(6) with time zone not null,
last_seen timestamp(6) with time zone not null,
latitude_centroid float(53) not null,
longitude_centroid float(53) not null,
name varchar(255),
user_id bigint not null,
primary key (id)
);
create table trips
(
id bigint generated by default as identity,
duration_seconds bigint not null,
end_time timestamp(6) with time zone not null,
estimated_distance_meters float(53),
start_time timestamp(6) with time zone not null,
transport_mode_inferred varchar(255),
end_place_id bigint,
start_place_id bigint,
user_id bigint not null,
primary key (id)
);
create table users
(
id bigint generated by default as identity,
display_name varchar(255) not null,
username varchar(255) not null,
primary key (id)
);
create table visits
(
id bigint generated by default as identity,
duration_seconds bigint not null,
end_time timestamp(6) with time zone not null,
start_time timestamp(6) with time zone not null,
place_id bigint not null,
user_id bigint not null,
primary key (id)
);
alter table if exists users
drop constraint if exists UKr43af9ap4edm43mmtq01oddj6;
alter table if exists users
add constraint UKr43af9ap4edm43mmtq01oddj6 unique (username);
alter table if exists raw_location_points
add constraint FKp2ffgs5y6vk7eul2x4x5topr7
foreign key (user_id)
references users;
alter table if exists significant_places
add constraint FK2cx1himtc2aapqh5yc6cvuwee
foreign key (user_id)
references users;
alter table if exists trips
add constraint FK88p5fhq7aynk5n7cem6tgk556
foreign key (end_place_id)
references significant_places;
alter table if exists trips
add constraint FKb3mmvmk5hbu58jq5hhse0ln0d
foreign key (start_place_id)
references significant_places;
alter table if exists trips
add constraint FK8wb14dx6ed0bpp3planbay88u
foreign key (user_id)
references users;
alter table if exists visits
add constraint FKhkvqtop7xrbnkpjqyqickus6c
foreign key (place_id)
references significant_places;
alter table if exists visits
add constraint FK5kmnbgokfpcalwrminoedrb68
foreign key (user_id)
references users;

View File

@@ -0,0 +1,12 @@
CREATE TABLE api_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_used_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT fk_api_token_user FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_api_token_token ON api_tokens(token);

View File

@@ -0,0 +1,7 @@
ALTER TABLE significant_places
ADD COLUMN geom geometry;
ALTER TABLE significant_places
ADD COLUMN total_duration_seconds BIGINT;
ALTER TABLE significant_places
ADD COLUMN visit_count INTEGER;

View File

@@ -0,0 +1,18 @@
CREATE TABLE processed_visits
(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id),
place_id BIGINT NOT NULL REFERENCES significant_places (id),
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
duration_seconds BIGINT NOT NULL,
original_visit_ids TEXT,
merged_count INTEGER DEFAULT 1,
CONSTRAINT fk_processed_visit_user FOREIGN KEY (user_id) REFERENCES users (id),
CONSTRAINT fk_processed_visit_place FOREIGN KEY (place_id) REFERENCES significant_places (id)
);
CREATE INDEX idx_processed_visits_user ON processed_visits (user_id);
CREATE INDEX idx_processed_visits_place ON processed_visits (place_id);
CREATE INDEX idx_processed_visits_time ON processed_visits (start_time, end_time);

View File

@@ -0,0 +1,2 @@
ALTER TABLE trips
ADD COLUMN travelled_distance_meters DOUBLE PRECISION;

View File

@@ -0,0 +1,2 @@
ALTER TABLE visits
ADD COLUMN processed BOOLEAN DEFAULT false;

View File

@@ -0,0 +1,7 @@
-- Add password column to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(255);
-- Create admin user with password 'admin' (BCrypt encoded)
INSERT INTO users (username, display_name, password)
VALUES ('admin', 'Administrator', '$2a$10$rXXm9rFzQeJLJSP7TE3LMO9vWRlW.ZKLEw1YNECzk.FC4lzOVzRIe')
ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password;

View File

@@ -0,0 +1,4 @@
ALTER TABLE significant_places DROP COLUMN visit_count;
ALTER TABLE significant_places DROP COLUMN total_duration_seconds;
ALTER TABLE significant_places DROP COLUMN first_seen;
ALTER TABLE significant_places DROP COLUMN last_seen;

View File

@@ -0,0 +1,2 @@
create index raw_location_points_user_id_timestamp_index
on raw_location_points (user_id, timestamp);

View File

@@ -0,0 +1,234 @@
.horizontal-date-picker {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: rgba(59, 59, 59, 0.9);
backdrop-filter: blur(10px);
padding: 10px 0;
z-index: 50;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
}
.date-picker-container {
display: flex;
overflow-x: auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
padding: 0 10px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scroll-snap-type: x mandatory;
}
.date-picker-container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.date-item {
flex: 0 0 auto;
padding: 8px 12px;
margin: 0 4px;
text-align: center;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
color: #f8f8f8;
min-width: 80px;
user-select: none;
scroll-snap-align: center;
}
.date-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.date-item.selected {
background-color: #4a89dc;
color: white;
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.date-item .day-name {
font-size: 0.8rem;
opacity: 0.8;
display: block;
}
.date-item .day-number {
font-size: 1.2rem;
font-weight: bold;
display: block;
}
.date-item .month-name {
font-size: 0.7rem;
display: block;
}
.date-item .month-year-name {
font-size: 0.7rem;
display: block;
font-weight: bold;
margin-top: 2px;
}
.date-nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: rgba(74, 137, 220, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 51;
}
.date-nav-button:hover {
background-color: rgba(74, 137, 220, 1);
}
.date-nav-prev {
left: 10px;
}
.date-nav-next {
right: 10px;
}
/* Month row styles */
.month-row-container {
display: flex;
flex-direction: column;
overflow-x: auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
padding: 0 10px;
margin-bottom: 8px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.month-row-container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.year-row {
display: flex;
justify-content: center;
margin-bottom: 8px;
position: relative;
}
.today-button {
position: absolute;
left: 10px;
padding: 4px 12px;
background-color: rgba(74, 137, 220, 0.8);
color: white;
border-radius: 16px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 5px;
}
.today-button:hover {
background-color: rgba(74, 137, 220, 1);
}
.year-item {
padding: 4px 16px;
margin: 0 8px;
text-align: center;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
color: #f8f8f8;
font-size: 1rem;
font-weight: bold;
}
.year-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.year-item.selected {
background-color: #4a89dc;
color: white;
}
.month-row {
display: flex;
overflow-x: auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.month-row::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.month-item {
flex: 0 0 auto;
padding: 4px 12px;
margin: 0 4px;
text-align: center;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
color: #f8f8f8;
min-width: 60px;
font-size: 0.8rem;
position: relative;
}
.month-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.month-item.selected {
background-color: #4a89dc;
color: white;
}
.month-item .year-label {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
background-color: rgba(74, 137, 220, 0.8);
padding: 2px 6px;
border-radius: 10px;
}
@media (max-width: 768px) {
.date-item {
min-width: 60px;
padding: 6px 8px;
}
.date-item .day-number {
font-size: 1rem;
}
.month-item {
min-width: 50px;
padding: 4px 8px;
font-size: 0.7rem;
}
}

View File

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="timeline">
<div class="timeline-date" th:text="${#temporals.format(date, 'EEEE, MMMM d, yyyy')}">Monday, October 2, 2023</div>
<div th:if="${timelineEntries.empty}" class="empty-state">
No timeline entries for this date.
</div>
<div th:each="entry : ${timelineEntries}"
th:class="'timeline-entry ' + ${entry.type}"
th:data-id="${entry.id}"
th:data-lat="${entry.type == 'place' ? entry.latitude : (entry.startLatitude != null ? entry.startLatitude : entry.endLatitude)}"
th:data-lng="${entry.type == 'place' ? entry.longitude : (entry.startLongitude != null ? entry.startLongitude : entry.endLongitude)}">
<div class="entry-time">
<span th:text="${entry.startTime}">07:00</span>
<span th:if="${entry.endTime != null}"> - </span>
<span th:if="${entry.endTime != null}" th:text="${entry.endTime}">08:30</span>
</div>
<div class="entry-description" th:text="${entry.description}">Home</div>
<div th:if="${entry.type == 'trip'}" class="entry-transport-mode">
<span th:text="${entry.transportMode}">Walking</span>
</div>
<div th:if="${entry.address != null}" class="entry-address" th:text="${entry.address}"></div>
<div th:if="${entry.distanceMeters != null}" class="entry-distance">
<span th:text="${#numbers.formatDecimal(entry.distanceMeters / 1000, 1, 1)}"></span> km
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,570 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reitti - Your Location Timeline</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel= "stylesheet" href= "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" >
<link rel= "stylesheet" href= "/css/main.css" >
<link rel= "stylesheet" href= "/css/date-picker.css" >
<script src="/js/HumanizeDuration.js"></script>
<script src="/js/horizontal-date-picker.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,100..900;1,9..144,100..900&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
height: 100vh;
}
header {
position: absolute;
top: 0;
left: 40%;
z-index: 10;
background: rgba(59, 59, 59, 0.62);
backdrop-filter: blur(10px);
color: white;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
nav {
display: flex;
gap: 20px;
}
.nav-link {
color: white;
text-decoration: none;
padding: 5px 10px;
border-radius: 4px;
transition: background-color 0.2s;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
#map {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.timeline::-webkit-scrollbar {
display: none;
}
.timeline {
position: absolute;
top: 0;
width: 50%;
bottom: 0;
background: #3b3b3b;
background: linear-gradient(90deg, rgb(17, 17, 17) 0%, rgba(59, 59, 59, 0.49) 76%, rgba(255, 255, 255, 0) 100%);
z-index: 5;
overflow-y: auto;
pointer-events: none;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.timeline-container {
pointer-events: all;
width: 350px;
font-family: "Serif";
padding-bottom: 250px;
}
.timeline-entry {
color: #bfbfbf;
padding: 1rem 2rem;
transition: color, padding 0.5s ease-in-out;
font-weight: lighter;
}
.timeline-entry.active {
color: #f8f8f8;
padding: 1rem 2.5rem;
transition: color, padding 0.5s ease-in-out;
}
.timeline-entry .entry-description {
font-family: "Fraunces";
font-size: 3rem;
transition: font-size 0.5s ease-in-out;
}
.timeline-entry .entry-icon {
padding: 8px;
}
.timeline-entry .entry-time {
padding: 0 32px;
}
.timeline-entry.active .entry-description {
font-family: "Fraunces";
font-size: 5rem;
transition: font-size 0.5s ease-in-out;
}
.htmx-request .htmx-indicator {
display: inline;
}
button {
background-color: transparent;
color: wheat;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #959595;
}
.timeline-header {
display: inline-flex;
align-items: baseline;
padding: 8px;
border-bottom: 1px black;
margin-left: 2rem;
pointer-events: auto;
}
.timeline-header {
position: sticky;
font-size: 1.4rem;
}
.date-item.selected {
background-color: unset;
color: wheat;
transform: scale(1.05);
box-shadow: unset;
}
.month-item.selected {
background-color: #4a89dc;
color: wheat;
font-weight: bolder;
background: unset;
}
.year-item.selected {
background-color: #4a89dc;
color: wheat;
font-weight: bolder;
background: unset;
}
.today-button {
background-color: unset;
}
.date-item {
padding: unset;
margin: unset;
border-radius: 0;
}
.date-item:hover {
background-color: unset;
border-bottom: 1px solid white;
}
.date-item.selected:hover {
border-bottom: 0;
}
.date-item .day-name,
.date-item .day-number {
font-size: 2rem;
font-weight: lighter;
}
.horizontal-date-picker {
position: fixed;
font-family: "Fraunces";
bottom: 0;
left: 0;
width: 100%;
background-color: rgba(59, 59, 59, 0.68);
backdrop-filter: blur(10px);
padding: 10px 0;
z-index: 50;
box-shadow: unset;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="timeline">
<span class="timeline-header">
<a href="/" class="nav-link"><i class="fas fa-house-flag"></i></a>
<a href="/settings" class="nav-link"><i class="fas fa-cog"></i></a>
<form th:action="@{/logout}" method="post" style="display: inline;">
<button type="submit" class="nav-link" style="background: none; border: none; cursor: pointer;"><i class="fas fa-right-from-bracket"></i>
</button>
</form>
</span>
<div class="timeline-container">
<div id="loading-indicator" style="display: none;">Loading...</div>
<!-- Timeline entries will be loaded here -->
</div>
</div>
<!-- Horizontal date picker will be initialized here -->
<div id="horizontal-date-picker-container"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Check if date is in URL parameters
const urlParams = new URLSearchParams(window.location.search);
let initialDate;
if (urlParams.has('date')) {
initialDate = urlParams.get('date');
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(initialDate)) {
initialDate = null;
}
}
// Set date picker to URL date or today's date
const today = new Date();
const formattedDate = initialDate || today.toISOString().split('T')[0]; // YYYY-MM-DD format
// Function to update URL with date parameter
function updateUrlWithDate(date) {
const url = new URL(window.location);
url.searchParams.set('date', date);
window.history.pushState({}, '', url);
}
// Initialize the map
const map = L.map('map').setView([60.1699, 24.9384], 12); // Helsinki coordinates as default
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png', {
maxZoom: 20,
attribution: '&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
}).addTo(map);
// Add scale control
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
// Function to load timeline data
function loadTimelineData(date) {
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'block';
fetch(`/api/timeline?selectedDate=${date}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
updateTimeline(data);
loadingIndicator.style.display = 'none';
})
.catch(error => {
console.error('Error fetching timeline data:', error);
loadingIndicator.style.display = 'none';
});
}
const selectedPath = L.polyline([], {
color: '#ff984f',
weight: 6,
opacity: 1,
lineJoin: 'round',
lineCap: 'round'
})
// Function to update the timeline with fetched data
function updateTimeline(data) {
const timelineContainer = document.querySelector('.timeline-container');
const loadingIndicator = document.getElementById('loading-indicator');
timelineContainer.innerHTML = '';
timelineContainer.appendChild(loadingIndicator);
// Clear existing markers and paths
map.eachLayer(layer => {
if (!layer._url) {
map.removeLayer(layer);
}
});
if (!data || !data.entries || data.entries.length === 0) {
const noDataMsg = document.createElement('div');
noDataMsg.className = 'timeline-entry';
noDataMsg.textContent = 'No timeline data available for this date.';
timelineContainer.appendChild(noDataMsg);
return;
}
const bounds = L.latLngBounds();
let hasValidCoords = false;
// Create timeline entries
data.entries.forEach(entry => {
const entryElement = document.createElement('div');
entryElement.className = `timeline-entry ${entry.type.toLowerCase()}`;
entryElement.dataset.id = entry.id;
// Set coordinates based on entry type
if (entry.type === 'VISIT' && entry.place) {
entryElement.dataset.lat = entry.place.latitude;
entryElement.dataset.lng = entry.place.longitude;
} else if (entry.type === 'TRIP') {
// For trips, use start place coordinates
if (entry.startPlace) {
entryElement.dataset.lat = entry.startPlace.latitude;
entryElement.dataset.lng = entry.startPlace.longitude;
}
// If trip has path data, add it
if (entry.path) {
entryElement.dataset.path = JSON.stringify(entry.path);
}
}
// Create icon element
const iconElement = document.createElement('span');
iconElement.className = 'entry-icon';
iconElement.innerHTML = entry.type === 'VISIT' ? '<i class="fas fa-map-marker-alt"></i>' : '<i class="fas fa-route"></i>';
// Create time element
const timeElement = document.createElement('div');
timeElement.className = 'entry-time';
const startTime = new Date(entry.startTime);
const endTime = new Date(entry.endTime);
timeElement.textContent = `${startTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
})} - ${endTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
})}`;
// Calculate duration
const durationMs = endTime - startTime;
const durationText = humanizeDuration(durationMs, {units: ["h", "m"], round: true });
const durationElement = document.createElement('span');
durationElement.className = 'entry-duration';
durationElement.textContent = `Duration: ${durationText}`;
// Create description element
const descElement = document.createElement('div');
descElement.className = 'entry-description';
// Create details element
const detailsElement = document.createElement('div');
detailsElement.className = 'entry-details';
if (entry.type === 'VISIT') {
const placeName = entry.place ? entry.place.name || 'Unknown Place' : 'Unknown Place';
descElement.textContent = placeName;
if (entry.place && entry.place.address) {
detailsElement.textContent = entry.place.address;
}
if (entry.place && entry.place.category) {
const categorySpan = document.createElement('span');
categorySpan.style.display = 'block';
categorySpan.style.marginTop = '5px';
categorySpan.style.fontStyle = 'italic';
categorySpan.textContent = entry.place.category;
detailsElement.appendChild(categorySpan);
}
} else if (entry.type === 'TRIP') {
const startName = entry.startPlace ? entry.startPlace.name || 'Unknown' : 'Unknown';
const endName = entry.endPlace ? entry.endPlace.name || 'Unknown' : 'Unknown';
descElement.textContent = `Trip`;
detailsElement.innerHTML = `<strong>From:</strong> ${startName}<br><strong>To:</strong> ${endName}`;
// Add distance if available
if (entry.distanceMeters) {
const distanceKm = (entry.distanceMeters / 1000).toFixed(1);
durationElement.textContent = `Distance: ${distanceKm} km`;
}
}
// Add elements to entry
entryElement.appendChild(descElement);
entryElement.appendChild(iconElement);
entryElement.appendChild(durationElement);
entryElement.appendChild(timeElement);
// Add entry to timeline
timelineContainer.appendChild(entryElement);
// Add markers for each entry with coordinates
const lat = parseFloat(entryElement.dataset.lat);
const lng = parseFloat(entryElement.dataset.lng);
if (!isNaN(lat) && !isNaN(lng)) {
// Determine marker color based on entry type
const markerColor = entry.type === 'VISIT' ? '#4a89dc' : '#e67e22';
// Create marker with custom icon
const marker = L.circleMarker([lat, lng], {
radius: 8,
fillColor: markerColor,
color: '#fff',
weight: 1,
opacity: 1,
fillOpacity: 0.8
}).addTo(map);
L.circle([lat, lng], {
color: '#4a89dc',
fillColor: '#6098e3',
fillOpacity: 0.1,
radius: 30
}).addTo(map);
// Add popup with basic info
const popupContent = entry.type === 'VISIT'
? `<b>${entry.place?.name || 'Unknown Place'}</b><br>${timeElement.textContent}`
: `<b>Trip</b><br>${timeElement.textContent}`;
marker.bindPopup(popupContent);
// Extend bounds for map fitting
bounds.extend([lat, lng]);
hasValidCoords = true;
}
// If entry has a path, add it to the map
if (entryElement.dataset.path) {
try {
const pathData = JSON.parse(entryElement.dataset.path);
const latlngs = pathData.map(coord => [coord.latitude, coord.longitude]);
// Create polyline
L.polyline(latlngs, {
color: '#4f73ff',
weight: 2,
opacity: 0.8,
lineJoin: 'round',
lineCap: 'round'
}).addTo(map);
// Extend bounds with all path points
latlngs.forEach(latlng => {
bounds.extend(latlng);
hasValidCoords = true;
});
} catch (e) {
console.error("Error parsing path data:", e);
}
}
});
// Fit map to bounds if we have valid coordinates
if (hasValidCoords) {
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 16
});
}
}
// Handle clicks on timeline entries
document.querySelector('.timeline-container').addEventListener('click', function (event) {
document.querySelectorAll('.timeline-container .timeline-entry')
.forEach(entry => entry.classList.remove('active'));
const entry = event.target.closest('.timeline-entry');
entry.classList.add('active');
if (entry) {
selectedPath.remove();
const newBounds = L.latLngBounds();
const lat = parseFloat(entry.dataset.lat);
const lng = parseFloat(entry.dataset.lng);
if (entry.dataset.path) {
const pathData = JSON.parse(entry.dataset.path);
const latlngs = pathData.map(coord => [coord.latitude, coord.longitude]);
latlngs.forEach(latlng => {
newBounds.extend(latlng);
});
selectedPath.setLatLngs(latlngs);
selectedPath.addTo(map)
}
newBounds.extend([lat, lng]);
map.flyToBounds(newBounds, {
padding: [50, 50],
maxZoom: 16
});
}
});
loadTimelineData(formattedDate);
// Parse the initial date properly to ensure correct date picker initialization
let dateToUse = new Date();
if (initialDate) {
// Parse the date from URL parameter (YYYY-MM-DD format)
const [year, month, day] = initialDate.split('-').map(Number);
// Note: month is 0-indexed in JavaScript Date
dateToUse = new Date(year, month - 1, day);
}
// Initialize horizontal date picker
new HorizontalDatePicker({
container: document.getElementById('horizontal-date-picker-container'),
selectedDate: dateToUse,
autoSelectOnScroll: true, // Enable auto-select on scroll
showNavButtons: false, // Show navigation buttons
daysToShow: 21, // Show more days
showMonthRow: true, // Enable month selection row
showYearRow: true, // Enable year selection row
yearsToShow: 5, // Show 5 years in the year row
allowFutureDates: false, // Disable selection of future dates
showTodayButton: true, // Show the Today button
// No min/max date for infinite scrolling
onDateSelect: (date, formattedDate) => {
// Update URL
updateUrlWithDate(formattedDate);
// Load timeline data
loadTimelineData(formattedDate);
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reitti - Login</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
.login-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 350px;
}
h1 {
text-align: center;
color: #4a89dc;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
background-color: #4a89dc;
color: white;
border: none;
padding: 12px;
border-radius: 4px;
cursor: pointer;
width: 100%;
font-size: 16px;
}
button:hover {
background-color: #3a70c0;
}
.error-message {
color: #e74c3c;
margin-bottom: 15px;
text-align: center;
}
</style>
</head>
<body>
<div class="login-container">
<h1>Reitti</h1>
<div th:if="${param.error}" class="error-message">
Invalid username or password
</div>
<form th:action="@{/login}" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,600 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reitti - Settings</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
header {
background-color: rgba(74, 137, 220, 0.85);
backdrop-filter: blur(5px);
color: white;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 60px;
}
nav {
display: flex;
gap: 20px;
}
.nav-link {
color: white;
text-decoration: none;
padding: 5px 10px;
border-radius: 4px;
transition: background-color 0.2s;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.container {
max-width: 1200px;
margin: 20px auto;
padding: 20px;
}
.settings-nav {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #ddd;
}
.settings-nav-item {
padding: 10px 20px;
cursor: pointer;
border-bottom: 3px solid transparent;
}
.settings-nav-item.active {
border-bottom: 3px solid #4a89dc;
font-weight: bold;
}
.settings-section {
display: none;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.settings-section.active {
display: block;
}
h2 {
margin-top: 0;
color: #333;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
}
tr:hover {
background-color: #f9f9f9;
}
.btn {
background-color: #4a89dc;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn:hover {
background-color: #3a70c0;
}
.btn-danger {
background-color: #e74c3c;
}
.btn-danger:hover {
background-color: #c0392b;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input[type="text"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.alert {
padding: 10px 15px;
margin-bottom: 15px;
border-radius: 4px;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.queue-status {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.queue-card {
background-color: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
flex: 1;
min-width: 200px;
}
.queue-name {
font-weight: bold;
margin-bottom: 10px;
}
.queue-count {
font-size: 24px;
color: #4a89dc;
}
.queue-time {
margin-top: 10px;
color: #666;
}
.progress-bar {
height: 8px;
background-color: #e0e0e0;
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #4a89dc;
width: 0%;
transition: width 0.5s ease;
}
/* Places Management Styles */
.places-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.place-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.place-map {
height: 150px;
background-color: #f0f0f0;
}
.place-details {
padding: 15px;
}
.place-info {
margin: 15px 0;
font-size: 0.9em;
color: #555;
}
.place-info div {
margin-bottom: 5px;
}
.pagination-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.pagination-buttons {
display: flex;
gap: 10px;
}
</style>
</head>
<body>
<header>
<h1>Reitti</h1>
<nav>
<a href="/" class="nav-link">Home</a>
<a href="/settings" class="nav-link">Settings</a>
<span class="nav-link" th:if="${username}" th:text="${username}"></span>
<form th:action="@{/logout}" method="post" style="display: inline;">
<button type="submit" class="nav-link" style="background: none; border: none; cursor: pointer;">Logout</button>
</form>
</nav>
</header>
<div class="container">
<div class="settings-nav">
<div class="settings-nav-item active" data-target="api-tokens">API Tokens</div>
<div class="settings-nav-item" data-target="user-management">User Management</div>
<div class="settings-nav-item" data-target="places-management">Places</div>
<div class="settings-nav-item" data-target="job-status">Job Status</div>
</div>
<!-- API Tokens Section -->
<div id="api-tokens" class="settings-section active">
<h2>API Tokens</h2>
<div th:if="${tokenMessage}" class="alert" th:classappend="${tokenSuccess ? 'alert-success' : 'alert-danger'}" th:text="${tokenMessage}"></div>
<form th:action="@{/settings/tokens}" method="post" style="margin-bottom: 20px;">
<div class="form-group">
<label for="tokenName">Token Name</label>
<input type="text" id="tokenName" name="name" required placeholder="Enter a name for this token">
</div>
<button type="submit" class="btn">Create New Token</button>
</form>
<table th:if="${!#lists.isEmpty(tokens)}">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="token : ${tokens}">
<td th:text="${token.name}"></td>
<td th:text="${token.token}"></td>
<td th:text="${#temporals.format(token.createdAt, 'yyyy-MM-dd HH:mm')}"></td>
<td th:text="${token.lastUsedAt != null ? #temporals.format(token.lastUsedAt, 'yyyy-MM-dd HH:mm') : 'Never'}"></td>
<td>
<form th:action="@{/settings/tokens/{id}/delete(id=${token.id})}" method="post" style="display: inline;">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
</tbody>
</table>
<p th:if="${#lists.isEmpty(tokens)}">No API tokens found. Create one to get started.</p>
</div>
<!-- User Management Section -->
<div id="user-management" class="settings-section">
<h2>User Management</h2>
<div th:if="${userMessage}" class="alert" th:classappend="${userSuccess ? 'alert-success' : 'alert-danger'}" th:text="${userMessage}"></div>
<h3>Existing Users</h3>
<table th:if="${!#lists.isEmpty(users)}">
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<td th:text="${user.username}"></td>
<td th:text="${user.displayName}"></td>
<td>
<form th:if="${user.username != #authentication.name}" th:action="@{/settings/users/{id}/delete(id=${user.id})}" method="post" style="display: inline;">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
<span th:if="${user.username == #authentication.name}">(Current User)</span>
</td>
</tr>
</tbody>
</table>
<p th:if="${#lists.isEmpty(users)}">No users found.</p>
<form th:action="@{/settings/users}" method="post" style="margin-top: 30px;">
<h3>Add New User</h3>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required placeholder="Enter username">
</div>
<div class="form-group">
<label for="displayName">Display Name</label>
<input type="text" id="displayName" name="displayName" required placeholder="Enter display name">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required placeholder="Enter password">
</div>
<button type="submit" class="btn">Create User</button>
</form>
</div>
<!-- Places Management Section -->
<div id="places-management" class="settings-section">
<h2>Significant Places</h2>
<div th:if="${placeMessage}" class="alert" th:classappend="${placeSuccess ? 'alert-success' : 'alert-danger'}" th:text="${placeMessage}"></div>
<div class="pagination-controls" th:if="${places != null && places.totalPages > 0}">
<span>Page <span th:text="${places.number + 1}"></span> of <span th:text="${places.totalPages}"></span></span>
<div class="pagination-buttons">
<a th:if="${places.number > 0}" th:href="@{/settings(tab='places-management', page=${places.number - 1})}" class="btn">Previous</a>
<a th:if="${places.number < places.totalPages - 1}" th:href="@{/settings(tab='places-management', page=${places.number + 1})}" class="btn">Next</a>
</div>
</div>
<div class="places-grid" th:if="${!#lists.isEmpty(places.content)}">
<div class="place-card" th:each="place : ${places.content}">
<div class="place-map" th:id="'map-' + ${place.id}"></div>
<div class="place-details">
<form th:action="@{/settings/places/{id}/update(id=${place.id})}" method="post">
<div class="form-group">
<label th:for="'name-' + ${place.id}">Name</label>
<input type="text" th:id="'name-' + ${place.id}" name="name" th:value="${place.name}" required>
</div>
<div class="place-info">
<div><strong>Address:</strong> <span th:text="${place.address ?: 'Not available'}"></span></div>
<div><strong>Category:</strong> <span th:text="${place.category ?: 'Not categorized'}"></span></div>
<div><strong>Coordinates:</strong> <span th:text="${place.latitudeCentroid + ', ' + place.longitudeCentroid}"></span></div>
</div>
<button type="submit" class="btn">Update</button>
</form>
</div>
</div>
</div>
<p th:if="${#lists.isEmpty(places.content)}">No significant places found.</p>
</div>
<!-- Job Status Section -->
<div id="job-status" class="settings-section">
<h2>Job Status</h2>
<div class="queue-status">
<div class="queue-card" th:each="queue : ${queueStats}">
<div class="queue-name" th:text="${queue.name()}">Location Data Processing</div>
<div class="queue-count" th:text="${queue.count()}">0</div>
<div class="queue-time" th:text="'Est. processing time: ' + ${queue.estimatedTime()}">Est. processing time: 0 min</div>
<div class="progress-bar">
<div class="progress-fill" th:style="'width:' + ${queue.rate()} + '%'"></div>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<button id="refreshStatus" class="btn">Refresh Status</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Tab navigation
const navItems = document.querySelectorAll('.settings-nav-item');
const sections = document.querySelectorAll('.settings-section');
// Check if there's an active tab in the URL
const urlParams = new URLSearchParams(window.location.search);
const activeTab = urlParams.get('tab');
if (activeTab) {
// Activate the tab from URL parameter
navItems.forEach(item => {
if (item.getAttribute('data-target') === activeTab) {
// Update active nav item
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
// Show target section
sections.forEach(section => {
section.classList.remove('active');
if (section.id === activeTab) {
section.classList.add('active');
}
});
}
});
}
navItems.forEach(item => {
item.addEventListener('click', function() {
const target = this.getAttribute('data-target');
// Update active nav item
navItems.forEach(nav => nav.classList.remove('active'));
this.classList.add('active');
// Show target section
sections.forEach(section => {
section.classList.remove('active');
if (section.id === target) {
section.classList.add('active');
}
});
// Update URL without reloading the page
const url = new URL(window.location);
url.searchParams.set('tab', target);
window.history.pushState({}, '', url);
// Start or stop queue stats auto-refresh based on tab
if (target === 'job-status') {
startQueueStatsAutoRefresh();
} else {
stopQueueStatsAutoRefresh();
}
});
});
// Refresh job status
const refreshButton = document.getElementById('refreshStatus');
let queueStatsInterval;
function refreshQueueStats() {
fetch('/api/v1/queue-stats')
.then(response => response.json())
.then(data => {
for (let i = 0; i < data.length; i++) {
const queue = data[i];
document.querySelector(`.queue-card:nth-child(${i+1}) .queue-count`).textContent = queue.count;
document.querySelector(`.queue-card:nth-child(${i+1}) .queue-time`).textContent = 'Est. processing time: ' + queue.estimatedTime;
document.querySelector(`.queue-card:nth-child(${i+1}) .progress-fill`).style.width = queue.rate + '%';
}
})
.catch(error => console.error('Error fetching queue stats:', error));
}
// Start auto-refresh when job status tab becomes visible
function startQueueStatsAutoRefresh() {
if (document.getElementById('job-status').classList.contains('active')) {
refreshQueueStats(); // Refresh immediately
queueStatsInterval = setInterval(refreshQueueStats, 1000); // Then every 5 seconds
} else {
stopQueueStatsAutoRefresh();
}
}
// Stop auto-refresh when tab is not visible
function stopQueueStatsAutoRefresh() {
if (queueStatsInterval) {
clearInterval(queueStatsInterval);
queueStatsInterval = null;
}
}
// Initial check
startQueueStatsAutoRefresh();
// Manual refresh button
if (refreshButton) {
refreshButton.addEventListener('click', refreshQueueStats);
}
// Initialize maps for places
function initPlaceMaps() {
const placeCards = document.querySelectorAll('.place-card');
placeCards.forEach(card => {
const mapContainer = card.querySelector('.place-map');
if (!mapContainer) return;
const placeId = mapContainer.id.split('-')[1];
// Get coordinates from the place info
const coordsText = card.querySelector('.place-info div:nth-child(3) span').textContent;
const [lat, lng] = coordsText.split(',').map(coord => parseFloat(coord.trim()));
if (isNaN(lat) || isNaN(lng)) return;
// Initialize map
const map = L.map(mapContainer.id).setView([lat, lng], 15);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// Add marker
const marker = L.marker([lat, lng]).addTo(map);
// Add circle to show approximate area
L.circle([lat, lng], {
color: '#4a89dc',
fillColor: '#4a89dc',
fillOpacity: 0.2,
radius: 50 // 50 meters radius
}).addTo(map);
});
}
// Check if we're on the places tab and initialize maps
if (document.getElementById('places-management').classList.contains('active')) {
// Load Leaflet CSS if not already loaded
if (!document.querySelector('link[href*="leaflet.css"]')) {
const leafletCSS = document.createElement('link');
leafletCSS.rel = 'stylesheet';
leafletCSS.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
leafletCSS.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
leafletCSS.crossOrigin = '';
document.head.appendChild(leafletCSS);
}
// Load Leaflet JS if not already loaded
if (!window.L) {
const leafletScript = document.createElement('script');
leafletScript.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
leafletScript.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
leafletScript.crossOrigin = '';
leafletScript.onload = initPlaceMaps;
document.head.appendChild(leafletScript);
} else {
initPlaceMaps();
}
}
// Initialize maps when switching to places tab
navItems.forEach(item => {
if (item.getAttribute('data-target') === 'places-management') {
item.addEventListener('click', function() {
setTimeout(initPlaceMaps, 100); // Small delay to ensure DOM is updated
});
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
package com.dedicatedcode.reitti.controller.api;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LocationDataApiControllerTest {
@Test
void receiveLocationData() {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,641 @@
{
"locations": [
{
"latitudeE7": 525121303,
"longitudeE7": 134309551,
"accuracy": 25,
"source": "GPS",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:31:26.860Z"
},
{
"latitudeE7": 525122834,
"longitudeE7": 134313306,
"accuracy": 24,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:32:31.475Z"
},
{
"latitudeE7": 525117217,
"longitudeE7": 134306453,
"accuracy": 46,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:33:32.406Z"
},
{
"latitudeE7": 525121351,
"longitudeE7": 134320476,
"accuracy": 48,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:34:36.153Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:34:32.478Z"
},
{
"latitudeE7": 525121351,
"longitudeE7": 134320476,
"accuracy": 48,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:35:36.176Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:35:32.492Z"
},
{
"latitudeE7": 525121589,
"longitudeE7": 134320949,
"accuracy": 48,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:36:32.566Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:38:36.316Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:38:32.498Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:39:32.545Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:40:32.583Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:41:36.472Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:41:32.595Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:43:32.637Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:44:36.601Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:44:32.684Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:45:32.685Z"
},
{
"latitudeE7": 525121564,
"longitudeE7": 134319537,
"accuracy": 51,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:46:32.734Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "IN_VEHICLE",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:47:56.833Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:47:34.024Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:49:34.888Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:50:34.917Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:51:35.159Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:52:13.083Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:52:35.147Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:55:13.228Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:54:36.107Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:56:36.142Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:58:13.356Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:57:36.164Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:59:31.388Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:00:36.200Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:02:31.511Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:01:36.243Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:03:36.203Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:05:31.649Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:04:36.444Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:06:36.247Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:08:31.790Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:09:36.257Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:11:31.934Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:10:36.428Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:12:36.302Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:13:36.573Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:14:32.075Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:14:36.650Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:15:36.580Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:17:32.210Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:18:36.635Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:20:32.366Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:21:36.622Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:23:32.672Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:22:36.667Z"
},
{
"latitudeE7": 525122021,
"longitudeE7": 134315335,
"accuracy": 71,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:24:36.622Z"
},
{
"latitudeE7": 525117217,
"longitudeE7": 134306453,
"accuracy": 46,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:25:37.093Z"
},
{
"latitudeE7": 525122702,
"longitudeE7": 134313952,
"accuracy": 21,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:27:06.063Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:26:37.656Z"
},
{
"latitudeE7": 525117217,
"longitudeE7": 134306453,
"accuracy": 46,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:28:36.815Z"
},
{
"latitudeE7": 525121186,
"longitudeE7": 134318951,
"accuracy": 56,
"activity": [
{
"activity": [
{
"type": "IN_VEHICLE",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:29:57.980Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:29:36.836Z"
},
{
"latitudeE7": 525121318,
"longitudeE7": 134314822,
"accuracy": 76,
"activity": [
{
"activity": [
{
"type": "IN_VEHICLE",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:31:31.538Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:30:37.819Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:32:32.469Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:33:32.563Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:34:33.611Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:36:28.685Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:35:33.621Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:37:33.649Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:39:28.834Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:40:33.687Z"
}
]
}