Compare commits

...

4 Commits

9 changed files with 123 additions and 20 deletions

View File

@ -1,6 +1,6 @@
package com.alttd.altitudeweb.mappers; package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.luckperms.Player; import com.alttd.altitudeweb.database.luckperms.PlayerWithGroup;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt; import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
import com.alttd.altitudeweb.model.StaffPlaytimeDto; import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -15,10 +15,10 @@ import java.util.concurrent.TimeUnit;
public final class StaffPtToStaffPlaytimeMapper { public final class StaffPtToStaffPlaytimeMapper {
private record PlaytimeInfo(long totalPlaytime, long lastPlayed) {} private record PlaytimeInfo(long totalPlaytime, long lastPlayed) {}
public List<StaffPlaytimeDto> map(List<StaffPt> sessions, List<Player> staffMembers, long from, long to) { public List<StaffPlaytimeDto> map(List<StaffPt> sessions, List<PlayerWithGroup> staffMembers, long from, long to, HashMap<String, String> staffGroupsMap) {
Map<UUID, PlaytimeInfo> playtimeData = getUuidPlaytimeInfoMap(sessions, from, to); Map<UUID, PlaytimeInfo> playtimeData = getUuidPlaytimeInfoMap(sessions, from, to);
for (Player staffMember : staffMembers) { for (PlayerWithGroup staffMember : staffMembers) {
if (!playtimeData.containsKey(staffMember.uuid())) { if (!playtimeData.containsKey(staffMember.uuid())) {
playtimeData.put(staffMember.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE)); playtimeData.put(staffMember.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE));
} }
@ -28,14 +28,22 @@ public final class StaffPtToStaffPlaytimeMapper {
for (Map.Entry<UUID, PlaytimeInfo> entry : playtimeData.entrySet()) { for (Map.Entry<UUID, PlaytimeInfo> entry : playtimeData.entrySet()) {
long lastPlayedMillis = entry.getValue().lastPlayed() == Long.MIN_VALUE ? 0L : entry.getValue().lastPlayed(); long lastPlayedMillis = entry.getValue().lastPlayed() == Long.MIN_VALUE ? 0L : entry.getValue().lastPlayed();
StaffPlaytimeDto dto = new StaffPlaytimeDto(); StaffPlaytimeDto dto = new StaffPlaytimeDto();
Optional<PlayerWithGroup> first = staffMembers.stream()
.filter(player -> player.uuid().equals(entry.getKey())).findFirst();
dto.setStaffMember(first.isPresent() ? first.get().username() : entry.getKey().toString());
dto.setStaffMember(staffMembers.stream() dto.setStaffMember(staffMembers.stream()
.filter(player -> player.uuid().equals(entry.getKey())) .filter(player -> player.uuid().equals(entry.getKey()))
.map(Player::username) .map(PlayerWithGroup::username)
.findFirst() .findFirst()
.orElse(entry.getKey().toString()) .orElse(entry.getKey().toString())
); );
dto.setLastPlayed(OffsetDateTime.ofInstant(Instant.ofEpochMilli(lastPlayedMillis), ZoneOffset.UTC)); dto.setLastPlayed(OffsetDateTime.ofInstant(Instant.ofEpochMilli(lastPlayedMillis), ZoneOffset.UTC));
dto.setPlaytime((int) TimeUnit.MILLISECONDS.toMinutes(entry.getValue().totalPlaytime())); dto.setPlaytime((int) TimeUnit.MILLISECONDS.toMinutes(entry.getValue().totalPlaytime()));
if (first.isPresent()) {
dto.setRole(staffGroupsMap.getOrDefault(first.get().group(), "Unknown"));
} else {
dto.setRole("Unknown");
}
results.add(dto); results.add(dto);
} }
return results; return results;

View File

@ -2,6 +2,7 @@ package com.alttd.altitudeweb.services.site;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.Player; import com.alttd.altitudeweb.database.luckperms.Player;
import com.alttd.altitudeweb.database.luckperms.PlayerWithGroup;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper; import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper; import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt; import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@ -23,17 +25,30 @@ import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class StaffPtService { public class StaffPtService {
private final static String STAFF_GROUPS = "'group.admin', 'group.developer', 'group.headmod', 'group.manager', 'group.moderator', 'group.owner', 'group.trainee'"; private final static HashMap<String, String> STAFF_GROUPS_MAP = new HashMap<>();
private final static String STAFF_GROUPS;
static {
STAFF_GROUPS_MAP.put("group.owner", "Owner");
STAFF_GROUPS_MAP.put("group.manager", "Manager");
STAFF_GROUPS_MAP.put("group.admin", "Admin");
STAFF_GROUPS_MAP.put("group.headmod", "Head Mod");
STAFF_GROUPS_MAP.put("group.moderator", "Moderator");
STAFF_GROUPS_MAP.put("group.trainee", "Trainee");
STAFF_GROUPS_MAP.put("group.developer", "Developer");
STAFF_GROUPS = STAFF_GROUPS_MAP.keySet().stream()
.map(group -> "'" + group + "'")
.collect(Collectors.joining(", "));
}
private final StaffPtToStaffPlaytimeMapper staffPtToStaffPlaytimeMapper; private final StaffPtToStaffPlaytimeMapper staffPtToStaffPlaytimeMapper;
public Optional<List<StaffPlaytimeDto>> getStaffPlaytime(Instant from, Instant to) { public Optional<List<StaffPlaytimeDto>> getStaffPlaytime(Instant from, Instant to) {
CompletableFuture<List<Player>> staffMembersFuture = new CompletableFuture<>(); CompletableFuture<List<PlayerWithGroup>> staffMembersFuture = new CompletableFuture<>();
CompletableFuture<List<StaffPt>> staffPlaytimeFuture = new CompletableFuture<>(); CompletableFuture<List<StaffPt>> staffPlaytimeFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS) Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> { .runQuery(sqlSession -> {
log.debug("Loading staff members"); log.debug("Loading staff members");
try { try {
List<Player> staffMemberList = sqlSession.getMapper(TeamMemberMapper.class) List<PlayerWithGroup> staffMemberList = sqlSession.getMapper(TeamMemberMapper.class)
.getTeamMembersOfGroupList(STAFF_GROUPS); .getTeamMembersOfGroupList(STAFF_GROUPS);
staffMembersFuture.complete(staffMemberList); staffMembersFuture.complete(staffMemberList);
} catch (Exception e) { } catch (Exception e) {
@ -41,14 +56,14 @@ public class StaffPtService {
staffMembersFuture.completeExceptionally(e); staffMembersFuture.completeExceptionally(e);
} }
}); });
List<Player> staffMembers = staffMembersFuture.join().stream() List<PlayerWithGroup> staffMembers = staffMembersFuture.join().stream()
.collect(Collectors.collectingAndThen( .collect(Collectors.collectingAndThen(
Collectors.toMap(Player::uuid, player -> player, (player1, player2) -> player1), Collectors.toMap(PlayerWithGroup::uuid, player -> player, (player1, player2) -> player1),
m -> new ArrayList<>(m.values()))); m -> new ArrayList<>(m.values())));
Connection.getConnection(Databases.PROXY_PLAYTIME) Connection.getConnection(Databases.PROXY_PLAYTIME)
.runQuery(sqlSession -> { .runQuery(sqlSession -> {
String staffUUIDs = staffMembers.stream() String staffUUIDs = staffMembers.stream()
.map(Player::uuid) .map(PlayerWithGroup::uuid)
.map(uuid -> "'" + uuid + "'") .map(uuid -> "'" + uuid + "'")
.collect(Collectors.joining(",")); .collect(Collectors.joining(","));
log.debug("Loading staff playtime for group"); log.debug("Loading staff playtime for group");
@ -62,7 +77,7 @@ public class StaffPtService {
} }
}); });
List<StaffPt> join = staffPlaytimeFuture.join(); List<StaffPt> join = staffPlaytimeFuture.join();
HashMap<String, String> staffGroupsMap = new HashMap<>(STAFF_GROUPS_MAP);
return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli())); return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli(), staffGroupsMap));
} }
} }

