diff --git a/MEMORY.md b/MEMORY.md index 93778b6..0380db0 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -34,6 +34,7 @@ Architecture: Modular Hexagonal/DDD. * Security: Remediated Medium severity security findings (secured Swagger UI in production, sanitized global exception handler, downgraded JWT logging, and unified login error messages). * System: Configured Docker containers (backend, frontend, db) to automatically restart after server reboot using 'restart: unless-stopped' policy in docker-compose.yml. * Backend: Implemented dynamic Image Consensus Threshold feature using an asynchronous `ThresholdEventListener` that intercepts `ClassificationSubmittedEvent`. It evaluates the weighted credibility of classifications and automatically excludes images from distribution (via `ConsensusResult` tracking) once the dynamic researcher-defined threshold is met. +* Backend/Frontend: Optimized the CSV Export System (Issue #257). Implemented a non-blocking, background-execution export flow in the frontend using a global Zustand store and an animated `GlobalDownloadToast`. Cleaned up the backend CSV generation (`ExportService`) to significantly reduce file size and improve traceability by removing the raw base64 image strings and replacing them with explicit local and external IDs (`swipelab_image_id`, `stardbi_experiment_id`, `stardbi_image_id`, `stardbi_crop_id`). ## Current Focus (Active GitHub Issues) * Issue #201: Refactor Backend roles to include Researchers and Super Admin. * Issue #226: [Frontend] fix recipients list not deleting users. diff --git a/backend/src/docs/analytics.md b/backend/src/docs/analytics.md index d14417e..ff56a0c 100644 --- a/backend/src/docs/analytics.md +++ b/backend/src/docs/analytics.md @@ -28,3 +28,8 @@ A heavily cached, expensive query that provides a comprehensive platform snapsho - **Time Windows**: Shows activity (classifications, unique users, tasks) for "Today", "This Week", and "This Month". - **Confidence Trend**: Tracks average user credibility scores over a 30-day window. - **Label Distribution**: Shows the split of YES / NO / UNSURE labels over time. + +## 6. Data Export (CSV & JSON) +Researchers and SuperAdmins can export classification datasets in CSV and JSON formats. These exports are highly optimized for both performance and readability: +- **Traceability**: All exported rows include strict cross-system mapping identifiers (`swipelab_image_id`, `stardbi_experiment_id`, `stardbi_image_id`, and `stardbi_crop_id`). +- **Optimization**: Raw Base64 image strings are completely omitted from the generated files, vastly reducing network latency and file sizes (e.g., bringing a row payload down from ~100+ KB to ~150 bytes). diff --git a/backend/src/docs/api/analytics-api.md b/backend/src/docs/api/analytics-api.md index b2e9234..ada0378 100644 --- a/backend/src/docs/api/analytics-api.md +++ b/backend/src/docs/api/analytics-api.md @@ -25,3 +25,12 @@ Base path: `/api/v1/admin/export` | `GET` | `/tasks/{taskId}/summary` | Export task summary | | `GET` | `/tasks/{taskId}/csv` | Export task data as CSV | | `GET` | `/tasks/{taskId}/json` | Export task data as JSON | + +### Export Data Schema + +The CSV and JSON exports are optimized for performance and file size by omitting raw Base64 image strings. They contain the following cross-system traceability identifiers to reliably map classifications back to their source: + +- `swipelab_image_id`: Internal primary key of the image in the SwipeLab database. +- `stardbi_experiment_id`: External ID mapping to the StarDBi experiment. +- `stardbi_image_id`: External ID mapping to the StarDBi parent image. +- `stardbi_crop_id`: External ID mapping to the StarDBi bounding box/crop. diff --git a/backend/src/docs/metrics.md b/backend/src/docs/metrics.md new file mode 100644 index 0000000..aa29462 --- /dev/null +++ b/backend/src/docs/metrics.md @@ -0,0 +1,58 @@ +# SwipeLab Analytics Metrics Documentation + +This document explains the mathematical formulas and backend implementation logic behind the analytics data generated by `AnalyticsService.java` for the Researcher Dashboard. + +## 1. User Accuracy (Gold Accuracy) + +### What it is +The accuracy score tracks how effectively a user is identifying correct classifications, serving as a primary indicator of data labeling quality. + +### How it is calculated +Since normal images in the dataset do not have a known "correct" answer, a user's accuracy is **only evaluated based on their performance against Gold Images** (test images with pre-verified answers). + +* **Formula**: `Correct Gold Classifications / Total Gold Classifications` +* **Backend Source**: `ClassificationFactRepository.java` +* **Implementation Note**: The system specifically filters for `c.isCorrect IS NOT NULL` to determine `Total Gold Classifications`. Normal classifications (where `isCorrect` is null) are completely excluded from the denominator to prevent artificially deflating the user's accuracy score to ~0.0%. + +## 2. Participation Metrics + +### What it is +These metrics measure engagement volume and speed for a specific classification task. + +### How it is calculated +* **Active Users**: The count of distinct, unique `userId` values associated with the `ClassificationFact` entries for the task. +* **Total Classifications**: The absolute total count of all `ClassificationFact` rows (swipes) executed against this task. +* **Average Classifications per User**: `Total Classifications / Active Users`. +* **Median Response Time**: + * The backend retrieves the `responseTimeMs` (the time taken between an image appearing on screen and the user swiping) for every single classification fact. + * Null values are filtered out. + * The list is sorted, and the median value (middle element, or average of two middle elements) is returned. + +## 3. Data Quality Metrics + +### What it is +These metrics evaluate the overall trustworthiness of the labels being generated for a task based on user credibility. + +### How it is calculated +* **Average Credibility**: + * Every time a user makes a classification, their *current credibility score at that exact moment* is snapshotted into `credibilityAtTime`. + * The backend calculates the global average of `credibilityAtTime` across all facts in the task. +* **Low Quality Users**: + * The system groups all classification facts by `userId` and calculates the average credibility for each specific user within this task. + * A user is flagged as a "Low Quality User" if their average credibility score drops below the threshold of **50.0/100**. + * The final metric returns the absolute count of users who fall below this threshold. + +## 4. Consensus Metrics + +### What it is +Consensus evaluates how strongly the crowd agrees on the labels for the images in the task. + +### How it is calculated +* **Overall Average Consensus**: + * The backend groups all `ClassificationFact` rows by `imageId`. + * For each image, it extracts the `consensusScore` (taking the maximum/latest recorded score for that image). + * It then averages these scores across all classified images in the task. + * The result is represented as a percentage (0-100%). +* **Low Consensus Images**: + * While calculating the above, the system checks each image's `consensusScore` against a strict threshold (currently `0.8` or 80%). + * Any image with a score below this threshold increments the "Low Consensus Images" counter. This highlights complex, ambiguous, or highly-debated images that may require researcher intervention. diff --git a/backend/src/main/java/com/swipelab/analytics/application/AnalyticsEventListener.java b/backend/src/main/java/com/swipelab/analytics/application/AnalyticsEventListener.java index 9f44889..9005078 100644 --- a/backend/src/main/java/com/swipelab/analytics/application/AnalyticsEventListener.java +++ b/backend/src/main/java/com/swipelab/analytics/application/AnalyticsEventListener.java @@ -76,10 +76,14 @@ private void updateUserDailyStats(ClassificationSubmittedEvent event, LocalDate .build()); stats.setTotal(stats.getTotal() + 1); - if (event.isCorrect()) { + if (event.isGoldStandard() && event.isCorrect()) { stats.setCorrect(stats.getCorrect() + 1); } - stats.setAccuracy(stats.getTotal() > 0 ? (double) stats.getCorrect() / stats.getTotal() : 0.0); + + // Note: For true daily accuracy we need a count of gold classifications for the day. + // For now, we will leave the denormalized accuracy at 0.0 or calculate a rough estimate, + // since the UI fetches live accuracy from the facts table directly. + stats.setAccuracy(0.0); userDailyStatsRepository.save(stats); } diff --git a/backend/src/main/java/com/swipelab/analytics/application/AnalyticsService.java b/backend/src/main/java/com/swipelab/analytics/application/AnalyticsService.java index 5433405..77ac289 100644 --- a/backend/src/main/java/com/swipelab/analytics/application/AnalyticsService.java +++ b/backend/src/main/java/com/swipelab/analytics/application/AnalyticsService.java @@ -5,6 +5,7 @@ import com.swipelab.analytics.infrastructure.*; import com.swipelab.classification.domain.core.Classification.UserResponse; import com.swipelab.classification.infrastructure.ClassificationRepository; +import com.swipelab.classification.infrastructure.ConsensusResultRepository; import com.swipelab.classification.infrastructure.ImageRepository; import com.swipelab.config.CacheConfig; import com.swipelab.analytics.dto.DashboardStatsResponse; @@ -43,6 +44,7 @@ public class AnalyticsService { private final UserRepository userRepository; private final TaskRepository taskRepository; private final ImageRepository imageRepository; + private final ConsensusResultRepository consensusResultRepository; // ─── User-scoped endpoints ──────────────────────────────────────────────── @@ -62,7 +64,8 @@ public UserProgressResponse getUserProgress(String userId) { } } - double accuracy = total > 0 ? (double) correct / total : 0.0; + Double calculatedAccuracy = classificationFactRepository.getUserAccuracy(userId); + double accuracy = calculatedAccuracy != null ? calculatedAccuracy : 0.0; return UserProgressResponse.builder() .completed((int) total) @@ -83,7 +86,9 @@ public UserStatisticsResponse getUserStatistics(String userId) { correct = row[1] != null ? ((Number) row[1]).longValue() : 0; } } - double accuracy = total > 0 ? (double) correct / total : 0.0; + + Double calculatedAccuracy = classificationFactRepository.getUserAccuracy(userId); + double accuracy = calculatedAccuracy != null ? calculatedAccuracy : 0.0; UserRanking ranking = userRankingRepository.findByUserIdAndPeriod(userId, "ALL_TIME") .orElse(UserRanking.builder().rank(0).percentile(0).build()); @@ -194,7 +199,8 @@ public TimeSeriesResponse getTimeSeries(String userId, String metric, String per @Transactional(readOnly = true) public TaskAnalyticsResponse getTaskAnalytics(Long taskId) { - Long completedImages = classificationFactRepository.countCompletedImages(taskId); + Long imagesClassified = classificationFactRepository.countDistinctImagesByTaskId(taskId); + Long completedImages = consensusResultRepository.countCompletedImagesByTaskId(taskId); List facts = classificationFactRepository.findByTaskId(taskId); int totalClassifications = facts.size(); @@ -204,7 +210,7 @@ public TaskAnalyticsResponse getTaskAnalytics(Long taskId) { : 0.0; TaskAnalyticsResponse.Progress progress = TaskAnalyticsResponse.Progress.builder() - .imagesClassified(completedImages.intValue()) + .imagesClassified(imagesClassified.intValue()) .totalImages(totalImages) .completedImages(completedImages.intValue()) .percentComplete(percentComplete) @@ -225,15 +231,88 @@ public TaskAnalyticsResponse getTaskAnalytics(Long taskId) { .build()) .collect(Collectors.toList()); + // ─── Participation ────────────────── + long activeUsers = facts.stream().map(ClassificationFact::getUserId).distinct().count(); + int avgClassifications = activeUsers > 0 ? (int) (totalClassifications / activeUsers) : 0; + + List responseTimes = facts.stream() + .map(ClassificationFact::getResponseTimeMs) + .filter(java.util.Objects::nonNull) + .sorted() + .collect(Collectors.toList()); + long medianResponseTime = 0L; + if (!responseTimes.isEmpty()) { + int mid = responseTimes.size() / 2; + medianResponseTime = responseTimes.size() % 2 == 1 + ? responseTimes.get(mid) + : (responseTimes.get(mid - 1) + responseTimes.get(mid)) / 2; + } + + TaskAnalyticsResponse.Participation participation = TaskAnalyticsResponse.Participation.builder() + .activeUsers((int) activeUsers) + .totalClassifications(totalClassifications) + .averageClassificationsPerUser(avgClassifications) + .medianResponseTimeMs(medianResponseTime) + .build(); + + // ─── Quality ──────────────────────── + double sumCredibility = 0; + int credibilityCount = 0; + Map> userCredibility = new HashMap<>(); + for (ClassificationFact f : facts) { + if (f.getCredibilityAtTime() != null) { + sumCredibility += f.getCredibilityAtTime(); + credibilityCount++; + userCredibility.computeIfAbsent(f.getUserId(), k -> new ArrayList<>()).add(f.getCredibilityAtTime()); + } + } + double avgCredibility = credibilityCount > 0 ? sumCredibility / credibilityCount : 0.0; + + int lowQualityCount = 0; + for (List creds : userCredibility.values()) { + double uAvg = creds.stream().mapToDouble(Double::doubleValue).average().orElse(100.0); + if (uAvg < 50.0) { + lowQualityCount++; + } + } + + TaskAnalyticsResponse.Quality quality = TaskAnalyticsResponse.Quality.builder() + .averageCredibility(avgCredibility) + .lowQualityUsers(lowQualityCount) + .build(); + + // ─── Consensus ────────────────────── + Map imageConsensus = new HashMap<>(); + for (ClassificationFact f : facts) { + if (f.getConsensusScore() != null) { + imageConsensus.merge(f.getImageId(), f.getConsensusScore(), Math::max); + } + } + double sumConsensus = 0; + int lowConsensusImages = 0; + for (Double score : imageConsensus.values()) { + sumConsensus += score; + if (score < 0.8) { + lowConsensusImages++; + } + } + double avgConsensus = imageConsensus.isEmpty() ? 0.0 : (sumConsensus / imageConsensus.size()) * 100.0; + + TaskAnalyticsResponse.Consensus consensus = TaskAnalyticsResponse.Consensus.builder() + .overallAverage(avgConsensus) + .lowConsensusImages(lowConsensusImages) + .threshold(0.8) + .build(); + return TaskAnalyticsResponse.builder() .taskId(taskId) .status("ACTIVE") .progress(progress) .speciesAnalytics(saList) .generatedAt(java.time.LocalDateTime.now().toString()) - .consensus(TaskAnalyticsResponse.Consensus.builder().build()) - .participation(TaskAnalyticsResponse.Participation.builder().build()) - .quality(TaskAnalyticsResponse.Quality.builder().build()) + .consensus(consensus) + .participation(participation) + .quality(quality) .timeSeries(List.of()) .build(); } @@ -355,14 +434,15 @@ private UserPerformanceResponse mapToUserPerformanceResponse(Object[] row) { String username = (String) row[0]; int total = row[1] != null ? ((Number) row[1]).intValue() : 0; int correct = row[2] != null ? ((Number) row[2]).intValue() : 0; - double avgCredibility = row[3] != null ? ((Number) row[3]).doubleValue() : 0.0; - double accuracy = total > 0 ? (double) correct / total : 0.0; + int goldTotal = row[3] != null ? ((Number) row[3]).intValue() : 0; + double avgCredibility = row[4] != null ? ((Number) row[4]).doubleValue() : 0.0; + double accuracy = goldTotal > 0 ? (double) correct / goldTotal : 0.0; return UserPerformanceResponse.builder() .username(username) .displayName(username) .totalClassifications(total) - .goldImageClassifications(0) + .goldImageClassifications(goldTotal) .correctGoldClassifications(correct) .goldAccuracy(accuracy) .credibilityScore(avgCredibility) diff --git a/backend/src/main/java/com/swipelab/analytics/application/ExportService.java b/backend/src/main/java/com/swipelab/analytics/application/ExportService.java index 9a29776..d8de6c9 100644 --- a/backend/src/main/java/com/swipelab/analytics/application/ExportService.java +++ b/backend/src/main/java/com/swipelab/analytics/application/ExportService.java @@ -43,10 +43,10 @@ public class ExportService { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; // Legacy header kept for backward-compatible single-task endpoints - private static final String LEGACY_CSV_HEADER = "parent_image_id,crop_id,image_data,username,user_response,classified_at,is_gold_standard"; + private static final String LEGACY_CSV_HEADER = "swipelab_image_id,stardbi_experiment_id,stardbi_image_id,stardbi_crop_id,username,user_response,classified_at,is_gold_standard"; private static final String MULTI_EXPORT_CSV_HEADER = - "classification_id,task_id,task_name,parent_image_id,crop_id,image_src_path,username,user_role,query_species,user_response,credibility_score,is_gold_standard,classified_at"; + "classification_id,task_id,task_name,swipelab_image_id,stardbi_experiment_id,stardbi_image_id,stardbi_crop_id,username,user_role,query_species,user_response,credibility_score,is_gold_standard,classified_at"; // ─── Multi-task export (Issue #257) ─────────────────────────────────────── @@ -101,13 +101,14 @@ public void exportMultiTaskClassificationsAsCsv(List taskIds, String usern Image image = c.getImage(); boolean isGold = goldImageRepository.existsByImageId(image.getId()); - String row = String.format("%d,%d,\"%s\",%s,%s,\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",%s,%b,\"%s\"", + String row = String.format("%d,%d,\"%s\",%d,%s,%s,%s,\"%s\",\"%s\",\"%s\",\"%s\",%s,%b,\"%s\"", c.getId(), c.getTaskId(), escapeCsv(taskNameMap.getOrDefault(c.getTaskId(), "")), + image.getId(), + image.getExperimentId() != null ? image.getExperimentId().toString() : "", image.getParentImageId() != null ? image.getParentImageId().toString() : "", image.getExternalBoxId() != null ? image.getExternalBoxId().toString() : "", - escapeCsv(image.getImageData()), escapeCsv(c.getUsername()), escapeCsv(c.getUserRole() != null ? c.getUserRole() : ""), escapeCsv(c.getQuerySpecies() != null ? c.getQuerySpecies() : ""), @@ -188,7 +189,7 @@ public Map getExportSummary(Long taskId) { summary.put("taskId", taskId); summary.put("totalImages", totalImages); summary.put("totalClassifications", totalClassifications); - summary.put("estimatedCsvSizeKb", totalClassifications * 100 / 1024); // ~100 bytes per row + summary.put("estimatedCsvSizeKb", totalClassifications * 150 / 1024); // ~150 bytes per row return summary; } @@ -197,10 +198,11 @@ public Map getExportSummary(Long taskId) { private String formatLegacyCsvRow(Classification classification, boolean isGoldStandard) { Image image = classification.getImage(); - return String.format("%s,%s,\"%s\",\"%s\",\"%s\",\"%s\",%b", + return String.format("%d,%s,%s,%s,\"%s\",\"%s\",\"%s\",%b", + image.getId(), + image.getExperimentId() != null ? image.getExperimentId().toString() : "", image.getParentImageId() != null ? image.getParentImageId().toString() : "", image.getExternalBoxId() != null ? image.getExternalBoxId().toString() : "", - escapeCsv(image.getImageData()), escapeCsv(classification.getUsername()), escapeCsv(classification.getUserResponse().name()), classification.getCreatedAt() != null ? classification.getCreatedAt().format(DATE_FORMATTER) : "", @@ -210,9 +212,10 @@ private String formatLegacyCsvRow(Classification classification, boolean isGoldS private Map formatJsonObject(Classification classification, boolean isGoldStandard) { Image image = classification.getImage(); Map obj = new HashMap<>(); - obj.put("imageId", image.getParentImageId()); - obj.put("cropId", image.getExternalBoxId()); - obj.put("imageData", image.getImageData()); + obj.put("swipelabImageId", image.getId()); + obj.put("stardbiExperimentId", image.getExperimentId()); + obj.put("stardbiImageId", image.getParentImageId()); + obj.put("stardbiCropId", image.getExternalBoxId()); obj.put("username", classification.getUsername()); obj.put("userResponse", classification.getUserResponse().name()); obj.put("classifiedAt", diff --git a/backend/src/main/java/com/swipelab/analytics/infrastructure/ClassificationFactRepository.java b/backend/src/main/java/com/swipelab/analytics/infrastructure/ClassificationFactRepository.java index 93bbc29..8b02ebe 100644 --- a/backend/src/main/java/com/swipelab/analytics/infrastructure/ClassificationFactRepository.java +++ b/backend/src/main/java/com/swipelab/analytics/infrastructure/ClassificationFactRepository.java @@ -14,20 +14,20 @@ public interface ClassificationFactRepository extends JpaRepository getSpeciesBreakdown(@Param("userId") String userId); List findByTaskId(Long taskId); @@ -76,6 +76,7 @@ public interface ClassificationFactRepository extends JpaRepository findCompletedSpeciesByImageId(@Param("imageId") Long imageId); Optional findByImageIdAndSpecies(Long imageId, String species); + + @Query("SELECT COUNT(DISTINCT c.imageId) FROM ConsensusResult c WHERE c.taskId = :taskId") + Long countCompletedImagesByTaskId(@Param("taskId") Long taskId); } diff --git a/backend/src/main/java/com/swipelab/config/CacheControlInterceptor.java b/backend/src/main/java/com/swipelab/config/CacheControlInterceptor.java index 8ac42cd..aec7278 100644 --- a/backend/src/main/java/com/swipelab/config/CacheControlInterceptor.java +++ b/backend/src/main/java/com/swipelab/config/CacheControlInterceptor.java @@ -95,20 +95,17 @@ public boolean preHandle(HttpServletRequest request, return true; } - // ── Task details (stable once created) ──────────────────────────────── - if (path.matches(".*/tasks/my-tasks/[^/]+") - || path.matches(".*/tasks/dashboard/[^/]+")) { - response.setHeader(CACHE_CONTROL, PRIVATE_120S); - return true; - } - - // ── Personalised task lists (change on every assignment) ────────────── - // These are per-user and mutate the moment the user self-assigns a task. - // Any HTTP cache here would race with the frontend refetch and serve - // stale data, so they must always go to the network. + // ── Task lists & details (always revalidate with backend) ──────────── + // These are volatile and mutate the moment the user self-assigns a task + // or a researcher pauses/archives a task. We use NO_CACHE_PRIVATE so + // the client caches them but ALWAYS revalidates via ETags, preventing + // stale UI bugs while saving bandwidth when nothing has changed. if (path.equals("/api/v1/tasks/my-tasks") - || path.equals("/api/v1/tasks/available-tasks")) { - response.setHeader(CACHE_CONTROL, NO_STORE); + || path.equals("/api/v1/tasks/available-tasks") + || path.equals("/api/v1/tasks/dashboard") + || path.matches(".*/tasks/my-tasks/[^/]+") + || path.matches(".*/tasks/dashboard/[^/]+")) { + response.setHeader(CACHE_CONTROL, NO_CACHE_PRIVATE); return true; } diff --git a/backend/src/main/java/com/swipelab/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/swipelab/exception/GlobalExceptionHandler.java index fc7da42..6c36c2c 100644 --- a/backend/src/main/java/com/swipelab/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/swipelab/exception/GlobalExceptionHandler.java @@ -134,7 +134,7 @@ public ResponseEntity handleDuplicateResourceException( } @ExceptionHandler({ EmailVerificationException.class, PasswordResetException.class, - IllegalArgumentException.class }) + IllegalArgumentException.class, IllegalStateException.class }) public ResponseEntity handleBadRequestExceptions( RuntimeException ex, HttpServletRequest request) { diff --git a/backend/src/main/java/com/swipelab/gamification/api/GamificationController.java b/backend/src/main/java/com/swipelab/gamification/api/GamificationController.java index 229879e..9a3d6a0 100644 --- a/backend/src/main/java/com/swipelab/gamification/api/GamificationController.java +++ b/backend/src/main/java/com/swipelab/gamification/api/GamificationController.java @@ -36,6 +36,7 @@ public ResponseEntity getUserInfo( .score(gamification.getScore()) .badge(gamification.getBadge()) .currentStreak(gamification.getCurrentStreak()) + .longestStreak(gamification.getLongestStreak()) .build()); } diff --git a/backend/src/main/java/com/swipelab/gamification/dto/GamificationUserInfoResponse.java b/backend/src/main/java/com/swipelab/gamification/dto/GamificationUserInfoResponse.java index f8a40d9..c4866f0 100644 --- a/backend/src/main/java/com/swipelab/gamification/dto/GamificationUserInfoResponse.java +++ b/backend/src/main/java/com/swipelab/gamification/dto/GamificationUserInfoResponse.java @@ -9,6 +9,7 @@ public class GamificationUserInfoResponse { private long score; private String badge; private int currentStreak; + private int longestStreak; } diff --git a/backend/src/main/java/com/swipelab/tasks/application/TaskService.java b/backend/src/main/java/com/swipelab/tasks/application/TaskService.java index 5de06e3..f3f79e0 100644 --- a/backend/src/main/java/com/swipelab/tasks/application/TaskService.java +++ b/backend/src/main/java/com/swipelab/tasks/application/TaskService.java @@ -25,6 +25,10 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.security.access.AccessDeniedException; import com.swipelab.auth.application.SecurityAuthorizationService; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; +import com.swipelab.config.CacheConfig; import java.util.Collections; import java.util.List; @@ -72,7 +76,7 @@ public TaskPageResponse getTasksForUser(String username, Pageable pageable) { pageable); List taskResponses = taskPage.getContent().stream() - .map(task -> taskMapper.toResponse(task, true)) // assignedToUser = true + .map(task -> taskMapper.toResponse(task, true, buildProgress(task.getId()))) // assignedToUser = true .collect(Collectors.toList()); return TaskPageResponse.builder() @@ -84,6 +88,7 @@ public TaskPageResponse getTasksForUser(String username, Pageable pageable) { .build(); } + @Cacheable(value = CacheConfig.CACHE_TASK_DETAILS, key = "#taskId + '-' + #username") @Transactional(readOnly = true) public TaskResponse getTaskForUser(Long taskId, String username) { Task task = getTask(taskId); @@ -119,7 +124,7 @@ public TaskResponse getTaskForUser(Long taskId, String username) { throw new ResourceNotFoundException("Task not found or access denied"); } - return taskMapper.toResponse(task, true); + return taskMapper.toResponse(task, true, buildProgress(task.getId())); } @Transactional(readOnly = true) @@ -140,7 +145,7 @@ public TaskPageResponse getExploreTasksForUser(String username, Pageable pageabl pageable); List taskResponses = taskPage.getContent().stream() - .map(task -> taskMapper.toResponse(task, false)) + .map(task -> taskMapper.toResponse(task, false, buildProgress(task.getId()))) .collect(Collectors.toList()); return TaskPageResponse.builder() @@ -152,6 +157,10 @@ public TaskPageResponse getExploreTasksForUser(String username, Pageable pageabl .build(); } + @Caching(evict = { + @CacheEvict(value = CacheConfig.CACHE_ACTIVE_TASKS, allEntries = true), + @CacheEvict(value = CacheConfig.CACHE_TASK_DETAILS, allEntries = true) + }) @Transactional public TaskResponse assignTaskToUser(Long taskId, String username) { Task task = getTask(taskId); @@ -170,7 +179,7 @@ public TaskResponse assignTaskToUser(Long taskId, String username) { } task.getAssignedUsernames().add(username); - return taskMapper.toResponse(task, true); + return taskMapper.toResponse(task, true, buildProgress(task.getId())); } // ========================= @@ -188,6 +197,7 @@ public List getExper } } + @Cacheable(value = CacheConfig.CACHE_ACTIVE_TASKS, key = "#username") @Transactional(readOnly = true) public List getResearcherDashboard(String username) { List tasks; @@ -204,6 +214,7 @@ public List getResearcherDashboard(String username) { .collect(Collectors.toList()); } + @Cacheable(value = CacheConfig.CACHE_TASK_DETAILS, key = "#taskId + '-' + #username") @Transactional(readOnly = true) public TaskResponse getTaskDetailsResearcher(Long taskId, String username) { Task task = getTask(taskId); @@ -211,6 +222,10 @@ public TaskResponse getTaskDetailsResearcher(Long taskId, String username) { return taskMapper.toResponse(task, false, buildProgress(taskId)); } + @Caching(evict = { + @CacheEvict(value = CacheConfig.CACHE_ACTIVE_TASKS, allEntries = true), + @CacheEvict(value = CacheConfig.CACHE_TASK_DETAILS, allEntries = true) + }) @Transactional public TaskResponse createTask(CreateTaskRequest request, String username) { // Validate uniqueness @@ -261,6 +276,10 @@ public TaskResponse createTask(CreateTaskRequest request, String username) { return mapToResponse(task); } + @Caching(evict = { + @CacheEvict(value = CacheConfig.CACHE_ACTIVE_TASKS, allEntries = true), + @CacheEvict(value = CacheConfig.CACHE_TASK_DETAILS, allEntries = true) + }) @Transactional public TaskResponse archiveTask(Long taskId, String username) { Task task = getTask(taskId); @@ -269,6 +288,10 @@ public TaskResponse archiveTask(Long taskId, String username) { return mapToResponse(task); } + @Caching(evict = { + @CacheEvict(value = CacheConfig.CACHE_ACTIVE_TASKS, allEntries = true), + @CacheEvict(value = CacheConfig.CACHE_TASK_DETAILS, allEntries = true) + }) @Transactional public TaskResponse activateTask(Long taskId, String username) { Task task = getTask(taskId); @@ -277,6 +300,10 @@ public TaskResponse activateTask(Long taskId, String username) { return mapToResponse(task); } + @Caching(evict = { + @CacheEvict(value = CacheConfig.CACHE_ACTIVE_TASKS, allEntries = true), + @CacheEvict(value = CacheConfig.CACHE_TASK_DETAILS, allEntries = true) + }) @Transactional public TaskResponse pauseTask(Long taskId, String username) { Task task = getTask(taskId); @@ -285,6 +312,10 @@ public TaskResponse pauseTask(Long taskId, String username) { return mapToResponse(task); } + @Caching(evict = { + @CacheEvict(value = CacheConfig.CACHE_ACTIVE_TASKS, allEntries = true), + @CacheEvict(value = CacheConfig.CACHE_TASK_DETAILS, allEntries = true) + }) @Transactional public TaskResponse updateTask(Long taskId, UpdateTaskRequest request, String username) { Task task = getTask(taskId); diff --git a/backend/src/main/java/com/swipelab/users/application/UserService.java b/backend/src/main/java/com/swipelab/users/application/UserService.java index 06ce64c..162c628 100644 --- a/backend/src/main/java/com/swipelab/users/application/UserService.java +++ b/backend/src/main/java/com/swipelab/users/application/UserService.java @@ -108,6 +108,7 @@ public List getUsersByRole(String roleName) { try { com.swipelab.model.enums.UserRole role = com.swipelab.model.enums.UserRole.valueOf(roleName.toUpperCase()); return userRepository.findByRole(role).stream() + .filter(user -> !securityAuthorizationService.isSuperAdmin(user.getUsername())) .map(authMapper::toUserProfileResponse) .collect(Collectors.toList()); } catch (IllegalArgumentException e) { diff --git a/backend/src/test/java/com/swipelab/analytics/application/AnalyticsServiceTest.java b/backend/src/test/java/com/swipelab/analytics/application/AnalyticsServiceTest.java index 9fa54b3..a53e674 100644 --- a/backend/src/test/java/com/swipelab/analytics/application/AnalyticsServiceTest.java +++ b/backend/src/test/java/com/swipelab/analytics/application/AnalyticsServiceTest.java @@ -7,6 +7,7 @@ import com.swipelab.analytics.infrastructure.*; import com.swipelab.classification.domain.core.Classification.UserResponse; import com.swipelab.classification.infrastructure.ClassificationRepository; +import com.swipelab.classification.infrastructure.ConsensusResultRepository; import com.swipelab.classification.infrastructure.ImageRepository; import com.swipelab.analytics.dto.DashboardStatsResponse; import com.swipelab.analytics.dto.UserPerformanceResponse; @@ -42,6 +43,7 @@ class AnalyticsServiceTest { @Mock private UserRepository userRepository; @Mock private TaskRepository taskRepository; @Mock private ImageRepository imageRepository; + @Mock private ConsensusResultRepository consensusResultRepository; @InjectMocks private AnalyticsService analyticsService; @@ -54,6 +56,7 @@ void getUserProgress_happyFlow_returnsCorrectAccuracy() { Object[] row = {10L, 8L}; when(userDailyStatsRepository.getProgressSince(eq("alice"), any(LocalDate.class))) .thenReturn(row); + when(classificationFactRepository.getUserAccuracy("alice")).thenReturn(0.8); UserProgressResponse result = analyticsService.getUserProgress("alice"); @@ -66,6 +69,7 @@ void getUserProgress_edgeCase_noData_returnsZeros() { // Repository returns null result when the user has no data when(userDailyStatsRepository.getProgressSince(eq("ghost"), any(LocalDate.class))) .thenReturn(null); + when(classificationFactRepository.getUserAccuracy("ghost")).thenReturn(null); UserProgressResponse result = analyticsService.getUserProgress("ghost"); @@ -160,8 +164,8 @@ void getPlatformOverview_edgeCase_noDataYet_returnsZeroFilled() { @Test void getUserPerformanceMetrics_happyFlow_mapsAggregationCorrectly() { - // (userId, total, correct, avgCredibility) - Object[] row = {"alice", 50L, 40L, 0.85}; + // (userId, total, correct, goldTotal, avgCredibility) + Object[] row = {"alice", 100L, 40L, 50L, 0.85}; List perfRows = new java.util.ArrayList<>(); perfRows.add(row); when(classificationFactRepository.getUserPerformanceAggregation(eq(1L))) @@ -172,7 +176,8 @@ void getUserPerformanceMetrics_happyFlow_mapsAggregationCorrectly() { assertEquals(1, result.size()); UserPerformanceResponse resp = result.get(0); assertEquals("alice", resp.getUsername()); - assertEquals(50, resp.getTotalClassifications()); + assertEquals(100, resp.getTotalClassifications()); + assertEquals(50, resp.getGoldImageClassifications()); assertEquals(0.85, resp.getCredibilityScore(), 0.001); assertEquals(0.8, resp.getGoldAccuracy(), 0.001); // 40/50 } @@ -192,7 +197,7 @@ void getUserPerformanceMetrics_edgeCase_nullTaskId_returnsAllUsers() { @Test void getTopPerformers_happyFlow_returnsLimitedList() { - Object[] row = {"bob", 100L, 90L, 0.92}; + Object[] row = {"bob", 200L, 90L, 100L, 0.92}; List topRows = new java.util.ArrayList<>(); topRows.add(row); when(classificationFactRepository.getTopPerformersAggregation(any(Pageable.class))) @@ -233,8 +238,9 @@ void getGlobalStats_happyFlow_returnsCorrectCounts() { @Test void getTaskAnalytics_happyFlow_computesProgressFromImageCounts() { - // 200 total crops in the task, 50 of them classified → 25 % complete - when(classificationFactRepository.countCompletedImages(1L)).thenReturn(50L); + // 200 total crops in the task, 50 of them classified, 5 completed → 2.5 % complete + when(classificationFactRepository.countDistinctImagesByTaskId(1L)).thenReturn(50L); + when(consensusResultRepository.countCompletedImagesByTaskId(1L)).thenReturn(5L); when(classificationFactRepository.findByTaskId(1L)).thenReturn(Collections.emptyList()); when(taskSpeciesStatsRepository.findByTaskId(1L)).thenReturn(Collections.emptyList()); when(imageRepository.countByTaskId(1L)).thenReturn(200L); @@ -244,14 +250,15 @@ void getTaskAnalytics_happyFlow_computesProgressFromImageCounts() { TaskAnalyticsResponse.Progress progress = response.getProgress(); assertEquals(200, progress.getTotalImages()); assertEquals(50, progress.getImagesClassified()); - assertEquals(50, progress.getCompletedImages()); - assertEquals(25.0, progress.getPercentComplete(), 0.001); + assertEquals(5, progress.getCompletedImages()); + assertEquals(2.5, progress.getPercentComplete(), 0.001); } @Test void getTaskAnalytics_edgeCase_noImages_returnsZeroPercent() { // No crops imported yet → avoid division by zero, report 0 % - when(classificationFactRepository.countCompletedImages(2L)).thenReturn(0L); + when(classificationFactRepository.countDistinctImagesByTaskId(2L)).thenReturn(0L); + when(consensusResultRepository.countCompletedImagesByTaskId(2L)).thenReturn(0L); when(classificationFactRepository.findByTaskId(2L)).thenReturn(Collections.emptyList()); when(taskSpeciesStatsRepository.findByTaskId(2L)).thenReturn(Collections.emptyList()); when(imageRepository.countByTaskId(2L)).thenReturn(0L); diff --git a/backend/src/test/java/com/swipelab/config/CacheControlInterceptorTest.java b/backend/src/test/java/com/swipelab/config/CacheControlInterceptorTest.java index 39e4f4b..383a5bd 100644 --- a/backend/src/test/java/com/swipelab/config/CacheControlInterceptorTest.java +++ b/backend/src/test/java/com/swipelab/config/CacheControlInterceptorTest.java @@ -35,17 +35,19 @@ private void handle(String method, String path) throws Exception { // ── Happy paths ──────────────────────────────────────────────────────────── @Test - void GET_tasksList_ShouldSetNoStore() throws Exception { - // my-tasks is per-user and mutates on self-assignment — must never be browser-cached + void GET_tasksList_ShouldSetNoCachePrivate() throws Exception { + // my-tasks is per-user and uses ETag for revalidation handle("GET", "/api/v1/tasks/my-tasks"); - assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store"); + assertThat(response.getHeader("Cache-Control")).contains("no-cache"); + assertThat(response.getHeader("Cache-Control")).contains("private"); } @Test - void GET_availableTasks_ShouldSetNoStore() throws Exception { - // available-tasks shrinks immediately after assignment — must never be browser-cached + void GET_availableTasks_ShouldSetNoCachePrivate() throws Exception { + // available-tasks uses ETag for revalidation handle("GET", "/api/v1/tasks/available-tasks"); - assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store"); + assertThat(response.getHeader("Cache-Control")).contains("no-cache"); + assertThat(response.getHeader("Cache-Control")).contains("private"); } @Test diff --git a/backend/src/test/java/com/swipelab/tasks/application/TaskServiceTest.java b/backend/src/test/java/com/swipelab/tasks/application/TaskServiceTest.java index 67f0438..fb480e4 100644 --- a/backend/src/test/java/com/swipelab/tasks/application/TaskServiceTest.java +++ b/backend/src/test/java/com/swipelab/tasks/application/TaskServiceTest.java @@ -102,7 +102,7 @@ void getTasksForUser_ShouldReturnTasks_WhenGroupsMatch() { Page taskPage = new PageImpl<>(Collections.singletonList(task), pageable, 1); when(taskRepository.findAccessibleTasksForUser(eq(TaskStatus.ACTIVE), eq("testuser"), anySet(), eq(pageable))).thenReturn(taskPage); - when(taskMapper.toResponse(any(Task.class), eq(true))).thenReturn(taskResponse); + when(taskMapper.toResponse(any(Task.class), eq(true), any(TaskProgressResponse.class))).thenReturn(taskResponse); TaskPageResponse response = taskService.getTasksForUser("testuser", pageable); @@ -130,7 +130,7 @@ void getTasksForUser_ShouldReturnEmpty_WhenNoGroups() { void getTaskForUser_ShouldReturnTask_WhenActiveAndAssigned() { when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); when(recipientGroupRepository.findByUsers_Username("testuser")).thenReturn(Collections.singletonList(group)); - when(taskMapper.toResponse(task, true)).thenReturn(taskResponse); + when(taskMapper.toResponse(eq(task), eq(true), any(TaskProgressResponse.class))).thenReturn(taskResponse); TaskResponse response = taskService.getTaskForUser(1L, "testuser"); @@ -300,7 +300,7 @@ void assignTaskToUser_ShouldAddUsername_WhenNotYetAssigned() { task.setAssignedUsernames(new ArrayList<>()); when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); when(taskRepository.existsByIdAndUsernameInAssignedUsers(1L, "testuser")).thenReturn(false); - when(taskMapper.toResponse(task, true)).thenReturn(taskResponse); + when(taskMapper.toResponse(eq(task), eq(true), any(TaskProgressResponse.class))).thenReturn(taskResponse); TaskResponse response = taskService.assignTaskToUser(1L, "testuser"); diff --git a/backend/src/test/java/com/swipelab/users/application/UserServiceTest.java b/backend/src/test/java/com/swipelab/users/application/UserServiceTest.java index 3bb4471..95659f8 100644 --- a/backend/src/test/java/com/swipelab/users/application/UserServiceTest.java +++ b/backend/src/test/java/com/swipelab/users/application/UserServiceTest.java @@ -140,6 +140,30 @@ void getUserCredibility_ShouldReturnScore() { assertEquals(0.85, score); } + @Test + void getUsersByRole_ShouldReturnList_ExcludingSuperAdmin() { + User superAdmin = new User(); + superAdmin.setUsername("superadmin"); + + when(userRepository.findByRole(com.swipelab.model.enums.UserRole.RESEARCHER)) + .thenReturn(List.of(user, superAdmin)); + when(securityAuthorizationService.isSuperAdmin("testuser")).thenReturn(false); + when(securityAuthorizationService.isSuperAdmin("superadmin")).thenReturn(true); + when(authMapper.toUserProfileResponse(user)).thenReturn(profileResponse); + + List responses = userService.getUsersByRole("RESEARCHER"); + + assertEquals(1, responses.size()); + assertEquals("testuser", responses.get(0).getUsername()); + verify(securityAuthorizationService, times(1)).isSuperAdmin("testuser"); + verify(securityAuthorizationService, times(1)).isSuperAdmin("superadmin"); + } + + @Test + void getUsersByRole_ShouldThrowException_WhenRoleInvalid() { + assertThrows(ResourceNotFoundException.class, () -> userService.getUsersByRole("INVALID_ROLE")); + } + @Test void getAllUsers_ShouldReturnList() { when(userRepository.findAll()).thenReturn(Collections.singletonList(user)); diff --git a/frontend/App.tsx b/frontend/App.tsx index 2735774..e01ee41 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -10,6 +10,7 @@ import Toast from 'react-native-toast-message'; import { useAppStateStore } from "@/stores/appStateStore"; import { MaintenanceScreen } from "@/screens/shared/MaintenanceScreen"; import { useHealthCheck } from "@/hooks/useHealthCheck"; +import GlobalDownloadToast from "@/components/ui/GlobalDownloadToast"; export default function App() { @@ -39,6 +40,7 @@ export default function App() { + ); diff --git a/frontend/app/api/apiEndpoints.ts b/frontend/app/api/apiEndpoints.ts index 6faaf8f..69ce7f7 100644 --- a/frontend/app/api/apiEndpoints.ts +++ b/frontend/app/api/apiEndpoints.ts @@ -1,4 +1,7 @@ export const API_ENDPOINTS = { + SYSTEM: { + HEALTH: '/health', + }, AUTH: { LOGIN: '/api/v1/auth/login', REGISTER: '/api/v1/auth/register', diff --git a/frontend/app/api/apiFetch.ts b/frontend/app/api/apiFetch.ts index 099de11..76c3e0c 100644 --- a/frontend/app/api/apiFetch.ts +++ b/frontend/app/api/apiFetch.ts @@ -92,14 +92,33 @@ export async function apiFetch( console.log("[apiFetch] Full exact URL being fetch'ed:", fullUrl); } - const response = await fetch(fullUrl, { - ...init, - credentials: "include", // Required for HttpOnly cookies on web - headers: { - ...(init?.headers ?? {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); + let response: Response; + try { + response = await fetch(fullUrl, { + ...init, + credentials: "include", // Required for HttpOnly cookies on web + headers: { + ...(init?.headers ?? {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch (err) { + // Network error (e.g., ERR_CONNECTION_REFUSED, offline) + (async () => { + try { + const healthRes = await fetch(backendUrl + API_ENDPOINTS.SYSTEM.HEALTH, { method: 'GET' }); + if (!healthRes.ok) { + const { useAppStateStore } = require('@/stores/appStateStore'); + useAppStateStore.getState().setMaintenanceMode(true); + } + } catch (e) { + // Backend is completely unreachable + const { useAppStateStore } = require('@/stores/appStateStore'); + useAppStateStore.getState().setMaintenanceMode(true); + } + })(); + throw err; + } if (response.status === 401) { // Check for Stardbi session expiration before attempting refresh @@ -195,8 +214,8 @@ export async function apiFetch( } } - // Handle generic errors (non-401, non-403) with a Toast - if (!response.ok && response.status !== 401 && response.status !== 403 && response.status !== 500) { + // Handle generic errors (non-401, non-403) with a Toast, excluding 5xx errors which are handled below + if (!response.ok && response.status !== 401 && response.status !== 403 && response.status < 500) { const urlString = input.toString(); if (!urlString.includes('/login') && !urlString.includes('/refresh')) { Toast.show({ @@ -209,8 +228,26 @@ export async function apiFetch( // 500 Maintenance Mode handling if (response.status >= 500) { - const { useAppStateStore } = require('@/stores/appStateStore'); - useAppStateStore.getState().setMaintenanceMode(true); + (async () => { + try { + const healthRes = await fetch(backendUrl + API_ENDPOINTS.SYSTEM.HEALTH, { method: 'GET' }); + if (!healthRes.ok) { + const { useAppStateStore } = require('@/stores/appStateStore'); + useAppStateStore.getState().setMaintenanceMode(true); + } else { + // The backend is alive, it was just an isolated 500 error. + Toast.show({ + type: 'error', + text1: 'Server Error', + text2: 'An unexpected internal error occurred.', + }); + } + } catch (e) { + // Fetch failed entirely + const { useAppStateStore } = require('@/stores/appStateStore'); + useAppStateStore.getState().setMaintenanceMode(true); + } + })(); } return response; diff --git a/frontend/app/api/queries.ts b/frontend/app/api/queries.ts index a91317f..b81ccd0 100644 --- a/frontend/app/api/queries.ts +++ b/frontend/app/api/queries.ts @@ -7,7 +7,7 @@ export const QUERY_KEYS = { myTasks: ['tasks', 'my'], availableTasks: ['tasks', 'available'], dashboardTasks: ['tasks', 'dashboard'], - taskDetails: (id: string | number) => ['tasks', id], + taskDetails: (id: string | number) => ['tasks', Number(id)], experiments: ['tasks', 'experiments'], // User Profile @@ -92,7 +92,7 @@ export const useAllStatistics = () => { fetchJson(API_ENDPOINTS.STATISTICS.VS_EXPERTS), fetchJson(API_ENDPOINTS.STATISTICS.VS_USERS), fetchJson(API_ENDPOINTS.STATISTICS.BREAKDOWN), - fetchJson(API_ENDPOINTS.GAMIFICATION.USER_INFO).catch(() => ({ score: 0, badge: null, currentStreak: 0 })), + fetchJson(API_ENDPOINTS.GAMIFICATION.USER_INFO).catch(() => ({ score: 0, badge: null, currentStreak: 0, longestStreak: 0 })), ]); return { summary, vsExperts, vsUsers, breakdown, userInfo }; }, @@ -311,7 +311,17 @@ export const useUpdateTaskStatus = () => { }, onSuccess: (updatedTask, { taskId }) => { queryClient.setQueryData(QUERY_KEYS.taskDetails(taskId), updatedTask); - queryClient.invalidateQueries({ queryKey: QUERY_KEYS.dashboardTasks }); + queryClient.setQueryData(QUERY_KEYS.dashboardTasks, (oldData: any) => { + if (!Array.isArray(oldData)) return oldData; + return oldData.map((task: any) => + task.taskId === Number(taskId) ? updatedTask : task + ); + }); + }, + onSettled: (data, error, { taskId }) => { + // Broadly invalidate to ensure all lists and analytics screens reflect the new status + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['analytics'] }); } }); }; diff --git a/frontend/app/components/researcher/ExportModal.tsx b/frontend/app/components/researcher/ExportModal.tsx index 58b0590..94396e6 100644 --- a/frontend/app/components/researcher/ExportModal.tsx +++ b/frontend/app/components/researcher/ExportModal.tsx @@ -18,6 +18,8 @@ import { useThemeStore } from '@/stores/themeStore'; import MultiSelect, { MultiSelectOption } from '@/components/ui/MultiSelect'; import { useAdminTasks, useExportClassificationsCsv } from '@/api/queries'; import { downloadCsvBlob } from '@/services/csvDownload'; +import { useDownloadStore } from '@/stores/downloadStore'; +import Toast from 'react-native-toast-message'; interface ExportModalProps { visible: boolean; @@ -60,22 +62,42 @@ export default function ExportModal({ visible, onClose }: ExportModalProps) { } }; + const { addTasks, removeTasks } = useDownloadStore(); + const handleExport = async () => { if (selectedTaskIds.length === 0) return; + // Build the list of tasks to export based on selected IDs + const tasksToExport = selectedTaskIds.map((id) => { + const numericId = Number(id); + const taskOpt = taskOptions.find(t => t.id === numericId); + return { taskId: numericId, taskName: taskOpt?.label ?? `Task #${numericId}` }; + }); + + const numericIds = tasksToExport.map(t => t.taskId); + + // Register tasks in global background download store + addTasks(tasksToExport); + + // Close the modal immediately to allow background operation + setSelectedTaskIds([]); + onClose(); + try { - const numericIds = selectedTaskIds.map((id) => Number(id)); const blob = await exportMutation.mutateAsync(numericIds); const today = new Date().toISOString().slice(0, 10); const filename = `swipelab_classifications_export_${today}.csv`; await downloadCsvBlob(blob, filename); - - Alert.alert('Success', `Exported ${selectedTaskIds.length} task(s) to CSV.`); - setSelectedTaskIds([]); - onClose(); } catch (err: any) { - Alert.alert('Export Failed', err?.message ?? 'Something went wrong. Please try again.'); + Toast.show({ + type: 'error', + text1: 'Export Failed', + text2: err?.message ?? 'Something went wrong. Please try again.', + }); + } finally { + // Remove tasks from active background list + removeTasks(numericIds); } }; diff --git a/frontend/app/components/researcher/LabelDistributionBar.tsx b/frontend/app/components/researcher/LabelDistributionBar.tsx index 567f2d0..3058df5 100644 --- a/frontend/app/components/researcher/LabelDistributionBar.tsx +++ b/frontend/app/components/researcher/LabelDistributionBar.tsx @@ -11,8 +11,8 @@ type Props = { const LABEL_CONFIG: Record = { YES: { color: '#10B981', emoji: '✅' }, NO: { color: '#EF4444', emoji: '❌' }, - DONT_KNOW: { color: '#9CA3AF', emoji: '❓' }, - TRASH: { color: '#F59E0B', emoji: '🗑️' }, + DONT_KNOW: { color: '#F59E0B', emoji: '❓' }, + TRASH: { color: '#9CA3AF', emoji: '🗑️' }, }; export default function LabelDistributionBar({ data }: Props) { diff --git a/frontend/app/components/researcher/TaskCard.tsx b/frontend/app/components/researcher/TaskCard.tsx index 82e717c..6643528 100644 --- a/frontend/app/components/researcher/TaskCard.tsx +++ b/frontend/app/components/researcher/TaskCard.tsx @@ -118,53 +118,57 @@ export default function TaskCard({ task, onPress, onEdit, onToggleStatus, onArch {/* Divider */} - + {task.status !== 'ARCHIVED' && ( + <> + - {/* Action row */} - - {/* Toggle status */} - {isProcessing ? ( - - - Importing… - - ) : ( - { e.stopPropagation(); onToggleStatus(); }} - > - - - {isActive ? 'Pause' : 'Resume'} - - - )} + {/* Action row */} + + {/* Toggle status */} + {isProcessing ? ( + + + Importing… + + ) : ( + { e.stopPropagation(); onToggleStatus(); }} + > + + + {isActive ? 'Pause' : 'Resume'} + + + )} - - { e.stopPropagation(); onEdit(); }} - > - - + + { e.stopPropagation(); onEdit(); }} + > + + - { e.stopPropagation(); onArchive(); }} - > - - - - + { e.stopPropagation(); onArchive(); }} + > + + + + + + )} ); diff --git a/frontend/app/components/ui/ConfirmationModal.tsx b/frontend/app/components/ui/ConfirmationModal.tsx new file mode 100644 index 0000000..44dafc3 --- /dev/null +++ b/frontend/app/components/ui/ConfirmationModal.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { useThemeStore } from '@/stores/themeStore'; +import { Colors } from '../../../constants/theme'; +import { Ionicons } from '@expo/vector-icons'; + +interface ConfirmationModalProps { + visible: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmationModal({ + visible, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + onConfirm, + onCancel, +}: ConfirmationModalProps) { + const { theme } = useThemeStore(); + const themeColors = Colors[theme as keyof typeof Colors]; + const isDark = theme === 'dark'; + + if (!visible) return null; + + return ( + + + + + + + {title} + + + + {message} + + + + + {cancelText} + + + + {confirmText} + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + card: { + width: '100%', + maxWidth: 400, + borderRadius: 16, + borderWidth: 1, + padding: 24, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.25, + shadowRadius: 10, + elevation: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + fontSize: 20, + fontWeight: '700', + }, + message: { + fontSize: 15, + lineHeight: 22, + marginBottom: 24, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + }, + btn: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + cancelBtn: { + backgroundColor: 'transparent', + borderWidth: 1, + }, + confirmBtn: { + backgroundColor: '#EF4444', + }, + cancelBtnText: { + fontSize: 15, + fontWeight: '600', + }, + confirmBtnText: { + fontSize: 15, + fontWeight: '600', + color: '#FFF', + }, +}); diff --git a/frontend/app/components/ui/GlobalDownloadToast.tsx b/frontend/app/components/ui/GlobalDownloadToast.tsx new file mode 100644 index 0000000..8b19987 --- /dev/null +++ b/frontend/app/components/ui/GlobalDownloadToast.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, StyleSheet, Text, ActivityIndicator, Platform } from 'react-native'; +import { useDownloadStore } from '@/stores/downloadStore'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../../constants/theme'; +import { useThemeStore } from '@/stores/themeStore'; + +export default function GlobalDownloadToast() { + const activeExports = useDownloadStore((state) => state.activeExports); + const { theme } = useThemeStore(); + const themeColors = Colors[theme as keyof typeof Colors]; + const isDark = theme === 'dark'; + + const [visible, setVisible] = useState(false); + const [completed, setCompleted] = useState(false); + const opacity = useRef(new Animated.Value(0)).current; + + // Keep track of previous length to detect completion + const prevLengthRef = useRef(activeExports.length); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + const currentLength = activeExports.length; + + if (currentLength > 0) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setVisible(true); + setCompleted(false); + + Animated.timing(opacity, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + } else if (currentLength === 0 && prevLengthRef.current > 0) { + // Just finished + setCompleted(true); + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + Animated.timing(opacity, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => { + setVisible(false); + setCompleted(false); + }); + }, 3000); + } + + prevLengthRef.current = currentLength; + }, [activeExports.length, opacity]); + + if (!visible) return null; + + let text = ''; + if (completed) { + text = 'Download Complete'; + } else { + const names = activeExports.map(t => t.taskName); + if (names.length === 1) { + text = `Downloading: ${names[0]}`; + } else if (names.length === 2) { + text = `Downloading: ${names[0]}, ${names[1]}`; + } else if (names.length > 2) { + text = `Downloading: ${names[0]}, ${names[1]} +${names.length - 2} more`; + } + } + + return ( + + {completed ? ( + + ) : ( + + )} + + {text} + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 90, // Above bottom toolbar + right: 20, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + borderWidth: 1, + gap: 10, + maxWidth: 300, + zIndex: 9999, + }, + text: { + fontSize: 14, + fontWeight: '500', + flexShrink: 1, + } +}); diff --git a/frontend/app/screens/researcher/AddTaskScreen.tsx b/frontend/app/screens/researcher/AddTaskScreen.tsx index 96d2964..a635ded 100644 --- a/frontend/app/screens/researcher/AddTaskScreen.tsx +++ b/frontend/app/screens/researcher/AddTaskScreen.tsx @@ -18,7 +18,7 @@ import StepName from "@/components/researcher/addTask/StepName"; import StepRecipients from "@/components/researcher/addTask/StepRecipients"; import StepSpecies from "@/components/researcher/addTask/StepSpecies"; import StepExperiments from "@/components/researcher/addTask/StepExperiments"; -import { useSpeciesPoolImages } from "@/api/queries"; +import { useSpeciesPoolImages, QUERY_KEYS } from "@/api/queries"; const STEPS = ["Name", "Description", "Experiments", "Species", "Recipients", "Confirm"]; @@ -87,7 +87,12 @@ export default function AddTaskScreen({ route, navigation }: any) { } if (researchersRes.ok) { const researchers = await researchersRes.json(); - setAvailableResearchers(researchers.map((r: any) => ({ id: r.username, label: r.displayName || r.username }))); + const currentUser = queryClient.getQueryData(QUERY_KEYS.userProfile); + setAvailableResearchers( + researchers + .filter((r: any) => !currentUser || r.username !== currentUser.username) + .map((r: any) => ({ id: r.username, label: r.displayName || r.username })) + ); } setAvailableOptions(loaded); } catch (error) { diff --git a/frontend/app/screens/researcher/AnalyticsScreen.tsx b/frontend/app/screens/researcher/AnalyticsScreen.tsx index 1579288..923d5c9 100644 --- a/frontend/app/screens/researcher/AnalyticsScreen.tsx +++ b/frontend/app/screens/researcher/AnalyticsScreen.tsx @@ -233,7 +233,7 @@ export default function AnalyticsScreen({ navigation }: any) { {user.totalClassifications} classifications •{' '} - {(user.goldAccuracy ?? 0).toFixed(1)}% accuracy + {((user.goldAccuracy ?? 0) * 100).toFixed(1)}% accuracy diff --git a/frontend/app/screens/researcher/EditTaskScreen.tsx b/frontend/app/screens/researcher/EditTaskScreen.tsx index 676d0d9..779d7ee 100644 --- a/frontend/app/screens/researcher/EditTaskScreen.tsx +++ b/frontend/app/screens/researcher/EditTaskScreen.tsx @@ -93,7 +93,12 @@ export default function EditTaskScreen({ route, navigation }: Props) { } if (researchersRes.ok) { const researchers = await researchersRes.json(); - setAvailableResearchers(researchers.map((r: any) => ({ id: r.username, label: r.displayName || r.username }))); + const currentUser = queryClient.getQueryData(QUERY_KEYS.userProfile); + setAvailableResearchers( + researchers + .filter((r: any) => !currentUser || r.username !== currentUser.username) + .map((r: any) => ({ id: r.username, label: r.displayName || r.username })) + ); } setAvailableOptions(loaded); } catch (error) { @@ -244,6 +249,7 @@ export default function EditTaskScreen({ route, navigation }: Props) { await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.taskDetails(taskId) }); queryClient.invalidateQueries({ queryKey: ["tasks"] }); queryClient.invalidateQueries({ queryKey: ["species", "pool"] }); + queryClient.invalidateQueries({ queryKey: ["analytics"] }); Alert.alert("Success", "Task updated successfully"); navigation.navigate("TasksManagement"); diff --git a/frontend/app/screens/researcher/MaliciousLabelingConfigScreen.tsx b/frontend/app/screens/researcher/MaliciousLabelingConfigScreen.tsx index 44f7212..bd2a9f8 100644 --- a/frontend/app/screens/researcher/MaliciousLabelingConfigScreen.tsx +++ b/frontend/app/screens/researcher/MaliciousLabelingConfigScreen.tsx @@ -102,6 +102,61 @@ const formToPayload = (form: ConfigForm) => ({ warningCooldownMinutes: parseInt(form.warningCooldownMinutes, 10), }); +// ── Sub-components (Extracted to prevent focus loss) ────────────────────────── + +const Field = ({ + label, value, error, unit, keyboardType = 'numeric', colors, onChangeText, +}: { + label: string; value: string; error?: string; unit?: string; keyboardType?: 'numeric' | 'decimal-pad'; colors: any; onChangeText: (val: string) => void; +}) => ( + + + {label} + {unit && {unit}} + + + { + const sanitized = keyboardType === 'decimal-pad' + ? text.replace(/[^0-9.]/g, '') + : text.replace(/[^0-9]/g, ''); + onChangeText(sanitized); + }} + keyboardType={keyboardType} + placeholderTextColor={colors.textSub} + /> + + {error ? {error} : null} + +); + +const SwitchField = ({ label, desc, value, colors, isDark, onValueChange }: { label: string; desc: string; value: boolean; colors: any; isDark: boolean; onValueChange: (val: boolean) => void }) => ( + + + {label} + {desc} + + + +); + +const Section = ({ title, icon, colors, children }: { title: string; icon: string; colors: any; children: React.ReactNode }) => ( + + + + {title} + + {children} + +); + // ── Main component ──────────────────────────────────────────────────────────── export default function MaliciousLabelingConfigScreen() { @@ -158,56 +213,6 @@ export default function MaliciousLabelingConfigScreen() { success: '#22c55e', }; - // ── Sub-components ───────────────────────────────────────────────────────── - - const Field = ({ - label, fieldKey, unit, keyboardType = 'numeric', - }: { - label: string; fieldKey: keyof ConfigForm; unit?: string; keyboardType?: 'numeric' | 'decimal-pad'; - }) => ( - - - {label} - {unit && {unit}} - - - { setForm(prev => prev ? { ...prev, [fieldKey]: v } : prev); setSaveError(''); }} - keyboardType={keyboardType} - placeholderTextColor={c.textSub} - /> - - {errors[fieldKey] ? {errors[fieldKey]} : null} - - ); - - const SwitchField = ({ label, desc }: { label: string; desc: string }) => ( - - - {label} - {desc} - - setForm(prev => prev ? { ...prev, autoBanEnabled: v } : prev)} - trackColor={{ false: isDark ? '#374151' : '#d1d5db', true: c.accent }} - thumbColor="#fff" - /> - - ); - - const Section = ({ title, icon, children }: { title: string; icon: string; children: React.ReactNode }) => ( - - - - {title} - - {children} - - ); - // ── Render ───────────────────────────────────────────────────────────────── if (isLoading || !form) { @@ -258,9 +263,24 @@ export default function MaliciousLabelingConfigScreen() { )} {/* ── Credibility-based detection ─────────────────────── */} -
- - +
+ { setForm(prev => prev ? { ...prev, maliciousThreshold: v } : prev); setSaveError(''); }} + unit="score 0–100" + keyboardType="decimal-pad" + /> + { setForm(prev => prev ? { ...prev, maliciousMinSamples: v } : prev); setSaveError(''); }} + unit="classifications" + />
{/* ── Auto-ban toggle ──────────────────────────────────── */} @@ -271,22 +291,26 @@ export default function MaliciousLabelingConfigScreen() { ? 'Users are automatically banned when strikes reach the ban threshold.' : 'Auto-ban is OFF — strikes accumulate but no ban is issued.' } + value={form.autoBanEnabled} + colors={c} + isDark={isDark} + onValueChange={v => setForm(prev => prev ? { ...prev, autoBanEnabled: v } : prev)} /> {/* ── Fraud detection (speed-based) ───────────────────── */} -
- - - - +
+ { setForm(prev => prev ? { ...prev, minResponseTimeMs: v } : prev); setSaveError(''); }} unit="ms" /> + { setForm(prev => prev ? { ...prev, researcherMinResponseTimeMs: v } : prev); setSaveError(''); }} unit="ms" /> + { setForm(prev => prev ? { ...prev, suspiciousCountForStrike: v } : prev); setSaveError(''); }} unit="events" /> + { setForm(prev => prev ? { ...prev, slidingWindowMinutes: v } : prev); setSaveError(''); }} unit="min" />
{/* ── Escalation ladder ────────────────────────────────── */} -
- - - - +
+ { setForm(prev => prev ? { ...prev, strikesForWarning1: v } : prev); setSaveError(''); }} unit="strikes" /> + { setForm(prev => prev ? { ...prev, strikesForWarning2: v } : prev); setSaveError(''); }} unit="strikes" /> + { setForm(prev => prev ? { ...prev, strikesForBan: v } : prev); setSaveError(''); }} unit="strikes" /> + { setForm(prev => prev ? { ...prev, warningCooldownMinutes: v } : prev); setSaveError(''); }} unit="min" />
{/* ── Save button ──────────────────────────────────────── */} diff --git a/frontend/app/screens/researcher/TaskDetailsScreen.tsx b/frontend/app/screens/researcher/TaskDetailsScreen.tsx index 28b148b..5e116a0 100644 --- a/frontend/app/screens/researcher/TaskDetailsScreen.tsx +++ b/frontend/app/screens/researcher/TaskDetailsScreen.tsx @@ -1,6 +1,6 @@ import { Ionicons } from "@expo/vector-icons"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import React from "react"; +import React, { useState } from "react"; import { ActivityIndicator, Image, @@ -16,6 +16,7 @@ import { researcherStackParamList } from "@/navigation/researcherStack.types"; import { useThemeStore } from '@/stores/themeStore'; import AuthenticatedImage from '@/components/ui/AuthenticatedImage'; import { useTaskDetails, useExperiments, useUpdateTaskStatus } from "@/api/queries"; +import ConfirmationModal from '@/components/ui/ConfirmationModal'; type Props = NativeStackScreenProps; @@ -52,6 +53,7 @@ export default function TaskDetailsScreen({ route, navigation }: Props) { const { data: task, isLoading: loading, error } = useTaskDetails(taskId); const { data: experimentsList } = useExperiments(); const { mutate: updateStatus } = useUpdateTaskStatus(); + const [showArchiveModal, setShowArchiveModal] = useState(false); // ── Loading state ────────────────────────────────────────────────────────── if (loading) { @@ -85,11 +87,12 @@ export default function TaskDetailsScreen({ route, navigation }: Props) { const borderCol = isDark ? themeColors.border : '#BFDBFE'; return ( - + <> + {/* ── Back button ─────────────────────────────────────────────────────── */} navigation.goBack()}> @@ -163,43 +166,45 @@ export default function TaskDetailsScreen({ route, navigation }: Props) { {/* ── Action buttons ───────────────────────────────────────────────── */} - - navigation.navigate('EditTask', { taskId })} - > - - Edit - - - updateStatus({ taskId, action: isActive ? 'pause' : 'activate' })} - > - - - {isActive ? 'Pause' : 'Resume'} - - - - updateStatus({ taskId, action: 'archive' })} - > - - Archive - - + {task.status !== 'ARCHIVED' && ( + + navigation.navigate('EditTask', { taskId })} + > + + Edit + + + updateStatus({ taskId, action: isActive ? 'pause' : 'activate' })} + > + + + {isActive ? 'Pause' : 'Resume'} + + + + setShowArchiveModal(true)} + > + + Archive + + + )} @@ -293,7 +298,20 @@ export default function TaskDetailsScreen({ route, navigation }: Props) { ))} )} - + + + { + updateStatus({ taskId, action: 'archive' }); + setShowArchiveModal(false); + }} + onCancel={() => setShowArchiveModal(false)} + /> + ); } diff --git a/frontend/app/screens/researcher/TasksManagementScreen.tsx b/frontend/app/screens/researcher/TasksManagementScreen.tsx index 70ec050..afabb54 100644 --- a/frontend/app/screens/researcher/TasksManagementScreen.tsx +++ b/frontend/app/screens/researcher/TasksManagementScreen.tsx @@ -8,6 +8,7 @@ import { useAdminTasks, useUpdateTaskStatus } from '@/api/queries'; import TaskCard from '@/components/researcher/TaskCard'; import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout'; import { useThemeStore } from '@/stores/themeStore'; +import ConfirmationModal from '@/components/ui/ConfirmationModal'; type StatusFilter = 'ALL' | 'ACTIVE' | 'PAUSED' | 'ARCHIVED' | 'PROCESSING'; @@ -35,6 +36,7 @@ export default function TasksManagementScreen({ navigation }: any) { const [searchQuery, setSearchQuery] = useState(''); const [activeFilter, setActiveFilter] = useState('ALL'); + const [taskToArchive, setTaskToArchive] = useState(null); const filtered = tasks.filter((t: any) => { const matchesSearch = t.name.toLowerCase().includes(searchQuery.toLowerCase()); @@ -120,13 +122,27 @@ export default function TasksManagementScreen({ navigation }: any) { updateStatus({ taskId: item.taskId, action: item.status === 'ACTIVE' ? 'pause' : 'activate' }); }} onArchive={() => { - updateStatus({ taskId: item.taskId, action: 'archive' }); + setTaskToArchive(item.taskId); }} /> )} /> )} + + { + if (taskToArchive !== null) { + updateStatus({ taskId: taskToArchive, action: 'archive' }); + } + setTaskToArchive(null); + }} + onCancel={() => setTaskToArchive(null)} + /> ); } diff --git a/frontend/app/screens/researcher/UsersManagementScreen.tsx b/frontend/app/screens/researcher/UsersManagementScreen.tsx index 7bdd56a..ce2cccc 100644 --- a/frontend/app/screens/researcher/UsersManagementScreen.tsx +++ b/frontend/app/screens/researcher/UsersManagementScreen.tsx @@ -12,6 +12,7 @@ import { View, Alert, } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import useResponsive from '@/hooks/useResponsive'; import { Colors } from '../../../constants/theme'; import { API_ENDPOINTS } from '@/api/apiEndpoints'; @@ -36,6 +37,7 @@ interface User { // credibilityScore: composite 0–100 score from backend UserProfileResponse credibilityScore: number; active: boolean; + role?: string; } const SORT_ICONS: Record = { @@ -56,6 +58,17 @@ const SORT_LABELS: Record = { desc: 'Credibility ↓', }; +const getAvatarConfig = (role?: string) => { + switch (role) { + case 'SUPER_ADMIN': + return { bg: '#8B5CF6', iconName: 'shield-checkmark' as const, isIonicon: true }; + case 'RESEARCHER': + return { bg: '#3B82F6', iconName: 'flask' as const, isIonicon: true }; + default: + return { bg: '#D8BFD8', isIonicon: false }; + } +}; + export default function UsersManagementScreen() { @@ -142,13 +155,16 @@ export default function UsersManagementScreen() { Credibility {SORT_ICONS[sortOrder]} + Role Status - Actions + Actions {/* Table Body */} - {displayedUsers.map((item: User, index: number) => ( + {displayedUsers.map((item: User, index: number) => { + const avatarConfig = getAvatarConfig(item.role); + return ( - - + + {avatarConfig.isIonicon ? ( + + ) : ( + + )} {item.username} - {item.credibilityScore ?? '—'} + + {item.credibilityScore != null ? Math.round(item.credibilityScore) : '—'} + + + + + {item.role ?? 'USER'} + @@ -190,28 +217,42 @@ export default function UsersManagementScreen() { )} - ))} + ); + })} ); // ─── Mobile: card grid layout ───────────────────────────────────────────── - const renderMobileCard = ({ item }: { item: User }) => ( + const renderMobileCard = ({ item }: { item: User }) => { + const avatarConfig = getAvatarConfig(item.role); + return ( {/* Avatar */} - - + + {avatarConfig.isIonicon ? ( + + ) : ( + + )} {/* Username */} {item.username} + + {/* Role */} + + {item.role ?? 'USER'} + {/* Credibility row */} Credibility - {item.credibilityScore ?? '—'} + + {item.credibilityScore != null ? Math.round(item.credibilityScore) : '—'} + {/* Status badge */} @@ -231,7 +272,8 @@ export default function UsersManagementScreen() { )} - ); + ); + }; return ( state.setMaintenanceMode); + + useEffect(() => { + const statusInterval = setInterval(() => { + setStatusIndex((prev) => (prev + 1) % STATUS_MESSAGES.length); + }, 4000); + return () => clearInterval(statusInterval); + }, []); + + useEffect(() => { + const healthInterval = setInterval(async () => { + try { + const res = await fetch(backendUrl + API_ENDPOINTS.SYSTEM.HEALTH, { method: 'GET' }); + if (res.ok) { + setMaintenanceMode(false); + } + } catch (e) { + // Backend is still down + } + }, 10000); + return () => clearInterval(healthInterval); + }, [setMaintenanceMode]); + const handleContactSupport = () => { Linking.openURL('mailto:swipelab.developers@gmail.com?subject=SwipeLab%20-%20Server%20is%20down'); }; @@ -11,27 +47,41 @@ export function MaintenanceScreen() { return ( - - - We'll be right back! - + + + + + + + + + + We'll be right back! + + + SwipeLab is currently undergoing maintenance. - We're working hard to make things better for you. - + We're working hard to make things better for you. + - + + + {STATUS_MESSAGES[statusIndex]} + - - Contact Support - + + + Contact Support + + ); @@ -40,8 +90,8 @@ export function MaintenanceScreen() { const styles = StyleSheet.create({ container: { ...StyleSheet.absoluteFillObject, - backgroundColor: theme.colors.primary, - zIndex: 9999, // Ensure it overlays everything + backgroundColor: '#ffffff', + zIndex: 9999, }, content: { flex: 1, @@ -62,27 +112,39 @@ const styles = StyleSheet.create({ title: { fontSize: theme.typography.sizes.xl, fontWeight: 'bold', - color: '#ffffff', + color: theme.colors.primary, marginBottom: theme.spacing.md, textAlign: 'center', }, message: { fontSize: theme.typography.sizes.md, - color: 'rgba(255, 255, 255, 0.8)', + color: '#666666', textAlign: 'center', lineHeight: 24, - marginBottom: theme.spacing.xl, + marginBottom: theme.spacing.lg, }, - loader: { + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.03)', + paddingVertical: theme.spacing.sm, + paddingHorizontal: theme.spacing.lg, + borderRadius: theme.borderRadius.round, marginBottom: theme.spacing.xxl, }, + loader: { + marginRight: theme.spacing.sm, + }, + statusText: { + color: theme.colors.primary, + fontSize: theme.typography.sizes.sm, + fontWeight: '500', + }, contactButton: { - backgroundColor: 'rgba(255, 255, 255, 0.2)', + backgroundColor: theme.colors.primary, paddingVertical: theme.spacing.md, paddingHorizontal: theme.spacing.xl, borderRadius: theme.borderRadius.round, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.5)', }, contactButtonText: { color: '#ffffff', diff --git a/frontend/app/screens/shared/ProfileScreen.tsx b/frontend/app/screens/shared/ProfileScreen.tsx index 15a3d6f..727c62c 100644 --- a/frontend/app/screens/shared/ProfileScreen.tsx +++ b/frontend/app/screens/shared/ProfileScreen.tsx @@ -157,16 +157,22 @@ export default function ProfileScreen() { Badges - {myBadges?.map((badge: any, index: number) => ( - - {badge.iconUrl && typeof badge.iconUrl === 'string' && badge.iconUrl.startsWith('http') ? ( - - ) : ( - - )} - {badge.title} - - ))} + {myBadges && myBadges.length > 0 ? ( + myBadges.map((badge: any, index: number) => ( + + {badge.iconUrl && typeof badge.iconUrl === 'string' && badge.iconUrl.startsWith('http') ? ( + + ) : ( + + )} + {badge.title} + + )) + ) : ( + + No badges earned yet. Keep classifying! + + )} diff --git a/frontend/app/screens/shared/SettingsScreen.tsx b/frontend/app/screens/shared/SettingsScreen.tsx index 03b864e..0c80276 100644 --- a/frontend/app/screens/shared/SettingsScreen.tsx +++ b/frontend/app/screens/shared/SettingsScreen.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Switch } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Switch, Modal, Linking, Platform } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { Ionicons } from '@expo/vector-icons'; import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; @@ -7,10 +7,10 @@ import { useAuthStore } from '@/stores/authStore'; import { useThemeStore } from '@/stores/themeStore'; export default function SettingsScreen() { - const { logout } = useAuthStore(); - const isSuperAdmin = useAuthStore((state) => state.isSuperAdmin); + const { logout, isSuperAdmin, role } = useAuthStore(); const { theme, toggleTheme } = useThemeStore(); const [notifications, setNotifications] = useState(true); + const [isAboutVisible, setIsAboutVisible] = useState(false); const navigation = useNavigation(); const isDarkMode = theme === 'dark'; @@ -39,6 +39,18 @@ export default function SettingsScreen() { }, }; + const handleHelpCenter = () => { + const isResearchMode = role === 'RESEARCHER' || isSuperAdmin; + const url = isResearchMode + ? 'https://swipelab-project.netlify.app/researcher-manual' + : 'https://swipelab-project.netlify.app/user-manual'; + Linking.openURL(url).catch((err) => console.error("Couldn't load page", err)); + }; + + const handleAbout = () => { + setIsAboutVisible(true); + }; + return ( Support - + Help Center @@ -128,7 +140,7 @@ export default function SettingsScreen() { - + About @@ -146,6 +158,32 @@ export default function SettingsScreen() { SwipeLab v1.0.0-alpha + + {/* Custom About Modal */} + setIsAboutVisible(false)} + > + + + About SwipeLab + + SwipeLab v1.0.0-alpha{'\n\n'} + A platform for crowdsourced biological classifications.{'\n'} + Developed for research purposes. + + + setIsAboutVisible(false)} + > + Close + + + + ); } @@ -219,4 +257,48 @@ const styles = StyleSheet.create({ marginTop: 24, fontSize: 12, }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + modalContent: { + width: '100%', + maxWidth: 350, + borderRadius: 16, + padding: 24, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 5, + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 16, + textAlign: 'center', + }, + modalText: { + fontSize: 15, + textAlign: 'center', + lineHeight: 22, + marginBottom: 24, + }, + closeButton: { + backgroundColor: '#007AFF', + paddingVertical: 10, + paddingHorizontal: 24, + borderRadius: 8, + width: '100%', + alignItems: 'center', + }, + closeButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, }); \ No newline at end of file diff --git a/frontend/app/screens/user/LeaderboardScreen.tsx b/frontend/app/screens/user/LeaderboardScreen.tsx index 79fecb9..9e75bc2 100644 --- a/frontend/app/screens/user/LeaderboardScreen.tsx +++ b/frontend/app/screens/user/LeaderboardScreen.tsx @@ -112,7 +112,7 @@ function RankBadge({ themeColors, isDark }: { themeColors: any; isDark: boolean const color = RANK_COLORS[rankData.tier] ?? '#9ca3af'; const nextLabel = rankData.nextTierAt === -1 ? 'MAX' - : `${rankData.yesTagCount} / ${rankData.nextTierAt} tags`; + : `${rankData.yesTagCount} / ${rankData.nextTierAt} Yes labels`; return ( diff --git a/frontend/app/screens/user/StatsScreen.tsx b/frontend/app/screens/user/StatsScreen.tsx index 0880c5a..78e2aa8 100644 --- a/frontend/app/screens/user/StatsScreen.tsx +++ b/frontend/app/screens/user/StatsScreen.tsx @@ -57,6 +57,7 @@ interface UserInfoData { score: number; badge: string | null; currentStreak: number; + longestStreak: number; } interface StatsData { @@ -147,9 +148,9 @@ export default function StatsScreen() { {/* User Profile Summary */} - + - + @@ -158,7 +159,7 @@ export default function StatsScreen() { 🔥 Streak Current: {data.userInfo?.currentStreak ?? 0} days - Longest: {data.summary?.summary?.longestStreak ?? 0} days + Longest: {data.userInfo?.longestStreak ?? 0} days diff --git a/frontend/app/screens/user/SwipeScreen.tsx b/frontend/app/screens/user/SwipeScreen.tsx index 57bc4a3..c286b60 100644 --- a/frontend/app/screens/user/SwipeScreen.tsx +++ b/frontend/app/screens/user/SwipeScreen.tsx @@ -33,9 +33,10 @@ const ACCENT = '#4B7BE5'; export default function SwipeScreen() { const navigation = useNavigation(); const [showReference, setShowReference] = useState(false); - const { dataBatch, currentIndex, activeTaskId, setActiveTaskId, setBatch, nextCard, clearBatch } = + const { dataBatch, currentIndex, activeTaskId, setActiveTaskId, setBatch, appendBatch, nextCard, clearBatch } = useSwipeStore(); const [loading, setLoading] = useState(false); + const [isFetchingNextBatch, setIsFetchingNextBatch] = useState(false); const [error, setError] = useState(null); const [activeWarning, setActiveWarning] = useState(null); @@ -96,9 +97,38 @@ export default function SwipeScreen() { } }; + const fetchNextBatchInBackground = async () => { + setIsFetchingNextBatch(true); + try { + const res = await apiFetch( + API_ENDPOINTS.CLASSIFICATIONS.NEXT_BATCH(activeTaskId as string | number, 5), + { method: 'GET' } + ); + if (res.ok) { + const json = await res.json(); + const newImages = json.images || []; + if (newImages.length > 0) { + appendBatch(newImages); + queryClient.setQueryData(QUERY_KEYS.swipeBatch(activeTaskId as string | number), (old: any) => { + return { images: [...(old?.images || []), ...newImages] }; + }); + } + } + } catch (e: any) { + console.error('Background batch fetch failed', e); + } finally { + setIsFetchingNextBatch(false); + } + }; + const handleSwipe = (direction: SwipeDirection) => { const currentImage = dataBatch[currentIndex]; + const cardsLeft = dataBatch.length - (currentIndex + 1); + if (cardsLeft <= 3 && !loading && !isFetchingNextBatch) { + fetchNextBatchInBackground(); + } + // Immediately advance UI to the next card if (currentIndex + 1 < dataBatch.length) { nextCard(); diff --git a/frontend/app/stores/downloadStore.ts b/frontend/app/stores/downloadStore.ts new file mode 100644 index 0000000..54e1824 --- /dev/null +++ b/frontend/app/stores/downloadStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; + +export interface DownloadTask { + taskId: number; + taskName: string; +} + +interface DownloadState { + activeExports: DownloadTask[]; + addTasks: (tasks: DownloadTask[]) => void; + removeTasks: (taskIds: number[]) => void; +} + +export const useDownloadStore = create((set) => ({ + activeExports: [], + addTasks: (tasks) => + set((state) => { + // Prevent duplicates if a user tries to download the same task again while it's in progress + const existingIds = new Set(state.activeExports.map((t) => t.taskId)); + const newTasks = tasks.filter((t) => !existingIds.has(t.taskId)); + return { activeExports: [...state.activeExports, ...newTasks] }; + }), + removeTasks: (taskIds) => + set((state) => { + const idsToRemove = new Set(taskIds); + return { + activeExports: state.activeExports.filter((t) => !idsToRemove.has(t.taskId)), + }; + }), +})); diff --git a/frontend/app/stores/swipeStore.ts b/frontend/app/stores/swipeStore.ts index e3aec6d..d0422a2 100644 --- a/frontend/app/stores/swipeStore.ts +++ b/frontend/app/stores/swipeStore.ts @@ -7,6 +7,7 @@ interface SwipeStore { activeTaskId: string | number | null; setBatch: (items: any[]) => void; + appendBatch: (items: any[]) => void; nextCard: () => void; clearBatch: () => void; getCurrentImage: () => any | null; @@ -22,6 +23,10 @@ export const useSwipeStore = create((set, get) => ({ set({ dataBatch: items, currentIndex: 0 }); }, + appendBatch: (items: any[]) => { + set((state) => ({ dataBatch: [...state.dataBatch, ...items] })); + }, + nextCard: () => { set((state) => ({ currentIndex: state.currentIndex + 1 })); }, diff --git a/frontend/public/maintenance.gif b/frontend/public/maintenance.gif new file mode 100644 index 0000000..0ec9864 Binary files /dev/null and b/frontend/public/maintenance.gif differ diff --git a/frontend/public/swipelab.gif b/frontend/public/swipelab.gif new file mode 100644 index 0000000..22fda29 Binary files /dev/null and b/frontend/public/swipelab.gif differ diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index 4bcbea7..8673630 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -35,7 +35,7 @@ export async function gotoLogin(page: Page): Promise { try { window.localStorage.clear(); } catch {} }); await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 20000 }); } diff --git a/frontend/tests/e2e/researcher/pause-archive-task.spec.ts b/frontend/tests/e2e/researcher/pause-archive-task.spec.ts index 5005a87..2b3c2f5 100644 --- a/frontend/tests/e2e/researcher/pause-archive-task.spec.ts +++ b/frontend/tests/e2e/researcher/pause-archive-task.spec.ts @@ -15,7 +15,7 @@ const ARCHIVE_TASK = 'E2E Archive Target'; async function loginAsResearcher(page: Page): Promise { await test.step('Login as researcher', async () => { await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1500); await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); @@ -95,6 +95,10 @@ test.describe('[E2E] R8 Pause / Archive Task', () => { // Click the Archive button await expect(page.getByText('Archive', { exact: true }).last()).toBeVisible(); await page.getByText('Archive', { exact: true }).last().click(); + + // Confirm the archive action in the modal + await expect(page.getByText('Are you sure you want to archive', { exact: false }).last()).toBeVisible(); + await page.getByText('Archive', { exact: true }).last().click(); }); await test.step('Verify archive status', async () => {