Mark indoor activities to exclude them from the heatmap

This commit is contained in:
Tim Zöller 2026-01-11 11:56:48 +01:00
parent 851ba87ef2
commit 22c4ca0964
34 changed files with 1626 additions and 58 deletions

View file

@ -152,6 +152,9 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/users/*/follow").authenticated() // Follow user
.requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user
// Protected endpoints - Admin API (data migration, maintenance)
.requestMatchers("/api/admin/**").authenticated()
// All other requests require authentication
.anyRequest().authenticated()
)

View file

@ -0,0 +1,45 @@
package org.operaton.fitpub.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.service.IndoorActivityMigrationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Admin endpoints for data migration and maintenance tasks.
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Slf4j
public class AdminController {
private final IndoorActivityMigrationService indoorActivityMigrationService;
/**
* Retroactively detect and update indoor activity flags for existing activities.
* Re-parses all FIT files to detect indoor activities based on SubSport field.
*
* This is a one-time migration endpoint to update existing data.
*
* @return number of activities updated
*/
@PostMapping("/migrate-indoor-flags")
public ResponseEntity<Map<String, Object>> migrateIndoorFlags() {
log.info("Admin: Starting indoor activity flag migration");
int updatedCount = indoorActivityMigrationService.updateIndoorFlagsForExistingActivities();
log.info("Admin: Indoor activity flag migration complete - {} activities updated", updatedCount);
return ResponseEntity.ok(Map.of(
"message", "Indoor activity flag migration complete",
"activitiesUpdated", updatedCount
));
}
}

View file