View File

@ -0,0 +1,6 @@
package com.alttd.altitudeweb.database.luckperms;
import java.util.UUID;
public record PlayerWithGroup(String username, UUID uuid, String group) {
}

View File

@ -22,16 +22,17 @@ public interface TeamMemberMapper {
@ConstructorArgs({ @ConstructorArgs({
@Arg(column = "username", javaType = String.class), @Arg(column = "username", javaType = String.class),
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class) @Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Arg(column = "group", javaType = String.class)
}) })
@Select(""" @Select("""
SELECT players.username, players.uuid SELECT players.username, players.uuid, permissions.permission AS 'group'
FROM luckperms_user_permissions AS permissions FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission IN (${groupPermissions}) WHERE permission IN (${groupPermissions})
AND server = 'global' AND server = 'global'
AND world = 'global' AND world = 'global'
""") """)
List<Player> getTeamMembersOfGroupList(@Param("groupPermissions") String groupPermissions); List<PlayerWithGroup> getTeamMembersOfGroupList(@Param("groupPermissions") String groupPermissions);
} }

View File

@ -18,19 +18,25 @@
</button> </button>
</div> </div>
<table mat-table [dataSource]="staffPt()" class="mat-elevation-z2 full-width"> <table mat-table [dataSource]="sortedStaffPt()" class="mat-elevation-z2 full-width" matSort (matSortChange)="sort.set($event)"
[matSortActive]="sort().active" [matSortDirection]="sort().direction" [matSortDisableClear]="true">
<ng-container matColumnDef="staff_member"> <ng-container matColumnDef="staff_member">
<th mat-header-cell *matHeaderCellDef> Staff Member</th> <th mat-header-cell *matHeaderCellDef mat-sort-header="staff_member">Staff Member</th>
<td mat-cell *matCellDef="let row"> {{ row.staff_member }}</td> <td mat-cell *matCellDef="let row"> {{ row.staff_member }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="playtime"> <ng-container matColumnDef="playtime">
<th mat-header-cell *matHeaderCellDef> Playtime</th> <th mat-header-cell *matHeaderCellDef mat-sort-header="playtime">Playtime</th>
<td mat-cell *matCellDef="let row" <td mat-cell *matCellDef="let row"
[style.color]="row.playtime < 420 ? 'red' : ''"> {{ minutesToHm(row.playtime) }} [style.color]="row.playtime < 420 ? 'red' : ''"> {{ minutesToHm(row.playtime) }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef mat-sort-header="role">Rank</th>
<td mat-cell *matCellDef="let row"> {{ row.role }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>

View File

@ -21,6 +21,7 @@
width: 100%; width: 100%;
} }
.no-data td { .no-data td {
text-align: center; text-align: center;
padding: 16px; padding: 16px;

View File

@ -4,13 +4,14 @@ import {MatTableModule} from '@angular/material/table';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon'; import {MatIconModule} from '@angular/material/icon';
import {MatTooltipModule} from '@angular/material/tooltip'; import {MatTooltipModule} from '@angular/material/tooltip';
import {MatSortModule, Sort} from '@angular/material/sort';
import {SiteService, StaffPlaytime} from '@api'; import {SiteService, StaffPlaytime} from '@api';
import {HeaderComponent} from '@header/header.component'; import {HeaderComponent} from '@header/header.component';
@Component({ @Component({
selector: 'app-staff-pt', selector: 'app-staff-pt',
standalone: true, standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule, MatTooltipModule, HeaderComponent], imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule, MatTooltipModule, MatSortModule, HeaderComponent],
templateUrl: './staff-pt.component.html', templateUrl: './staff-pt.component.html',
styleUrl: './staff-pt.component.scss' styleUrl: './staff-pt.component.scss'
}) })
@ -18,13 +19,31 @@ export class StaffPtComponent implements OnInit {
siteService = inject(SiteService); siteService = inject(SiteService);
staffPt = signal<StaffPlaytime[]>([]); staffPt = signal<StaffPlaytime[]>([]);
sort = signal<Sort>({active: 'playtime', direction: 'desc'} as Sort);
sortedStaffPt = computed<StaffPlaytime[]>(() => {
const data = [...this.staffPt()];
const {active, direction} = this.sort();
if (!direction || !active) return data;
const dir = direction === 'asc' ? 1 : -1;
return data.sort((a, b) => {
switch (active) {
case 'staff_member':
return a.staff_member.localeCompare(b.staff_member) * dir;
case 'role':
return a.role.localeCompare(b.role) * dir;
case 'playtime':
default:
return ((a.playtime ?? 0) - (b.playtime ?? 0)) * dir;
}
});
});
weekStart = signal<Date>(this.getStartOfWeek(new Date())); weekStart = signal<Date>(this.getStartOfWeek(new Date()));
weekEnd = computed(() => this.getEndOfWeek(this.weekStart())); weekEnd = computed(() => this.getEndOfWeek(this.weekStart()));
todayStart = signal<Date>(this.startOfDay(new Date())); todayStart = signal<Date>(this.startOfDay(new Date()));
displayedColumns = ['staff_member', 'playtime']; displayedColumns = ['staff_member', 'playtime', 'role'];
ngOnInit(): void { ngOnInit(): void {
this.loadCurrentWeek(); this.loadCurrentWeek();

View File

@ -483,3 +483,42 @@ main .container {
.margin-auto { .margin-auto {
margin: auto margin: auto
} }
/* Angular Material global theme bridge to app CSS variables */
.theme-light, .theme-dark {
/* Ensure Material table text and headers follow theme font color */
.mat-mdc-table,
.mat-mdc-header-cell,
.mat-mdc-cell,
.mat-mdc-header-row,
.mat-mdc-row {
color: var(--font-color);
}
/* Sort arrow color */
.mat-sort-header-arrow {
color: var(--font-color);
}
}
/* Dark theme overrides for Angular Material tables to ensure readable backgrounds */
.theme-dark {
/* Set a dark surface for the table container */
.mat-mdc-table {
background-color: var(--color-secondary);
}
/* Ensure rows and cells don't bring back white backgrounds from the prebuilt theme */
.mat-mdc-header-row,
.mat-mdc-row,
.mat-mdc-header-cell,
.mat-mdc-cell {
background-color: transparent;
}
/* Optional: keep sort header arrow readable (already set in the bridge, but safe here) */
.mat-sort-header-arrow {
color: var(--font-color);
}
}

View File

@ -40,6 +40,11 @@ components:
$ref: '#/components/schemas/StaffPlaytime' $ref: '#/components/schemas/StaffPlaytime'
StaffPlaytime: StaffPlaytime:
type: object type: object
required:
- staff_member
- playtime
- last_played
- role
properties: properties:
staff_member: staff_member:
type: string type: string
@ -51,3 +56,6 @@ components:
type: string type: string
format: date-time format: date-time
description: Last played timestamp description: Last played timestamp
role:
type: string
description: The role of the staff member