Search function, declaudification

This commit is contained in:
Tim Zöller 2026-01-14 16:53:34 +01:00
parent 6e7d52f827
commit 612d67ccda
17 changed files with 668 additions and 3061 deletions

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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.
*

View file

@ -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.
*

View file

@ -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');
}
};

View file

@ -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)

View file

@ -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">

View file

@ -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">

View file

@ -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">