@ -30,6 +30,9 @@ public class AuthController {
@Value("${fitpub.registration.enabled:true}")
private boolean registrationEnabled;
@Value("${fitpub.registration.password:#{null}}")
private String configuredRegistrationPassword;
/**
* Register a new user account.
*
@ -48,6 +51,24 @@ public class AuthController {
.body(null);
}
// Check registration password if configured
// Check for both null and blank (empty or whitespace-only strings)
log.debug("Registration password check - configured: '{}', provided: '{}'",
configuredRegistrationPassword, request.getRegistrationPassword());
if (configuredRegistrationPassword != null && !configuredRegistrationPassword.trim().isEmpty()) {
String providedPassword = request.getRegistrationPassword();
if (providedPassword == null || providedPassword.trim().isEmpty() ||
!configuredRegistrationPassword.equals(providedPassword)) {
log.warn("Registration attempt with invalid registration password for username: {} (expected: '{}', got: '{}')",
request.getUsername(), configuredRegistrationPassword, providedPassword);
throw new IllegalArgumentException("Invalid registration password");
}
log.info("Registration password validated successfully for username: {}", request.getUsername());
} else {
log.info("No registration password configured - allowing open registration for username: {}", request.getUsername());
}
log.info("Registration request received for username: {}", request.getUsername());
try {

View file

@ -500,9 +500,9 @@ public class UserController {
}
/**
* Unfollow a user.
* Unfollow a user (local or remote).
*
* @param username the username to unfollow
* @param username the username to unfollow (local username or @username@domain format)
* @param userDetails the authenticated user
* @return success response
*/
@ -517,11 +517,25 @@ public class UserController {
User currentUser = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("Current user not found"));
// Get the user to unfollow
User userToUnfollow = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
String followingActorUri;
boolean isRemoteUser = username.contains("@") && username.indexOf("@") > 0;
String followingActorUri = userToUnfollow.getActorUri(baseUrl);
if (isRemoteUser) {
// Remote user - discover actor URI via WebFinger
try {
followingActorUri = webFingerClient.discoverActor(username);
log.debug("Resolved remote user {} to actor URI: {}", username, followingActorUri);
} catch (Exception e) {
log.error("Failed to discover remote actor: {}", username, e);
return ResponseEntity.status(404)
.body(Map.of("error", "Could not find remote user: " + username));
}
} else {
// Local user
User userToUnfollow = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
followingActorUri = userToUnfollow.getActorUri(baseUrl);
}
// Find the follow relationship
Optional<Follow> follow = followRepository.findByFollowerIdAndFollowingActorUri(
@ -533,7 +547,22 @@ public class UserController {
.body(Map.of("error", "Not following this user"));
}
// Delete the follow relationship
// Send Undo Follow activity to remote server (mandatory for proper federation)
if (isRemoteUser) {
try {
federationService.sendUndoFollowActivity(
followingActorUri,
currentUser,
follow.get().getActivityId()
);
log.info("Sent Undo Follow activity to remote server for {}", username);
} catch (Exception e) {
log.warn("Failed to send Undo Follow activity to {}, but continuing with local deletion", username, e);
// Continue with local deletion even if federation fails
}
}
// Delete the local follow relationship
followRepository.delete(follow.get());
log.info("Deleted follow: {} -> {}", currentUser.getUsername(), username);

View file

@ -38,4 +38,10 @@ public class RegisterRequest {
@Size(max = 500, message = "Bio must not exceed 500 characters")
private String bio;
/**
* Optional registration password (invite code) for controlled access.
* Only required if configured in fitpub.registration.password property.
*/
private String registrationPassword;
}

View file

@ -58,6 +58,11 @@ public class TimelineActivityDTO {
// GPS track availability
private Boolean hasGpsTrack; // True if activity has GPS data
// Indoor activity flag
private Boolean indoor; // True if activity was performed indoors
private String subSport; // SubSport field from FIT file (e.g., INDOOR_CYCLING, TREADMILL)
private String indoorDetectionMethod; // How indoor flag was determined
// Metrics summary
private ActivityMetricsSummary metrics;
@ -85,6 +90,9 @@ public class TimelineActivityDTO {
.avatarUrl(avatarUrl)
.isLocal(true)
.hasGpsTrack(activity.getSimplifiedTrack() != null)
.indoor(activity.getIndoor() != null ? activity.getIndoor() : false)
.subSport(activity.getSubSport())
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
.build();
}

View file

@ -107,6 +107,29 @@ public class Activity {
@Column(name = "source_file_format", nullable = false, length = 10)
private String sourceFileFormat;
/**
* Indicates if this is an indoor activity (e.g., virtual rides, indoor trainer sessions).
* Indoor activities are displayed in timeline but excluded from heatmap generation.
*/
@Column(name = "indoor", nullable = false)
@Builder.Default
private Boolean indoor = false;
/**
* SubSport from FIT file (e.g., INDOOR_CYCLING, TREADMILL, ROAD, MOUNTAIN, TRAIL).
* NULL for GPX files or if not available.
*/
@Column(name = "sub_sport", length = 50)
private String subSport;
/**
* Method used to determine the indoor flag.
* Values: FIT_SUBSPORT, GPX_EXTENSION, HEURISTIC_NO_GPS, HEURISTIC_STATIONARY, MANUAL
* NULL for legacy activities uploaded before this feature.
*/
@Column(name = "indoor_detection_method", length = 20)
private String indoorDetectionMethod;
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
private ActivityMetrics metrics;
@ -160,4 +183,20 @@ public class Activity {
FOLLOWERS,
PRIVATE
}
/**
* Methods for detecting indoor activities
*/
public enum IndoorDetectionMethod {
/** Detected from FIT file SubSport field (most accurate) */
FIT_SUBSPORT,
/** Detected from GPX file extension fields */
GPX_EXTENSION,
/** Heuristic: No GPS track data present */
HEURISTIC_NO_GPS,
/** Heuristic: GPS track exists but all points are stationary (within 50m radius) */
HEURISTIC_STATIONARY,
/** Manually set by user */
MANUAL
}
}

View file

@ -321,4 +321,14 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
@Param("visibilities") List<String> visibilities,
@Param("currentUserId") UUID currentUserId,
Pageable pageable);
/**
* Find activities by source file format where raw activity file is present.
* Used for retroactive data migration.
*
* @param sourceFileFormat the file format (e.g., "FIT", "GPX")
* @return list of activities with raw files
*/
@Query("SELECT a FROM Activity a WHERE a.sourceFileFormat = :sourceFileFormat AND a.rawActivityFile IS NOT NULL")
List<Activity> findBySourceFileFormatAndRawActivityFileNotNull(@Param("sourceFileFormat") String sourceFileFormat);
}

View file

@ -107,6 +107,7 @@ public interface UserHeatmapGridRepository extends JpaRepository<UserHeatmapGrid
CROSS JOIN LATERAL jsonb_array_elements(CAST(a.track_points_json AS jsonb)) AS point
WHERE a.id = :activityId
AND a.track_points_json IS NOT NULL
AND a.indoor = FALSE
),
snapped_grid AS (
SELECT
@ -149,6 +150,7 @@ public interface UserHeatmapGridRepository extends JpaRepository<UserHeatmapGrid
CROSS JOIN LATERAL jsonb_array_elements(CAST(a.track_points_json AS jsonb)) AS point
WHERE a.user_id = :userId
AND a.track_points_json IS NOT NULL
AND a.indoor = FALSE
),
snapped_grid AS (
SELECT

View file

@ -261,6 +261,10 @@ public class ActivityFileService {
.elevationLoss(parsedData.getElevationLoss())
.rawActivityFile(rawFile)
.sourceFileFormat(parsedData.getSourceFormat())
.indoor(parsedData.getIndoor() != null ? parsedData.getIndoor() : false)
.subSport(parsedData.getSubSport())
.indoorDetectionMethod(parsedData.getIndoorDetectionMethod() != null ?
parsedData.getIndoorDetectionMethod().name() : null)
.build();
// Convert track points to JSONB

View file

@ -365,6 +365,51 @@ public class FederationService {
}
}
/**
* Send Undo Follow activity to remote actor's inbox.
* This notifies the remote server that we're unfollowing them.
*
* @param remoteActorUri the actor URI being unfollowed
* @param localUser the local user who is unfollowing
* @param originalFollowActivityId the ID of the original Follow activity
*/
public void sendUndoFollowActivity(String remoteActorUri, User localUser, String originalFollowActivityId) {
try {
log.info("Sending Undo Follow activity from {} to {}", localUser.getUsername(), remoteActorUri);
// 1. Fetch remote actor to get inbox URL
RemoteActor remoteActor = fetchRemoteActor(remoteActorUri);
// 2. Reconstruct the original Follow activity
String actorUri = baseUrl + "/users/" + localUser.getUsername();
Map<String, Object> followActivity = new HashMap<>();
followActivity.put("@context", "https://www.w3.org/ns/activitystreams");
followActivity.put("type", "Follow");
followActivity.put("id", originalFollowActivityId);
followActivity.put("actor", actorUri);
followActivity.put("object", remoteActorUri);
// 3. Create Undo activity wrapping the Follow
String undoId = baseUrl + "/activities/undo/" + UUID.randomUUID();
Map<String, Object> undoActivity = new HashMap<>();
undoActivity.put("@context", "https://www.w3.org/ns/activitystreams");
undoActivity.put("type", "Undo");
undoActivity.put("id", undoId);
undoActivity.put("actor", actorUri);
undoActivity.put("object", followActivity);
undoActivity.put("published", Instant.now().toString());
// 4. Send to remote actor's inbox (HTTP-signed)
sendActivity(remoteActor.getInboxUrl(), undoActivity, localUser);
log.info("Undo Follow activity sent successfully: {} -> {}", localUser.getUsername(), remoteActorUri);
} catch (Exception e) {
log.error("Failed to send Undo Follow activity from {} to {}", localUser.getUsername(), remoteActorUri, e);
// Don't throw exception - we still want to delete the local follow even if federation fails
}
}
/**
* Send an Undo activity (for unlike, unfollow, etc.).
*

View file

@ -0,0 +1,161 @@
package org.operaton.fitpub.service;
import com.garmin.fit.Decode;
import com.garmin.fit.MesgBroadcaster;
import com.garmin.fit.SessionMesg;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.repository.ActivityRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Service for retroactively detecting and updating indoor activity flags.
* This is a data migration service to update existing activities in the database.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class IndoorActivityMigrationService {
private final ActivityRepository activityRepository;
/**
* Retroactively update indoor flags for all existing FIT activities.
* Re-parses stored FIT files to detect indoor activities based on SubSport field.
*
* @return number of activities updated
*/
@Transactional
public int updateIndoorFlagsForExistingActivities() {
log.info("Starting retroactive indoor activity detection for all FIT activities");
// Find all activities with FIT files
List<Activity> fitActivities = activityRepository.findBySourceFileFormatAndRawActivityFileNotNull("FIT");
log.info("Found {} FIT activities to analyze", fitActivities.size());
AtomicInteger updatedCount = new AtomicInteger(0);
AtomicInteger errorCount = new AtomicInteger(0);
fitActivities.forEach(activity -> {
try {
IndoorDetectionResult result = detectIndoorFromFitFile(activity.getRawActivityFile());
boolean changed = false;
if (result.isIndoor() != activity.getIndoor()) {
activity.setIndoor(result.isIndoor());
changed = true;
}
if (result.getSubSport() != null && !result.getSubSport().equals(activity.getSubSport())) {
activity.setSubSport(result.getSubSport());
changed = true;
}
if (result.getDetectionMethod() != null &&
!result.getDetectionMethod().name().equals(activity.getIndoorDetectionMethod())) {
activity.setIndoorDetectionMethod(result.getDetectionMethod().name());
changed = true;
}
if (changed) {
activityRepository.save(activity);
updatedCount.incrementAndGet();
log.info("Updated activity {} - indoor: {}, subSport: {}, method: {}",
activity.getId(), result.isIndoor(), result.getSubSport(),
result.getDetectionMethod());
}
} catch (Exception e) {
errorCount.incrementAndGet();
log.warn("Failed to process activity {}: {}", activity.getId(), e.getMessage());
}
});
log.info("Retroactive indoor detection complete: {} activities updated, {} errors",
updatedCount.get(), errorCount.get());
return updatedCount.get();
}
/**
* Detect if a FIT file represents an indoor activity.
* Checks the SubSport field from the session message.
*
* @param fitFileBytes raw FIT file bytes
* @return detection result with indoor flag, SubSport, and detection method
*/
private IndoorDetectionResult detectIndoorFromFitFile(byte[] fitFileBytes) {
IndoorDetectionResult result = new IndoorDetectionResult();
result.setIndoor(false);
if (fitFileBytes == null || fitFileBytes.length == 0) {
return result;
}
AtomicBoolean isIndoor = new AtomicBoolean(false);
AtomicReference<String> subSport = new AtomicReference<>(null);
AtomicReference<Activity.IndoorDetectionMethod> method = new AtomicReference<>(null);
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(fitFileBytes)) {
Decode decode = new Decode();
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
// Listen for session messages to extract SubSport
broadcaster.addListener((SessionMesg session) -> {
if (session.getSubSport() != null) {
String subSportStr = session.getSubSport().toString();
subSport.set(subSportStr);
String subSportUpper = subSportStr.toUpperCase();
boolean detected = subSportUpper.contains("INDOOR") ||
subSportUpper.contains("TREADMILL") ||
subSportUpper.contains("VIRTUAL") ||
subSportUpper.contains("TRAINER");
if (detected) {
isIndoor.set(true);
method.set(Activity.IndoorDetectionMethod.FIT_SUBSPORT);
log.debug("Detected indoor activity from SubSport: {}", subSportStr);
}
}
});
// Decode the FIT file
if (!decode.checkFileIntegrity(inputStream)) {
log.warn("FIT file integrity check failed");
return result;
}
// Reset stream and read
inputStream.reset();
decode.read(inputStream, broadcaster);
result.setIndoor(isIndoor.get());
result.setSubSport(subSport.get());
result.setDetectionMethod(method.get());
} catch (Exception e) {
log.warn("Failed to parse FIT file: {}", e.getMessage());
}
return result;
}
/**
* Result of indoor activity detection.
*/
@Data
private static class IndoorDetectionResult {
private boolean indoor;
private String subSport;
private Activity.IndoorDetectionMethod detectionMethod;
}
}

View file

@ -194,12 +194,15 @@ public class UserService {
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// 2. Verify password
log.debug("Verifying password for account deletion - user: {}, password provided: {}, hash exists: {}",
user.getUsername(), password != null && !password.isEmpty(), user.getPasswordHash() != null);
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
log.warn("Invalid password provided for account deletion: {}", user.getUsername());
log.warn("Invalid password provided for account deletion: {} (password matches: false)", user.getUsername());
throw new BadCredentialsException("Invalid password");
}
log.info("Password verified for account deletion: {}", user.getUsername());
log.info("Password verified successfully for account deletion: {}", user.getUsername());
// 3. Send Delete activity to followers (best effort)
try {

View file

@ -112,6 +112,12 @@ public class FitParser {
log.info("No GPS track points found in FIT file - likely an indoor activity");
// Default to UTC timezone for indoor activities
parsedData.setTimezone("UTC");
// Mark as indoor if not already detected from SubSport
if (!parsedData.getIndoor()) {
parsedData.setIndoor(true);
parsedData.setIndoorDetectionMethod(Activity.IndoorDetectionMethod.HEURISTIC_NO_GPS);
}
} else {
// Determine timezone from first GPS coordinate
determineTimezone(parsedData);
@ -328,6 +334,24 @@ public class FitParser {
if (session.getSport() != null) {
parsedData.setActivityType(mapSportToActivityType(session.getSport()));
}
// Extract SubSport and detect indoor activities
if (session.getSubSport() != null) {
String subSportStr = session.getSubSport().toString();
parsedData.setSubSport(subSportStr);
// Detect indoor activities from SubSport field
String subSportUpper = subSportStr.toUpperCase();
boolean isIndoor = subSportUpper.contains("INDOOR") ||
subSportUpper.contains("TREADMILL") ||
subSportUpper.contains("VIRTUAL") ||
subSportUpper.contains("TRAINER");
if (isIndoor) {
parsedData.setIndoor(true);
parsedData.setIndoorDetectionMethod(Activity.IndoorDetectionMethod.FIT_SUBSPORT);
log.debug("Detected indoor activity from SubSport: {}", subSportStr);
}
}
}
/**

View file

@ -93,8 +93,11 @@ public class GpxParser {
// Apply speed smoothing
smoothSpeedData(parsedData);
log.info("Successfully parsed GPX file: {} track points, activity type: {}, timezone: {}",
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
// Detect indoor activities (GPX files use heuristic detection)
detectIndoorActivity(parsedData);
log.info("Successfully parsed GPX file: {} track points, activity type: {}, timezone: {}, indoor: {}",
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone(), parsedData.getIndoor());
return parsedData;
} catch (GpxFileProcessingException e) {
@ -614,4 +617,78 @@ public class GpxParser {
.orElse(0);
return (int) Math.round(sum);
}
/**
* Detects indoor activities using heuristics.
* GPX files don't have SubSport field, so we use GPS movement analysis.
*
* Heuristic: If all GPS points are within 50 meters of each other, it's likely indoor.
*/
private void detectIndoorActivity(ParsedActivityData parsedData) {
List<TrackPointData> points = parsedData.getTrackPoints();
if (points.isEmpty()) {
// No GPS data - likely indoor
parsedData.setIndoor(true);
parsedData.setIndoorDetectionMethod(Activity.IndoorDetectionMethod.HEURISTIC_NO_GPS);
return;
}
// Check if all points are within a small radius (stationary GPS)
if (isStationaryGps(points)) {
parsedData.setIndoor(true);
parsedData.setIndoorDetectionMethod(Activity.IndoorDetectionMethod.HEURISTIC_STATIONARY);
log.debug("Detected indoor activity: GPS track is stationary (all points within 50m radius)");
}
}
/**
* Checks if GPS track is stationary (all points within 50 meters of first point).
* Used to detect indoor activities like treadmill runs or trainer rides with GPS enabled.
*/
private boolean isStationaryGps(List<TrackPointData> points) {
if (points.size() < 10) {
// Too few points to determine - assume outdoor
return false;
}
TrackPointData firstPoint = points.get(0);
double firstLat = firstPoint.getLatitude();
double firstLon = firstPoint.getLongitude();
// Check if all points are within 50 meters of the first point
final double MAX_RADIUS_METERS = 50.0;
for (TrackPointData point : points) {
double distance = haversineDistance(firstLat, firstLon, point.getLatitude(), point.getLongitude());
if (distance > MAX_RADIUS_METERS) {
// Found a point outside the radius - not stationary
return false;
}
}
// All points within 50m radius - likely indoor activity
log.debug("GPS track is stationary: {} points all within {}m radius", points.size(), MAX_RADIUS_METERS);
return true;
}
/**
* Calculates distance between two GPS coordinates using Haversine formula.
*
* @return distance in meters
*/
private double haversineDistance(double lat1, double lon1, double lat2, double lon2) {
final double EARTH_RADIUS = 6371000; // meters
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
}

View file

@ -32,6 +32,9 @@ public class ParsedActivityData {
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
private ActivityMetricsData metrics;
private String sourceFormat; // "FIT" or "GPX"
private Boolean indoor = false; // Indicates if this is an indoor activity
private String subSport; // SubSport from FIT file (e.g., INDOOR_CYCLING, TREADMILL, ROAD)
private Activity.IndoorDetectionMethod indoorDetectionMethod; // How indoor flag was determined
/**
* Data class for track point information.

View file

@ -78,6 +78,9 @@ fitpub:
# Registration settings
registration:
enabled: ${REGISTRATION_ENABLED:true}
# Optional password required for new registrations (soft invite system)
# Leave empty to allow open registration
password: ${REGISTRATION_PASSWORD:}
# Storage settings
storage:

View file

@ -0,0 +1,11 @@
-- Add indoor flag to activities table
-- Indoor activities (e.g., virtual rides, indoor trainer sessions) should be displayed in timeline
-- but excluded from heatmap generation to avoid polluting outdoor activity visualization
ALTER TABLE activities
ADD COLUMN indoor BOOLEAN NOT NULL DEFAULT FALSE;
-- Create index for efficient querying of outdoor activities for heatmap
CREATE INDEX idx_activity_indoor ON activities(indoor);
COMMENT ON COLUMN activities.indoor IS 'Indicates if this is an indoor activity (e.g., virtual rides, trainer sessions). Indoor activities are excluded from heatmap generation.';

View file

@ -0,0 +1,9 @@
-- Add SubSport and indoor detection method columns to activities table
-- These columns provide metadata about how indoor activities were detected
ALTER TABLE activities
ADD COLUMN sub_sport VARCHAR(50),
ADD COLUMN indoor_detection_method VARCHAR(20);
COMMENT ON COLUMN activities.sub_sport IS 'SubSport from FIT file (e.g., INDOOR_CYCLING, TREADMILL, ROAD, MOUNTAIN, TRAIL). NULL for GPX files or if not available.';
COMMENT ON COLUMN activities.indoor_detection_method IS 'How the indoor flag was determined: FIT_SUBSPORT, GPX_EXTENSION, HEURISTIC_NO_GPS, HEURISTIC_STATIONARY, MANUAL, or NULL for legacy activities.';

View file

@ -1014,4 +1014,37 @@ h1 {
.indoor-activity-placeholder .fw-bold {
color: var(--dark-text) !important;
}
/* 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;
}
/* Typography - Dark Mode Fix */
strong {
color: var(--dark-text);
}
b {
color: var(--dark-text);
}
small {
color: var(--dark-text);
}
/* Indoor badge - Dark Mode */
.badge.bg-warning.text-dark {
background: rgba(255, 165, 0, 0.25) !important; /* Orange with transparency */
color: var(--neon-orange) !important;
border: 1px solid var(--neon-orange);
}
}

View file

@ -131,6 +131,12 @@ const FitPubTimeline = {
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
${activity.activityType}
</span>
${activity.indoor
? `<span class="badge bg-warning text-dark ms-2" title="${activity.indoorDetectionMethod ? 'Detected via: ' + activity.indoorDetectionMethod : 'Indoor Activity'}">
<i class="bi bi-house-door"></i> Indoor
</span>`
: ''
}
</div>
</div>

View file

@ -117,6 +117,31 @@
#zipFileInput {
display: none;
}
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
.status-SUCCESS {
background: rgba(57, 255, 20, 0.25);
color: #39ff14;
border: 1px solid #39ff14;
}
.status-FAILED {
background: rgba(220, 53, 69, 0.25);
color: #ff6b7a;
border: 1px solid #dc3545;
}
.status-PENDING, .status-PROCESSING {
background: rgba(255, 193, 7, 0.25);
color: #ffc107;
border: 1px solid #ffc107;
}
.job-card:hover {
box-shadow: 0 2px 8px rgba(255, 20, 147, 0.3);
}
}
</style>
</head>
<body>

View file

@ -34,6 +34,9 @@
<h2 id="activityTitle">Activity Title</h2>
<p class="text-muted mb-2">
<span id="activityType" class="activity-type-badge"></span>
<span id="indoorBadge" class="badge bg-warning text-dark ms-2" style="display: none;">
<i class="bi bi-house-door"></i> Indoor
</span>
<span class="ms-2">
<i class="bi bi-calendar"></i>
<span id="activityDate"></span>
@ -507,6 +510,17 @@
document.querySelector('#visibilityBadge i').className = `bi bi-${visIcon}`;
document.getElementById('visibilityBadge').className = `ms-2 visibility-${activity.visibility.toLowerCase()}`;
// Indoor badge
const indoorBadge = document.getElementById('indoorBadge');
if (activity.indoor === true) {
indoorBadge.style.display = 'inline-block';
if (activity.indoorDetectionMethod) {
indoorBadge.title = `Detected via: ${activity.indoorDetectionMethod}`;
}
} else {
indoorBadge.style.display = 'none';
}
// Description
if (activity.description) {
document.getElementById('activityDescription').textContent = activity.description;

View file

@ -160,6 +160,12 @@
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
${activity.activityType}
</span>
${activity.indoor
? `<span class="badge bg-warning text-dark ms-2" title="${activity.indoorDetectionMethod ? 'Detected via: ' + activity.indoorDetectionMethod : 'Indoor Activity'}">
<i class="bi bi-house-door"></i> Indoor
</span>`
: ''
}
<span class="ms-2">
<i class="bi bi-calendar"></i>
${FitPub.formatDateWithTimezone(activity.startedAt, activity.timezone || 'UTC')}

View file

@ -117,7 +117,7 @@
</div>
<!-- Confirm Password -->
<div class="mb-4">
<div class="mb-3">
<label for="confirmPassword" class="form-label">
Confirm Password <span class="text-danger">*</span>
</label>
@ -133,6 +133,25 @@
</div>
</div>
<!-- Registration Password (Invite Code) -->
<div class="mb-4" id="registrationPasswordField" style="display: none;">
<label for="registrationPassword" class="form-label">
Registration Password <span class="text-danger">*</span>
</label>
<input type="password"
class="form-control"
id="registrationPassword"
name="registrationPassword"
placeholder="Enter the registration password"
autocomplete="off">
<div class="form-text">
<i class="bi bi-key"></i> This instance requires a registration password to create new accounts
</div>
<div class="invalid-feedback">
Registration password is required.
</div>
</div>
<!-- Terms and Conditions -->
<div class="mb-3 form-check">
<input type="checkbox"
@ -197,7 +216,7 @@
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
const form = document.getElementById('registerForm');
const registerBtn = document.getElementById('registerBtn');
const registerBtnText = document.getElementById('registerBtnText');
@ -209,6 +228,18 @@
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirmPassword');
const confirmPasswordFeedback = document.getElementById('confirmPasswordFeedback');
const registrationPasswordField = document.getElementById('registrationPasswordField');
const registrationPasswordInput = document.getElementById('registrationPassword');
// Check if registration password is required by checking URL parameters
// If ?invite=true or REGISTRATION_PASSWORD is set, show the field
const urlParams = new URLSearchParams(window.location.search);
const showRegistrationPassword = urlParams.has('invite') || urlParams.has('code');
// Always show the field and let backend validate
// This simplifies the logic - if not required, backend will ignore it
registrationPasswordField.style.display = 'block';
registrationPasswordInput.required = true;
// Password confirmation validation
confirmPassword.addEventListener('input', function() {
@ -252,7 +283,8 @@
username: document.getElementById('username').value,
email: document.getElementById('email').value,
displayName: document.getElementById('displayName').value,
password: password.value
password: password.value,
registrationPassword: registrationPasswordInput.value || null
};
try {

View file

@ -125,6 +125,58 @@
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
color: white;
}
/* Dark Mode Support */
@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 {
color: #a8a8c0;
}
.error-message {
color: #a8a8c0;
}
.error-actions {
background: #1a0a30;
border: 2px solid #00ffff;
}
.error-actions p {
color: #a8a8c0;
}
.btn-home {
background: linear-gradient(135deg, #ff1493 0%, #9d00ff 100%);
border-color: #ff1493;
}
.btn-home:hover {
box-shadow: 0 10px 25px rgba(255, 20, 147, 0.4);
}
.btn-login {
background: linear-gradient(135deg, #00ffff 0%, #39ff14 100%);
color: #0f0520;
}
.btn-login:hover {
box-shadow: 0 10px 25px rgba(0, 255, 255, 0.4);
}
}
</style>
</head>
<body>

View file

@ -114,6 +114,61 @@
.btn-back:hover {
color: #764ba2;
}
/* Dark Mode Support */
@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 {
color: #a8a8c0;
}
.error-message {
color: #a8a8c0;
}
.error-suggestions {
background: #1a0a30;
border: 2px solid #00ffff;
}
.error-suggestions h5 {
color: #e8e8f0;
}
.error-suggestions li {
color: #a8a8c0;
}
.btn-home {
background: linear-gradient(135deg, #ff1493 0%, #9d00ff 100%);
border-color: #ff1493;
}
.btn-home:hover {
box-shadow: 0 10px 25px rgba(255, 20, 147, 0.4);
}
.btn-back {
color: #00ffff;
}
.btn-back:hover {
color: #ff1493;
}
}
</style>
</head>
<body>

View file

@ -114,6 +114,61 @@
.btn-back:hover {
color: #fee140;
}
/* Dark Mode Support */
@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 {
color: #a8a8c0;
}
.error-message {
color: #a8a8c0;
}
.error-suggestions {
background: #1a0a30;
border: 2px solid #00ffff;
}
.error-suggestions h5 {
color: #e8e8f0;
}
.error-suggestions li {
color: #a8a8c0;
}
.btn-home {
background: linear-gradient(135deg, #ff1493 0%, #9d00ff 100%);
border-color: #ff1493;
}
.btn-home:hover {
box-shadow: 0 10px 25px rgba(255, 20, 147, 0.4);
}
.btn-back {
color: #00ffff;
}
.btn-back:hover {
color: #ff1493;
}
}
</style>
</head>
<body>

View file

@ -81,6 +81,61 @@
.btn-back:hover {
color: #f093fb;
}
/* Dark Mode Support */
@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 {
color: #a8a8c0;
}
.error-message {
color: #a8a8c0;
}
.error-code {
background: #1a0a30;
border: 2px solid #00ffff;
}
.error-code strong {
color: #ff1493;
}
.error-code div {
color: #a8a8c0;
}
.btn-home {
background: linear-gradient(135deg, #ff1493 0%, #9d00ff 100%);
border-color: #ff1493;
}
.btn-home:hover {
box-shadow: 0 10px 25px rgba(255, 20, 147, 0.4);
}
.btn-back {
color: #00ffff;
}
.btn-back:hover {
color: #ff1493;
}
}
</style>
</head>
<body>

View file

@ -62,11 +62,16 @@
<script th:inline="javascript">
const targetUsername = /*[[${username}]]*/ '';
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('usernameDisplay').textContent = targetUsername;
loadFollowing();
async function loadFollowing() {
// Check if viewing own following list
const currentUser = FitPubAuth.getCurrentUser();
const isOwnProfile = currentUser && currentUser.username === targetUsername;
loadFollowing(isOwnProfile);
async function loadFollowing(isOwnProfile) {
try {
const response = await fetch(`/api/users/${targetUsername}/following`);
@ -81,7 +86,7 @@
document.getElementById('followingContent').classList.remove('d-none');
if (following.length > 0) {
renderFollowing(following);
renderFollowing(following, isOwnProfile);
} else {
document.getElementById('followingEmpty').classList.remove('d-none');
}
@ -93,7 +98,44 @@
}
}
function renderFollowing(following) {
async function handleUnfollow(username, button) {
if (!confirm(`Are you sure you want to unfollow @${username}?`)) {
return;
}
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Unfollowing...';
try {
const response = await FitPubAuth.authenticatedFetch(`/api/users/${username}/follow`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to unfollow');
}
// Remove the user from the list
button.closest('.border-bottom').remove();
// Check if list is now empty
const list = document.getElementById('followingList');
if (list.children.length === 0) {
document.getElementById('followingEmpty').classList.remove('d-none');
}
FitPub.showAlert('success', `Successfully unfollowed @${username}`);
} catch (error) {
console.error('Unfollow error:', error);
FitPub.showAlert('danger', error.message || 'Failed to unfollow user');
button.disabled = false;
button.innerHTML = originalText;
}
}
function renderFollowing(following, isOwnProfile) {
const list = document.getElementById('followingList');
list.innerHTML = following.map(user => `
<div class="d-flex align-items-center py-3 border-bottom">
@ -107,7 +149,7 @@
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="flex-grow-1">
<h6 class="mb-0">
${user.local ?
`<a href="/profile/${escapeHtml(user.username)}" class="text-decoration-none">${escapeHtml(user.displayName || user.username)}</a>` :
@ -120,6 +162,14 @@
</p>
${user.bio ? `<p class="small mt-1 mb-0 text-muted">${sanitizeHtml(user.bio)}</p>` : ''}
</div>
${isOwnProfile ? `
<div class="ms-3">
<button class="btn btn-sm btn-outline-danger"
onclick="handleUnfollow('${escapeHtml(user.username || user.handle)}', this)">
<i class="bi bi-person-dash"></i> Unfollow
</button>
</div>
` : ''}
</div>
</div>
</div>