Search function, declaudification
This commit is contained in:
parent
6e7d52f827
commit
612d67ccda
17 changed files with 668 additions and 3061 deletions
|
|
@ -1,366 +0,0 @@
|
||||||
# Dark Mode Fixes - Complete Summary
|
|
||||||
|
|
||||||
## Issues Found and Fixed
|
|
||||||
|
|
||||||
I systematically analyzed all 31 HTML template files and CSS files in the FitPub application to identify dark mode issues. Here's what was found and fixed:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Fixed Issues
|
|
||||||
|
|
||||||
### 1. **Form Elements** (CRITICAL FIX)
|
|
||||||
**Problem**: Form labels, checkbox labels, and form helper text were showing as dark text on dark backgrounds.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- Login page (`auth/login.html`)
|
|
||||||
- Registration page (`auth/register.html`)
|
|
||||||
- Activity upload (`activities/upload.html`)
|
|
||||||
- Activity edit (`activities/edit.html`)
|
|
||||||
- Profile edit (`profile/edit.html`)
|
|
||||||
- Batch upload (`activities/batch-upload.html`)
|
|
||||||
|
|
||||||
**Fix Applied**: Added to `fitpub.css`:
|
|
||||||
```css
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
/* Form elements - Dark Mode Fix */
|
|
||||||
.form-label {
|
|
||||||
color: var(--dark-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-label {
|
|
||||||
color: var(--dark-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-text {
|
|
||||||
color: var(--dark-text-muted) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/static/css/fitpub.css` (lines 1018-1029)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. **Typography Elements** (CRITICAL FIX)
|
|
||||||
**Problem**: `<strong>`, `<b>`, and `<small>` tags had no explicit dark mode color, causing dark text on dark backgrounds in activity detail pages.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- Activity detail page (`activities/detail.html`) - Weather section, additional metrics
|
|
||||||
|
|
||||||
**Fix Applied**: Added to `fitpub.css`:
|
|
||||||
```css
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
/* Typography - Dark Mode Fix */
|
|
||||||
strong {
|
|
||||||
color: var(--dark-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
b {
|
|
||||||
color: var(--dark-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
color: var(--dark-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/static/css/fitpub.css` (lines 1031-1042)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. **Batch Upload Status Badges** (HIGH PRIORITY FIX)
|
|
||||||
**Problem**: Success/Failed/Pending status badges had hardcoded light-mode colors, making them unreadable in dark mode.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- Batch upload page (`activities/batch-upload.html`)
|
|
||||||
|
|
||||||
**Before** (Light Mode Only):
|
|
||||||
```css
|
|
||||||
.status-SUCCESS {
|
|
||||||
background: #d4edda; /* Light green */
|
|
||||||
color: #155724; /* Dark green text */
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-FAILED {
|
|
||||||
background: #f8d7da; /* Light red */
|
|
||||||
color: #721c24; /* Dark red text */
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-PENDING, .status-PROCESSING {
|
|
||||||
background: #fff3cd; /* Light yellow */
|
|
||||||
color: #856404; /* Dark yellow text */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After** (Dark Mode Added):
|
|
||||||
```css
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.status-SUCCESS {
|
|
||||||
background: rgba(57, 255, 20, 0.25); /* Neon green with transparency */
|
|
||||||
color: #39ff14; /* Bright neon green text */
|
|
||||||
border: 1px solid #39ff14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-FAILED {
|
|
||||||
background: rgba(220, 53, 69, 0.25); /* Red with transparency */
|
|
||||||
color: #ff6b7a; /* Bright red text */
|
|
||||||
border: 1px solid #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-PENDING, .status-PROCESSING {
|
|
||||||
background: rgba(255, 193, 7, 0.25); /* Yellow with transparency */
|
|
||||||
color: #ffc107; /* Bright yellow text */
|
|
||||||
border: 1px solid #ffc107;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/templates/activities/batch-upload.html` (lines 121-144)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. **404 Error Page** (CRITICAL FIX)
|
|
||||||
**Problem**: The 404 "Not Found" page had NO dark mode support at all - fully white background with dark text.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- `error/404.html`
|
|
||||||
|
|
||||||
**Fix Applied**: Added comprehensive dark mode styles:
|
|
||||||
```css
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.error-container {
|
|
||||||
background: linear-gradient(135deg, #2d0052 0%, #1a0033 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-card {
|
|
||||||
background: #251040;
|
|
||||||
color: #e8e8f0;
|
|
||||||
border: 3px solid #ff1493;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-title {
|
|
||||||
color: #e8e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-subtitle,
|
|
||||||
.error-message {
|
|
||||||
color: #a8a8c0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-suggestions {
|
|
||||||
background: #1a0a30;
|
|
||||||
border: 2px solid #00ffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-home {
|
|
||||||
background: linear-gradient(135deg, #ff1493 0%, #9d00ff 100%);
|
|
||||||
border-color: #ff1493;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back {
|
|
||||||
color: #00ffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back:hover {
|
|
||||||
color: #ff1493;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/templates/error/404.html` (lines 118-171)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. **403 Forbidden Page** (CRITICAL FIX)
|
|
||||||
**Problem**: The 403 "Forbidden" page had NO dark mode support.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- `error/403.html`
|
|
||||||
|
|
||||||
**Fix Applied**: Added comprehensive dark mode styles (same pattern as 404 page)
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/templates/error/403.html` (lines 129-179)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. **500 Internal Server Error Page** (CRITICAL FIX)
|
|
||||||
**Problem**: The 500 error page had NO dark mode support.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- `error/500.html`
|
|
||||||
|
|
||||||
**Fix Applied**: Added comprehensive dark mode styles (same pattern as 404 page)
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/templates/error/500.html` (lines 118-171)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. **Generic Error Page** (CRITICAL FIX)
|
|
||||||
**Problem**: The generic error page had NO dark mode support.
|
|
||||||
|
|
||||||
**Affected Pages**:
|
|
||||||
- `error/error.html`
|
|
||||||
|
|
||||||
**Fix Applied**: Added comprehensive dark mode styles with error code styling
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `src/main/resources/templates/error/error.html` (lines 85-138)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Already Fixed (No Action Needed)
|
|
||||||
|
|
||||||
These elements were already properly styled for dark mode in the existing CSS:
|
|
||||||
|
|
||||||
1. **Text Muted** - `text-muted` class
|
|
||||||
- Already styled at line 652-654 in `fitpub.css`
|
|
||||||
|
|
||||||
2. **Background Light** - `bg-light` class
|
|
||||||
- Already overridden at line 555-557 in `fitpub.css`
|
|
||||||
|
|
||||||
3. **Cards** - `.card`, `.card-body`, `.card-header`
|
|
||||||
- Already styled at lines 564-577 in `fitpub.css`
|
|
||||||
|
|
||||||
4. **Timeline Cards** - `.timeline-card`
|
|
||||||
- Already styled at lines 590-617 in `fitpub.css`
|
|
||||||
|
|
||||||
5. **Metric Cards** - `.metric-card`, `.metric-label`, `.metric-value`
|
|
||||||
- Already styled at lines 620-632 in `fitpub.css`
|
|
||||||
|
|
||||||
6. **File Upload Area** - `.file-upload-area`
|
|
||||||
- Already styled at lines 635-644 in `fitpub.css`
|
|
||||||
|
|
||||||
7. **Forms** - `.form-control`, `.form-select`, `.input-group-text`
|
|
||||||
- Already styled at lines 705-730 in `fitpub.css`
|
|
||||||
|
|
||||||
8. **Modals** - `.modal-content`, `.modal-header`, `.modal-footer`
|
|
||||||
- Already styled at lines 733-754 in `fitpub.css`
|
|
||||||
|
|
||||||
9. **Dropdowns** - `.dropdown-menu`, `.dropdown-item`
|
|
||||||
- Already styled at lines 757-774 in `fitpub.css`
|
|
||||||
|
|
||||||
10. **Alerts** - `.alert-success`, `.alert-danger`, `.alert-info`, `.alert-warning`
|
|
||||||
- Already styled at lines 777-799 in `fitpub.css`
|
|
||||||
|
|
||||||
11. **Tables** - `.table`, `.table-striped`, `.table-hover`
|
|
||||||
- Already styled at lines 802-813 in `fitpub.css`
|
|
||||||
|
|
||||||
12. **Pagination** - `.pagination`, `.page-link`, `.page-item`
|
|
||||||
- Already styled at lines 827-849 in `fitpub.css`
|
|
||||||
|
|
||||||
13. **Empty States** - `.empty-state`, `.empty-state-icon`
|
|
||||||
- Already styled at lines 677-691 in `fitpub.css`
|
|
||||||
|
|
||||||
14. **Footer** - `footer`, `footer.bg-light`
|
|
||||||
- Already styled at lines 955-984 in `fitpub.css`
|
|
||||||
|
|
||||||
15. **Indoor Activity Placeholder** - `#indoorPlaceholder`
|
|
||||||
- Already styled at lines 987-1016 in `fitpub.css`
|
|
||||||
|
|
||||||
16. **Notifications Page**
|
|
||||||
- Has comprehensive inline dark mode styles (lines 89-149 in `notifications.html`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary Statistics
|
|
||||||
|
|
||||||
### Files Modified: 5
|
|
||||||
1. `src/main/resources/static/css/fitpub.css` - Main CSS file
|
|
||||||
2. `src/main/resources/templates/activities/batch-upload.html` - Batch upload page
|
|
||||||
3. `src/main/resources/templates/error/404.html` - 404 error page
|
|
||||||
4. `src/main/resources/templates/error/403.html` - 403 error page
|
|
||||||
5. `src/main/resources/templates/error/500.html` - 500 error page
|
|
||||||
6. `src/main/resources/templates/error/error.html` - Generic error page
|
|
||||||
|
|
||||||
### Issues Fixed: 7
|
|
||||||
1. ✅ Form labels - Dark text on dark background
|
|
||||||
2. ✅ Form check labels - Dark text on dark background
|
|
||||||
3. ✅ Form helper text - Dark text on dark background
|
|
||||||
4. ✅ Typography elements (strong, b, small) - No dark mode color
|
|
||||||
5. ✅ Batch upload status badges - Light mode colors only
|
|
||||||
6. ✅ Error pages (404, 403, 500, generic) - No dark mode support
|
|
||||||
|
|
||||||
### CSS Lines Added: ~190 lines
|
|
||||||
- Main CSS file: 25 lines
|
|
||||||
- Batch upload page: 25 lines
|
|
||||||
- Error pages: ~140 lines total (4 pages × ~35 lines each)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
To verify all fixes are working:
|
|
||||||
|
|
||||||
### ✅ Forms (Enable Dark Mode in OS)
|
|
||||||
- [ ] Login page - Labels are visible (light text)
|
|
||||||
- [ ] Registration page - Labels and helper text are visible
|
|
||||||
- [ ] Activity upload - Form labels visible
|
|
||||||
- [ ] Activity edit - Form labels visible
|
|
||||||
- [ ] Profile edit - Form labels visible
|
|
||||||
- [ ] Checkbox labels are visible
|
|
||||||
|
|
||||||
### ✅ Activity Detail Page
|
|
||||||
- [ ] Strong text in metrics section is visible
|
|
||||||
- [ ] Weather section text is visible
|
|
||||||
- [ ] Additional metrics section is readable
|
|
||||||
|
|
||||||
### ✅ Batch Upload Page
|
|
||||||
- [ ] Success badge is visible (neon green)
|
|
||||||
- [ ] Failed badge is visible (bright red)
|
|
||||||
- [ ] Pending/Processing badge is visible (bright yellow)
|
|
||||||
- [ ] Job cards have proper dark background
|
|
||||||
|
|
||||||
### ✅ Error Pages
|
|
||||||
- [ ] 404 page - Dark background, light text
|
|
||||||
- [ ] 403 page - Dark background, light text, login button visible
|
|
||||||
- [ ] 500 page - Dark background, light text
|
|
||||||
- [ ] Generic error page - Dark background, error code visible
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color Palette Used
|
|
||||||
|
|
||||||
### Dark Mode Colors (from `fitpub.css`):
|
|
||||||
```css
|
|
||||||
--dark-bg: #0f0520 /* Main background */
|
|
||||||
--dark-bg-alt: #1a0a30 /* Alternative background */
|
|
||||||
--dark-surface: #251040 /* Card/surface background */
|
|
||||||
--dark-surface-hover: #301550 /* Hover state */
|
|
||||||
--dark-text: #e8e8f0 /* Primary text */
|
|
||||||
--dark-text-muted: #a8a8c0 /* Muted/secondary text */
|
|
||||||
--dark-border: #3d2060 /* Borders */
|
|
||||||
```
|
|
||||||
|
|
||||||
### Neon Accents (maintain 80s aesthetic):
|
|
||||||
```css
|
|
||||||
--neon-pink: #ff1493
|
|
||||||
--neon-purple: #9d00ff
|
|
||||||
--neon-cyan: #00ffff
|
|
||||||
--neon-yellow: #ffff00
|
|
||||||
--neon-orange: #ff6600
|
|
||||||
--neon-green: #39ff14
|
|
||||||
--neon-blue: #00d4ff
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
✅ **All dark mode issues have been fixed!**
|
|
||||||
|
|
||||||
The application now has **complete dark mode support** across all pages:
|
|
||||||
- ✅ Forms are fully readable
|
|
||||||
- ✅ Typography has proper contrast
|
|
||||||
- ✅ Status badges have neon colors
|
|
||||||
- ✅ Error pages have dark backgrounds
|
|
||||||
- ✅ All Bootstrap components are styled
|
|
||||||
- ✅ 80s neon aesthetic maintained
|
|
||||||
|
|
||||||
No more dark text on dark backgrounds! 🎉
|
|
||||||
|
|
@ -1,696 +0,0 @@
|
||||||
# FitPub Federation Testing Guide
|
|
||||||
|
|
||||||
This guide explains how to test the instance-to-instance federation functionality by running two FitPub instances locally.
|
|
||||||
|
|
||||||
## Docker Compose Setup (Recommended)
|
|
||||||
|
|
||||||
The easiest way to test federation is using Docker Compose, which automatically sets up two complete FitPub instances with separate databases and proper networking.
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Docker Network (fitpub-federation) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
|
||||||
│ │ Instance 1 │ │ Instance 2 │ │
|
|
||||||
│ │ (instance1.local) │◄─────►│ (instance2.local) │ │
|
|
||||||
│ │ Port: 8080 │ │ Port: 8081 │ │
|
|
||||||
│ └──────────────────────┘ └──────────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ PostgreSQL 1 │ │ PostgreSQL 2 │ │
|
|
||||||
│ │ (postgres1) │ │ (postgres2) │ │
|
|
||||||
│ │ Port: 5432 │ │ Port: 5433 │ │
|
|
||||||
│ └──────────────────┘ └──────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
▲ ▲
|
|
||||||
│ │
|
|
||||||
localhost:8080 localhost:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
1. **Start both instances**:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.federation-test.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check status**:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.federation-test.yml ps
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Access the instances**:
|
|
||||||
- Instance 1: http://localhost:8080
|
|
||||||
- Instance 2: http://localhost:8081
|
|
||||||
|
|
||||||
4. **Follow the [Test Scenarios](#test-scenarios) below** to verify federation functionality
|
|
||||||
|
|
||||||
5. **View logs** (in separate terminals):
|
|
||||||
```bash
|
|
||||||
# Instance 1 logs
|
|
||||||
docker-compose -f docker-compose.federation-test.yml logs -f fitpub1
|
|
||||||
|
|
||||||
# Instance 2 logs
|
|
||||||
docker-compose -f docker-compose.federation-test.yml logs -f fitpub2
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Stop and clean up**:
|
|
||||||
```bash
|
|
||||||
# Stop containers
|
|
||||||
docker-compose -f docker-compose.federation-test.yml down
|
|
||||||
|
|
||||||
# Stop and remove volumes (complete cleanup)
|
|
||||||
docker-compose -f docker-compose.federation-test.yml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Overview
|
|
||||||
|
|
||||||
The Docker Compose setup includes:
|
|
||||||
|
|
||||||
- **postgres1**: PostgreSQL 16 with PostGIS 3.4 for Instance 1
|
|
||||||
- Database: `fitpub1`
|
|
||||||
- Port: 5432 (internal), 5434 (on host)
|
|
||||||
|
|
||||||
- **postgres2**: PostgreSQL 16 with PostGIS 3.4 for Instance 2
|
|
||||||
- Database: `fitpub2`
|
|
||||||
- Port: 5432 (internal), 5433 (on host)
|
|
||||||
|
|
||||||
- **fitpub1**: FitPub Instance 1
|
|
||||||
- Domain: `instance1.local:8080`
|
|
||||||
- Port: 8080
|
|
||||||
- Network alias: `instance1.local`
|
|
||||||
|
|
||||||
- **fitpub2**: FitPub Instance 2
|
|
||||||
- Domain: `instance2.local:8081`
|
|
||||||
- Port: 8081
|
|
||||||
- Network alias: `instance2.local`
|
|
||||||
|
|
||||||
### Docker-Specific Commands
|
|
||||||
|
|
||||||
**Access database directly**:
|
|
||||||
```bash
|
|
||||||
# Instance 1 database
|
|
||||||
docker exec -it fitpub-postgres1 psql -U fitpub -d fitpub1
|
|
||||||
|
|
||||||
# Instance 2 database
|
|
||||||
docker exec -it fitpub-postgres2 psql -U fitpub -d fitpub2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Inspect network**:
|
|
||||||
```bash
|
|
||||||
docker network inspect fitpub-federation
|
|
||||||
```
|
|
||||||
|
|
||||||
**View container details**:
|
|
||||||
```bash
|
|
||||||
docker inspect fitpub-instance1
|
|
||||||
docker inspect fitpub-instance2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Restart a single service**:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.federation-test.yml restart fitpub1
|
|
||||||
docker-compose -f docker-compose.federation-test.yml restart fitpub2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rebuild images** (after code changes):
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.federation-test.yml build
|
|
||||||
docker-compose -f docker-compose.federation-test.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Troubleshooting
|
|
||||||
|
|
||||||
**Container won't start**:
|
|
||||||
```bash
|
|
||||||
# Check logs for errors
|
|
||||||
docker-compose -f docker-compose.federation-test.yml logs fitpub1
|
|
||||||
docker-compose -f docker-compose.federation-test.yml logs fitpub2
|
|
||||||
|
|
||||||
# Check health status
|
|
||||||
docker ps -a | grep fitpub
|
|
||||||
```
|
|
||||||
|
|
||||||
**Database connection issues**:
|
|
||||||
```bash
|
|
||||||
# Verify database is healthy
|
|
||||||
docker-compose -f docker-compose.federation-test.yml ps postgres1
|
|
||||||
docker-compose -f docker-compose.federation-test.yml ps postgres2
|
|
||||||
|
|
||||||
# Check database logs
|
|
||||||
docker-compose -f docker-compose.federation-test.yml logs postgres1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Network connectivity issues**:
|
|
||||||
```bash
|
|
||||||
# Test DNS resolution from inside container
|
|
||||||
docker exec -it fitpub-instance1 ping instance2.local
|
|
||||||
docker exec -it fitpub-instance2 ping instance1.local
|
|
||||||
|
|
||||||
# Test HTTP connectivity
|
|
||||||
docker exec -it fitpub-instance1 curl http://instance2.local:8081/.well-known/webfinger
|
|
||||||
```
|
|
||||||
|
|
||||||
**Port already in use**:
|
|
||||||
```bash
|
|
||||||
# Find process using port 8080
|
|
||||||
lsof -ti:8080 | xargs kill -9
|
|
||||||
|
|
||||||
# Or use different external ports in docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
**Volume permission issues**:
|
|
||||||
```bash
|
|
||||||
# Remove all volumes and start fresh
|
|
||||||
docker-compose -f docker-compose.federation-test.yml down -v
|
|
||||||
docker-compose -f docker-compose.federation-test.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Platform warning on Apple Silicon (M1/M2/M3 Macs)**:
|
|
||||||
```
|
|
||||||
The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)
|
|
||||||
```
|
|
||||||
This is expected and safe to ignore. Docker will use emulation (Rosetta 2) to run the amd64 images. Performance may be slightly slower than native ARM images, but fully functional for testing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual Setup (Alternative)
|
|
||||||
|
|
||||||
If you prefer to run the instances directly without Docker, follow these instructions:
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Java 17+
|
|
||||||
- Maven 3.8+
|
|
||||||
- PostgreSQL 13+ with PostGIS extension
|
|
||||||
- Two separate PostgreSQL databases
|
|
||||||
- Two different port numbers for the applications
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
### Step 1: Create Two PostgreSQL Databases
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Connect to PostgreSQL
|
|
||||||
psql -U postgres
|
|
||||||
|
|
||||||
# Create databases
|
|
||||||
CREATE DATABASE fitpub_instance1;
|
|
||||||
CREATE DATABASE fitpub_instance2;
|
|
||||||
|
|
||||||
# Enable PostGIS extension for both databases
|
|
||||||
\c fitpub_instance1
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
||||||
|
|
||||||
\c fitpub_instance2
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
||||||
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Prepare Application Profiles
|
|
||||||
|
|
||||||
Create two separate application configuration files:
|
|
||||||
|
|
||||||
#### `application-instance1.yml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
port: 8080
|
|
||||||
|
|
||||||
spring:
|
|
||||||
datasource:
|
|
||||||
url: jdbc:postgresql://localhost:5432/fitpub_instance1
|
|
||||||
username: postgres
|
|
||||||
password: your_password
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: validate
|
|
||||||
|
|
||||||
fitpub:
|
|
||||||
base-url: http://localhost:8080
|
|
||||||
domain: localhost:8080
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
net.javahippie.fitpub: DEBUG
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `application-instance2.yml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
port: 8081
|
|
||||||
|
|
||||||
spring:
|
|
||||||
datasource:
|
|
||||||
url: jdbc:postgresql://localhost:5432/fitpub_instance2
|
|
||||||
username: postgres
|
|
||||||
password: your_password
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: validate
|
|
||||||
|
|
||||||
fitpub:
|
|
||||||
base-url: http://localhost:8081
|
|
||||||
domain: localhost:8081
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
net.javahippie.fitpub: DEBUG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Build the Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Instances
|
|
||||||
|
|
||||||
### Terminal 1: Start Instance 1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
java -jar target/feditrack-1.0-SNAPSHOT.jar --spring.profiles.active=instance1
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait for the application to start completely. You should see:
|
|
||||||
```
|
|
||||||
Started FitPubApplication in X.XXX seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
### Terminal 2: Start Instance 2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
java -jar target/feditrack-1.0-SNAPSHOT.jar --spring.profiles.active=instance2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Scenarios
|
|
||||||
|
|
||||||
### Test 1: User Registration
|
|
||||||
|
|
||||||
**Instance 1 (http://localhost:8080)**
|
|
||||||
1. Navigate to http://localhost:8080/register
|
|
||||||
2. Register user: `alice` / `alice@localhost1.test` / `password123`
|
|
||||||
3. Login
|
|
||||||
|
|
||||||
**Instance 2 (http://localhost:8081)**
|
|
||||||
1. Navigate to http://localhost:8081/register
|
|
||||||
2. Register user: `bob` / `bob@localhost2.test` / `password123`
|
|
||||||
3. Login
|
|
||||||
|
|
||||||
### Test 2: WebFinger Discovery
|
|
||||||
|
|
||||||
**From Instance 1, discover Bob on Instance 2:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/.well-known/webfinger?resource=acct:bob@localhost:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subject": "acct:bob@localhost:8081",
|
|
||||||
"aliases": [
|
|
||||||
"http://localhost:8081/users/bob"
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"rel": "self",
|
|
||||||
"type": "application/activity+json",
|
|
||||||
"href": "http://localhost:8081/users/bob"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**From Instance 2, discover Alice on Instance 1:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8081/.well-known/webfinger?resource=acct:alice@localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 3: Remote User Discovery via UI
|
|
||||||
|
|
||||||
**On Instance 1 (Alice following Bob):**
|
|
||||||
|
|
||||||
1. Login as Alice
|
|
||||||
2. Navigate to http://localhost:8080/discover
|
|
||||||
3. In the "Follow Remote Users" section, enter: `bob@localhost:8081`
|
|
||||||
4. Click "Search"
|
|
||||||
5. Verify Bob's profile appears with avatar, display name, and bio
|
|
||||||
6. Click "Follow" button
|
|
||||||
7. Verify notification appears: "Follow request sent to bob@localhost:8081"
|
|
||||||
|
|
||||||
**Verify on Instance 2 (Bob's perspective):**
|
|
||||||
|
|
||||||
1. Login as Bob on http://localhost:8081
|
|
||||||
2. Check notifications - you should see: "alice@localhost:8080 followed you"
|
|
||||||
3. Navigate to http://localhost:8081/users/bob/followers
|
|
||||||
4. Verify alice@localhost:8080 appears in followers list
|
|
||||||
|
|
||||||
### Test 4: Following Relationship Check
|
|
||||||
|
|
||||||
**Check via API:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From Instance 2, check Bob's followers
|
|
||||||
curl http://localhost:8081/api/users/bob/followers | jq
|
|
||||||
|
|
||||||
# Expected: Alice should be in the list
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check via UI:**
|
|
||||||
|
|
||||||
On Instance 2:
|
|
||||||
1. Navigate to http://localhost:8081/users/bob
|
|
||||||
2. Check "Followers" count - should be 1
|
|
||||||
3. Click on "Followers" - Alice should be listed
|
|
||||||
|
|
||||||
On Instance 1:
|
|
||||||
1. Navigate to http://localhost:8080/users/alice
|
|
||||||
2. Check "Following" count - should be 1
|
|
||||||
3. Click on "Following" - Bob should be listed
|
|
||||||
|
|
||||||
### Test 5: Activity Federation
|
|
||||||
|
|
||||||
**Bob uploads a workout on Instance 2:**
|
|
||||||
|
|
||||||
1. Login as Bob on http://localhost:8081
|
|
||||||
2. Navigate to http://localhost:8081/upload
|
|
||||||
3. Upload a FIT file (use test file from `src/test/resources/`)
|
|
||||||
4. Set title: "Morning 10K Run"
|
|
||||||
5. Set visibility: "Public"
|
|
||||||
6. Click "Upload"
|
|
||||||
|
|
||||||
**Verify on Instance 1 (Alice's federated timeline):**
|
|
||||||
|
|
||||||
1. Login as Alice on http://localhost:8080
|
|
||||||
2. Navigate to http://localhost:8080/timeline/federated
|
|
||||||
3. Verify Bob's "Morning 10K Run" activity appears with:
|
|
||||||
- Federation badge: "🌐 Remote"
|
|
||||||
- Bob's avatar and @bob@localhost:8081
|
|
||||||
- Map preview (if map image URL is available)
|
|
||||||
- Metrics (distance, duration, pace, elevation)
|
|
||||||
- Link to view on origin server
|
|
||||||
|
|
||||||
### Test 6: Remote Activity Details
|
|
||||||
|
|
||||||
**Click on Remote Activity:**
|
|
||||||
|
|
||||||
From Alice's federated timeline:
|
|
||||||
1. Click on Bob's "Morning 10K Run" activity title
|
|
||||||
2. Verify it opens Bob's activity on Instance 2 (http://localhost:8081/activities/{id}) in a new tab
|
|
||||||
3. Alternatively, click "View on Origin Server" button
|
|
||||||
|
|
||||||
### Test 7: Incoming Activity via ActivityPub
|
|
||||||
|
|
||||||
**Test with manual ActivityPub POST:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a test activity
|
|
||||||
cat > test-activity.json <<EOF
|
|
||||||
{
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "Create",
|
|
||||||
"id": "http://localhost:8081/activities/create/test-123",
|
|
||||||
"actor": "http://localhost:8081/users/bob",
|
|
||||||
"published": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
||||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
"cc": ["http://localhost:8081/users/bob/followers"],
|
|
||||||
"object": {
|
|
||||||
"type": "Note",
|
|
||||||
"id": "http://localhost:8081/workouts/test-456",
|
|
||||||
"attributedTo": "http://localhost:8081/users/bob",
|
|
||||||
"name": "Test Workout via ActivityPub",
|
|
||||||
"content": "Testing direct ActivityPub activity delivery",
|
|
||||||
"published": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
||||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
"workoutData": {
|
|
||||||
"activityType": "RUN",
|
|
||||||
"distance": 5000,
|
|
||||||
"duration": "PT25M30S",
|
|
||||||
"elevationGain": 50
|
|
||||||
},
|
|
||||||
"attachment": [
|
|
||||||
{
|
|
||||||
"type": "Document",
|
|
||||||
"mediaType": "image/png",
|
|
||||||
"name": "Map",
|
|
||||||
"url": "http://localhost:8081/activities/test-456/map.png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Post to Alice's inbox on Instance 1
|
|
||||||
curl -X POST http://localhost:8080/users/alice/inbox \
|
|
||||||
-H "Content-Type: application/activity+json" \
|
|
||||||
-d @test-activity.json
|
|
||||||
|
|
||||||
# Expected response: 202 Accepted
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify in database:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- On Instance 1
|
|
||||||
SELECT * FROM remote_activities WHERE remote_actor_uri = 'http://localhost:8081/users/bob';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 8: Unfollow Workflow
|
|
||||||
|
|
||||||
**Alice unfollows Bob:**
|
|
||||||
|
|
||||||
1. On Instance 1, navigate to http://localhost:8080/users/alice/following
|
|
||||||
2. Find Bob in the following list
|
|
||||||
3. Click "Unfollow"
|
|
||||||
4. Verify confirmation dialog
|
|
||||||
5. Confirm unfollow
|
|
||||||
|
|
||||||
**Verify Undo Activity:**
|
|
||||||
|
|
||||||
Check Instance 2 logs for incoming Undo activity:
|
|
||||||
```
|
|
||||||
Processing Undo activity for user bob
|
|
||||||
Deleted follow from actor: http://localhost:8080/users/alice
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check Database:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- On Instance 2
|
|
||||||
SELECT * FROM follows WHERE remote_actor_uri = 'http://localhost:8080/users/alice'
|
|
||||||
AND following_actor_uri = 'http://localhost:8081/users/bob';
|
|
||||||
-- Should return 0 rows
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 9: Accept Activity Flow
|
|
||||||
|
|
||||||
**Bob follows Alice (reverse direction):**
|
|
||||||
|
|
||||||
1. On Instance 2, login as Bob
|
|
||||||
2. Navigate to http://localhost:8081/discover
|
|
||||||
3. Search for: `alice@localhost:8080`
|
|
||||||
4. Click "Follow"
|
|
||||||
5. Verify "Follow request sent" notification
|
|
||||||
|
|
||||||
**Check Follow Status:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- On Instance 2
|
|
||||||
SELECT status FROM follows WHERE follower_id = (SELECT id FROM users WHERE username = 'bob')
|
|
||||||
AND following_actor_uri = 'http://localhost:8080/users/alice';
|
|
||||||
-- Should return 'PENDING'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify Accept on Instance 1:**
|
|
||||||
|
|
||||||
Check Instance 1 logs for outgoing Accept activity:
|
|
||||||
```
|
|
||||||
Sending Accept activity to http://localhost:8081/users/bob/inbox
|
|
||||||
Accept activity sent successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check Updated Status:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- On Instance 2
|
|
||||||
SELECT status FROM follows WHERE follower_id = (SELECT id FROM users WHERE username = 'bob')
|
|
||||||
AND following_actor_uri = 'http://localhost:8080/users/alice';
|
|
||||||
-- Should return 'ACCEPTED'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check Notification:**
|
|
||||||
|
|
||||||
On Instance 2:
|
|
||||||
1. Navigate to http://localhost:8081/notifications
|
|
||||||
2. Verify notification: "alice@localhost:8080 accepted your follow request"
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Instance Won't Start
|
|
||||||
|
|
||||||
**Problem:** Port already in use
|
|
||||||
```
|
|
||||||
Port 8080 is already in use
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:** Kill the process using the port or use a different port
|
|
||||||
```bash
|
|
||||||
lsof -ti:8080 | xargs kill -9
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Connection Error
|
|
||||||
|
|
||||||
**Problem:** Connection refused to PostgreSQL
|
|
||||||
|
|
||||||
**Solution:** Check PostgreSQL is running
|
|
||||||
```bash
|
|
||||||
brew services start postgresql
|
|
||||||
# or
|
|
||||||
sudo systemctl start postgresql
|
|
||||||
```
|
|
||||||
|
|
||||||
### WebFinger Not Working
|
|
||||||
|
|
||||||
**Problem:** 404 when accessing /.well-known/webfinger
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check if the controller is mapped correctly
|
|
||||||
2. Verify Spring Security allows unauthenticated access to WebFinger endpoint
|
|
||||||
3. Check logs for any errors
|
|
||||||
|
|
||||||
### Remote Activities Not Appearing
|
|
||||||
|
|
||||||
**Problem:** Bob's activities don't show up in Alice's federated timeline
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Verify follow relationship exists and status is ACCEPTED:
|
|
||||||
```sql
|
|
||||||
SELECT * FROM follows WHERE follower_id = (SELECT id FROM users WHERE username = 'alice')
|
|
||||||
AND following_actor_uri LIKE '%bob%';
|
|
||||||
```
|
|
||||||
2. Check InboxProcessor logs for incoming Create activities
|
|
||||||
3. Verify RemoteActivity was created:
|
|
||||||
```sql
|
|
||||||
SELECT * FROM remote_activities;
|
|
||||||
```
|
|
||||||
4. Check TimelineService is fetching both local and remote activities
|
|
||||||
|
|
||||||
### Map Preview Not Loading
|
|
||||||
|
|
||||||
**Problem:** Remote activity map shows "Map not available"
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Remote activities use `mapImageUrl` field which must be set when creating the activity
|
|
||||||
- For testing, you may need to implement map image generation on the origin server
|
|
||||||
- Check if the URL in `map_image_url` is accessible
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
- [ ] Both instances start successfully on different ports
|
|
||||||
- [ ] WebFinger discovery works in both directions
|
|
||||||
- [ ] Remote user discovery UI works
|
|
||||||
- [ ] Follow request is sent and creates PENDING follow
|
|
||||||
- [ ] Accept activity is received and updates status to ACCEPTED
|
|
||||||
- [ ] Follower/following lists show both local and remote users
|
|
||||||
- [ ] Remote activities appear in federated timeline
|
|
||||||
- [ ] Remote activities show federation badge
|
|
||||||
- [ ] Map preview loads from remote server
|
|
||||||
- [ ] "View on Origin Server" opens correct URL
|
|
||||||
- [ ] Unfollow sends Undo activity and removes follow
|
|
||||||
- [ ] Notifications are created for follow/accept events
|
|
||||||
- [ ] No errors in console logs
|
|
||||||
|
|
||||||
## Database Inspection Queries
|
|
||||||
|
|
||||||
### Check All Follows
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- On any instance
|
|
||||||
SELECT
|
|
||||||
f.id,
|
|
||||||
f.follower_id,
|
|
||||||
u.username as follower_username,
|
|
||||||
f.following_actor_uri,
|
|
||||||
f.remote_actor_uri,
|
|
||||||
f.status,
|
|
||||||
f.created_at
|
|
||||||
FROM follows f
|
|
||||||
LEFT JOIN users u ON f.follower_id = u.id
|
|
||||||
ORDER BY f.created_at DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Remote Actors
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
actor_uri,
|
|
||||||
username,
|
|
||||||
domain,
|
|
||||||
display_name,
|
|
||||||
last_fetched
|
|
||||||
FROM remote_actors
|
|
||||||
ORDER BY last_fetched DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Remote Activities
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
activity_uri,
|
|
||||||
remote_actor_uri,
|
|
||||||
activity_type,
|
|
||||||
title,
|
|
||||||
total_distance,
|
|
||||||
total_duration_seconds,
|
|
||||||
published_at,
|
|
||||||
visibility,
|
|
||||||
map_image_url
|
|
||||||
FROM remote_activities
|
|
||||||
ORDER BY published_at DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Clean Up
|
|
||||||
|
|
||||||
To reset the test environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop both instances (Ctrl+C in both terminals)
|
|
||||||
|
|
||||||
# Drop and recreate databases
|
|
||||||
psql -U postgres <<EOF
|
|
||||||
DROP DATABASE fitpub_instance1;
|
|
||||||
DROP DATABASE fitpub_instance2;
|
|
||||||
CREATE DATABASE fitpub_instance1;
|
|
||||||
CREATE DATABASE fitpub_instance2;
|
|
||||||
\c fitpub_instance1
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
||||||
\c fitpub_instance2
|
|
||||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
## Additional Testing Tips
|
|
||||||
|
|
||||||
1. **Use Browser Developer Tools** to inspect network requests for ActivityPub activities
|
|
||||||
2. **Monitor Logs** in both terminal windows to see federation events in real-time
|
|
||||||
3. **Test Edge Cases** like following yourself, duplicate follows, following non-existent users
|
|
||||||
4. **Test Different Visibility Levels** (PUBLIC, FOLLOWERS, PRIVATE)
|
|
||||||
5. **Simulate Network Failures** by stopping one instance mid-federation
|
|
||||||
6. **Test Concurrent Operations** (both users following each other simultaneously)
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
✅ All checklist items pass
|
|
||||||
✅ No errors in logs
|
|
||||||
✅ Activities federate within 1-2 seconds
|
|
||||||
✅ UI updates reflect federation state correctly
|
|
||||||
✅ Database state is consistent across both instances
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
# Indoor Activity Detection - Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Implemented multi-format indoor activity detection with retroactive migration support. Indoor activities (trainer rides, treadmill runs, virtual activities) are now:
|
|
||||||
- ✅ **Visible in timeline** with full GPS visualization
|
|
||||||
- ✅ **Excluded from heatmap** to prevent pollution of outdoor activity maps
|
|
||||||
- ✅ **Automatically detected** from FIT SubSport field or GPS heuristics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Changes
|
|
||||||
|
|
||||||
### Migration V20 (Already Applied)
|
|
||||||
- Added `indoor` boolean column (defaults to FALSE)
|
|
||||||
- Created index `idx_activity_indoor` for efficient heatmap queries
|
|
||||||
|
|
||||||
### Migration V21 (New - Just Applied)
|
|
||||||
- Added `sub_sport` VARCHAR(50) - SubSport from FIT files
|
|
||||||
- Added `indoor_detection_method` VARCHAR(20) - How indoor flag was determined
|
|
||||||
|
|
||||||
### Schema Columns
|
|
||||||
|
|
||||||
| Column | Type | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `indoor` | BOOLEAN NOT NULL | Main flag - exclude from heatmap if TRUE |
|
|
||||||
| `sub_sport` | VARCHAR(50) NULL | FIT SubSport field (e.g., INDOOR_CYCLING, ROAD, TRAIL) |
|
|
||||||
| `indoor_detection_method` | VARCHAR(20) NULL | Detection method enum value |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detection Methods
|
|
||||||
|
|
||||||
### 1. FIT Files - SubSport Field (Most Accurate)
|
|
||||||
**Method**: `FIT_SUBSPORT`
|
|
||||||
|
|
||||||
Reads the SubSport field from FIT file session message and detects:
|
|
||||||
- `INDOOR_CYCLING` → Indoor
|
|
||||||
- `TREADMILL` → Indoor
|
|
||||||
- `VIRTUAL_ACTIVITY` → Indoor (Zwift, RGT, etc.)
|
|
||||||
- `TRAINER` → Indoor
|
|
||||||
- `ROAD`, `MOUNTAIN`, `TRAIL` → Outdoor
|
|
||||||
|
|
||||||
**Implementation**: `FitParser.extractSessionData()` (lines 332-348)
|
|
||||||
|
|
||||||
### 2. FIT Files - No GPS Data
|
|
||||||
**Method**: `HEURISTIC_NO_GPS`
|
|
||||||
|
|
||||||
If FIT file has no GPS track points → marked as indoor.
|
|
||||||
|
|
||||||
**Implementation**: `FitParser.parse()` (lines 111-120)
|
|
||||||
|
|
||||||
### 3. GPX Files - Stationary GPS
|
|
||||||
**Method**: `HEURISTIC_STATIONARY`
|
|
||||||
|
|
||||||
If all GPS points are within 50 meters of first point → marked as indoor.
|
|
||||||
Detects trainer rides or treadmill runs with GPS enabled (phone/tablet).
|
|
||||||
|
|
||||||
**Implementation**: `GpxParser.detectIndoorActivity()` + `isStationaryGps()` (lines 627-673)
|
|
||||||
|
|
||||||
### 4. GPX Files - No GPS Data
|
|
||||||
**Method**: `HEURISTIC_NO_GPS`
|
|
||||||
|
|
||||||
If GPX file has no GPS track points → marked as indoor.
|
|
||||||
|
|
||||||
**Implementation**: `GpxParser.detectIndoorActivity()` (lines 630-634)
|
|
||||||
|
|
||||||
### 5. Manual Override (Future)
|
|
||||||
**Method**: `MANUAL`
|
|
||||||
|
|
||||||
User can manually flag activity as indoor (UI not yet implemented).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### For NEW Uploads (After This Feature)
|
|
||||||
|
|
||||||
#### FIT File Upload:
|
|
||||||
1. User uploads FIT file
|
|
||||||
2. `FitParser.extractSessionData()` reads SubSport field
|
|
||||||
3. Sets `parsedData.setSubSport("INDOOR_CYCLING")` (example)
|
|
||||||
4. Detects indoor keywords → sets `parsedData.setIndoor(true)`
|
|
||||||
5. Sets `parsedData.setIndoorDetectionMethod(FIT_SUBSPORT)`
|
|
||||||
6. `ActivityFileService` saves to database with all fields populated
|
|
||||||
7. **Done** - no re-parsing needed
|
|
||||||
|
|
||||||
#### GPX File Upload:
|
|
||||||
1. User uploads GPX file
|
|
||||||
2. `GpxParser.detectIndoorActivity()` analyzes GPS points
|
|
||||||
3. Checks if all points within 50m radius
|
|
||||||
4. Sets `parsedData.setIndoor(true)` if stationary
|
|
||||||
5. Sets `parsedData.setIndoorDetectionMethod(HEURISTIC_STATIONARY)`
|
|
||||||
6. `ActivityFileService` saves to database
|
|
||||||
7. **Done** - no re-parsing needed
|
|
||||||
|
|
||||||
### For Timeline Loading
|
|
||||||
|
|
||||||
**Fast database query** - no file parsing:
|
|
||||||
```sql
|
|
||||||
SELECT id, activity_type, title, indoor, sub_sport,
|
|
||||||
indoor_detection_method, ...
|
|
||||||
FROM activities
|
|
||||||
WHERE ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Heatmap Generation
|
|
||||||
|
|
||||||
**Automatically excludes indoor activities**:
|
|
||||||
|
|
||||||
Updated queries in `UserHeatmapGridRepository`:
|
|
||||||
```sql
|
|
||||||
-- Single activity update
|
|
||||||
WHERE a.id = :activityId
|
|
||||||
AND a.track_points_json IS NOT NULL
|
|
||||||
AND a.indoor = FALSE -- NEW
|
|
||||||
|
|
||||||
-- Full user recalculation
|
|
||||||
WHERE a.user_id = :userId
|
|
||||||
AND a.track_points_json IS NOT NULL
|
|
||||||
AND a.indoor = FALSE -- NEW
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Structure
|
|
||||||
|
|
||||||
### Key Files Modified
|
|
||||||
|
|
||||||
1. **Database**:
|
|
||||||
- `V20__add_indoor_flag_to_activities.sql` - Indoor flag column
|
|
||||||
- `V21__add_indoor_detection_metadata.sql` - SubSport & detection method columns
|
|
||||||
|
|
||||||
2. **Entity**:
|
|
||||||
- `Activity.java` - Added `subSport`, `indoorDetectionMethod` fields
|
|
||||||
- `Activity.IndoorDetectionMethod` - New enum for detection methods
|
|
||||||
|
|
||||||
3. **Parsers**:
|
|
||||||
- `FitParser.java` - Extract SubSport, detect indoor from FIT files
|
|
||||||
- `GpxParser.java` - Heuristic detection with GPS analysis (Haversine distance)
|
|
||||||
- `ParsedActivityData.java` - Added fields for parsed data transfer
|
|
||||||
|
|
||||||
4. **Service**:
|
|
||||||
- `ActivityFileService.java` - Save SubSport & detection method to database
|
|
||||||
|
|
||||||
5. **Repository**:
|
|
||||||
- `UserHeatmapGridRepository.java` - Exclude indoor activities from heatmap queries
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Test Indoor Detection
|
|
||||||
|
|
||||||
#### 1. Upload Zwift FIT File
|
|
||||||
Expected:
|
|
||||||
- `indoor = TRUE`
|
|
||||||
- `sub_sport = "VIRTUAL_ACTIVITY"`
|
|
||||||
- `indoor_detection_method = "FIT_SUBSPORT"`
|
|
||||||
- Visible in timeline ✅
|
|
||||||
- Not in heatmap ✅
|
|
||||||
|
|
||||||
#### 2. Upload Treadmill FIT File
|
|
||||||
Expected:
|
|
||||||
- `indoor = TRUE`
|
|
||||||
- `sub_sport = "TREADMILL"`
|
|
||||||
- `indoor_detection_method = "FIT_SUBSPORT"`
|
|
||||||
|
|
||||||
#### 3. Upload GPX with Stationary GPS
|
|
||||||
Expected:
|
|
||||||
- `indoor = TRUE`
|
|
||||||
- `sub_sport = NULL` (GPX doesn't have SubSport)
|
|
||||||
- `indoor_detection_method = "HEURISTIC_STATIONARY"`
|
|
||||||
|
|
||||||
#### 4. Upload Outdoor Ride FIT
|
|
||||||
Expected:
|
|
||||||
- `indoor = FALSE`
|
|
||||||
- `sub_sport = "ROAD"` or `"MOUNTAIN"`
|
|
||||||
- `indoor_detection_method = NULL` (not indoor)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **New uploads**: SubSport extracted during normal parsing (no overhead)
|
|
||||||
- **Timeline loading**: Simple column read (instant)
|
|
||||||
- **Heatmap queries**: Added `AND indoor = FALSE` filter (uses index)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Phase 2 (Optional):
|
|
||||||
1. **GPX Extension Parsing**: Read Garmin/Strava custom XML extensions
|
|
||||||
2. **Manual Override UI**: Allow users to manually mark activities as indoor
|
|
||||||
3. **Activity Edit**: Update indoor flag via activity edit form
|
|
||||||
4. **Statistics**: Show "X indoor activities excluded from heatmap" message
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Query Examples
|
|
||||||
|
|
||||||
### Find All Indoor Activities
|
|
||||||
```sql
|
|
||||||
SELECT id, title, activity_type, sub_sport, indoor_detection_method
|
|
||||||
FROM activities
|
|
||||||
WHERE indoor = TRUE;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Find FIT Activities with SubSport
|
|
||||||
```sql
|
|
||||||
SELECT id, title, sub_sport
|
|
||||||
FROM activities
|
|
||||||
WHERE sub_sport IS NOT NULL
|
|
||||||
ORDER BY created_at DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Count Detection Methods
|
|
||||||
```sql
|
|
||||||
SELECT indoor_detection_method, COUNT(*) as count
|
|
||||||
FROM activities
|
|
||||||
WHERE indoor = TRUE
|
|
||||||
GROUP BY indoor_detection_method;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
✅ **Fully implemented** multi-format indoor detection
|
|
||||||
✅ **Backward compatible** - existing activities default to outdoor
|
|
||||||
✅ **Automatic detection** on upload for new activities
|
|
||||||
✅ **Heatmap exclusion** automatic via SQL filters
|
|
||||||
✅ **Timeline display** includes all activities
|
|
||||||
✅ **Works for FIT and GPX** files with different detection strategies
|
|
||||||
✅ **Type-safe** detection method enum
|
|
||||||
✅ **Well documented** with inline comments
|
|
||||||
|
|
||||||
The solution is production-ready and scales to all supported file formats!
|
|
||||||
360
MVP_COMPLETE.md
360
MVP_COMPLETE.md
|
|
@ -1,360 +0,0 @@
|
||||||
# FitPub - Phase 1 (MVP) Complete! 🎉
|
|
||||||
|
|
||||||
**Date Completed:** November 29, 2025
|
|
||||||
**Status:** ✅ All MVP features implemented and functional
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
FitPub is a **federated fitness tracking platform** that integrates with the Fediverse through ActivityPub. Users can upload FIT files from GPS-enabled fitness devices, visualize their activities on interactive maps, and share workouts with followers across Mastodon, Pleroma, and other federated platforms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Been Built
|
|
||||||
|
|
||||||
### 1. Core Fitness Tracking Features ✅
|
|
||||||
|
|
||||||
**FIT File Processing**
|
|
||||||
- Binary FIT file parsing using FIT SDK
|
|
||||||
- GPS track extraction (lat/lon/elevation)
|
|
||||||
- Activity metrics parsing (heart rate, cadence, power, speed)
|
|
||||||
- Track simplification using Douglas-Peucker algorithm
|
|
||||||
- PostGIS LineString geometry storage
|
|
||||||
- Comprehensive test coverage with real FIT files
|
|
||||||
|
|
||||||
**Activity Management**
|
|
||||||
- Upload FIT files with drag-and-drop
|
|
||||||
- Create/Read/Update/Delete operations
|
|
||||||
- Activity metadata (title, description, visibility)
|
|
||||||
- Three visibility levels: PUBLIC, FOLLOWERS, PRIVATE
|
|
||||||
- Paginated activity lists
|
|
||||||
- Activity statistics (distance, duration, pace, elevation)
|
|
||||||
|
|
||||||
**Map Visualization**
|
|
||||||
- Interactive Leaflet.js maps
|
|
||||||
- OpenStreetMap tile layers
|
|
||||||
- GeoJSON track rendering
|
|
||||||
- Start/finish markers (green/red)
|
|
||||||
- Auto-fit bounds to track
|
|
||||||
- Preview maps on timeline cards
|
|
||||||
- Elevation profile charts (Chart.js)
|
|
||||||
|
|
||||||
### 2. User Management & Authentication ✅
|
|
||||||
|
|
||||||
**User Registration & Login**
|
|
||||||
- Secure user registration with validation
|
|
||||||
- JWT-based authentication
|
|
||||||
- Password hashing with BCrypt
|
|
||||||
- Session management via localStorage
|
|
||||||
- Protected routes with client-side checks
|
|
||||||
|
|
||||||
**User Profiles**
|
|
||||||
- View own profile (`/profile`)
|
|
||||||
- Edit profile (display name, bio, avatar URL)
|
|
||||||
- Public user profiles (`/users/{username}`)
|
|
||||||
- Activity list on profiles (paginated)
|
|
||||||
- Follower/following counts (UI ready)
|
|
||||||
- Settings page placeholder
|
|
||||||
|
|
||||||
**REST API**
|
|
||||||
- `POST /api/auth/register` - User registration
|
|
||||||
- `POST /api/auth/login` - User login
|
|
||||||
- `GET /api/users/me` - Get current user
|
|
||||||
- `PUT /api/users/me` - Update profile
|
|
||||||
- `GET /api/users/{username}` - Get user by username
|
|
||||||
- `GET /api/activities/user/{username}` - Get user's public activities
|
|
||||||
|
|
||||||
### 3. Timeline & Social Features ✅
|
|
||||||
|
|
||||||
**Three Timeline Views**
|
|
||||||
- **Public Timeline** (`/timeline`) - All public activities from all users
|
|
||||||
- **Federated Timeline** (`/timeline/federated`) - Activities from followed users
|
|
||||||
- **User Timeline** (`/timeline/user`) - Current user's own activities
|
|
||||||
|
|
||||||
**Timeline Features**
|
|
||||||
- Activity cards with preview maps
|
|
||||||
- User information (avatar, display name, username)
|
|
||||||
- Clickable user profiles from timeline
|
|
||||||
- Activity type badges (Run, Ride, Hike)
|
|
||||||
- Metrics summary (distance, duration, pace, elevation)
|
|
||||||
- "Time ago" formatting (e.g., "2h ago")
|
|
||||||
- Pagination (prev/next, numbered pages)
|
|
||||||
- Empty states and loading spinners
|
|
||||||
|
|
||||||
### 4. ActivityPub Federation ✅
|
|
||||||
|
|
||||||
**Actor Implementation**
|
|
||||||
- ActivityPub Actor profiles (`/users/{username}`)
|
|
||||||
- JSON-LD serialization with @context
|
|
||||||
- RSA keypair generation for HTTP signatures
|
|
||||||
- Public key embedding in actor profiles
|
|
||||||
|
|
||||||
**WebFinger Support**
|
|
||||||
- User discovery via `/.well-known/webfinger`
|
|
||||||
- Account identifier parsing (`acct:user@domain`)
|
|
||||||
- Links to ActivityPub actor profiles
|
|
||||||
|
|
||||||
**Collections**
|
|
||||||
- Inbox endpoint (`POST /users/{username}/inbox`)
|
|
||||||
- Outbox endpoint (`GET /users/{username}/outbox`)
|
|
||||||
- Followers collection (`GET /users/{username}/followers`)
|
|
||||||
- Following collection (`GET /users/{username}/following`)
|
|
||||||
|
|
||||||
**Federation Activities**
|
|
||||||
- Follow: Remote users can follow local users
|
|
||||||
- Accept: Auto-accept follow requests
|
|
||||||
- Undo: Unfollow support
|
|
||||||
- HTTP Signature signing and verification
|
|
||||||
- Remote actor caching
|
|
||||||
- Follower inbox distribution (ready for outbound activities)
|
|
||||||
|
|
||||||
### 5. Database & Architecture ✅
|
|
||||||
|
|
||||||
**PostgreSQL + PostGIS**
|
|
||||||
- Users table with indexes
|
|
||||||
- Activities table with geospatial support
|
|
||||||
- Activity metrics (one-to-one)
|
|
||||||
- Follows table for federation
|
|
||||||
- Remote actors cache
|
|
||||||
- Flyway migrations (6 migrations)
|
|
||||||
- GIST index on simplified_track
|
|
||||||
- GIN index on track_points_json
|
|
||||||
|
|
||||||
**Backend Stack**
|
|
||||||
- Java 17+
|
|
||||||
- Spring Boot 4
|
|
||||||
- Spring Security (JWT)
|
|
||||||
- Spring Data JPA
|
|
||||||
- Hibernate Spatial
|
|
||||||
- Maven build system
|
|
||||||
|
|
||||||
**Frontend Stack**
|
|
||||||
- Thymeleaf templates
|
|
||||||
- Bootstrap 5.3.2
|
|
||||||
- Leaflet.js for maps
|
|
||||||
- Chart.js for charts
|
|
||||||
- HTMX for dynamic interactions
|
|
||||||
- Vanilla JavaScript (auth.js, timeline.js, fitpub.js)
|
|
||||||
|
|
||||||
### 6. User Interface ✅
|
|
||||||
|
|
||||||
**Pages Implemented**
|
|
||||||
- Home page (`/`)
|
|
||||||
- Login (`/login`)
|
|
||||||
- Registration (`/register`)
|
|
||||||
- Public timeline (`/timeline`)
|
|
||||||
- Federated timeline (`/timeline/federated`)
|
|
||||||
- User timeline (`/timeline/user`)
|
|
||||||
- My activities (`/activities`)
|
|
||||||
- Activity upload (`/activities/upload`)
|
|
||||||
- Activity detail (`/activities/{id}`)
|
|
||||||
- Activity edit (`/activities/{id}/edit`)
|
|
||||||
- My profile (`/profile`)
|
|
||||||
- Profile edit (`/profile/edit`)
|
|
||||||
- Public user profile (`/users/{username}`)
|
|
||||||
- Settings (`/settings` - placeholder)
|
|
||||||
|
|
||||||
**UI Features**
|
|
||||||
- Responsive mobile design (Bootstrap grid)
|
|
||||||
- Dynamic navigation (shows/hides based on auth)
|
|
||||||
- Loading states and spinners
|
|
||||||
- Empty states with helpful messages
|
|
||||||
- Form validation and error handling
|
|
||||||
- Success/error notifications
|
|
||||||
- Character counters (bio: 500 chars)
|
|
||||||
- Avatar preview on edit
|
|
||||||
- Delete confirmation modals
|
|
||||||
- Pagination controls
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `POST /api/auth/register` - Register new user
|
|
||||||
- `POST /api/auth/login` - Login (returns JWT)
|
|
||||||
|
|
||||||
### Users
|
|
||||||
- `GET /api/users/me` - Get current user (auth required)
|
|
||||||
- `PUT /api/users/me` - Update profile (auth required)
|
|
||||||
- `GET /api/users/{username}` - Get user by username (public)
|
|
||||||
|
|
||||||
### Activities
|
|
||||||
- `POST /api/activities/upload` - Upload FIT file (auth required)
|
|
||||||
- `GET /api/activities` - List user's activities (paginated, auth required)
|
|
||||||
- `GET /api/activities/{id}` - Get activity details (auth required)
|
|
||||||
- `PUT /api/activities/{id}` - Update activity (auth required)
|
|
||||||
- `DELETE /api/activities/{id}` - Delete activity (auth required)
|
|
||||||
- `GET /api/activities/{id}/track` - Get GPS track GeoJSON (public for PUBLIC activities)
|
|
||||||
- `GET /api/activities/user/{username}` - Get user's public activities (public)
|
|
||||||
|
|
||||||
### Timeline
|
|
||||||
- `GET /api/timeline/public` - Public timeline (public)
|
|
||||||
- `GET /api/timeline/federated` - Federated timeline (auth required)
|
|
||||||
- `GET /api/timeline/user` - User timeline (auth required)
|
|
||||||
|
|
||||||
### ActivityPub
|
|
||||||
- `GET /.well-known/webfinger` - WebFinger user discovery
|
|
||||||
- `GET /users/{username}` - Actor profile (ActivityPub JSON-LD)
|
|
||||||
- `POST /users/{username}/inbox` - Receive federated activities
|
|
||||||
- `GET /users/{username}/outbox` - User's outbox collection
|
|
||||||
- `GET /users/{username}/followers` - Followers collection
|
|
||||||
- `GET /users/{username}/following` - Following collection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
**Authentication & Authorization**
|
|
||||||
- JWT tokens with expiration
|
|
||||||
- BCrypt password hashing
|
|
||||||
- HTTP Signatures for ActivityPub
|
|
||||||
- CORS configuration
|
|
||||||
- Protected routes (server + client-side)
|
|
||||||
- Input validation
|
|
||||||
- XSS protection via escaping
|
|
||||||
|
|
||||||
**Access Control**
|
|
||||||
- Public activities visible to all
|
|
||||||
- PRIVATE activities only to owner
|
|
||||||
- FOLLOWERS visibility (structure ready)
|
|
||||||
- Email only shown to own profile
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Works
|
|
||||||
|
|
||||||
✅ **Complete user journey:**
|
|
||||||
1. Register account
|
|
||||||
2. Login with JWT
|
|
||||||
3. Upload FIT file
|
|
||||||
4. View activity on map
|
|
||||||
5. Edit activity details
|
|
||||||
6. Set visibility (public/followers/private)
|
|
||||||
7. View activities on timeline
|
|
||||||
8. Click on user to see their profile
|
|
||||||
9. View user's public activities
|
|
||||||
10. Edit own profile
|
|
||||||
11. Follow/be followed (federation ready)
|
|
||||||
|
|
||||||
✅ **Federation tested:**
|
|
||||||
- ActivityPub actor profiles
|
|
||||||
- WebFinger discovery
|
|
||||||
- Follow requests (inbound)
|
|
||||||
- Accept activities
|
|
||||||
- Undo/unfollow
|
|
||||||
- Remote actor caching
|
|
||||||
|
|
||||||
✅ **All CRUD operations working:**
|
|
||||||
- Users (Create, Read, Update)
|
|
||||||
- Activities (Create, Read, Update, Delete)
|
|
||||||
- Profiles (Read, Update)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Statistics
|
|
||||||
|
|
||||||
**Lines of Code:**
|
|
||||||
- Java: ~8,000 lines
|
|
||||||
- HTML/Thymeleaf: ~2,500 lines
|
|
||||||
- JavaScript: ~2,000 lines
|
|
||||||
- CSS: ~250 lines
|
|
||||||
- SQL (Flyway): ~150 lines
|
|
||||||
|
|
||||||
**Files Created:**
|
|
||||||
- Controllers: 9
|
|
||||||
- Services: 6
|
|
||||||
- Repositories: 5
|
|
||||||
- Entities: 6
|
|
||||||
- DTOs: 10+
|
|
||||||
- Templates: 15
|
|
||||||
- JavaScript modules: 3
|
|
||||||
- Flyway migrations: 6
|
|
||||||
|
|
||||||
**Database Tables:**
|
|
||||||
- users
|
|
||||||
- activities
|
|
||||||
- activity_metrics
|
|
||||||
- follows
|
|
||||||
- remote_actors
|
|
||||||
- flyway_schema_history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Limitations (By Design for MVP)
|
|
||||||
|
|
||||||
1. **Follower/following counts** - UI displays 0 (real counts in Phase 2)
|
|
||||||
2. **Follow button** - Placeholder on public profiles (Phase 2)
|
|
||||||
3. **Likes & comments** - Not implemented (Phase 2)
|
|
||||||
4. **Notifications** - Not implemented (Phase 2)
|
|
||||||
5. **Avatar upload** - URL-based only (file upload in Phase 5)
|
|
||||||
6. **Outbound federation** - Structure ready, not sending Create activities yet (Phase 2)
|
|
||||||
7. **Settings page** - Placeholder with links (Phase 2)
|
|
||||||
8. **Email/password change** - Not implemented (Phase 2)
|
|
||||||
9. **Advanced charts** - HR/pace over time (Phase 2)
|
|
||||||
10. **Error pages** - Using defaults (custom 404/403 in Phase 2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- Java 17+
|
|
||||||
- Maven 3.8+
|
|
||||||
- PostgreSQL 13+ with PostGIS
|
|
||||||
- FIT files from GPS device
|
|
||||||
|
|
||||||
**Quick Start:**
|
|
||||||
1. Configure database in `application-dev.yml`
|
|
||||||
2. Run: `mvn spring-boot:run`
|
|
||||||
3. Navigate to: `http://localhost:8080`
|
|
||||||
4. Register account
|
|
||||||
5. Upload FIT file
|
|
||||||
6. Explore!
|
|
||||||
|
|
||||||
**Test with Federation:**
|
|
||||||
1. Add user in WebFinger format: `user@localhost:8080`
|
|
||||||
2. From Mastodon, search for local user
|
|
||||||
3. Follow the user
|
|
||||||
4. Check followers count (coming in Phase 2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps (Phase 2)
|
|
||||||
|
|
||||||
The MVP is **complete and functional**. Moving forward:
|
|
||||||
|
|
||||||
**Phase 2 priorities:**
|
|
||||||
- Implement likes and comments
|
|
||||||
- Populate follower/following counts with real data
|
|
||||||
- Add follow/unfollow buttons on profiles
|
|
||||||
- Send Create activities to followers when posting
|
|
||||||
- Build notifications system
|
|
||||||
- Enhanced charts (HR/pace over time)
|
|
||||||
- Custom error pages
|
|
||||||
- More complete settings page
|
|
||||||
|
|
||||||
**See CLAUDE.md for full roadmap**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Achievements 🏆
|
|
||||||
|
|
||||||
✅ **Fully functional fitness tracking app**
|
|
||||||
✅ **Complete ActivityPub federation**
|
|
||||||
✅ **Beautiful, responsive UI**
|
|
||||||
✅ **Secure authentication system**
|
|
||||||
✅ **RESTful API**
|
|
||||||
✅ **PostgreSQL + PostGIS integration**
|
|
||||||
✅ **Interactive maps with Leaflet**
|
|
||||||
✅ **Timeline with pagination**
|
|
||||||
✅ **User profiles and settings**
|
|
||||||
✅ **WebFinger discovery**
|
|
||||||
✅ **HTTP Signatures**
|
|
||||||
|
|
||||||
**FitPub Phase 1 (MVP) is COMPLETE and ready for use!** 🎉
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Built with ❤️ using Java, Spring Boot, PostgreSQL, and the Fediverse**
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# FIT/GPX Timestamp Verification Report
|
|
||||||
**Date:** January 3, 2026
|
|
||||||
**Issue:** Batch imported files showing wrong dates
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**✅ TIMESTAMP PARSING IS WORKING CORRECTLY!**
|
|
||||||
|
|
||||||
All timestamp parsing, database persistence, and chronological ordering tests pass successfully.
|
|
||||||
|
|
||||||
## Test Files Analysis
|
|
||||||
|
|
||||||
### FIT File: `69287079d5e0a4532ba818ee.fit`
|
|
||||||
- **Parsed Date:** November 27, 2025 at 15:49:09
|
|
||||||
- **Activity Type:** Walking
|
|
||||||
- **Duration:** 48 minutes 54 seconds
|
|
||||||
- **Distance:** 3,005 meters (~3 km)
|
|
||||||
- **Location:** Near Mainz, Germany (lat: 49.99°, lon: 8.26°)
|
|
||||||
- **Timezone:** Europe/Berlin
|
|
||||||
- **Age:** 36 days ago (RECENT!)
|
|
||||||
|
|
||||||
**Raw FIT Data:**
|
|
||||||
- Raw FIT timestamp: 1,133,189,349 seconds (since FIT epoch 1989-12-31)
|
|
||||||
- Unix timestamp: 1,764,254,949 seconds (after adding 631,065,600 offset)
|
|
||||||
- UTC time: 2025-11-27T14:49:09Z
|
|
||||||
- Local time (Berlin): 2025-11-27T15:49:09
|
|
||||||
|
|
||||||
### GPX File: `7410863774.gpx`
|
|
||||||
- **Parsed Date:** July 3, 2022 at 19:47:51
|
|
||||||
- **Activity Type:** Running
|
|
||||||
- **Duration:** 29 minutes 33 seconds
|
|
||||||
- **Distance:** 4,113 meters (~4.1 km)
|
|
||||||
- **Location:** Near Freiburg, Germany (lat: 48.01°, lon: 7.85°)
|
|
||||||
- **Timezone:** Europe/Berlin
|
|
||||||
- **Age:** 1,279 days ago (3.5 years old)
|
|
||||||
|
|
||||||
**Raw GPX Data:**
|
|
||||||
- Raw XML timestamp: `2022-07-03T19:47:51Z`
|
|
||||||
- Correctly parsed as ISO-8601 format
|
|
||||||
|
|
||||||
## Verification Tests
|
|
||||||
|
|
||||||
### 1. FIT Epoch Offset Verification ✅
|
|
||||||
- **Unix epoch:** 1970-01-01 00:00:00 UTC = 0 seconds
|
|
||||||
- **FIT epoch:** 1989-12-31 00:00:00 UTC = 631,065,600 seconds
|
|
||||||
- **Calculated offset:** 631,065,600 seconds (CORRECT!)
|
|
||||||
- **Offset in years:** 19.997 years
|
|
||||||
|
|
||||||
### 2. Timestamp Parsing Tests ✅
|
|
||||||
- FitParser correctly adds 631,065,600 offset to FIT timestamps
|
|
||||||
- GpxParser correctly parses ISO-8601 timestamps from XML
|
|
||||||
- Both convert to LocalDateTime using Europe/Berlin timezone
|
|
||||||
- Timestamps pass validation (within reasonable date range)
|
|
||||||
|
|
||||||
### 3. Database Persistence Tests ✅
|
|
||||||
All three database round-trip tests passed:
|
|
||||||
|
|
||||||
**FIT File Persistence:**
|
|
||||||
- Parsed: `2025-11-27T15:49:09`
|
|
||||||
- Saved to DB: `2025-11-27T15:49:09`
|
|
||||||
- Queried from DB: `2025-11-27T15:49:09`
|
|
||||||
- **✅ PERFECT MATCH**
|
|
||||||
|
|
||||||
**GPX File Persistence:**
|
|
||||||
- Parsed: `2022-07-03T19:47:51`
|
|
||||||
- Saved to DB: `2022-07-03T19:47:51`
|
|
||||||
- Queried from DB: `2022-07-03T19:47:51`
|
|
||||||
- **✅ PERFECT MATCH**
|
|
||||||
|
|
||||||
**Chronological Ordering:**
|
|
||||||
- Query `ORDER BY started_at DESC` returns newest first
|
|
||||||
- FIT file (2025-11-27) appears before GPX file (2022-07-03)
|
|
||||||
- **✅ CORRECT ORDER**
|
|
||||||
|
|
||||||
### 4. Integration Tests ✅
|
|
||||||
- `FitParserIntegrationTest`: 4 tests passed
|
|
||||||
- `GpxParserIntegrationTest`: 9 tests passed
|
|
||||||
- `DatePersistenceTest`: 3 tests passed
|
|
||||||
- `TimestampDebuggingTest`: 4 tests passed
|
|
||||||
- **Total: 20/20 tests passed**
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
**The timestamp parsing system is 100% correct!**
|
|
||||||
|
|
||||||
### What This Means:
|
|
||||||
|
|
||||||
1. **FIT file timestamps** are correctly converted from FIT epoch (1989-12-31) to Unix time
|
|
||||||
2. **GPX file timestamps** are correctly parsed from ISO-8601 XML format
|
|
||||||
3. **Database persistence** maintains exact timestamp values (no corruption)
|
|
||||||
4. **Chronological ordering** works correctly (newest activities first)
|
|
||||||
5. **Timezone handling** correctly uses Europe/Berlin for local time display
|
|
||||||
|
|
||||||
### About Your Batch Import:
|
|
||||||
|
|
||||||
Your test FIT file IS from November 27, 2025 (recent). If your batch imported files show dates from 2024, there are three possible explanations:
|
|
||||||
|
|
||||||
1. **The files ARE actually from 2024** - Your GPS device/export captured activities that were recorded in 2024. The parsing is showing the correct date!
|
|
||||||
|
|
||||||
2. **Different test file vs batch files** - The test file (`69287079d5e0a4532ba818ee.fit`) is from Nov 2025, but your batch import might have contained different files from 2024.
|
|
||||||
|
|
||||||
3. **Frontend display issue** - The dates are correct in the database, but there might be a timezone conversion issue in the frontend when displaying them.
|
|
||||||
|
|
||||||
### How to Verify Your Data:
|
|
||||||
|
|
||||||
Run this SQL query to check the actual dates in your database:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
started_at,
|
|
||||||
ended_at,
|
|
||||||
timezone,
|
|
||||||
activity_type,
|
|
||||||
created_at
|
|
||||||
FROM activities
|
|
||||||
WHERE user_id = 'YOUR_USER_ID'
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
LIMIT 10;
|
|
||||||
```
|
|
||||||
|
|
||||||
This will show you the actual timestamps stored in the database for your most recent activities.
|
|
||||||
|
|
||||||
## Test Files Location
|
|
||||||
|
|
||||||
- FIT: `src/test/resources/69287079d5e0a4532ba818ee.fit`
|
|
||||||
- GPX: `src/test/resources/7410863774.gpx`
|
|
||||||
|
|
||||||
## Added Test Coverage
|
|
||||||
|
|
||||||
New comprehensive tests created:
|
|
||||||
- `TimestampDebuggingTest.java` - Low-level timestamp conversion debugging
|
|
||||||
- `DatePersistenceTest.java` - Database round-trip verification
|
|
||||||
- Enhanced existing integration tests with date range assertions
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
1. ✅ Timestamp parsing is correct - no code changes needed
|
|
||||||
2. ✅ Database persistence is correct - no schema changes needed
|
|
||||||
3. ⚠️ Verify frontend date display (check for timezone conversion issues)
|
|
||||||
4. ⚠️ Query your actual database to confirm what dates are stored
|
|
||||||
5. ✅ All tests now include date validation to catch future regressions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Report generated:** 2026-01-03
|
|
||||||
**Test suite:** FitPub Activity Date Verification
|
|
||||||
**Status:** ✅ ALL SYSTEMS OPERATIONAL
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script to trigger retroactive indoor activity flag migration
|
|
||||||
# This script calls the admin API endpoint to update existing activities
|
|
||||||
|
|
||||||
echo "🔄 Starting indoor activity flag migration..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if JWT token is provided
|
|
||||||
if [ -z "$JWT_TOKEN" ]; then
|
|
||||||
echo "⚠️ No JWT token provided."
|
|
||||||
echo ""
|
|
||||||
echo "To use this script, you need to provide a valid JWT token:"
|
|
||||||
echo " 1. Login to your FitPub account at http://localhost:8080/login"
|
|
||||||
echo " 2. Open browser developer tools (F12)"
|
|
||||||
echo " 3. Go to Application/Storage -> Local Storage"
|
|
||||||
echo " 4. Copy the value of 'jwt_token'"
|
|
||||||
echo " 5. Run this script with: JWT_TOKEN='your-token-here' ./migrate-indoor-flags.sh"
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Call the migration endpoint
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: Bearer $JWT_TOKEN" \
|
|
||||||
http://localhost:8080/api/admin/migrate-indoor-flags)
|
|
||||||
|
|
||||||
# Extract HTTP status code
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
|
||||||
|
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
|
||||||
echo "✅ Migration successful!"
|
|
||||||
echo ""
|
|
||||||
echo "Response:"
|
|
||||||
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
|
|
||||||
else
|
|
||||||
echo "❌ Migration failed!"
|
|
||||||
echo ""
|
|
||||||
echo "Response:"
|
|
||||||
echo "$BODY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package net.javahippie.fitpub.controller;
|
package net.javahippie.fitpub.controller;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.javahippie.fitpub.model.dto.ActivityDTO;
|
import net.javahippie.fitpub.model.dto.ActivityDTO;
|
||||||
|
|
@ -27,6 +29,9 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -200,25 +205,37 @@ public class ActivityController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all activities for the authenticated user with pagination.
|
* Lists all activities for the authenticated user with pagination and optional filters.
|
||||||
*
|
*
|
||||||
* @param userDetails the authenticated user
|
* @param userDetails the authenticated user
|
||||||
* @param page page number (default: 0)
|
* @param page page number (default: 0)
|
||||||
* @param size page size (default: 10)
|
* @param size page size (default: 10)
|
||||||
|
* @param search optional search text for title/description
|
||||||
|
* @param date optional date filter (formats: dd.mm.yyyy, yyyy-mm-dd, or yyyy)
|
||||||
* @return page of activities
|
* @return page of activities
|
||||||
*/
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<?> getUserActivities(
|
public ResponseEntity<?> getUserActivities(
|
||||||
@AuthenticationPrincipal UserDetails userDetails,
|
@AuthenticationPrincipal UserDetails userDetails,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "10") int size
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) String date
|
||||||
) {
|
) {
|
||||||
log.info("User {} retrieving activities (page: {}, size: {})", userDetails.getUsername(), page, size);
|
log.info("User {} retrieving activities (page: {}, size: {}, search: {}, date: {})",
|
||||||
|
userDetails.getUsername(), page, size, search, date);
|
||||||
|
|
||||||
UUID userId = getUserId(userDetails);
|
UUID userId = getUserId(userDetails);
|
||||||
|
|
||||||
org.springframework.data.domain.Page<Activity> activityPage =
|
// Use search if filters provided, otherwise use standard method
|
||||||
fitFileService.getUserActivitiesPaginated(userId, page, size);
|
org.springframework.data.domain.Page<Activity> activityPage;
|
||||||
|
if (search != null ) {
|
||||||
|
activityPage = fitFileService.searchUserActivities(
|
||||||
|
userId, search, page, size
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
activityPage = fitFileService.getUserActivitiesPaginated(userId, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
|
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
|
||||||
|
|
@ -529,4 +546,62 @@ public class ActivityController {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse date filter string into date range.
|
||||||
|
* Supports formats: dd.mm.yyyy, yyyy-mm-dd, yyyy
|
||||||
|
*
|
||||||
|
* @param dateStr the date string to parse
|
||||||
|
* @return DateRange with start and end times, or null values if invalid/empty
|
||||||
|
*/
|
||||||
|
private DateRange parseDateFilter(String dateStr) {
|
||||||
|
if (dateStr == null || dateStr.trim().isEmpty()) {
|
||||||
|
return new DateRange(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Year only (yyyy)
|
||||||
|
if (dateStr.matches("^\\d{4}$")) {
|
||||||
|
int year = Integer.parseInt(dateStr);
|
||||||
|
LocalDateTime start = LocalDateTime.of(year, 1, 1, 0, 0, 0);
|
||||||
|
LocalDateTime end = LocalDateTime.of(year, 12, 31, 23, 59, 59);
|
||||||
|
return new DateRange(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dd.mm.yyyy format
|
||||||
|
if (dateStr.matches("^\\d{2}\\.\\d{2}\\.\\d{4}$")) {
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
LocalDate date = LocalDate.parse(dateStr, formatter);
|
||||||
|
return new DateRange(
|
||||||
|
date.atStartOfDay(),
|
||||||
|
date.atTime(23, 59, 59)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// yyyy-mm-dd format
|
||||||
|
if (dateStr.matches("^\\d{4}-\\d{2}-\\d{2}$")) {
|
||||||
|
LocalDate date = LocalDate.parse(dateStr);
|
||||||
|
return new DateRange(
|
||||||
|
date.atStartOfDay(),
|
||||||
|
date.atTime(23, 59, 59)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("Invalid date format: {}", dateStr);
|
||||||
|
return new DateRange(null, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error parsing date filter: {}", dateStr, e);
|
||||||
|
return new DateRange(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to hold date range for filtering.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
private static class DateRange {
|
||||||
|
private final LocalDateTime start;
|
||||||
|
private final LocalDateTime end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,25 +48,36 @@ public class TimelineController {
|
||||||
* Get the federated timeline for the authenticated user.
|
* Get the federated timeline for the authenticated user.
|
||||||
* Shows activities from users they follow.
|
* Shows activities from users they follow.
|
||||||
*
|
*
|
||||||
* GET /api/timeline/federated?page=0&size=20
|
* GET /api/timeline/federated?page=0&size=20&search=morning
|
||||||
*
|
*
|
||||||
* @param userDetails the authenticated user details
|
* @param userDetails the authenticated user details
|
||||||
* @param page page number (default: 0)
|
* @param page page number (default: 0)
|
||||||
* @param size page size (default: 20)
|
* @param size page size (default: 20)
|
||||||
|
* @param search optional search text for title/description
|
||||||
* @return page of timeline activities
|
* @return page of timeline activities
|
||||||
*/
|
*/
|
||||||
@GetMapping("/federated")
|
@GetMapping("/federated")
|
||||||
public ResponseEntity<Page<TimelineActivityDTO>> getFederatedTimeline(
|
public ResponseEntity<Page<TimelineActivityDTO>> getFederatedTimeline(
|
||||||
@AuthenticationPrincipal UserDetails userDetails,
|
@AuthenticationPrincipal UserDetails userDetails,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) String search
|
||||||
) {
|
) {
|
||||||
UUID userId = getUserId(userDetails);
|
UUID userId = getUserId(userDetails);
|
||||||
log.debug("Federated timeline request from user: {}", userId);
|
log.debug("Federated timeline request from user: {} (search: {})", userId, search);
|
||||||
|
|
||||||
// Sort by activity start date descending (latest first)
|
// Sort by activity start date descending (latest first)
|
||||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startedAt"));
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startedAt"));
|
||||||
Page<TimelineActivityDTO> timeline = timelineService.getFederatedTimeline(userId, pageable);
|
|
||||||
|
// Use search if filters provided, otherwise use standard timeline
|
||||||
|
Page<TimelineActivityDTO> timeline;
|
||||||
|
if (search != null) {
|
||||||
|
timeline = timelineService.searchFederatedTimeline(
|
||||||
|
userId, search, pageable
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
timeline = timelineService.getFederatedTimeline(userId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(timeline);
|
return ResponseEntity.ok(timeline);
|
||||||
}
|
}
|
||||||
|
|
@ -76,30 +87,41 @@ public class TimelineController {
|
||||||
* Shows all public activities from all users.
|
* Shows all public activities from all users.
|
||||||
* Optionally authenticated - if user is logged in, will show which activities they've liked.
|
* Optionally authenticated - if user is logged in, will show which activities they've liked.
|
||||||
*
|
*
|
||||||
* GET /api/timeline/public?page=0&size=20
|
* GET /api/timeline/public?page=0&size=20&search=morning&date=2024
|
||||||
*
|
*
|
||||||
* @param userDetails the authenticated user details (optional)
|
* @param userDetails the authenticated user details (optional)
|
||||||
* @param page page number (default: 0)
|
* @param page page number (default: 0)
|
||||||
* @param size page size (default: 20)
|
* @param size page size (default: 20)
|
||||||
|
* @param search optional search text for title/description
|
||||||
* @return page of timeline activities
|
* @return page of timeline activities
|
||||||
*/
|
*/
|
||||||
@GetMapping("/public")
|
@GetMapping("/public")
|
||||||
public ResponseEntity<Page<TimelineActivityDTO>> getPublicTimeline(
|
public ResponseEntity<Page<TimelineActivityDTO>> getPublicTimeline(
|
||||||
@AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails,
|
@AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) String search
|
||||||
) {
|
) {
|
||||||
UUID userId = null;
|
UUID userId = null;
|
||||||
if (userDetails != null) {
|
if (userDetails != null) {
|
||||||
userId = getUserId(userDetails);
|
userId = getUserId(userDetails);
|
||||||
log.debug("Public timeline request from authenticated user: {}", userId);
|
log.debug("Public timeline request from authenticated user: {} (search: {})", userId, search);
|
||||||
} else {
|
} else {
|
||||||
log.debug("Public timeline request (unauthenticated)");
|
log.debug("Public timeline request (unauthenticated) (search: {})", search);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by activity start date descending (latest first)
|
// Sort by activity start date descending (latest first)
|
||||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startedAt"));
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startedAt"));
|
||||||
Page<TimelineActivityDTO> timeline = timelineService.getPublicTimeline(userId, pageable);
|
|
||||||
|
// Use search if filters provided, otherwise use standard timeline
|
||||||
|
Page<TimelineActivityDTO> timeline;
|
||||||
|
if (search != null) {
|
||||||
|
timeline = timelineService.searchPublicTimeline(
|
||||||
|
userId, search, pageable
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
timeline = timelineService.getPublicTimeline(userId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(timeline);
|
return ResponseEntity.ok(timeline);
|
||||||
}
|
}
|
||||||
|
|
@ -108,25 +130,36 @@ public class TimelineController {
|
||||||
* Get the user's own timeline.
|
* Get the user's own timeline.
|
||||||
* Shows only activities by the authenticated user.
|
* Shows only activities by the authenticated user.
|
||||||
*
|
*
|
||||||
* GET /api/timeline/user?page=0&size=20
|
* GET /api/timeline/user?page=0&size=20&search=morning
|
||||||
*
|
*
|
||||||
* @param userDetails the authenticated user details
|
* @param userDetails the authenticated user details
|
||||||
* @param page page number (default: 0)
|
* @param page page number (default: 0)
|
||||||
* @param size page size (default: 20)
|
* @param size page size (default: 20)
|
||||||
|
* @param search optional search text for title/description
|
||||||
* @return page of timeline activities
|
* @return page of timeline activities
|
||||||
*/
|
*/
|
||||||
@GetMapping("/user")
|
@GetMapping("/user")
|
||||||
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimeline(
|
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimeline(
|
||||||
@AuthenticationPrincipal UserDetails userDetails,
|
@AuthenticationPrincipal UserDetails userDetails,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) String search
|
||||||
) {
|
) {
|
||||||
UUID userId = getUserId(userDetails);
|
UUID userId = getUserId(userDetails);
|
||||||
log.debug("User timeline request from user: {}", userId);
|
log.debug("User timeline request from user: {} (search: {})", userId, search);
|
||||||
|
|
||||||
// Sort by activity start date descending (latest first)
|
// Sort by activity start date descending (latest first)
|
||||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startedAt"));
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startedAt"));
|
||||||
Page<TimelineActivityDTO> timeline = timelineService.getUserTimeline(userId, pageable);
|
|
||||||
|
// Use search if filters provided, otherwise use standard timeline
|
||||||
|
Page<TimelineActivityDTO> timeline;
|
||||||
|
if (search != null) {
|
||||||
|
timeline = timelineService.searchUserTimeline(
|
||||||
|
userId, search, pageable
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
timeline = timelineService.getUserTimeline(userId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(timeline);
|
return ResponseEntity.ok(timeline);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,4 +331,144 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||||
*/
|
*/
|
||||||
@Query("SELECT a FROM Activity a WHERE a.sourceFileFormat = :sourceFileFormat AND a.rawActivityFile IS NOT NULL")
|
@Query("SELECT a FROM Activity a WHERE a.sourceFileFormat = :sourceFileFormat AND a.rawActivityFile IS NOT NULL")
|
||||||
List<Activity> findBySourceFileFormatAndRawActivityFileNotNull(@Param("sourceFileFormat") String sourceFileFormat);
|
List<Activity> findBySourceFileFormatAndRawActivityFileNotNull(@Param("sourceFileFormat") String sourceFileFormat);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search public timeline with text and date filters.
|
||||||
|
* OPTIMIZED: Single query with JOINs and WHERE conditions for search.
|
||||||
|
*
|
||||||
|
* @param visibility the visibility level
|
||||||
|
* @param searchText search text for title/description (use null to skip)
|
||||||
|
* @param currentUserId the current user ID (for liked status, can be null)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of Object[] results (same structure as findPublicTimelineWithStats)
|
||||||
|
*/
|
||||||
|
@Query(value = """
|
||||||
|
SELECT
|
||||||
|
a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
u.username, u.display_name, u.avatar_url,
|
||||||
|
COUNT(DISTINCT l.id) AS likes_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comments_count,
|
||||||
|
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user
|
||||||
|
FROM activities a
|
||||||
|
INNER JOIN users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN likes l ON a.id = l.activity_id
|
||||||
|
LEFT JOIN comments c ON a.id = c.activity_id AND c.deleted = false
|
||||||
|
LEFT JOIN likes ul ON a.id = ul.activity_id AND ul.user_id = CAST(:currentUserId AS uuid)
|
||||||
|
WHERE a.visibility = :visibility
|
||||||
|
AND (:searchText IS NULL OR (
|
||||||
|
LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
|
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
|
))
|
||||||
|
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
u.username, u.display_name, u.avatar_url, ul.id
|
||||||
|
ORDER BY a.started_at DESC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
Page<Object[]> searchPublicTimeline(@Param("visibility") String visibility,
|
||||||
|
@Param("searchText") String searchText,
|
||||||
|
@Param("currentUserId") UUID currentUserId,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search user timeline with text and date filters.
|
||||||
|
* OPTIMIZED: Single query with JOINs and WHERE conditions for search.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param searchText search text for title/description (use null to skip)
|
||||||
|
* @param currentUserId the current user ID (for liked status, usually same as userId)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of Object[] results (same structure as findPublicTimelineWithStats)
|
||||||
|
*/
|
||||||
|
@Query(value = """
|
||||||
|
SELECT
|
||||||
|
a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
u.username, u.display_name, u.avatar_url,
|
||||||
|
COUNT(DISTINCT l.id) AS likes_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comments_count,
|
||||||
|
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user
|
||||||
|
FROM activities a
|
||||||
|
INNER JOIN users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN likes l ON a.id = l.activity_id
|
||||||
|
LEFT JOIN comments c ON a.id = c.activity_id AND c.deleted = false
|
||||||
|
LEFT JOIN likes ul ON a.id = ul.activity_id AND ul.user_id = CAST(:currentUserId AS uuid)
|
||||||
|
WHERE a.user_id = CAST(:userId AS uuid)
|
||||||
|
AND (:searchText IS NULL OR (
|
||||||
|
LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
|
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
|
))
|
||||||
|
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
u.username, u.display_name, u.avatar_url, ul.id
|
||||||
|
ORDER BY a.started_at DESC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
Page<Object[]> searchUserTimeline(@Param("userId") UUID userId,
|
||||||
|
@Param("searchText") String searchText,
|
||||||
|
@Param("currentUserId") UUID currentUserId,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search federated timeline with text and date filters.
|
||||||
|
* OPTIMIZED: Single query with JOINs and WHERE conditions for search.
|
||||||
|
*
|
||||||
|
* @param userIds list of user IDs to include
|
||||||
|
* @param visibilities list of visibility levels
|
||||||
|
* @param searchText search text for title/description (use null to skip)
|
||||||
|
* @param currentUserId the current user ID (for liked status)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of Object[] results (same structure as findPublicTimelineWithStats)
|
||||||
|
*/
|
||||||
|
@Query(value = """
|
||||||
|
SELECT
|
||||||
|
a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
u.username, u.display_name, u.avatar_url,
|
||||||
|
COUNT(DISTINCT l.id) AS likes_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comments_count,
|
||||||
|
CASE WHEN ul.id IS NOT NULL THEN true ELSE false END AS liked_by_current_user
|
||||||
|
FROM activities a
|
||||||
|
INNER JOIN users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN likes l ON a.id = l.activity_id
|
||||||
|
LEFT JOIN comments c ON a.id = c.activity_id AND c.deleted = false
|
||||||
|
LEFT JOIN likes ul ON a.id = ul.activity_id AND ul.user_id = CAST(:currentUserId AS uuid)
|
||||||
|
WHERE a.user_id IN (:userIds)
|
||||||
|
AND a.visibility IN (:visibilities)
|
||||||
|
AND (:searchText IS NULL OR (
|
||||||
|
LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
|
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
|
))
|
||||||
|
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
u.username, u.display_name, u.avatar_url, ul.id
|
||||||
|
ORDER BY a.started_at DESC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
Page<Object[]> searchFederatedTimeline(@Param("userIds") List<UUID> userIds,
|
||||||
|
@Param("visibilities") List<String> visibilities,
|
||||||
|
@Param("searchText") String searchText,
|
||||||
|
@Param("currentUserId") UUID currentUserId,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search user's own activities with text and date filters.
|
||||||
|
* Simpler JPQL query for "My Activities" page.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param searchText search text for title/description (use null to skip)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of activities
|
||||||
|
*/
|
||||||
|
@Query("SELECT a FROM Activity a WHERE a.userId = :userId " +
|
||||||
|
"AND (:searchText IS NULL OR " +
|
||||||
|
" LOWER(a.title) LIKE LOWER(CONCAT('%', :searchText, '%')) OR " +
|
||||||
|
" LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%')))")
|
||||||
|
Page<Activity> searchByUserIdAndFilters(@Param("userId") UUID userId,
|
||||||
|
@Param("searchText") String searchText,
|
||||||
|
Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -388,6 +389,41 @@ public class FitFileService {
|
||||||
return activityRepository.findByUserIdAndVisibilityOrderByStartedAtDesc(userId, Activity.Visibility.PUBLIC, pageable);
|
return activityRepository.findByUserIdAndVisibilityOrderByStartedAtDesc(userId, Activity.Visibility.PUBLIC, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search user's activities with text and date filters.
|
||||||
|
* Used for "My Activities" page with search functionality.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param searchText text to search in title and description (null to skip)
|
||||||
|
* @param startOfDay start of date range (null to skip)
|
||||||
|
* @param endOfDay end of date range (null to skip)
|
||||||
|
* @param page page number (0-indexed)
|
||||||
|
* @param size page size
|
||||||
|
* @return page of activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public org.springframework.data.domain.Page<Activity> searchUserActivities(
|
||||||
|
UUID userId,
|
||||||
|
String searchText,
|
||||||
|
int page,
|
||||||
|
int size
|
||||||
|
) {
|
||||||
|
org.springframework.data.domain.Pageable pageable =
|
||||||
|
org.springframework.data.domain.PageRequest.of(page, size, org.springframework.data.domain.Sort.by("startedAt").descending());
|
||||||
|
|
||||||
|
// If no filters, use existing optimized method
|
||||||
|
if (searchText == null ) {
|
||||||
|
return getUserActivitiesPaginated(userId, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use search query with filters
|
||||||
|
return activityRepository.searchByUserIdAndFilters(
|
||||||
|
userId,
|
||||||
|
searchText,
|
||||||
|
pageable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing activity's metadata.
|
* Update an existing activity's metadata.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -202,6 +203,134 @@ public class TimelineService {
|
||||||
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
|
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search public timeline with text and date filters.
|
||||||
|
* OPTIMIZED: Uses single query with JOINs to fetch all data
|
||||||
|
*
|
||||||
|
* @param userId optional user ID for checking liked status (null for unauthenticated)
|
||||||
|
* @param searchText text to search in title and description (null to skip)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<TimelineActivityDTO> searchPublicTimeline(
|
||||||
|
UUID userId,
|
||||||
|
String searchText,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
log.debug("Searching public timeline (userId: {}, search: {})",
|
||||||
|
userId, searchText);
|
||||||
|
|
||||||
|
// Create unsorted Pageable since ORDER BY is already in the native query
|
||||||
|
Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
|
||||||
|
|
||||||
|
// Use optimized search query with JOINs and WHERE conditions
|
||||||
|
Page<Object[]> results = activityRepository.searchPublicTimeline(
|
||||||
|
Activity.Visibility.PUBLIC.name(),
|
||||||
|
searchText,
|
||||||
|
userId,
|
||||||
|
unsortedPageable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map results using TimelineResultMapper
|
||||||
|
List<TimelineActivityDTO> timelineActivities = results.getContent().stream()
|
||||||
|
.map(timelineResultMapper::mapToTimelineActivityDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
log.debug("Found {} activities matching search criteria", timelineActivities.size());
|
||||||
|
|
||||||
|
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search user's own timeline with text and date filters.
|
||||||
|
* OPTIMIZED: Uses single query with JOINs to fetch all data
|
||||||
|
*
|
||||||
|
* @param userId the user's ID
|
||||||
|
* @param searchText text to search in title and description (null to skip)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<TimelineActivityDTO> searchUserTimeline(
|
||||||
|
UUID userId,
|
||||||
|
String searchText,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
log.debug("Searching user timeline (userId: {}, search: {})",
|
||||||
|
userId, searchText);
|
||||||
|
|
||||||
|
// Create unsorted Pageable since ORDER BY is already in the native query
|
||||||
|
Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
|
||||||
|
|
||||||
|
// Use optimized search query with JOINs and WHERE conditions
|
||||||
|
Page<Object[]> results = activityRepository.searchUserTimeline(
|
||||||
|
userId,
|
||||||
|
searchText,
|
||||||
|
userId, // currentUserId same as userId for user's own timeline
|
||||||
|
unsortedPageable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map results using TimelineResultMapper
|
||||||
|
List<TimelineActivityDTO> timelineActivities = results.getContent().stream()
|
||||||
|
.map(timelineResultMapper::mapToTimelineActivityDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
log.debug("Found {} activities matching search criteria", timelineActivities.size());
|
||||||
|
|
||||||
|
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search federated timeline with text and date filters.
|
||||||
|
* Includes activities from followed users that match the search criteria.
|
||||||
|
*
|
||||||
|
* NOTE: This is a simplified implementation that searches local activities only.
|
||||||
|
* Remote activities are not included in search results.
|
||||||
|
*
|
||||||
|
* @param userId the authenticated user's ID
|
||||||
|
* @param searchText text to search in title and description (null to skip)
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<TimelineActivityDTO> searchFederatedTimeline(
|
||||||
|
UUID userId,
|
||||||
|
String searchText,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
log.debug("Searching federated timeline (userId: {}, search: {})",
|
||||||
|
userId, searchText);
|
||||||
|
|
||||||
|
User currentUser = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||||
|
|
||||||
|
// Get followed local user IDs
|
||||||
|
List<UUID> followedUserIds = getFollowedLocalUserIds(userId);
|
||||||
|
followedUserIds.add(userId); // Include the current user's own activities
|
||||||
|
|
||||||
|
// Create unsorted Pageable since ORDER BY is already in the native query
|
||||||
|
Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
|
||||||
|
|
||||||
|
// Use optimized search query with JOINs and WHERE conditions
|
||||||
|
Page<Object[]> results = activityRepository.searchFederatedTimeline(
|
||||||
|
followedUserIds,
|
||||||
|
List.of(Activity.Visibility.PUBLIC.name(), Activity.Visibility.FOLLOWERS.name()),
|
||||||
|
searchText,
|
||||||
|
userId,
|
||||||
|
unsortedPageable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map results using TimelineResultMapper
|
||||||
|
List<TimelineActivityDTO> timelineActivities = results.getContent().stream()
|
||||||
|
.map(timelineResultMapper::mapToTimelineActivityDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
log.debug("Found {} activities matching search criteria", timelineActivities.size());
|
||||||
|
|
||||||
|
return new PageImpl<>(timelineActivities, pageable, results.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get IDs of local users that the given user follows.
|
* Get IDs of local users that the given user follows.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ const FitPubTimeline = {
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
timelineType: 'public',
|
timelineType: 'public',
|
||||||
|
searchText: '',
|
||||||
|
dateFilter: '',
|
||||||
|
searchDebounceTimer: null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the timeline
|
* Initialize the timeline
|
||||||
|
|
@ -14,6 +17,7 @@ const FitPubTimeline = {
|
||||||
*/
|
*/
|
||||||
init: function(type) {
|
init: function(type) {
|
||||||
this.timelineType = type;
|
this.timelineType = type;
|
||||||
|
this.setupSearchHandlers();
|
||||||
this.loadTimeline(0);
|
this.loadTimeline(0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -59,6 +63,19 @@ const FitPubTimeline = {
|
||||||
throw new Error('Invalid timeline type');
|
throw new Error('Invalid timeline type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append search parameters if present
|
||||||
|
if (this.searchText) {
|
||||||
|
endpoint += `&search=${encodeURIComponent(this.searchText)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dateFilter) {
|
||||||
|
// Only add if valid format
|
||||||
|
const validation = this.validateDateFormat(this.dateFilter);
|
||||||
|
if (validation.valid) {
|
||||||
|
endpoint += `&date=${encodeURIComponent(this.dateFilter)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch timeline data
|
// Fetch timeline data
|
||||||
const response = fetchOptions.useAuth
|
const response = fetchOptions.useAuth
|
||||||
? await FitPubAuth.authenticatedFetch(endpoint)
|
? await FitPubAuth.authenticatedFetch(endpoint)
|
||||||
|
|
@ -76,7 +93,7 @@ const FitPubTimeline = {
|
||||||
timelineList.classList.remove('d-none');
|
timelineList.classList.remove('d-none');
|
||||||
pagination.classList.remove('d-none');
|
pagination.classList.remove('d-none');
|
||||||
} else {
|
} else {
|
||||||
emptyState.classList.remove('d-none');
|
this.showEmptyState(emptyState);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.totalPages = data.totalPages;
|
this.totalPages = data.totalPages;
|
||||||
|
|
@ -362,10 +379,10 @@ const FitPubTimeline = {
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
const map = L.map(mapId, {
|
const map = L.map(mapId, {
|
||||||
zoomControl: true,
|
zoomControl: false,
|
||||||
scrollWheelZoom: false,
|
scrollWheelZoom: false,
|
||||||
dragging: true,
|
dragging: false,
|
||||||
touchZoom: true
|
touchZoom: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add tile layer
|
// Add tile layer
|
||||||
|
|
@ -604,5 +621,129 @@ const FitPubTimeline = {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
element.style.backgroundColor = '#f8f9fa';
|
element.style.backgroundColor = '#f8f9fa';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup search input handlers with debounce
|
||||||
|
*/
|
||||||
|
setupSearchHandlers: function() {
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const clearBtn = document.getElementById('clearSearchBtn');
|
||||||
|
const searchHint = document.getElementById('searchHint');
|
||||||
|
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
// Text search with 300ms debounce
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
this.searchText = e.target.value.trim();
|
||||||
|
this.debouncedSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
searchInput.value = '';
|
||||||
|
this.searchText = '';
|
||||||
|
searchHint.textContent = '';
|
||||||
|
this.loadTimeline(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate date format and provide feedback
|
||||||
|
* @param {string} dateStr - Date string to validate
|
||||||
|
* @returns {Object} Validation result with valid flag and message
|
||||||
|
*/
|
||||||
|
validateDateFormat: function(dateStr) {
|
||||||
|
// Year only (yyyy)
|
||||||
|
if (/^\d{4}$/.test(dateStr)) {
|
||||||
|
const year = parseInt(dateStr);
|
||||||
|
if (year >= 1900 && year <= 2100) {
|
||||||
|
return { valid: true, message: `Searching all activities in ${year}` };
|
||||||
|
}
|
||||||
|
return { valid: false, message: 'Invalid year (must be 1900-2100)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// dd.mm.yyyy format
|
||||||
|
if (/^\d{2}\.\d{2}\.\d{4}$/.test(dateStr)) {
|
||||||
|
const [day, month, year] = dateStr.split('.').map(Number);
|
||||||
|
if (this.isValidDate(year, month, day)) {
|
||||||
|
return { valid: true, message: `Searching activities on ${dateStr}` };
|
||||||
|
}
|
||||||
|
return { valid: false, message: 'Invalid date' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// yyyy-mm-dd format
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
if (this.isValidDate(year, month, day)) {
|
||||||
|
return { valid: true, message: `Searching activities on ${dateStr}` };
|
||||||
|
}
|
||||||
|
return { valid: false, message: 'Invalid date' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial input - don't show error yet
|
||||||
|
if (/^\d{1,4}$/.test(dateStr) || /^\d{2}\.\d{0,2}/.test(dateStr) || /^\d{4}-\d{0,2}/.test(dateStr)) {
|
||||||
|
return { valid: false, message: 'Enter full date: dd.mm.yyyy, yyyy-mm-dd, or yyyy' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, message: 'Use format: dd.mm.yyyy, yyyy-mm-dd, or yyyy' };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is valid
|
||||||
|
* @param {number} year - Year
|
||||||
|
* @param {number} month - Month (1-12)
|
||||||
|
* @param {number} day - Day (1-31)
|
||||||
|
* @returns {boolean} True if valid date
|
||||||
|
*/
|
||||||
|
isValidDate: function(year, month, day) {
|
||||||
|
if (month < 1 || month > 12) return false;
|
||||||
|
if (day < 1 || day > 31) return false;
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return date.getFullYear() === year &&
|
||||||
|
date.getMonth() === month - 1 &&
|
||||||
|
date.getDate() === day;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced search with 300ms delay
|
||||||
|
*/
|
||||||
|
debouncedSearch: function() {
|
||||||
|
clearTimeout(this.searchDebounceTimer);
|
||||||
|
|
||||||
|
// Show loading hint
|
||||||
|
const searchHint = document.getElementById('searchHint');
|
||||||
|
if ((this.searchText || this.dateFilter) && searchHint && !searchHint.classList.contains('text-danger')) {
|
||||||
|
searchHint.textContent = 'Searching...';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchDebounceTimer = setTimeout(() => {
|
||||||
|
this.currentPage = 0; // Reset to first page
|
||||||
|
this.loadTimeline(0)
|
||||||
|
.then(i => searchHint.textContent = '');
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show appropriate empty state based on search
|
||||||
|
* @param {HTMLElement} emptyState - Empty state element
|
||||||
|
*/
|
||||||
|
showEmptyState: function(emptyState) {
|
||||||
|
const emptyTitle = emptyState.querySelector('h4');
|
||||||
|
const emptyMessage = emptyState.querySelector('p.text-muted');
|
||||||
|
|
||||||
|
if (this.searchText || this.dateFilter) {
|
||||||
|
if (emptyTitle) emptyTitle.textContent = 'No Activities Found';
|
||||||
|
if (emptyMessage) emptyMessage.textContent = 'Try adjusting your search filters or date range.';
|
||||||
|
} else {
|
||||||
|
// Original empty state messages
|
||||||
|
if (emptyTitle) emptyTitle.textContent = 'No Activities Yet';
|
||||||
|
if (emptyMessage) emptyMessage.textContent = 'Be the first to share your fitness activities with the community!';
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -605,10 +605,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render map or indoor placeholder
|
// Render map or indoor placeholder
|
||||||
if (hasGpsTrack && activity.simplifiedTrack) {
|
if (hasGpsTrack && activity.trackPoints.length > 0) {
|
||||||
document.getElementById('mapSection').style.display = 'block';
|
document.getElementById('mapSection').style.display = 'block';
|
||||||
document.getElementById('indoorPlaceholder').style.display = 'none';
|
document.getElementById('indoorPlaceholder').style.display = 'none';
|
||||||
renderMap(activity.simplifiedTrack, activity);
|
renderMap(activity.trackPoints, activity);
|
||||||
} else {
|
} else {
|
||||||
// Show indoor activity placeholder
|
// Show indoor activity placeholder
|
||||||
document.getElementById('mapSection').style.display = 'none';
|
document.getElementById('mapSection').style.display = 'none';
|
||||||
|
|
@ -749,11 +749,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMap(simplifiedTrack, activity) {
|
function flattenTrackPoints(trackPoints) {
|
||||||
|
return trackPoints.map(coordinates => ([coordinates.longitude, coordinates.latitude]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap(trackPoints, activity) {
|
||||||
// Parse GeoJSON from simplifiedTrack
|
// Parse GeoJSON from simplifiedTrack
|
||||||
const geoJson = {
|
const geoJson = {
|
||||||
type: 'LineString',
|
type: 'LineString',
|
||||||
coordinates: simplifiedTrack.coordinates
|
coordinates: flattenTrackPoints(trackPoints)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create map (needs to be done after container is visible)
|
// Create map (needs to be done after container is visible)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,34 @@
|
||||||
Activities from athletes you follow
|
Activities from athletes you follow
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="card mb-4" id="searchCard">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="searchInput"
|
||||||
|
placeholder="Search activities by title or description..."
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted mt-1 d-block" id="searchHint"></small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary w-100" id="clearSearchBtn">
|
||||||
|
<i class="bi bi-x-circle"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading Indicator -->
|
<!-- Loading Indicator -->
|
||||||
<div id="loadingIndicator" class="text-center py-5">
|
<div id="loadingIndicator" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,34 @@
|
||||||
Discover public fitness activities from the FitPub community
|
Discover public fitness activities from the FitPub community
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="card mb-4" id="searchCard">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="searchInput"
|
||||||
|
placeholder="Search activities by title or description..."
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted mt-1 d-block" id="searchHint"></small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary w-100" id="clearSearchBtn">
|
||||||
|
<i class="bi bi-x-circle"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading Indicator -->
|
<!-- Loading Indicator -->
|
||||||
<div id="loadingIndicator" class="text-center py-5">
|
<div id="loadingIndicator" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,34 @@
|
||||||
Your fitness activities
|
Your fitness activities
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="card mb-4" id="searchCard">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="searchInput"
|
||||||
|
placeholder="Search activities by title or description..."
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted mt-1 d-block" id="searchHint"></small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary w-100" id="clearSearchBtn">
|
||||||
|
<i class="bi bi-x-circle"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading Indicator -->
|
<!-- Loading Indicator -->
|
||||||
<div id="loadingIndicator" class="text-center py-5">
|
<div id="loadingIndicator" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue