Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
32ccd39
feat: Add initial version of onboarding
markstockhausen Feb 25, 2026
99f89e1
fix onboarding flow
markstockhausen Feb 25, 2026
4a8eeba
conform to Prettier formatting
markstockhausen Feb 25, 2026
49e98c2
Update profile setup
markstockhausen Feb 27, 2026
513e8c4
Fix skip feature, update package-lock.json
markstockhausen Mar 2, 2026
47042eb
Add milestone celebration toasts during mapping sessions
markstockhausen Mar 3, 2026
23c8d8b
Update screenshot on main page, extract CompetencyNetworkViz into reu…
markstockhausen Mar 3, 2026
2d85326
Merge remote-tracking branch 'origin/main' into feature/onboarding
markstockhausen Mar 4, 2026
6f48404
Fix package-lock.json
markstockhausen Mar 4, 2026
333175a
Merge branch 'main' into feature/onboarding
markstockhausen Mar 4, 2026
305892f
Conform to Prettier formatting
markstockhausen Mar 4, 2026
ed96f32
Merge branch 'feature/onboarding' of https://github.com/ls1intum/memo…
markstockhausen Mar 4, 2026
44e8467
Fix package-lock.json
markstockhausen Mar 4, 2026
0cb944d
Fix package-lock.json
markstockhausen Mar 4, 2026
f05a802
Implement AI suggestions
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
3,176 changes: 3,073 additions & 103 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,22 @@
"dependencies": {
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "1.2.4",
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"keycloak-js": "^26.2.2",
"lucide-react": "0.525.0",
"motion": "12.23.24",
"radix-ui": "^1.4.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "3.3.1",
"tailwind-variants": "3.1.1"
},
Expand Down
Binary file added public/sessionPreview2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
Expand All @@ -25,20 +27,30 @@ public class SchedulingController {
private final SchedulingService schedulingService;

@GetMapping("/next-relationship")
@Operation(summary = "Get next relationship to vote on", description = "Returns the next competency pair for this user. 204 if no tasks remain.")
@Operation(summary = "Get next relationship to vote on", description = "Returns a competency pair for the user to map. Uses coverage (70%) and consensus (30%) pipelines. Returns 204 if no tasks remain.")
public ResponseEntity<RelationshipTaskResponse> getNextRelationship(
@RequestHeader("X-User-Id") String userId) {
return schedulingService.getNextTask(userId)
@RequestHeader("X-User-Id") String userId,
@org.springframework.web.bind.annotation.RequestParam(required = false) java.util.List<String> skippedIds) {
return schedulingService.getNextTask(userId, skippedIds)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}

@PostMapping("/vote")
@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.")
@Operation(summary = "Submit a vote on a relationship", description = "Records the user's vote on a competency relationship. Accepts either a relationshipId or originId+destinationId pair (for swapped direction). MATCHES and UNRELATED votes are bidirectional.")
public ResponseEntity<VoteResponse> submitVote(
@RequestHeader("X-User-Id") String userId,
@Valid @RequestBody VoteRequest request) {
VoteResponse response = schedulingService.submitVote(userId, request);
return ResponseEntity.ok(response);
}

@DeleteMapping("/vote/{relationshipId}")
@Operation(summary = "Undo a vote on a relationship", description = "Removes the current user's vote from a relationship. Decrements vote counters and deletes the relationship if no votes remain.")
public ResponseEntity<Void> unvote(
@RequestHeader("X-User-Id") String userId,
@PathVariable String relationshipId) {
schedulingService.unvote(userId, relationshipId);
return ResponseEntity.noContent().build();
}
}
29 changes: 17 additions & 12 deletions server/src/main/java/de/tum/cit/memo/dto/VoteRequest.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package de.tum.cit.memo.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import com.fasterxml.jackson.annotation.JsonIgnore;
import de.tum.cit.memo.enums.RelationshipType;
import jakarta.validation.constraints.NotBlank;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand All @@ -15,20 +16,24 @@
@AllArgsConstructor
public class VoteRequest {

/**
* Origin competency ID.
*/
@NotBlank(message = "originId is required")
@Schema(description = "Origin competency ID", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Existing relationship ID (alternative to originId+destinationId)")
private String relationshipId;

@Schema(description = "Origin competency ID (required if relationshipId is absent)")
private String originId;

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

@NotNull(message = "Relationship type is required")
private RelationshipType relationshipType;

@JsonIgnore
@AssertTrue(message = "Either relationshipId or both originId and destinationId must be provided")
private boolean isIdentifiable() {
boolean hasRelationshipId = relationshipId != null && !relationshipId.isBlank();
boolean hasOriginAndDestination = originId != null && !originId.isBlank()
&& destinationId != null && !destinationId.isBlank();
return hasRelationshipId || hasOriginAndDestination;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ AND NOT EXISTS (
SELECT 1 FROM CompetencyRelationshipVote v
WHERE v.relationshipId = r.id AND v.userId = :userId
)
AND (:skippedIds IS NULL OR r.id NOT IN :skippedIds)
ORDER BY r.entropy DESC
""")
List<CompetencyRelationship> findHighEntropyRelationshipsExcludingUser(
@Param("userId") String userId,
@Param("minVotes") int minVotes,
@Param("maxVotes") int maxVotes,
@Param("minEntropy") double minEntropy,
@Param("skippedIds") List<String> skippedIds,
org.springframework.data.domain.Pageable pageable);

/** All relationships where both endpoints are within the given ID pool. */
Expand All @@ -45,7 +47,9 @@ WHERE NOT EXISTS (
SELECT 1 FROM CompetencyRelationshipVote v
WHERE v.relationshipId = r.id AND v.userId = :userId
)
AND (:skippedIds IS NULL OR r.id NOT IN :skippedIds)
""")
List<CompetencyRelationship> findUnvotedByUser(@Param("userId") String userId,
List<CompetencyRelationship> findUnvotedByUserAndNotSkipped(@Param("userId") String userId,
@Param("skippedIds") List<String> skippedIds,
org.springframework.data.domain.Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@

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

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

boolean existsByRelationshipIdAndUserId(String relationshipId, String userId);

Optional<CompetencyRelationshipVote> findByRelationshipIdAndUserId(String relationshipId, String userId);

@Query(value = """
SELECT CAST(v.created_at AS DATE) AS vote_date,
COUNT(*) AS vote_count
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public ContributorStatsResponse getStats(String userId) {
// pre-window total
List<DailyVoteCount> rawCounts = voteRepository.findDailyVoteCountsWithPreWindowTotal(userId, sinceInstant);

Map<LocalDate, Integer> countMap = new LinkedHashMap<>();
long windowVotes = 0;
long preWindowVotes = 0;
for (DailyVoteCount row : rawCounts) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,33 +58,42 @@ public class SchedulingService {

/** Returns the next pair for a user to vote on, or empty if none left. */
@Transactional
public Optional<RelationshipTaskResponse> getNextTask(String userId) {
public Optional<RelationshipTaskResponse> getNextTask(String userId, List<String> skippedIds) {
assertUserExists(userId);
List<String> skipList;
if (skippedIds != null) {
skipList = skippedIds;
} else {
skipList = List.of();
}

if (random.nextDouble() < COVERAGE_WEIGHT) {
log.debug("Coverage pipeline for user {}", userId);
return coveragePipeline(userId);
return coveragePipeline(userId, skipList);
}

log.debug("Consensus pipeline for user {}", userId);
RelationshipTaskResponse task = consensusPipeline(userId);
RelationshipTaskResponse task = consensusPipeline(userId, skipList);
if (task != null) {
return Optional.of(task);
}

log.debug("No consensus candidates, falling back to coverage");
return coveragePipeline(userId);
return coveragePipeline(userId, skipList);
}

@Transactional
public VoteResponse submitVote(String userId, VoteRequest request) {
assertUserExists(userId);
String originId = request.getOriginId();
String destinationId = request.getDestinationId();
if (originId == null || destinationId == null) {
throw new InvalidOperationException("originId and destinationId are required");
}

CompetencyRelationship rel = findOrCreateRelationship(originId, destinationId);
CompetencyRelationship rel;
if (request.getRelationshipId() != null && !request.getRelationshipId().isBlank()) {
rel = relationshipRepository.findById(request.getRelationshipId())
.orElseThrow(() -> new ResourceNotFoundException(
"Relationship not found: " + request.getRelationshipId()));
} else {
rel = findOrCreateRelationship(request.getOriginId(), request.getDestinationId());
}
if (!recordVoteIfAbsent(rel.getId(), userId, request.getRelationshipType())) {
log.debug("Duplicate vote ignored for user {} on {}", userId, rel.getId());
return toVoteResponse(rel);
Expand All @@ -101,7 +110,52 @@ public VoteResponse submitVote(String userId, VoteRequest request) {
return toVoteResponse(rel);
}

private Optional<RelationshipTaskResponse> coveragePipeline(String userId) {
@Transactional
public void unvote(String userId, String relationshipId) {
assertUserExists(userId);

CompetencyRelationship rel = relationshipRepository.findById(relationshipId)
.orElseThrow(() -> new ResourceNotFoundException("Relationship not found: " + relationshipId));

CompetencyRelationshipVote vote = voteRepository.findByRelationshipIdAndUserId(relationshipId, userId)
.orElseThrow(() -> new ResourceNotFoundException(
"No vote found for user " + userId + " on relationship " + relationshipId));

voteRepository.delete(vote);
removeVote(rel, vote.getRelationshipType());

// If symmetric, also remove the mirrored vote on the reverse relationship
RelationshipType type = vote.getRelationshipType();
if (type == RelationshipType.MATCHES || type == RelationshipType.UNRELATED) {
relationshipRepository.findByOriginIdAndDestinationId(rel.getDestinationId(), rel.getOriginId())
.ifPresent(reverse -> {
voteRepository.findByRelationshipIdAndUserId(reverse.getId(), userId)
.ifPresent(mirrorVote -> {
voteRepository.delete(mirrorVote);
removeVote(reverse, mirrorVote.getRelationshipType());
});
});
}
}

private void removeVote(CompetencyRelationship rel, RelationshipType type) {
switch (type) {
case ASSUMES -> rel.setVoteAssumes(Math.max(0, rel.getVoteAssumes() - 1));
case EXTENDS -> rel.setVoteExtends(Math.max(0, rel.getVoteExtends() - 1));
case MATCHES -> rel.setVoteMatches(Math.max(0, rel.getVoteMatches() - 1));
case UNRELATED -> rel.setVoteUnrelated(Math.max(0, rel.getVoteUnrelated() - 1));
}
rel.recalculateEntropy();

if (rel.getTotalVotes() == 0) {
competencyRepository.decrementDegree(List.of(rel.getOriginId(), rel.getDestinationId()));
relationshipRepository.delete(rel);
} else {
relationshipRepository.save(rel);
}
}

private Optional<RelationshipTaskResponse> coveragePipeline(String userId, List<String> skippedIds) {
List<String> poolIds = getLowDegreeCompetencyIds();
if (poolIds.size() < 2) {
log.debug("Not enough competencies to form pairs");
Expand All @@ -113,6 +167,17 @@ private Optional<RelationshipTaskResponse> coveragePipeline(String userId) {
pairKey(r.getDestinationId(), r.getOriginId())))
.collect(Collectors.toSet());

// Also exclude skipped pairs so we don't present them again
if (skippedIds != null && !skippedIds.isEmpty()) {
for (String skippedId : skippedIds) {
String[] parts = skippedId.split(":");
if (parts.length == 2) {
existingPairs.add(pairKey(parts[0], parts[1]));
existingPairs.add(pairKey(parts[1], parts[0]));
}
}
}

List<String> pool = new ArrayList<>(poolIds);
Collections.shuffle(pool, random);

Expand All @@ -126,16 +191,16 @@ private Optional<RelationshipTaskResponse> coveragePipeline(String userId) {

log.debug("Pool fully connected, finding any unvoted relationship");
return relationshipRepository
.findUnvotedByUser(userId, PageRequest.of(0, 1))
.findUnvotedByUserAndNotSkipped(userId, skippedIds, PageRequest.of(0, 1))
.stream().findFirst()
.map(rel -> toTaskResponse(rel, "COVERAGE"));
}

private RelationshipTaskResponse consensusPipeline(String userId) {
private RelationshipTaskResponse consensusPipeline(String userId, List<String> skippedIds) {
List<CompetencyRelationship> candidates = relationshipRepository
.findHighEntropyRelationshipsExcludingUser(
userId, CONSENSUS_MIN_VOTES, CONSENSUS_MAX_VOTES, CONSENSUS_MIN_ENTROPY,
PageRequest.of(0, CONSENSUS_CANDIDATE_LIMIT));
skippedIds, PageRequest.of(0, CONSENSUS_CANDIDATE_LIMIT));

if (candidates.isEmpty()) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
-- New competency_relationships table with aggregated vote counters + entropy
-- Create scheduling tables for competency mapping

-- competency_relationships: One row per unique (origin, destination) pair
-- Stores aggregated vote counters and precomputed entropy for scheduling
CREATE TABLE "competency_relationships" (
"id" VARCHAR(30) NOT NULL,
"origin_id" VARCHAR(30) NOT NULL,
"destination_id" VARCHAR(30) NOT NULL,

-- vote counters (denormalized for fast reads)
-- Aggregated vote counters (denormalized for fast reads)
"vote_assumes" INT NOT NULL DEFAULT 0,
"vote_extends" INT NOT NULL DEFAULT 0,
"vote_matches" INT NOT NULL DEFAULT 0,
Expand All @@ -24,7 +27,7 @@ CREATE TABLE "competency_relationships" (
CONSTRAINT "chk_origin_ne_dest" CHECK ("origin_id" <> "destination_id")
);

-- individual votes (one per user per relationship)
-- competency_relationships_votes: Raw vote log (one vote per user per relationship)
CREATE TABLE "competency_relationships_votes" (
"id" VARCHAR(30) NOT NULL,
"relationship_id" VARCHAR(30) NOT NULL,
Expand All @@ -36,7 +39,7 @@ CREATE TABLE "competency_relationships_votes" (
CONSTRAINT "uk_relationship_user" UNIQUE ("relationship_id", "user_id")
);

-- indexes for scheduling queries
-- Indexes for efficient scheduling queries
CREATE INDEX "idx_rel_entropy" ON "competency_relationships"("entropy" DESC) WHERE "total_votes" > 0;
CREATE INDEX "idx_rel_total_votes" ON "competency_relationships"("total_votes");
CREATE INDEX "idx_rel_origin" ON "competency_relationships"("origin_id");
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from './components/theme-provider';
import { Layout } from './components/Layout';
import { HomePage } from './pages/HomePage';
Expand All @@ -27,6 +28,7 @@ export function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="memo-theme">
<QueryClientProvider client={queryClient}>
<Toaster position="top-center" richColors />
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Footer } from './footer';

export function Layout() {
return (
<div className="relative min-h-screen flex flex-col bg-gradient-to-br from-[#d7e3ff] via-[#f3f5ff] to-[#e8ecff] dark:bg-slate-950">
<div className="relative min-h-[calc(100vh+3rem)] flex flex-col bg-gradient-to-br from-[#d7e3ff] via-[#f3f5ff] to-[#e8ecff] dark:bg-slate-950">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.9),_rgba(237,242,255,0.55))]" />
<Navbar />
<div className="flex-1 flex flex-col w-full">
Expand Down
Loading