Initial commit: Complete Memories app with Leaflet map, backend fixes, enhancements, and docs
Some checks failed
CI/CD Pipeline / test (3.1) (push) Has been cancelled
CI/CD Pipeline / lint (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
maq
2025-11-04 21:52:45 -08:00
parent b34bb98a61
commit ae0a6b575b
26 changed files with 1301 additions and 0 deletions

57
Push to Gitea for Memories.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
# Memories Project: Automate Git Init, Commit, and Push to Gitea
# Run from /Users/maq/memories directory
# Usage: bash setup-git.sh
set -e # Exit on any error
echo "=== Memories Project Git Automation ==="
echo "Current directory: $(pwd)"
if [ "$(basename $(pwd))" != "memories" ]; then
echo "Error: Run this from /Users/maq/memories"
exit 1
fi
# Step 1: Initialize Git if needed
if [ ! -d ".git" ]; then
echo "Initializing Git repository..."
git init
else
echo "Git repository already exists."
fi
# Step 2: Add and commit new files (docs and workflow)
echo "Adding and committing documentation files..."
git add .gitignore README.md .gitea/workflows/deploy.yml
git commit -m "Add .gitignore, README.md, and Gitea CI/CD workflow" || echo "No changes to commit (files may already be added)"
# Step 3: Add and commit the full project
echo "Adding and committing full project files..."
git add .
git commit -m "Initial commit: Complete Memories app with Leaflet map, backend fixes, enhancements, and docs" || echo "Full project already committed"
# Step 4: Prompt for Gitea repo URL
echo ""
echo "Enter your Gitea repository URL (e.g., http://localhost:3000/maq/memories.git or git@your-server:maq/memories.git):"
read -r GITEA_URL
if [ -z "$GITEA_URL" ]; then
echo "Error: URL is required."
exit 1
fi
# Step 5: Set up remote and push
echo "Setting up remote: $GITEA_URL"
git remote add origin $GITEA_URL 2>/dev/null || git remote set-url origin $GITEA_URL
echo "Setting branch to main..."
git branch -M main
echo "Pushing to Gitea (may prompt for credentials)..."
git push -u origin main
echo ""
echo "=== Success! ==="
echo "Your Memories project is now in Gitea at: $GITEA_URL"
echo "Check the 'Actions' tab for CI/CD workflow results."
echo "Next: Visit the repo in your browser to verify."

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

146
backend/analysis.py Normal file
View File

@@ -0,0 +1,146 @@
import datetime
import numpy as np
from geoalchemy2.elements import WKTElement
from models import Location, Visit, Trip
class LocationAnalyzer:
def __init__(self, visit_radius_meters: float = 50.0, min_visit_duration_minutes: int = 10):
self.visit_radius_meters = visit_radius_meters
self.min_visit_duration_minutes = min_visit_duration_minutes
def detect_visits(self, locations: list[Location]) -> list[Visit]:
visits = []
if not locations:
return visits
current_visit_locations = []
for i in range(len(locations)):
if not current_visit_locations:
current_visit_locations.append(locations[i])
else:
# Calculate distance from the first location of the current potential visit
first_loc = current_visit_locations[0]
dist = self._haversine_distance(first_loc.latitude, first_loc.longitude,
locations[i].latitude, locations[i].longitude)
if dist <= self.visit_radius_meters:
current_visit_locations.append(locations[i])
else:
# Check if the accumulated locations form a valid visit
if len(current_visit_locations) > 1:
duration = (current_visit_locations[-1].timestamp - current_visit_locations[0].timestamp).total_seconds() / 60
if duration >= self.min_visit_duration_minutes:
# Create Visit object with computed fields
start_time = current_visit_locations[0].timestamp
end_time = current_visit_locations[-1].timestamp
# Compute centroid
lats = np.array([loc.latitude for loc in current_visit_locations])
lons = np.array([loc.longitude for loc in current_visit_locations])
centroid_lat = np.mean(lats)
centroid_lon = np.mean(lons)
centroid_point = WKTElement(f'POINT({centroid_lon} {centroid_lat})', srid=4326)
visit = Visit(
start_time=start_time,
end_time=end_time,
centroid_geom=centroid_point
)
visits.append(visit)
current_visit_locations = [locations[i]] # Start a new potential visit
# Check for a visit at the very end of the locations list
if len(current_visit_locations) > 1:
duration = (current_visit_locations[-1].timestamp - current_visit_locations[0].timestamp).total_seconds() / 60
if duration >= self.min_visit_duration_minutes:
start_time = current_visit_locations[0].timestamp
end_time = current_visit_locations[-1].timestamp
lats = np.array([loc.latitude for loc in current_visit_locations])
lons = np.array([loc.longitude for loc in current_visit_locations])
centroid_lat = np.mean(lats)
centroid_lon = np.mean(lons)
centroid_point = WKTElement(f'POINT({centroid_lon} {centroid_lat})', srid=4326)
visit = Visit(
start_time=start_time,
end_time=end_time,
centroid_geom=centroid_point
)
visits.append(visit)
return visits
def detect_trips(self, locations: list[Location], visits: list[Visit]) -> list[Trip]:
trips = []
if not locations:
return trips
# For simplicity, identify trips as sequences between visits
# First, get all visit time ranges
visit_time_ranges = []
for visit in visits:
visit_time_ranges.append((visit.start_time, visit.end_time))
current_trip_locations = []
for loc in locations:
in_visit = False
for start, end in visit_time_ranges:
if start <= loc.timestamp <= end:
in_visit = True
break
if not in_visit:
current_trip_locations.append(loc)
else:
if len(current_trip_locations) > 1: # At least 2 points for a trip
# Create Trip object
start_time = current_trip_locations[0].timestamp
end_time = current_trip_locations[-1].timestamp
# Create LINESTRING
coords = [(loc.longitude, loc.latitude) for loc in current_trip_locations]
linestring_str = f'LINESTRING({", ".join([f"{lon} {lat}" for lon, lat in coords])})'
path_line = WKTElement(linestring_str, srid=4326)
trip = Trip(
start_time=start_time,
end_time=end_time,
transport_mode="unknown",
path_geom=path_line
)
trips.append(trip)
current_trip_locations = []
# Check for final trip
if len(current_trip_locations) > 1:
start_time = current_trip_locations[0].timestamp
end_time = current_trip_locations[-1].timestamp
coords = [(loc.longitude, loc.latitude) for loc in current_trip_locations]
linestring_str = f'LINESTRING({", ".join([f"{lon} {lat}" for lon, lat in coords])})'
path_line = WKTElement(linestring_str, srid=4326)
trip = Trip(
start_time=start_time,
end_time=end_time,
transport_mode="unknown",
path_geom=path_line
)
trips.append(trip)
return trips
def _haversine_distance(self, lat1, lon1, lat2, lon2):
R = 6371000 # Radius of Earth in meters
phi1 = np.radians(lat1)
phi2 = np.radians(lat2)
delta_phi = np.radians(lat2 - lat1)
delta_lambda = np.radians(lon2 - lon1)
a = np.sin(delta_phi / 2)**2 + np.cos(phi1) * np.cos(phi2) * np.sin(delta_lambda / 2)**2
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
return R * c

27
backend/data_importer.py Normal file
View File

@@ -0,0 +1,27 @@
import json
import datetime
from models import Location
class GoogleTakeoutImporter:
def import_locations(self, json_filepath: str) -> list[Location]:
locations = []
try:
with open(json_filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
for loc_data in data.get('locations', []):
timestamp_ms = int(loc_data['timestampMs'])
timestamp = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0)
latitude = loc_data['latitudeE7'] / 1e7
longitude = loc_data['longitudeE7'] / 1e7
locations.append({
'latitude': latitude,
'longitude': longitude,
'timestamp': timestamp
})
except FileNotFoundError:
print(f"Error: File not found: {json_filepath}")
except json.JSONDecodeError:
print("Error: Invalid JSON format.")
except Exception as e:
print(f"An unexpected error occurred during import: {e}")
return locations

172
backend/main.py Normal file
View File

@@ -0,0 +1,172 @@
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
from geoalchemy2.elements import WKTElement
import os
import datetime
from datetime import datetime as dt
import json
import redis
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
from io import BytesIO
from data_importer import GoogleTakeoutImporter
from analysis import LocationAnalyzer
from models import SessionLocal, engine, Base, Location, Visit, Trip, create_db_tables
app = FastAPI()
# Redis connection
redis_client = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))
# Dependency to get the DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.on_event("startup")
async def startup_event():
create_db_tables()
@app.post("/upload")
async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db)):
if not file.filename:
raise HTTPException(status_code=400, detail="No selected file")
file_location = f"/tmp/{file.filename}"
try:
with open(file_location, "wb+") as file_object:
file_object.write(await file.read())
importer = GoogleTakeoutImporter()
raw_locations = importer.import_locations(file_location)
new_locations = []
for loc_data in raw_locations:
if all(k in loc_data for k in ['latitude', 'longitude', 'timestamp']):
if isinstance(loc_data['timestamp'], str):
timestamp = dt.fromisoformat(loc_data['timestamp'].replace('Z', '+00:00'))
else:
timestamp = loc_data['timestamp']
point = WKTElement(f'POINT({loc_data["longitude"]} {loc_data["latitude"]})', srid=4326)
new_location = Location(
latitude=loc_data['latitude'],
longitude=loc_data['longitude'],
timestamp=timestamp,
geom=point
)
new_locations.append(new_location)
analyzer = LocationAnalyzer()
visits = analyzer.detect_visits(new_locations)
trips = analyzer.detect_trips(new_locations, visits)
db.add_all(new_locations)
db.add_all(visits)
db.add_all(trips)
db.commit()
# Invalidate cache
redis_client.flushdb()
return JSONResponse(content={
'message': f'Successfully imported {len(new_locations)} locations, detected {len(visits)} visits and {len(trips)} trips.'
}, status_code=200)
except Exception as e:
if 'db' in locals():
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}")
finally:
if os.path.exists(file_location):
os.remove(file_location)
@app.get("/data/locations")
def get_locations(db: Session = Depends(get_db)):
cache_key = "locations"
cached = redis_client.get(cache_key)
if cached:
return JSONResponse(content=json.loads(cached))
locations = db.query(Location).all()
data = {"locations": [{"id": l.id, "lat": l.latitude, "lon": l.longitude, "time": l.timestamp.isoformat()} for l in locations]}
redis_client.setex(cache_key, 300, json.dumps(data)) # Cache for 5 min
return data
@app.get("/data/visits")
def get_visits(db: Session = Depends(get_db)):
cache_key = "visits"
cached = redis_client.get(cache_key)
if cached:
return JSONResponse(content=json.loads(cached))
visits = db.query(Visit).all()
data = {"visits": [{"id": v.id, "start": v.start_time.isoformat(), "end": v.end_time.isoformat(),
"centroid": str(v.centroid_geom) if v.centroid_geom else None} for v in visits]}
redis_client.setex(cache_key, 300, json.dumps(data))
return data
@app.get("/data/trips")
def get_trips(db: Session = Depends(get_db)):
cache_key = "trips"
cached = redis_client.get(cache_key)
if cached:
return JSONResponse(content=json.loads(cached))
trips = db.query(Trip).all()
data = {"trips": [{"id": t.id, "start": t.start_time.isoformat(), "end": t.end_time.isoformat(),
"mode": t.transport_mode, "path": str(t.path_geom) if t.path_geom else None} for t in trips]}
redis_client.setex(cache_key, 300, json.dumps(data))
return data
@app.get("/export/report")
def export_report(db: Session = Depends(get_db)):
# Fetch data
visits = db.query(Visit).count()
trips = db.query(Trip).count()
locations = db.query(Location).count()
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=letter)
styles = getSampleStyleSheet()
story = []
# Title
title = Paragraph("Travel Memories Report", styles['Title'])
story.append(title)
story.append(Spacer(1, 12))
# Stats
stats_data = [
['Metric', 'Count'],
['Total Locations', locations],
['Detected Visits', visits],
['Detected Trips', trips]
]
stats_table = Table(stats_data)
stats_table.setStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 14),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
('GRID', (0, 0), (-1, -1), 1, colors.black)
])
story.append(stats_table)
# Build PDF
doc.build(story)
buffer.seek(0)
return StreamingResponse(buffer, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=travel-report.pdf"})

65
backend/models.py Normal file
View File

@@ -0,0 +1,65 @@
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from geoalchemy2 import Geometry
import datetime
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@db:5432/memories_db")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Location(Base):
__tablename__ = "locations"
id = Column(Integer, primary_key=True, index=True)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
geom = Column(Geometry(geometry_type='POINT', srid=4326), nullable=False)
# Optional: Link to a user if multi-user is implemented
# user_id = Column(Integer, ForeignKey("users.id"))
# user = relationship("User")
def __repr__(self):
return f"<Location(id={self.id}, lat={self.latitude}, lon={self.longitude}, time={self.timestamp})>"
class Visit(Base):
__tablename__ = "visits"
id = Column(Integer, primary_key=True, index=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
# A visit can be represented by a polygon or a centroid, for simplicity we'll use a point for now
centroid_geom = Column(Geometry(geometry_type='POINT', srid=4326), nullable=True)
# Optional: Link to a user
# user_id = Column(Integer, ForeignKey("users.id"))
# user = relationship("User")
def __repr__(self):
return f"<Visit(id={self.id}, start={self.start_time}, end={self.end_time})>"
class Trip(Base):
__tablename__ = "trips"
id = Column(Integer, primary_key=True, index=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
transport_mode = Column(String, default="unknown")
# A trip can be represented by a LineString
path_geom = Column(Geometry(geometry_type='LINESTRING', srid=4326), nullable=True)
# Optional: Link to a user
# user_id = Column(Integer, ForeignKey("users.id"))
# user = relationship("User")
def __repr__(self):
return f"<Trip(id={self.id}, mode={self.transport_mode}, start={self.start_time}, end={self.end_time})>"
# Function to create all tables
def create_db_tables():
Base.metadata.create_all(bind=engine)

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi
uvicorn
SQLAlchemy
psycopg2-binary
python-dotenv
pika
pandas
numpy
scipy
GeoAlchemy2
python-multipart
redis
reportlab

77
docker-compose.yml Normal file
View File

@@ -0,0 +1,77 @@
version: '3.8'
services:
db:
image: postgis/postgis:16-3.4
restart: always
environment:
POSTGRES_DB: memories_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "5432:5432"
rabbitmq:
image: rabbitmq:3-management
restart: always
ports:
- "5672:5672"
- "15672:15672" # Management UI
environment:
RABBITMQ_DEFAULT_USER: user
RABBITMQ_DEFAULT_PASS: password
redis:
image: redis:alpine
restart: always
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
backend:
build: ./backend
restart: always
command: uvicorn main:app --host 0.0.0.0 --port 8000
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://user:password@db:5432/memories_db
RABBITMQ_URL: amqp://user:password@rabbitmq:5672/%2F
REDIS_URL: redis://redis:6379
depends_on:
- db
- rabbitmq
- redis
frontend:
build: ./frontend
restart: always
ports:
- "3000:80" # Serve React app on port 80 inside container, map to 3000 outside
depends_on:
- backend
# Optional: Analysis worker for background tasks
worker:
build: ./backend
command: python worker.py # Need to create worker.py
volumes:
- ./backend:/app
environment:
DATABASE_URL: postgresql://user:password@db:5432/memories_db
RABBITMQ_URL: amqp://user:password@rabbitmq:5672/%2F
REDIS_URL: redis://redis:6379
depends_on:
- db
- rabbitmq
- redis
volumes:
db_data:
redis_data:

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine as build
WORKDIR /app
COPY package.json ./
COPY . .
RUN npm install
RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.18",
"mapbox-gl": "^2.15.0",
"react-router-dom": "^6.20.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Memories App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"short_name": "Memories",
"name": "Travel Memories App",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

74
frontend/src/App.js Normal file
View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Typography, Container, Box, AppBar, Toolbar, Button } from '@mui/material';
import { Routes, Route, Link } from 'react-router-dom';
import Login from './components/Login';
import Register from './components/Register';
import Dashboard from './components/Dashboard';
import UploadForm from './components/UploadForm';
import MapView from './components/MapView';
import ExportView from './components/ExportView';
function App() {
return (
<Box sx={{ flexGrow: 1 }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Memories
</Typography>
<Button color="inherit" component={Link} to="/">
Home
</Button>
<Button color="inherit" component={Link} to="/dashboard">
Dashboard
</Button>
<Button color="inherit" component={Link} to="/upload">
Upload
</Button>
<Button color="inherit" component={Link} to="/map">
Map
</Button>
<Button color="inherit" component={Link} to="/export">
Export
</Button>
<Button color="inherit" component={Link} to="/login">
Login
</Button>
<Button color="inherit" component={Link} to="/register">
Register
</Button>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ mt: 4 }}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/upload" element={<UploadForm />} />
<Route path="/map" element={<MapView />} />
<Route path="/export" element={<ExportView />} />
<Route path="/" element={
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Welcome to Memories!
</Typography>
<Typography variant="body1">
Your journey to narrative-driven travel logs begins here.
</Typography>
<Box sx={{ mt: 4 }}>
<Button variant="contained" component={Link} to="/upload" sx={{ mr: 2 }}>
Get Started - Upload Data
</Button>
<Button variant="contained" component={Link} to="/dashboard">
View Dashboard
</Button>
</Box>
</Box>
} />
</Routes>
</Container>
</Box>
);
}
export default App;

View File

@@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Card, CardContent, Button, Grid } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
const Dashboard = () => {
const [stats, setStats] = useState({ locations: 0, visits: 0, trips: 0 });
const navigate = useNavigate();
useEffect(() => {
loadStats();
}, []);
const loadStats = async () => {
try {
const [locRes, visitsRes, tripsRes] = await Promise.all([
axios.get('http://localhost:8000/data/locations'), // Assume endpoint exists or add it
axios.get('http://localhost:8000/data/visits'),
axios.get('http://localhost:8000/data/trips')
]);
setStats({
locations: locRes.data.locations?.length || 0,
visits: visitsRes.data.visits?.length || 0,
trips: tripsRes.data.trips?.length || 0
});
} catch (err) {
console.error('Failed to load stats:', err);
}
};
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography variant="h5">Total Locations</Typography>
<Typography variant="h2">{stats.locations}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography variant="h5">Visits</Typography>
<Typography variant="h2">{stats.visits}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography variant="h5">Trips</Typography>
<Typography variant="h2">{stats.trips}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Box sx={{ mt: 4 }}>
<Button variant="contained" onClick={() => navigate('/upload')} sx={{ mr: 2 }}>
Upload Data
</Button>
<Button variant="contained" onClick={() => navigate('/map')} sx={{ mr: 2 }}>
View Map
</Button>
<Button variant="contained" onClick={() => navigate('/export')}>
Export Report
</Button>
</Box>
</Box>
);
};
export default Dashboard;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Box, Typography, Button, Alert } from '@mui/material';
import axios from 'axios';
const ExportView = () => {
const [message, setMessage] = React.useState('');
const handleExport = async () => {
try {
const response = await axios.get('http://localhost:8000/export/report', { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `travel-report-${new Date().toISOString().split('T')[0]}.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
setMessage('Report exported successfully!');
} catch (err) {
setMessage(`Export failed: ${err.response?.data?.detail || err.message}`);
}
};
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 4 }}>
<Typography variant="h4" gutterBottom>
Export Travel Report
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
Generate a PDF report of your travels.
</Typography>
<Button variant="contained" onClick={handleExport} size="large">
Download Report
</Button>
{message && <Alert severity={message.includes('failed') ? 'error' : 'success'} sx={{ mt: 2 }}>
{message}
</Alert>}
</Box>
);
};
export default ExportView;

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { TextField, Button, Typography, Container, Box, Paper } from '@mui/material';
function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
console.log('Login attempt with:', { email, password });
// Here you would typically send a request to your backend
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Sign in
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign In
</Button>
</Box>
</Box>
</Container>
);
}
export default Login;

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
import { Box, Typography } from '@mui/material';
import axios from 'axios';
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN'; // Replace with your Mapbox token
const MapView = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const [dataLoaded, setDataLoaded] = React.useState(false);
useEffect(() => {
if (map.current) return; // initialize map only once
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: [-74.5, 40], // Initial center (NYC as example)
zoom: 9
});
map.current.on('load', () => {
loadData();
});
}, []);
const loadData = async () => {
try {
const [visitsRes, tripsRes] = await Promise.all([
axios.get('http://localhost:8000/data/visits'),
axios.get('http://localhost:8000/data/trips')
]);
const visits = visitsRes.data.visits;
const trips = tripsRes.data.trips;
// Add visits as circles
map.current.addSource('visits', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: visits.map(v => {
const coords = v.centroid.match(/POINT\\(([^)]+)\\)/)[1].split(' ').map(Number);
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: coords },
properties: { start: v.start, end: v.end }
};
})
}
});
map.current.addLayer({
id: 'visits',
type: 'circle',
source: 'visits',
paint: {
'circle-radius': 10,
'circle-color': '#007cbf'
}
});
// Add trips as lines
if (trips.length > 0) {
map.current.addSource('trips', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: trips.map(t => {
// Parse LINESTRING
const coordsStr = t.path.match(/LINESTRING\\((.*)\\)/)[1];
const coords = coordsStr.split(', ').map(pair => pair.split(' ').map(Number));
return {
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { mode: t.mode, start: t.start, end: t.end }
};
}).filter(f => f.geometry.coordinates.length > 1)
}
});
map.current.addLayer({
id: 'trips',
type: 'line',
source: 'trips',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#f03b20', 'line-width': 4 }
});
}
setDataLoaded(true);
} catch (err) {
console.error('Failed to load data:', err);
}
};
return (
<Box sx={{ height: '100vh', width: '100%' }}>
<Typography variant="h4" sx={{ p: 2 }}>Travel Map</Typography>
<div ref={mapContainer} style={{ height: 'calc(100% - 64px)', width: '100%' }} />
{!dataLoaded && <Typography sx={{ p: 2 }}>Loading map data...</Typography>}
</Box>
);
};
export default MapView;

View File

@@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { TextField, Button, Typography, Container, Box, Paper } from '@mui/material';
function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
if (password !== confirmPassword) {
alert("Passwords don't match!");
return;
}
console.log('Register attempt with:', { email, password });
// Here you would typically send a request to your backend
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Sign up
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="confirmPassword"
label="Confirm Password"
type="password"
id="confirmPassword"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign Up
</Button>
</Box>
</Box>
</Container>
);
}
export default Register;

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { Button, Box, Typography, TextField, Alert } from '@mui/material';
import axios from 'axios';
const UploadForm = () => {
const [file, setFile] = useState(null);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const handleFileChange = (e) => {
setFile(e.target.files[0]);
};
const handleUpload = async () => {
if (!file) {
setError('Please select a file');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
setError('');
const response = await axios.post('http://localhost:8000/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setMessage(response.data.message);
} catch (err) {
setError(`Upload failed: ${err.response?.data?.detail || err.message}`);
}
};
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 4 }}>
<Typography variant="h4" gutterBottom>
Upload Location Data
</Typography>
<TextField
type="file"
onChange={handleFileChange}
inputProps={{ accept: '.json,.csv' }}
fullWidth
sx={{ mb: 2 }}
/>
<Button variant="contained" onClick={handleUpload} fullWidth sx={{ mb: 2 }}>
Upload
</Button>
{message && <Alert severity="success">{message}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
</Box>
);
};
export default UploadForm;

13
frontend/src/index.js Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,49 @@
import googlemaps
import json
import datetime
class LocationDataManager:
def __init__(self, api_key):
self.gmaps = googlemaps.Client(key=api_key)
def get_coordinates(self, address):
try:
geocode_result = self.gmaps.geocode(address)
if geocode_result:
location = geocode_result[0]['geometry']['location']
return location['lat'], location['lng']
else:
return None, None
except Exception as e:
print(f"Error during geocoding: {e}")
return None, None
def get_address(self, latitude, longitude):
try:
reverse_geocode_result = self.gmaps.reverse_geocode((latitude, longitude))
if reverse_geocode_result:
return reverse_geocode_result[0]['formatted_address']
else:
return None
except Exception as e:
print(f"Error during reverse geocoding: {e}")
return None
def load_location_history(self, json_filepath):
locations = []
try:
with open(json_filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
for location in data.get('locations', []):
timestamp_ms = int(location['timestampMs'])
timestamp = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0)
lat = location['latitudeE7'] / 1e7
lng = location['longitudeE7'] / 1e7
locations.append({'timestamp': timestamp, 'lat': lat, 'lng': lng})
except FileNotFoundError:
print(f"Error: File not found: {json_filepath}")
except json.JSONDecodeError:
print("Error: Invalid JSON format.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
return locations

View File

@@ -0,0 +1,42 @@
import tkinter as tk
from tkinter import ttk
from location_data_manager import LocationDataManager
class LocationHistoryVisualizer:
def __init__(self, api_key, json_filepath):
self.root = tk.Tk()
self.root.title("Location History Visualizer")
self.location_data_manager = LocationDataManager(api_key)
self.json_filepath = json_filepath
self.create_widgets()
self.load_and_display_location_history()
def create_widgets(self):
history_label = ttk.Label(self.root, text="Location History:")
history_label.grid(row=0, column=0, padx=5, pady=5)
self.history_display = tk.Text(self.root, wrap=tk.WORD, width=60, height=20)
self.history_display.grid(row=1, column=0, columnspan=2, padx=5, pady=5)
def load_and_display_location_history(self):
locations = self.location_data_manager.load_location_history(self.json_filepath)
if not locations:
self.history_display.insert(tk.END, "No location history data found or an error occurred.")
return
for location in locations:
try:
address = self.location_data_manager.get_address(location['lat'], location['lng'])
if address:
self.history_display.insert(tk.END, f"{location['timestamp']}: {address}\n")
else:
self.history_display.insert(tk.END, f"{location['timestamp']}: ({location['lat']}, {location['lng']})\n")
except Exception as e:
print(f"Error getting address: {e}")
self.history_display.insert(tk.END, f"{location['timestamp']}: ({location['lat']}, {location['lng']}) - Error getting address\n")
def run(self):
self.root.mainloop()

View File

@@ -0,0 +1,12 @@
import os
from location_history_visualizer import LocationHistoryVisualizer
if __name__ == "__main__":
google_maps_api_key = os.environ.get("GOOGLE_MAPS_API_KEY")
json_filepath = "/Users/maq/Desktop/Desktop2.7.2025/Desktop/MAYTE FILES2/takeout folders/New Folder With Items/LocationHistory.json" # Replace with your actual file path
if google_maps_api_key:
app = LocationHistoryVisualizer(google_maps_api_key, json_filepath)
app.run()
else:
print("Please set the GOOGLE_MAPS_API_KEY environment variable.")

16
templates/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memories App</title>
</head>
<body>
<h1>Welcome to Memories!</h1>
<p>Upload your Google Takeout Location History JSON file to get started.</p>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept=".json">
<input type="submit" value="Upload">
</form>
</body>
</html>