Search function, declaudification
This commit is contained in:
parent
6e7d52f827
commit
612d67ccda
17 changed files with 668 additions and 3061 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package net.javahippie.fitpub.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
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 page page number (default: 0)
|
||||
* @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
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getUserActivities(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@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);
|
||||
|
||||
org.springframework.data.domain.Page<Activity> activityPage =
|
||||
fitFileService.getUserActivitiesPaginated(userId, page, size);
|
||||
// Use search if filters provided, otherwise use standard method
|
||||
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
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 page page number (default: 0)
|
||||
* @param size page size (default: 20)
|
||||
* @param search optional search text for title/description
|
||||
* @return page of timeline activities
|
||||
*/
|
||||
@GetMapping("/federated")
|
||||
public ResponseEntity<Page<TimelineActivityDTO>> getFederatedTimeline(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String search
|
||||
) {
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
|
@ -76,30 +87,41 @@ public class TimelineController {
|
|||
* Shows all public activities from all users.
|
||||
* 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 page page number (default: 0)
|
||||
* @param size page size (default: 20)
|
||||
* @param search optional search text for title/description
|
||||
* @return page of timeline activities
|
||||
*/
|
||||
@GetMapping("/public")
|
||||
public ResponseEntity<Page<TimelineActivityDTO>> getPublicTimeline(
|
||||
@AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String search
|
||||
) {
|
||||
UUID userId = null;
|
||||
if (userDetails != null) {
|
||||
userId = getUserId(userDetails);
|
||||
log.debug("Public timeline request from authenticated user: {}", userId);
|
||||
log.debug("Public timeline request from authenticated user: {} (search: {})", userId, search);
|
||||
} else {
|
||||
log.debug("Public timeline request (unauthenticated)");
|
||||
log.debug("Public timeline request (unauthenticated) (search: {})", search);
|
||||
}
|
||||
|
||||
// Sort by activity start date descending (latest first)
|
||||
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);
|
||||
}
|
||||
|
|
@ -108,25 +130,36 @@ public class TimelineController {
|
|||
* Get the user's own timeline.
|
||||
* 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 page page number (default: 0)
|
||||
* @param size page size (default: 20)
|
||||
* @param search optional search text for title/description
|
||||
* @return page of timeline activities
|
||||
*/
|
||||
@GetMapping("/user")
|
||||
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimeline(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String search
|
||||
) {
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -388,6 +389,41 @@ public class FitFileService {
|
|||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import org.springframework.data.domain.Pageable;
|
|||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
|
@ -202,6 +203,134 @@ public class TimelineService {
|
|||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ const FitPubTimeline = {
|
|||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
timelineType: 'public',
|
||||
searchText: '',
|
||||
dateFilter: '',
|
||||
searchDebounceTimer: null,
|
||||
|
||||
/**
|
||||
* Initialize the timeline
|
||||
|
|
@ -14,6 +17,7 @@ const FitPubTimeline = {
|
|||
*/
|
||||
init: function(type) {
|
||||
this.timelineType = type;
|
||||
this.setupSearchHandlers();
|
||||
this.loadTimeline(0);
|
||||
},
|
||||
|
||||
|
|
@ -59,6 +63,19 @@ const FitPubTimeline = {
|
|||
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
|
||||
const response = fetchOptions.useAuth
|
||||
? await FitPubAuth.authenticatedFetch(endpoint)
|
||||
|
|
@ -76,7 +93,7 @@ const FitPubTimeline = {
|
|||
timelineList.classList.remove('d-none');
|
||||
pagination.classList.remove('d-none');
|
||||
} else {
|
||||
emptyState.classList.remove('d-none');
|
||||
this.showEmptyState(emptyState);
|
||||
}
|
||||
|
||||
this.totalPages = data.totalPages;
|
||||
|
|
@ -362,10 +379,10 @@ const FitPubTimeline = {
|
|||
|
||||
// Initialize map
|
||||
const map = L.map(mapId, {
|
||||
zoomControl: true,
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
dragging: true,
|
||||
touchZoom: true
|
||||
dragging: false,
|
||||
touchZoom: false
|
||||
});
|
||||
|
||||
// Add tile layer
|
||||
|
|
@ -604,5 +621,129 @@ const FitPubTimeline = {
|
|||
</div>
|
||||
`;
|
||||
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
|
||||
if (hasGpsTrack && activity.simplifiedTrack) {
|
||||
if (hasGpsTrack && activity.trackPoints.length > 0) {
|
||||
document.getElementById('mapSection').style.display = 'block';
|
||||
document.getElementById('indoorPlaceholder').style.display = 'none';
|
||||
renderMap(activity.simplifiedTrack, activity);
|
||||
renderMap(activity.trackPoints, activity);
|
||||
} else {
|
||||
// Show indoor activity placeholder
|
||||
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
|
||||
const geoJson = {
|
||||
type: 'LineString',
|
||||
coordinates: simplifiedTrack.coordinates
|
||||
coordinates: flattenTrackPoints(trackPoints)
|
||||
};
|
||||
|
||||
// Create map (needs to be done after container is visible)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,34 @@
|
|||
Activities from athletes you follow
|
||||
</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 -->
|
||||
<div id="loadingIndicator" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
|
|
|
|||
|
|
@ -34,6 +34,34 @@
|
|||
Discover public fitness activities from the FitPub community
|
||||
</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 -->
|
||||
<div id="loadingIndicator" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
|
|
|
|||
|
|
@ -34,6 +34,34 @@
|
|||
Your fitness activities
|
||||
</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 -->
|
||||
<div id="loadingIndicator" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue