Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
102 changes: 44 additions & 58 deletions src/main/java/io/papermc/patchroulette/controller/ApiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import io.papermc.patchroulette.model.Patch;
import io.papermc.patchroulette.model.PatchId;
import io.papermc.patchroulette.model.Status;
import io.papermc.patchroulette.model.PatchState;
import io.papermc.patchroulette.service.PatchService;
import io.papermc.patchroulette.util.TimeUtil;
import jakarta.persistence.EntityNotFoundException;
Expand All @@ -13,7 +13,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -44,7 +43,7 @@ public ApiController(final PatchService patchService) {
public ResponseEntity<List<String>> getAvailablePatches(
@RequestParam final String minecraftVersion) {
return ResponseEntity.ok(this.patchService.getAvailablePatches(minecraftVersion).stream()
.map(Patch::getPath)
.map(patch -> patch.id().getPath())
.toList());
}

Expand All @@ -55,17 +54,30 @@ public record PatchDetails(
@Nullable Instant lastUpdated,
@Nullable Duration duration) {}

private static PatchDetails toPatchDetails(final Patch patch) {
final PatchState state = patch.state();
return switch (state) {
case PatchState.Available a ->
new PatchDetails(patch.id().getPath(), state.label(), null, patch.lastUpdated(), null);
case PatchState.InProgress w ->
new PatchDetails(
patch.id().getPath(), state.label(), w.responsibleUser(), patch.lastUpdated(), null);
case PatchState.Completed c ->
new PatchDetails(
patch.id().getPath(),
state.label(),
c.responsibleUser(),
patch.lastUpdated(),
c.duration());
};
}

@PreAuthorize("hasRole('PATCH')")
@GetMapping(value = "/get-all-patches", produces = "application/json")
public ResponseEntity<List<PatchDetails>> getAllPatches(
@RequestParam final String minecraftVersion) {
return ResponseEntity.ok(this.patchService.getAllPatches(minecraftVersion).stream()
.map(patch -> new PatchDetails(
patch.getPath(),
patch.getStatus().name(),
patch.getResponsibleUser(),
patch.getLastUpdated(),
patch.getDuration()))
.map(ApiController::toPatchDetails)
.toList());
}

Expand Down Expand Up @@ -163,6 +175,7 @@ public static class UserStats {
public long wip;
public long done;
public Duration timeSpent;
private final List<TimeUtil.TimeInterval> intervals = new ArrayList<>();

public UserStats(String user, long wip, long done, Duration timeSpent) {
this.user = user;
Expand All @@ -182,64 +195,37 @@ public ResponseEntity<Stats> stats(@RequestParam final String minecraftVersion)
long done = 0;
final Map<String, UserStats> users = new HashMap<>();

// Track intervals for each user
final Map<String, List<TimeUtil.TimeInterval>> userIntervals = new HashMap<>();

for (Patch patch : allPatches) {
final Status status = patch.getStatus();
switch (status) {
case AVAILABLE -> available++;
case WIP -> wip++;
case DONE -> done++;
}

final String responsibleUser = patch.getResponsibleUser();
if (responsibleUser != null) {
users.compute(responsibleUser, (user, userStats) -> {
if (userStats == null) {
userStats = new UserStats(responsibleUser, 0, 0, Duration.ZERO);
}
if (status == Status.WIP) {
userStats.wip++;
} else if (status == Status.DONE) {
userStats.done++;
for (final Patch patch : allPatches) {
switch (patch.state()) {
case PatchState.Available a -> available++;
case PatchState.InProgress w -> {
wip++;
users.computeIfAbsent(
w.responsibleUser(), user -> new UserStats(user, 0, 0, Duration.ZERO))
.wip++;
}
case PatchState.Completed c -> {
done++;
final UserStats userStats = users.computeIfAbsent(
c.responsibleUser(), user -> new UserStats(user, 0, 0, Duration.ZERO));
userStats.done++;
final Instant lastUpdated = patch.lastUpdated();
if (lastUpdated != null) {
userStats.intervals.add(
new TimeUtil.TimeInterval(lastUpdated.minus(c.duration()), lastUpdated));
}
return userStats;
});

// Track the time interval for this patch if it has duration
final Duration duration = patch.getDuration();
final Instant lastUpdated = patch.getLastUpdated();
if (duration != null && lastUpdated != null) {
final Instant startTime = lastUpdated.minus(duration);

userIntervals
.computeIfAbsent(responsibleUser, k -> new ArrayList<>())
.add(new TimeUtil.TimeInterval(startTime, lastUpdated));
}
}
}

// Calculate accurate time spent for each user by merging overlapping intervals
Duration totalTimeSpent = Duration.ZERO;
for (Map.Entry<String, List<TimeUtil.TimeInterval>> entry : userIntervals.entrySet()) {
String user = entry.getKey();
List<TimeUtil.TimeInterval> intervals = entry.getValue();

// Sort intervals by start time
for (final UserStats userStats : users.values()) {
final List<TimeUtil.TimeInterval> intervals = userStats.intervals;
intervals.sort(Comparator.comparing(TimeUtil.TimeInterval::start));

// Merge overlapping intervals
List<TimeUtil.TimeInterval> mergedIntervals = TimeUtil.mergeOverlappingIntervals(intervals);

// Calculate total duration from merged intervals
Duration userDuration = TimeUtil.calculateDuration(mergedIntervals);

// Present for every userIntervals key: both maps are populated together above.
final UserStats userStats = Objects.requireNonNull(users.get(user));
final Duration userDuration =
TimeUtil.calculateDuration(TimeUtil.mergeOverlappingIntervals(intervals));
userStats.timeSpent = userDuration;

// Add to total time
totalTimeSpent = totalTimeSpent.plus(userDuration);
}

Expand Down
93 changes: 2 additions & 91 deletions src/main/java/io/papermc/patchroulette/model/Patch.java
Original file line number Diff line number Diff line change
@@ -1,96 +1,7 @@
package io.papermc.patchroulette.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import java.time.Duration;
import java.time.Instant;
import org.jspecify.annotations.Nullable;

@Entity
@IdClass(PatchId.class)
public class Patch {

@Id
private String minecraftVersion;

@Id
@Column(columnDefinition = "VARCHAR(1024)")
private String path;

@Enumerated(EnumType.ORDINAL)
private Status status;

@Nullable
private String responsibleUser;

@Nullable
private Instant lastUpdated;

@Nullable
private Duration duration;

public Patch() {}

public String getMinecraftVersion() {
return this.minecraftVersion;
}

public void setMinecraftVersion(final String minecraftVersion) {
this.minecraftVersion = minecraftVersion;
}

public String getPath() {
return this.path;
}

public void setPath(final String path) {
this.path = path;
}

public Status getStatus() {
return this.status;
}

public void setStatus(final Status status) {
this.status = status;
}

public @Nullable String getResponsibleUser() {
return this.responsibleUser;
}

public void setResponsibleUser(final @Nullable String responsibleUser) {
this.responsibleUser = responsibleUser;
}

public @Nullable Instant getLastUpdated() {
return lastUpdated;
}

public void setLastUpdated(final @Nullable Instant lastUpdated) {
this.lastUpdated = lastUpdated;
}

public @Nullable Duration getDuration() {
return duration;
}

public void setDuration(final @Nullable Duration duration) {
this.duration = duration;
}

public void updateDuration() {
if (this.lastUpdated != null) {
final Duration elapsed = Duration.between(this.lastUpdated, Instant.now());
if (this.duration == null) {
this.duration = elapsed;
} else {
this.duration = this.duration.plus(elapsed);
}
}
}
}
/** Domain model of a patch, persisted via {@code repository.PatchEntity}. */
public record Patch(PatchId id, PatchState state, @Nullable Instant lastUpdated) {}
36 changes: 36 additions & 0 deletions src/main/java/io/papermc/patchroulette/model/PatchState.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.papermc.patchroulette.model;

import java.time.Duration;
import java.time.Instant;

/**
* Lifecycle state of a patch. Each state carries the data that is only valid in
* that state, so invalid combinations (e.g. a WIP patch without a responsible
* user) are unrepresentable.
*/
public sealed interface PatchState {

/** The status label exposed by the API. */
String label();

record Available() implements PatchState {
@Override
public String label() {
return "AVAILABLE";
}
}

record InProgress(String responsibleUser, Instant startedAt) implements PatchState {
@Override
public String label() {
return "WIP";
}
}

record Completed(String responsibleUser, Duration duration) implements PatchState {
@Override
public String label() {
return "DONE";
}
}
Comment on lines +16 to +35
}
8 changes: 8 additions & 0 deletions src/main/java/io/papermc/patchroulette/model/StateType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.papermc.patchroulette.model;

/** Discriminator for {@link PatchState}, stored in the state_type column. */
public enum StateType {
AVAILABLE,
WIP,
DONE,
}
7 changes: 0 additions & 7 deletions src/main/java/io/papermc/patchroulette/model/Status.java

This file was deleted.

59 changes: 59 additions & 0 deletions src/main/java/io/papermc/patchroulette/repository/PatchCodec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package io.papermc.patchroulette.repository;

import io.papermc.patchroulette.model.Patch;
import io.papermc.patchroulette.model.PatchId;
import io.papermc.patchroulette.model.PatchState;
import io.papermc.patchroulette.model.StateType;
import java.util.Objects;

/** Converts between the domain {@link Patch} and the JPA {@link PatchEntity}. */
public final class PatchCodec {
private PatchCodec() {}

public static Patch toDomain(final PatchEntity entity) {
return new Patch(
new PatchId(entity.getMinecraftVersion(), entity.getPath()),
switch (entity.getStateType()) {
case AVAILABLE -> new PatchState.Available();
case WIP ->
new PatchState.InProgress(
Objects.requireNonNull(
entity.getResponsibleUser(), "WIP patch missing responsible user"),
Objects.requireNonNull(entity.getStartedAt(), "WIP patch missing start time"));
case DONE ->
new PatchState.Completed(
Objects.requireNonNull(
entity.getResponsibleUser(), "DONE patch missing responsible user"),
Objects.requireNonNull(entity.getDuration(), "DONE patch missing duration"));
},
entity.getLastUpdated());
}

public static PatchEntity toEntity(final Patch patch) {
final PatchEntity entity = new PatchEntity();
entity.setMinecraftVersion(patch.id().getMinecraftVersion());
entity.setPath(patch.id().getPath());
switch (patch.state()) {
case PatchState.Available a -> {
entity.setStateType(StateType.AVAILABLE);
entity.setResponsibleUser(null);
entity.setStartedAt(null);
entity.setDuration(null);
}
case PatchState.InProgress w -> {
entity.setStateType(StateType.WIP);
entity.setResponsibleUser(w.responsibleUser());
entity.setStartedAt(w.startedAt());
entity.setDuration(null);
}
case PatchState.Completed c -> {
entity.setStateType(StateType.DONE);
entity.setResponsibleUser(c.responsibleUser());
entity.setStartedAt(null);
entity.setDuration(c.duration());
}
}
entity.setLastUpdated(patch.lastUpdated());
return entity;
}
}
Loading