Speed up upload
This commit is contained in:
parent
a560036265
commit
9dee8a7e84
4 changed files with 714 additions and 145 deletions
|
|
@ -11,9 +11,9 @@ import org.operaton.fitpub.model.entity.User;
|
|||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.ActivityFileService;
|
||||
import org.operaton.fitpub.service.ActivityImageService;
|
||||
import org.operaton.fitpub.service.ActivityPostProcessingService;
|
||||
import org.operaton.fitpub.service.FederationService;
|
||||
import org.operaton.fitpub.service.FitFileService;
|
||||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
|
@ -23,12 +23,9 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST controller for activity management.
|
||||
|
|
@ -43,6 +40,7 @@ public class ActivityController {
|
|||
private final ActivityFileService activityFileService;
|
||||
private final FitFileService fitFileService;
|
||||
private final UserRepository userRepository;
|
||||
private final ActivityPostProcessingService activityPostProcessingService;
|
||||
private final FederationService federationService;
|
||||
private final ActivityImageService activityImageService;
|
||||
private final org.operaton.fitpub.service.WeatherService weatherService;
|
||||
|
|
@ -90,122 +88,25 @@ public class ActivityController {
|
|||
request.getVisibility()
|
||||
);
|
||||
|
||||
// Send ActivityPub Create activity to followers if public or followers-only
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC ||
|
||||
activity.getVisibility() == Activity.Visibility.FOLLOWERS) {
|
||||
// Trigger async post-processing (non-blocking):
|
||||
// - Personal Records checking
|
||||
// - Weather data fetching
|
||||
// - Heatmap grid updates
|
||||
// - Federation push (includes image generation)
|
||||
//
|
||||
// Operations run in separate transactions with proper ordering:
|
||||
// - Personal Records and Heatmap run in parallel
|
||||
// - Weather → Federation run sequentially (weather must complete before federation)
|
||||
//
|
||||
// Activity is immediately visible to user, processing continues in background
|
||||
activityPostProcessingService.processActivityAsync(activity.getId(), user.getId());
|
||||
|
||||
String activityUri = baseUrl + "/activities/" + activity.getId();
|
||||
String actorUri = baseUrl + "/users/" + user.getUsername();
|
||||
|
||||
// Create the Note object representing the activity
|
||||
Map<String, Object> noteObject = new HashMap<>();
|
||||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
noteObject.put("content", formatActivityContent(activity));
|
||||
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||
noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
|
||||
noteObject.put("cc", List.of(actorUri + "/followers"));
|
||||
} else {
|
||||
noteObject.put("to", List.of(actorUri + "/followers"));
|
||||
}
|
||||
|
||||
// Add URL to the activity page
|
||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||
|
||||
// Generate and attach activity image
|
||||
String imageUrl = activityImageService.generateActivityImage(activity);
|
||||
if (imageUrl != null) {
|
||||
Map<String, Object> imageAttachment = new HashMap<>();
|
||||
imageAttachment.put("type", "Image");
|
||||
imageAttachment.put("mediaType", "image/png");
|
||||
imageAttachment.put("url", imageUrl);
|
||||
imageAttachment.put("name", "Activity map showing " + activity.getActivityType() + " route");
|
||||
noteObject.put("attachment", List.of(imageAttachment));
|
||||
}
|
||||
|
||||
federationService.sendCreateActivity(
|
||||
activityUri,
|
||||
noteObject,
|
||||
user,
|
||||
activity.getVisibility() == Activity.Visibility.PUBLIC
|
||||
);
|
||||
}
|
||||
log.info("Activity {} created and queued for async post-processing", activity.getId());
|
||||
|
||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format activity content for ActivityPub.
|
||||
* Uses plain text with Unicode symbols for maximum compatibility across Fediverse platforms.
|
||||
*/
|
||||
private String formatActivityContent(Activity activity) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
// Title (if present)
|
||||
if (activity.getTitle() != null && !activity.getTitle().isEmpty()) {
|
||||
content.append(activity.getTitle()).append("\n\n");
|
||||
}
|
||||
|
||||
// Description (if present)
|
||||
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
|
||||
content.append(activity.getDescription()).append("\n\n");
|
||||
}
|
||||
|
||||
// Activity type with emoji
|
||||
String activityEmoji = getActivityEmoji(activity.getActivityType());
|
||||
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
|
||||
content.append(activityEmoji).append(" ").append(formattedType);
|
||||
|
||||
// Metrics on separate lines
|
||||
if (activity.getTotalDistance() != null) {
|
||||
content.append("\n📏 ")
|
||||
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0));
|
||||
}
|
||||
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
long hours = activity.getTotalDurationSeconds() / 3600;
|
||||
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
|
||||
long seconds = activity.getTotalDurationSeconds() % 60;
|
||||
content.append("\n⏱️ ");
|
||||
if (hours > 0) {
|
||||
content.append(hours).append("h ");
|
||||
}
|
||||
content.append(minutes).append("m ").append(seconds).append("s");
|
||||
}
|
||||
|
||||
if (activity.getElevationGain() != null) {
|
||||
content.append("\n⛰️ ")
|
||||
.append(String.format("%.0f m", activity.getElevationGain()));
|
||||
}
|
||||
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an emoji for the activity type.
|
||||
*/
|
||||
private String getActivityEmoji(Activity.ActivityType type) {
|
||||
return switch (type) {
|
||||
case RUN -> "🏃";
|
||||
case RIDE -> "🚴";
|
||||
case HIKE -> "🥾";
|
||||
case WALK -> "🚶";
|
||||
case SWIM -> "🏊";
|
||||
case ALPINE_SKI, BACKCOUNTRY_SKI, NORDIC_SKI -> "⛷️";
|
||||
case SNOWBOARD -> "🏂";
|
||||
case ROWING -> "🚣";
|
||||
case KAYAKING, CANOEING -> "🛶";
|
||||
case INLINE_SKATING -> "⛸️";
|
||||
case ROCK_CLIMBING, MOUNTAINEERING -> "🧗";
|
||||
case YOGA -> "🧘";
|
||||
case WORKOUT -> "💪";
|
||||
default -> "🏋️";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple HTML escaping.
|
||||
|
|
|
|||
|
|
@ -98,12 +98,14 @@ public class ActivityFileService {
|
|||
private final TrackSimplifier trackSimplifier;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PersonalRecordService personalRecordService;
|
||||
// Async operations moved to ActivityPostProcessingService:
|
||||
// - PersonalRecordService (async)
|
||||
// - HeatmapGridService (async)
|
||||
// - WeatherService (async)
|
||||
// Synchronous operations remain here:
|
||||
private final AchievementService achievementService;
|
||||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
|
||||
/**
|
||||
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
|
||||
|
|
@ -301,15 +303,9 @@ public class ActivityFileService {
|
|||
savedActivity.getId());
|
||||
}
|
||||
|
||||
// Execute side effects based on processing options
|
||||
// In batch import mode, these are skipped and executed later as a batch
|
||||
|
||||
if (!options.isSkipPersonalRecords()) {
|
||||
log.debug("Checking personal records for activity {}", savedActivity.getId());
|
||||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
} else {
|
||||
log.debug("Skipping personal records check for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
// Execute synchronous side effects based on processing options
|
||||
// Personal Records, Heatmap, and Weather are now handled asynchronously by caller (ActivityController)
|
||||
// In batch import mode, even synchronous operations are skipped and executed later as a batch
|
||||
|
||||
if (!options.isSkipAchievements()) {
|
||||
log.debug("Checking achievements for activity {}", savedActivity.getId());
|
||||
|
|
@ -318,13 +314,6 @@ public class ActivityFileService {
|
|||
log.debug("Skipping achievements check for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
|
||||
if (!options.isSkipHeatmap()) {
|
||||
log.debug("Updating heatmap for activity {}", savedActivity.getId());
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
} else {
|
||||
log.debug("Skipping heatmap update for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
|
||||
if (!options.isSkipTrainingLoad()) {
|
||||
log.debug("Updating training load for activity {}", savedActivity.getId());
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
|
|
@ -339,18 +328,9 @@ public class ActivityFileService {
|
|||
log.debug("Skipping summaries update for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
|
||||
if (!options.isSkipWeather()) {
|
||||
// Fetch weather data (async, non-blocking)
|
||||
try {
|
||||
log.debug("Fetching weather for activity {}", savedActivity.getId());
|
||||
weatherService.fetchWeatherForActivity(savedActivity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch weather data for activity {}: {}", savedActivity.getId(), e.getMessage());
|
||||
// Don't fail the activity creation if weather fetching fails
|
||||
}
|
||||
} else {
|
||||
log.debug("Skipping weather fetch for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
// Note: Async post-processing (Personal Records, Heatmap, Weather, Federation)
|
||||
// is triggered by the caller (ActivityController) via ActivityPostProcessingService
|
||||
// This keeps ActivityFileService focused on file parsing and initial activity save
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,340 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Service for asynchronous post-processing of activities after upload.
|
||||
* Coordinates expensive operations (Personal Records, Weather, Heatmap, Federation)
|
||||
* in separate transactions to avoid blocking the upload response.
|
||||
*
|
||||
* Each operation runs asynchronously with REQUIRES_NEW transaction propagation
|
||||
* to ensure fault isolation - failures in one operation don't affect others.
|
||||
*
|
||||
* Operations execute in the following order:
|
||||
* - Personal Records: Runs independently (parallel)
|
||||
* - Heatmap: Runs independently (parallel)
|
||||
* - Weather → Federation: Sequential chain (weather must complete before federation)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ActivityPostProcessingService {
|
||||
|
||||
private final PersonalRecordService personalRecordService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final FederationService federationService;
|
||||
private final ActivityImageService activityImageService;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* Orchestrates async post-processing operations for an uploaded activity.
|
||||
* Called after activity is saved and immediately visible to the user.
|
||||
*
|
||||
* Personal Records and Heatmap run independently in parallel.
|
||||
* Weather and Federation run sequentially (weather must complete first for future share pic integration).
|
||||
*
|
||||
* All operations use separate transactions (REQUIRES_NEW) for fault isolation.
|
||||
* Errors are logged but don't propagate - each operation fails independently.
|
||||
*
|
||||
* @param activityId the saved activity ID
|
||||
* @param userId the user ID who uploaded the activity
|
||||
*/
|
||||
public void processActivityAsync(UUID activityId, UUID userId) {
|
||||
log.info("Starting async post-processing for activity {} by user {}", activityId, userId);
|
||||
|
||||
// Launch independent async operations (run in parallel)
|
||||
updatePersonalRecordsAsync(activityId);
|
||||
updateHeatmapAsync(activityId);
|
||||
|
||||
// Sequential chain: Weather → Federation
|
||||
// Weather must complete before federation for potential weather data in share images
|
||||
fetchWeatherAsync(activityId)
|
||||
.thenCompose(result -> publishToFederationAsync(activityId, userId))
|
||||
.exceptionally(ex -> {
|
||||
log.error("Failed async post-processing chain (Weather → Federation) for activity {}: {}",
|
||||
activityId, ex.getMessage(), ex);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously check and update personal records for the activity.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
* @return CompletableFuture that completes when processing is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> updatePersonalRecordsAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Checking personal records for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
personalRecordService.checkAndUpdatePersonalRecords(activity);
|
||||
|
||||
log.info("Async: Personal records updated for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to update personal records for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously update heatmap grid with activity GPS data.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
* @return CompletableFuture that completes when processing is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> updateHeatmapAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Updating heatmap for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
heatmapGridService.updateHeatmapForActivity(activity);
|
||||
|
||||
log.info("Async: Heatmap updated for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to update heatmap for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously fetch weather data for the activity location and time.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* Must complete before federation push to allow future integration of weather in share images.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
* @return CompletableFuture that completes when weather fetch is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> fetchWeatherAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Fetching weather for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
weatherService.fetchWeatherForActivity(activity);
|
||||
|
||||
log.info("Async: Weather fetched for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to fetch weather for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously publish activity to the Fediverse (ActivityPub federation).
|
||||
* Generates activity image and sends Create activity to all follower inboxes.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* Only publishes if activity visibility is PUBLIC or FOLLOWERS.
|
||||
* This method should run AFTER weather fetch completes for future share pic integration.
|
||||
*
|
||||
* @param activityId the activity ID to publish
|
||||
* @param userId the user ID who owns the activity
|
||||
* @return CompletableFuture that completes when federation is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> publishToFederationAsync(UUID activityId, UUID userId) {
|
||||
try {
|
||||
log.debug("Async: Publishing activity {} to Fediverse", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
|
||||
|
||||
// Only publish if activity is PUBLIC or FOLLOWERS
|
||||
if (activity.getVisibility() != Activity.Visibility.PUBLIC &&
|
||||
activity.getVisibility() != Activity.Visibility.FOLLOWERS) {
|
||||
log.debug("Async: Skipping federation for private activity {}", activityId);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
String activityUri = baseUrl + "/activities/" + activity.getId();
|
||||
String actorUri = baseUrl + "/users/" + user.getUsername();
|
||||
|
||||
// Generate activity image (map with GPS track)
|
||||
String imageUrl = null;
|
||||
try {
|
||||
imageUrl = activityImageService.generateActivityImage(activity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Async: Failed to generate activity image for {}: {}", activityId, e.getMessage());
|
||||
// Continue without image
|
||||
}
|
||||
|
||||
// Build ActivityPub Note object
|
||||
Map<String, Object> noteObject = new HashMap<>();
|
||||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
noteObject.put("content", formatActivityContent(activity));
|
||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||
|
||||
// Set visibility (to/cc fields)
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||
noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
|
||||
noteObject.put("cc", List.of(actorUri + "/followers"));
|
||||
} else {
|
||||
// FOLLOWERS only
|
||||
noteObject.put("to", List.of(actorUri + "/followers"));
|
||||
}
|
||||
|
||||
// Attach activity image if generated
|
||||
if (imageUrl != null) {
|
||||
Map<String, Object> imageAttachment = new HashMap<>();
|
||||
imageAttachment.put("type", "Image");
|
||||
imageAttachment.put("mediaType", "image/png");
|
||||
imageAttachment.put("url", imageUrl);
|
||||
imageAttachment.put("name", "Activity map showing " + activity.getActivityType() + " route");
|
||||
noteObject.put("attachment", List.of(imageAttachment));
|
||||
}
|
||||
|
||||
// Send to all follower inboxes
|
||||
federationService.sendCreateActivity(
|
||||
activityUri,
|
||||
noteObject,
|
||||
user,
|
||||
activity.getVisibility() == Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
log.info("Async: Activity {} published to Fediverse", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to publish activity {} to Fediverse: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format activity content for ActivityPub Note.
|
||||
* Uses plain text with Unicode symbols for maximum compatibility across Fediverse platforms.
|
||||
*
|
||||
* Format:
|
||||
* - Title (if present)
|
||||
* - Description (if present)
|
||||
* - Activity type with emoji
|
||||
* - Distance (if present)
|
||||
* - Duration (if present)
|
||||
* - Elevation gain (if present)
|
||||
*
|
||||
* @param activity the activity to format
|
||||
* @return formatted content string
|
||||
*/
|
||||
private String formatActivityContent(Activity activity) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
// Title (if present)
|
||||
if (activity.getTitle() != null && !activity.getTitle().isEmpty()) {
|
||||
content.append(activity.getTitle()).append("\n\n");
|
||||
}
|
||||
|
||||
// Description (if present)
|
||||
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
|
||||
content.append(activity.getDescription()).append("\n\n");
|
||||
}
|
||||
|
||||
// Activity type with emoji
|
||||
String activityEmoji = getActivityEmoji(activity.getActivityType());
|
||||
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
|
||||
content.append(activityEmoji).append(" ").append(formattedType);
|
||||
|
||||
// Metrics on separate lines
|
||||
if (activity.getTotalDistance() != null) {
|
||||
content.append("\n📏 ")
|
||||
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0));
|
||||
}
|
||||
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
long hours = activity.getTotalDurationSeconds() / 3600;
|
||||
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
|
||||
long seconds = activity.getTotalDurationSeconds() % 60;
|
||||
content.append("\n⏱️ ");
|
||||
if (hours > 0) {
|
||||
content.append(hours).append("h ");
|
||||
}
|
||||
content.append(minutes).append("m ").append(seconds).append("s");
|
||||
}
|
||||
|
||||
if (activity.getElevationGain() != null) {
|
||||
content.append("\n⛰️ ")
|
||||
.append(String.format("%.0f m", activity.getElevationGain()));
|
||||
}
|
||||
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an emoji for the activity type.
|
||||
*
|
||||
* @param type the activity type
|
||||
* @return emoji representing the activity type
|
||||
*/
|
||||
private String getActivityEmoji(Activity.ActivityType type) {
|
||||
return switch (type) {
|
||||
case RUN -> "🏃";
|
||||
case RIDE -> "🚴";
|
||||
case HIKE -> "🥾";
|
||||
case WALK -> "🚶";
|
||||
case SWIM -> "🏊";
|
||||
case ALPINE_SKI, BACKCOUNTRY_SKI, NORDIC_SKI -> "⛷️";
|
||||
case SNOWBOARD -> "🏂";
|
||||
case ROWING -> "🚣";
|
||||
case KAYAKING, CANOEING -> "🛶";
|
||||
case INLINE_SKATING -> "⛸️";
|
||||
case ROCK_CLIMBING, MOUNTAINEERING -> "🧗";
|
||||
case YOGA -> "🧘";
|
||||
case WORKOUT -> "💪";
|
||||
default -> "🏋️";
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue