Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

League UI for Leaderboard and Profile Pages #210

Open
wants to merge 25 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3d2f40b
Modularization of task scheduling
GODrums Nov 29, 2024
dda9e42
Improve inline documentation
GODrums Nov 29, 2024
07b0c5c
Add league points calculation and new task for leaderboard
GODrums Nov 30, 2024
e75b255
Liquibase changelog
GODrums Nov 30, 2024
e07bc89
Schedule point calculation
GODrums Dec 1, 2024
4301a08
Introduce PlacementBonus
GODrums Dec 1, 2024
31200e8
Merge branch 'develop' into 170-league-points-calculation-service
GODrums Dec 1, 2024
0e8c58a
Improve JavaDocs
GODrums Dec 2, 2024
5409312
Add more JavaDocs
GODrums Dec 2, 2024
e3e1212
fix: new player condition
GODrums Dec 3, 2024
1219d97
Extract algorithm constants
GODrums Dec 3, 2024
28ede25
League Icons in Leaderboard
GODrums Dec 4, 2024
fd44275
Merge branch 'develop' into 170-league-icons-ui
GODrums Dec 8, 2024
8c66e4d
Add League Overview
GODrums Dec 8, 2024
13b1f21
Merge branch 'develop' into 170-league-icons-ui
GODrums Dec 8, 2024
7dfc8af
Merge branch 'develop' into 170-league-icons-ui
GODrums Dec 9, 2024
2c1d89d
chore: run prettier
GODrums Dec 30, 2024
bcbbdce
Merge branch 'develop' into 170-league-icons-ui
GODrums Dec 30, 2024
b856c52
chore: run lint
GODrums Dec 30, 2024
77add5f
chore: update API specs and client
github-actions[bot] Dec 30, 2024
79afae0
Add button to recalculate points
GODrums Jan 3, 2025
1553fe4
Fetch own leaguepoints
GODrums Jan 4, 2025
ad26cbe
chore: prettier format
GODrums Jan 4, 2025
532b6f3
Remove reviewing button
GODrums Jan 4, 2025
bdf6f8c
Replace crown with star
GODrums Jan 8, 2025
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
11 changes: 11 additions & 0 deletions server/application-server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ paths:
responses:
"200":
description: OK
/workspace/league/reset:
put:
tags:
- workspace
operationId: resetAndRecalculateLeagues
responses:
"200":
description: OK
/workspace/team/{teamId}/repository/{repositoryOwner}/{repositoryName}:
post:
tags:
Expand Down Expand Up @@ -649,6 +657,9 @@ components:
type: string
htmlUrl:
type: string
leaguePoints:
type: integer
format: int32
TeamInfo:
required:
- color
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public record UserInfoDTO(
String email,
@NonNull String avatarUrl,
@NonNull String name,
@NonNull String htmlUrl
@NonNull String htmlUrl,
int leaguePoints
) {
public static UserInfoDTO fromUser(User user) {
return new UserInfoDTO(
Expand All @@ -19,7 +20,8 @@ public static UserInfoDTO fromUser(User user) {
user.getEmail(),
user.getAvatarUrl(),
user.getName(),
user.getHtmlUrl()
user.getHtmlUrl(),
user.getLeaguePoints()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,14 @@ public ResponseEntity<TeamInfoDTO> deleteTeam(@PathVariable Long teamId) {
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}

@PutMapping("/league/reset")
public ResponseEntity<Void> resetAndRecalculateLeagues() {
try {
workspaceService.resetAndRecalculateLeagues();
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
import de.tum.in.www1.hephaestus.gitprovider.user.UserInfoDTO;
import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository;
import de.tum.in.www1.hephaestus.gitprovider.user.UserTeamsDTO;
import de.tum.in.www1.hephaestus.leaderboard.LeaderboardService;
import de.tum.in.www1.hephaestus.leaderboard.LeaguePointsCalculationService;
import de.tum.in.www1.hephaestus.syncing.GitHubDataSyncService;
import de.tum.in.www1.hephaestus.syncing.NatsConsumerService;
import jakarta.transaction.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -62,6 +65,12 @@ public class WorkspaceService {
@Autowired
private LabelRepository labelRepository;

@Autowired
private LeaderboardService leaderboardService;

@Autowired
private LeaguePointsCalculationService leaguePointsCalculationService;

@Value("${nats.enabled}")
private boolean isNatsEnabled;

Expand Down Expand Up @@ -324,4 +333,47 @@ public void automaticallyAssignTeams() {
});
});
}

/**
* Reset and recalculate league points for all users until 01/01/2024
*/
@Transactional
public void resetAndRecalculateLeagues() {
logger.info("Resetting and recalculating league points for all users");

// Reset all users to default points (1000)
userRepository
.findAll()
.forEach(user -> {
user.setLeaguePoints(LeaguePointsCalculationService.POINTS_DEFAULT);
userRepository.save(user);
});

// Get all pull request reviews and issue comments to calculate past leaderboards
var now = OffsetDateTime.now();
var weekAgo = now.minusWeeks(1);

// While we still have reviews in the past, calculate leaderboard and update points
do {
var leaderboard = leaderboardService.createLeaderboard(weekAgo, now, Optional.empty());
if (leaderboard.isEmpty()) {
break;
}

// Update league points for each user
leaderboard.forEach(entry -> {
var user = userRepository.findByLoginWithEagerMergedPullRequests(entry.user().login()).orElseThrow();
int newPoints = leaguePointsCalculationService.calculateNewPoints(user, entry);
user.setLeaguePoints(newPoints);
userRepository.save(user);
});

// Move time window back one week
now = weekAgo;
weekAgo = weekAgo.minusWeeks(1);
// only recalculate points for the last year
} while (weekAgo.isAfter(OffsetDateTime.parse("2024-01-01T00:00:00Z")));

logger.info("Finished recalculating league points");
}
}
58 changes: 58 additions & 0 deletions webapp/src/app/core/modules/openapi/api/workspace.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,4 +957,62 @@ export class WorkspaceService implements WorkspaceServiceInterface {
);
}

/**
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
*/
public resetAndRecalculateLeagues(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any>;
public resetAndRecalculateLeagues(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpResponse<any>>;
public resetAndRecalculateLeagues(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpEvent<any>>;
public resetAndRecalculateLeagues(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any> {

let localVarHeaders = this.defaultHeaders;

let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept;
if (localVarHttpHeaderAcceptSelected === undefined) {
// to determine the Accept header
const httpHeaderAccepts: string[] = [
];
localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts);
}
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}

let localVarHttpContext: HttpContext | undefined = options && options.context;
if (localVarHttpContext === undefined) {
localVarHttpContext = new HttpContext();
}

let localVarTransferCache: boolean | undefined = options && options.transferCache;
if (localVarTransferCache === undefined) {
localVarTransferCache = true;
}


let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}

let localVarPath = `/workspace/league/reset`;
return this.httpClient.request<any>('put', `${this.configuration.basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
withCredentials: this.configuration.withCredentials,
headers: localVarHeaders,
observe: observe,
transferCache: localVarTransferCache,
reportProgress: reportProgress
}
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,10 @@ export interface WorkspaceServiceInterface {
*/
removeUserFromTeam(login: string, teamId: number, extraHttpRequestParams?: any): Observable<UserInfo>;

/**
*
*
*/
resetAndRecalculateLeagues(extraHttpRequestParams?: any): Observable<{}>;

}
1 change: 1 addition & 0 deletions webapp/src/app/core/modules/openapi/model/user-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ export interface UserInfo {
avatarUrl: string;
name: string;
htmlUrl: string;
leaguePoints?: number;
}

3 changes: 2 additions & 1 deletion webapp/src/app/home/home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ <h2 class="text-xl text-muted-foreground">Hi {{ userValue.name }} 👋</h2>
</div>
<app-leaderboard-filter [teams]="teams()" />
</div>
<div class="col-span-2">
<div class="col-span-2 space-y-4">
@if (query.error()) {
<div hlmAlert class="max-w-xl" variant="destructive">
<lucide-angular [img]="CircleX" hlmAlertIcon />
<h4 hlmAlertTitle>Something went wrong...</h4>
<p hlmAlertDesc>We couldn't load the leaderboard. Please try again later.</p>
</div>
} @else {
<app-leaderboard-league [leaguePoints]="userMeQuery.data()?.userInfo?.leaguePoints" />
<div class="border rounded-md border-input overflow-auto">
<app-leaderboard [leaderboard]="query.data()" [isLoading]="query.isPending()" />
</div>
Expand Down
12 changes: 10 additions & 2 deletions webapp/src/app/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ import { LeaderboardComponent } from '@app/home/leaderboard/leaderboard.componen
import { LeaderboardFilterComponent } from './leaderboard/filter/filter.component';
import { SecurityStore } from '@app/core/security/security-store.service';
import { HlmAlertModule } from '@spartan-ng/ui-alert-helm';
import { MetaService } from '@app/core/modules/openapi';
import { MetaService, UserService } from '@app/core/modules/openapi';
import { LeaderboardLegendComponent } from './leaderboard/legend/legends.component';
import { LeaderboardLeagueComponent } from './leaderboard/league/league.component';

dayjs.extend(isoWeek);

@Component({
selector: 'app-home',
standalone: true,
imports: [LeaderboardComponent, LeaderboardFilterComponent, HlmAlertModule, LucideAngularModule, LeaderboardLegendComponent],
imports: [LeaderboardComponent, LeaderboardFilterComponent, HlmAlertModule, LucideAngularModule, LeaderboardLegendComponent, LeaderboardLeagueComponent],
templateUrl: './home.component.html'
})
export class HomeComponent {
Expand All @@ -28,6 +29,7 @@ export class HomeComponent {
securityStore = inject(SecurityStore);
metaService = inject(MetaService);
leaderboardService = inject(LeaderboardService);
userService = inject(UserService);

signedIn = this.securityStore.signedIn;
user = this.securityStore.loadedUser;
Expand Down Expand Up @@ -59,4 +61,10 @@ export class HomeComponent {
queryKey: ['meta'],
queryFn: async () => lastValueFrom(this.metaService.getMetaData())
}));

userMeQuery = injectQuery(() => ({
enabled: !!this.user(),
queryKey: ['user', { id: this.user()?.username }],
queryFn: async () => lastValueFrom(this.userService.getUserProfile(this.user()!.username))
}));
}
9 changes: 8 additions & 1 deletion webapp/src/app/home/leaderboard/leaderboard.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
<thead appTableHeader>
<tr appTableRow>
<th appTableHead class="text-center">Rank</th>
<th appTableHead class="text-center px-0.5">League</th>
<th appTableHead>Contributor</th>
<th appTableHead class="flex items-center gap-1 text-github-done-foreground">
<th appTableHead class="flex justify-center items-center gap-1 text-github-done-foreground">
<hlm-icon size="sm" name="lucideAward" />
Score
</th>
Expand All @@ -14,6 +15,9 @@
@if (isLoading()) {
@for (entry of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; track entry; let idx = $index) {
<tr appTableRow id="skeleton">
<td appTableCell class="flex justify-center">
<hlm-skeleton class="h-5 w-7" [style.width.px]="20 + 1 * idx" />
</td>
<td appTableCell class="flex justify-center">
<hlm-skeleton class="h-5 w-7" [style.width.px]="20 + 1 * idx" />
</td>
Expand Down Expand Up @@ -44,6 +48,9 @@
@for (entry of leaderboard(); track entry.user.login) {
<tr appTableRow routerLink="/user/{{ entry.user.login }}" routerLinkActive="active" ariaCurrentWhenActive="page" [class]="trClass(entry)">
<td appTableCell class="text-center">{{ entry.rank }}</td>
<td appTableCell class="flex justify-center items-center px-0.5">
<app-icon-league [leaguePoints]="entry.user.leaguePoints" />
</td>
<td appTableCell class="py-2">
<span class="flex items-center gap-2 font-medium">
<hlm-avatar>
Expand Down
4 changes: 3 additions & 1 deletion webapp/src/app/home/leaderboard/leaderboard.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TableComponent } from 'app/ui/table/table.component';
import { ReviewsPopoverComponent } from './reviews-popover/reviews-popover.component';
import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm';
import { lucideAward } from '@ng-icons/lucide';
import { LeagueIconComponent } from '@app/ui/league/icon/league-icon.component';

@Component({
selector: 'app-leaderboard',
Expand All @@ -32,7 +33,8 @@ import { lucideAward } from '@ng-icons/lucide';
ReviewsPopoverComponent,
NgIconComponent,
HlmIconComponent,
RouterLink
RouterLink,
LeagueIconComponent
],
providers: [provideIcons({ lucideAward })],
templateUrl: './leaderboard.component.html'
Expand Down
11 changes: 11 additions & 0 deletions webapp/src/app/home/leaderboard/league/league.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div hlmCard>
<div hlmCardContent class="py-4">
<span class="flex justify-between items-center pb-2 gap-2">
<h3 class="text-base font-semibold tracking-wide">Your League</h3>
<app-league-info-modal />
</span>
<div class="flex items-center gap-2">
<app-league-elo-card [leaguePoints]="leaguePoints()" />
</div>
</div>
</div>
15 changes: 15 additions & 0 deletions webapp/src/app/home/leaderboard/league/league.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Component, input } from '@angular/core';
import { HlmCardModule } from '@spartan-ng/ui-card-helm';
import { HlmButtonModule } from '@spartan-ng/ui-button-helm';
import { LeagueEloCardComponent } from '@app/ui/league/elo-card/elo-card.component';
import { LeagueInfoModalComponent } from '@app/ui/league/info-modal/info-modal.component';

@Component({
selector: 'app-leaderboard-league',
standalone: true,
imports: [HlmCardModule, HlmButtonModule, LeagueEloCardComponent, LeagueInfoModalComponent],
templateUrl: './league.component.html'
})
export class LeaderboardLeagueComponent {
leaguePoints = input<number>();
}
26 changes: 26 additions & 0 deletions webapp/src/app/home/leaderboard/league/league.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type Meta, type StoryObj } from '@storybook/angular';
import { LeaderboardLeagueComponent } from './league.component';

const meta: Meta<LeaderboardLeagueComponent> = {
component: LeaderboardLeagueComponent,
tags: ['autodocs'],
parameters: {
layout: 'centered'
},
args: {
leaguePoints: 1100
},
argTypes: {
leaguePoints: {
control: {
type: 'number'
},
description: 'Current League Points to be displayed'
}
}
};

export default meta;
type Story = StoryObj<LeaderboardLeagueComponent>;

export const Default: Story = {};
Loading
Loading