Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
650775a
style: change font and polish UI
markstockhausen Jan 27, 2026
79e45a2
style: update font weight
markstockhausen Jan 27, 2026
430e1c2
style: conform to Prettier formatting
markstockhausen Feb 3, 2026
73a402b
Implement optimized Scheduling Service and Pipeline Logic
markstockhausen Feb 3, 2026
8850cf6
style: update comments
markstockhausen Feb 3, 2026
fe676c3
Implement AI suggestions
markstockhausen Feb 9, 2026
b199432
Update Axios, Optimise scheduling pipeline
markstockhausen Feb 9, 2026
67d6e58
Optimise pipeline logic and clean up code
markstockhausen Feb 10, 2026
60d7f66
Optimise pipeline
markstockhausen Feb 10, 2026
6251a3a
Update logic for bidirectional relationship types
markstockhausen Feb 10, 2026
e683410
Streamline implementation
markstockhausen Feb 10, 2026
141bebd
Conform to Prettier formatting
markstockhausen Feb 10, 2026
64c4f20
Implement AI suggestions
markstockhausen Feb 17, 2026
d863e77
Further AI suggestions
markstockhausen Feb 17, 2026
846fd3d
Merge branch 'main' into feature/implement-scheduling-service
markstockhausen Feb 17, 2026
7916901
feat: add dashboard with contribution heatmap, badges, and session su…
markstockhausen Feb 17, 2026
4b03244
style: fix Prettier formatting
markstockhausen Feb 17, 2026
2190551
Update heatmap
markstockhausen Feb 24, 2026
785d9fa
Conform to Prettier formatting
markstockhausen Feb 24, 2026
2704820
Fix security audit
markstockhausen Feb 24, 2026
d675645
Merge branch 'main' into feature/personal-dashboard
markstockhausen Feb 27, 2026
11585f8
style: change font and polish UI
markstockhausen Jan 27, 2026
ad5044c
Implement AI suggestions
markstockhausen Feb 17, 2026
ad58faa
Simplify relationship saving
markstockhausen Feb 27, 2026
9442bf4
Conform to Prettier formatting
markstockhausen Feb 27, 2026
f9c89cc
Sync package-lock with minimatch
markstockhausen Feb 27, 2026
ff8f807
Update package-lock, implement AI suggestions
markstockhausen Feb 27, 2026
912c97c
Update undo logic, Implement Copilot suggestions
markstockhausen Feb 27, 2026
3c5fdce
Resolve dependency conflicts
markstockhausen Feb 27, 2026
2b3c8c8
Optimize contributor stats query
markstockhausen Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
838 changes: 427 additions & 411 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package de.tum.cit.memo.controller;

import de.tum.cit.memo.dto.ContributorStatsResponse;
import de.tum.cit.memo.service.ContributorStatsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Tag(name = "Contributor Stats", description = "Endpoints for contributor statistics and gamification")
public class ContributorStatsController {

private final ContributorStatsService contributorStatsService;

@GetMapping("/{userId}/stats")
@Operation(summary = "Get contributor statistics", description = "Returns contribution stats including total votes, streaks, daily counts for heatmap, "
+ "and earned badge IDs.")
public ResponseEntity<ContributorStatsResponse> getContributorStats(@PathVariable String userId) {
ContributorStatsResponse stats = contributorStatsService.getStats(userId);
return ResponseEntity.ok(stats);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public ResponseEntity<RelationshipTaskResponse> getNextRelationship(
}

@PostMapping("/vote")
@Operation(summary = "Submit a vote on a relationship", description = "Records the user's vote on a competency pair identified by originId + destinationId.")
@Operation(summary = "Submit a vote on a relationship", description = "Records the user's vote on a competency relationship. Requires originId and destinationId pair. MATCHES and UNRELATED votes are bidirectional.")
public ResponseEntity<VoteResponse> submitVote(
@RequestHeader("X-User-Id") String userId,
@Valid @RequestBody VoteRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package de.tum.cit.memo.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.util.List;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContributorStatsResponse {

private int totalVotes;
private int currentStreak;
private int longestStreak;
private List<DailyCount> dailyCounts;
private List<String> earnedBadges;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DailyCount {
private LocalDate date;
private int count;
}
}
16 changes: 13 additions & 3 deletions server/src/main/java/de/tum/cit/memo/dto/VoteRequest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package de.tum.cit.memo.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import de.tum.cit.memo.enums.RelationshipType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand All @@ -13,12 +15,20 @@
@AllArgsConstructor
public class VoteRequest {

@NotNull
/**
* Origin competency ID.
*/
@NotBlank(message = "originId is required")
@Schema(description = "Origin competency ID", requiredMode = Schema.RequiredMode.REQUIRED)
private String originId;

@NotNull
/**
* Destination competency ID.
*/
@NotBlank(message = "destinationId is required")
@Schema(description = "Destination competency ID", requiredMode = Schema.RequiredMode.REQUIRED)
private String destinationId;

@NotNull
@NotNull(message = "Relationship type is required")
private RelationshipType relationshipType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public void recalculateEntropy() {
}

double ent = 0.0;
int[] counts = { voteAssumes, voteExtends, voteMatches, voteUnrelated };
int[] counts = {voteAssumes, voteExtends, voteMatches, voteUnrelated};
for (int count : counts) {
if (count > 0) {
double p = (double) count / total;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@

import de.tum.cit.memo.entity.CompetencyRelationshipVote;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.List;

@Repository
public interface CompetencyRelationshipVoteRepository extends JpaRepository<CompetencyRelationshipVote, String> {

boolean existsByRelationshipIdAndUserId(String relationshipId, String userId);

long countByUserId(String userId);

@Query(value = "SELECT CAST(v.created_at AS DATE) AS vote_date, COUNT(*) AS vote_count "
+ "FROM competency_relationships_votes v "
+ "WHERE v.user_id = :userId AND v.created_at >= :since "
+ "GROUP BY CAST(v.created_at AS DATE) "
+ "ORDER BY vote_date", nativeQuery = true)
List<Object[]> findDailyVoteCountsByUserId(@Param("userId") String userId, @Param("since") Instant since);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ private CompetencyRelationship buildAndSave(String originId, String destinationI
.destinationId(destinationId)
.build();

CompetencyRelationship saved = relationshipRepository.save(relationship);
competencyRepository.incrementDegree(List.of(originId, destinationId));
return relationshipRepository.save(relationship);
return saved;
}

private static void validateDistinctIds(String originId, String destinationId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package de.tum.cit.memo.service;

import de.tum.cit.memo.dto.ContributorStatsResponse;
import de.tum.cit.memo.dto.ContributorStatsResponse.DailyCount;
import de.tum.cit.memo.repository.CompetencyRelationshipVoteRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* Computes contributor statistics on-the-fly from vote timestamps.
* All date calculations use UTC.
*/
@Service
@RequiredArgsConstructor
public class ContributorStatsService {

private final CompetencyRelationshipVoteRepository voteRepository;

@Transactional(readOnly = true)
public ContributorStatsResponse getStats(String userId) {
LocalDate today = LocalDate.now(ZoneOffset.UTC);
LocalDate since = today.minusDays(365);
Instant sinceInstant = since.atStartOfDay(ZoneOffset.UTC).toInstant();

// Fetch raw daily counts from votes
List<Object[]> rawCounts = voteRepository.findDailyVoteCountsByUserId(userId, sinceInstant);
Comment thread
markstockhausen marked this conversation as resolved.
Outdated

// Build ordered map of date -> count
Map<LocalDate, Integer> countMap = new LinkedHashMap<>();
int totalVotes = 0;
for (Object[] row : rawCounts) {
LocalDate date = ((java.sql.Date) row[0]).toLocalDate();
int count = ((Number) row[1]).intValue();
countMap.put(date, count);
totalVotes += count;
}

// Also count votes before the 365-day window
long olderVotes = voteRepository.countByUserId(userId) - totalVotes;
totalVotes += (int) Math.max(olderVotes, 0);
Comment thread
markstockhausen marked this conversation as resolved.
Outdated
Comment thread
markstockhausen marked this conversation as resolved.
Outdated

// Build daily counts list for heatmap
List<DailyCount> dailyCounts = new ArrayList<>();
for (Map.Entry<LocalDate, Integer> entry : countMap.entrySet()) {
dailyCounts.add(DailyCount.builder()
.date(entry.getKey())
.count(entry.getValue())
.build());
}

// Calculate streaks
int currentStreak = calculateCurrentStreak(countMap, today);
int longestStreak = calculateLongestStreak(countMap, since, today);

// Evaluate badges
List<String> earnedBadges = evaluateBadges(totalVotes, currentStreak, longestStreak);

return ContributorStatsResponse.builder()
.totalVotes(totalVotes)
.currentStreak(currentStreak)
.longestStreak(longestStreak)
.dailyCounts(dailyCounts)
.earnedBadges(earnedBadges)
.build();
}

/**
* Current streak: count consecutive days ending at today (or yesterday).
*/
private int calculateCurrentStreak(Map<LocalDate, Integer> countMap, LocalDate today) {
// Check if the user contributed today; if not, start from yesterday
LocalDate checkDate = countMap.containsKey(today) ? today : today.minusDays(1);
int streak = 0;
while (countMap.containsKey(checkDate) && countMap.get(checkDate) > 0) {
streak++;
checkDate = checkDate.minusDays(1);
}
return streak;
}

/**
* Longest streak within the daily counts window.
*/
private int calculateLongestStreak(Map<LocalDate, Integer> countMap, LocalDate since, LocalDate today) {
int longest = 0;
int current = 0;
LocalDate d = since;
while (!d.isAfter(today)) {
if (countMap.containsKey(d) && countMap.get(d) > 0) {
current++;
longest = Math.max(longest, current);
} else {
current = 0;
}
d = d.plusDays(1);
}
return longest;
}

/**
* Evaluate which badges the user has earned.
*/
private List<String> evaluateBadges(int totalVotes, int currentStreak, int longestStreak) {
List<String> badges = new ArrayList<>();
int maxStreak = Math.max(currentStreak, longestStreak);

if (totalVotes >= 1) {
badges.add("first-steps");
}
if (totalVotes >= 10) {
badges.add("getting-started");
}
if (totalVotes >= 50) {
badges.add("half-century");
}
if (totalVotes >= 100) {
badges.add("century");
}
if (maxStreak >= 3) {
badges.add("streak-3");
}
if (maxStreak >= 7) {
badges.add("streak-7");
}
if (maxStreak >= 30) {
badges.add("streak-30");
}

return badges;
}
}
Loading