Compare commits

...

3 Commits

Author SHA1 Message Date
John Montagu, the 4th Earl of Sandvich 242562d662
Revamp homepage layout and components
Added git commit history and two-column responsive layout
2024-12-08 01:34:04 -08:00
John Montagu, the 4th Earl of Sandvich 69df0a49e4
feat: Add minimum ringers required in events
Implement Hopcroft-Karp algorithm to determine maximal bipartite
matching to determine if every role in an event can be filled without
ringers.
2024-12-08 01:31:16 -08:00
John Montagu, the 4th Earl of Sandvich ee4f3715ab
Add additional data when fetching teams
Added TeamWithRoleSchema to extend TeamRoleSchema with team role/TL data.
2024-12-08 01:26:10 -08:00
17 changed files with 416 additions and 41 deletions

View File

@ -19,12 +19,6 @@ a.button {
padding: unset;
}
@media (hover: hover) {
a:hover {
background-color: var(--accent-transparent);
}
}
button {
display: flex;
align-items: center;

View File

@ -34,6 +34,7 @@ export type { TeamInviteSchemaList } from './models/TeamInviteSchemaList';
export type { TeamLogsTfIntegrationSchema } from './models/TeamLogsTfIntegrationSchema';
export { TeamRole } from './models/TeamRole';
export type { TeamSchema } from './models/TeamSchema';
export type { TeamWithRoleSchema } from './models/TeamWithRoleSchema';
export type { UpdateEventJson } from './models/UpdateEventJson';
export type { ValidationError } from './models/ValidationError';
export type { ValidationErrorElement } from './models/ValidationErrorElement';

View File

@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TeamWithRoleSchema = {
createdAt: string;
id: number;
isTeamLeader: boolean;
minuteOffset: number;
playerCount: number;
role: string;
teamName: string;
tzTimezone: string;
};

View File

@ -2,8 +2,8 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TeamSchema } from './TeamSchema';
import type { TeamWithRoleSchema } from './TeamWithRoleSchema';
export type ViewTeamsResponse = {
teams: Array<TeamSchema>;
teams: Array<TeamWithRoleSchema>;
};

View File

@ -0,0 +1,11 @@
export default interface Commit {
verified: boolean;
html_url: string;
sha: string;
commit: {
author: {
name: string;
};
message: string;
};
};

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { useCommitsStore } from "@/stores/commits";
import { onMounted, ref } from "vue";
import GithubCommitHistoryItem from "./GithubCommitHistoryItem.vue";
const commitsStore = useCommitsStore();
onMounted(() => {
commitsStore.fetchCommits();
});
</script>
<template>
<div class="commit-history">
<div class="header">
<h2>Commit History</h2>
<a
class="icon"
href="https://github.com/HumanoidSandvichDispenser/availabili.tf/commits"
>
<button class="icon">
<i class="bi bi-view-list"></i>
</button>
</a>
</div>
<div>
<GithubCommitHistoryItem
v-for="commit in commitsStore.commits.slice(0, 5)"
:commit="commit"
/>
</div>
</div>
</template>
<style scoped>
.header {
display: flex;
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import type Commit from "@/commit";
import { computed } from "vue";
const props = defineProps<{
commit: Commit;
}>();
const summary = computed(() => {
return props.commit.commit.message.split("\n\n")[0];
});
const description = computed(() => {
return props.commit.commit.message.split("\n\n")[1];
});
</script>
<template>
<div class="commit-history-item">
<div class="header">
<span class="circle"></span>
<a :href="commit.html_url">
<h3>
{{ summary }}
</h3>
</a>
</div>
<div class="description" v-if="description">
{{ description }}
</div>
</div>
</template>
<style scoped>
.header {
display: flex;
align-items: center;
}
.header a {
color: var(--text);
}
.circle {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--green);
margin-right: 1em;
}
.description {
padding: 0.5rem 2rem;
color: var(--subtext-0);
}
</style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
</script>
<template>
<div class="match-card">
<div class="match-scores">
<div class="team-and-score">
<span class="team-name">
NVBLU
</span>
<span class="score">
3
</span>
</div>
<span class="score">-</span>
<div class="team-and-score">
<span class="score">
2
</span>
<span class="team-name">
RED
</span>
</div>
</div>
</div>
</template>
<style scoped>
.match-card {
display: flex;
justify-content: space-between;
padding: 1rem;
border: 1px solid var(--text);
border-radius: 8px;
}
.match-scores {
display: flex;
justify-content: space-evenly;
width: 100%;
font-weight: 700;
}
.match-scores span {
font-weight: 700;
}
.team-and-score {
display: flex;
gap: 1rem;
align-items: center;
}
.match-scores .score {
font-size: 1.5rem;
}
</style>

View File

@ -11,27 +11,54 @@ onMounted(() => {
</script>
<template>
<aside>
<div>
<div class="teams-header">
<h3>Your Teams</h3>
<RouterLink to="/team/register">
<div>
<div class="teams-header">
<h2>
<i class="bi bi-people-fill margin"></i>
Your Teams
</h2>
<div class="button-group">
<button class="small">
<i class="bi bi-person-plus-fill margin" />
Join a team
</button>
<RouterLink class="button" to="/team/register">
<button class="small accent">
<i class="bi bi-plus-circle-fill margin"></i>
New
</button>
</RouterLink>
</div>
<div
v-if="teams.teams"
v-for="team in teams.teams"
>
<RouterLink :to="'/team/id/' + team.id">
{{ team.teamName }}
</RouterLink>
</div>
</div>
</aside>
<div
v-if="teams.teamsWithRole"
v-for="(team, _, i) in teams.teamsWithRole"
>
<div class="team-item">
<div class="major-info">
<RouterLink :to="'/team/id/' + team.id">
{{ team.teamName }}
</RouterLink>
<span class="tag" v-if="team.isTeamLeader">Team Leader</span>
<span class="tag">{{ teams.roleNames[team.role] }}</span>
</div>
<div class="member-info">
<div class="subtext">
{{ team.playerCount }} member(s)
</div>
<RouterLink
class="button"
:to="{ 'name': 'schedule', 'query': { 'teamId': team.id } }"
>
<button class="icon" v-tooltip="'View schedule'">
<i class="bi bi-calendar-fill"></i>
</button>
</RouterLink>
</div>
</div>
<hr v-if="i < Object.keys(teams.teams).length - 1" />
</div>
</div>
</template>
<style scoped>
@ -48,4 +75,43 @@ aside {
.teams-header h3 {
font-weight: 600;
}
.button-group {
display: flex;
gap: 0.5rem;
}
.team-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1rem 0;
}
.team-item a {
color: var(--text);
font-size: 1rem;
font-weight: 600;
}
.tag {
font-size: 9pt;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background-color: var(--crust);
}
.team-item .major-info, .team-item .member-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-info button.icon {
color: var(--overlay-0);
}
.member-info button.icon:hover {
color: var(--text);
}
</style>

View File

@ -6,7 +6,6 @@ import { createPinia } from "pinia";
import VueSelect from "vue-select";
import { TooltipDirective } from "vue3-tooltip";
import "vue3-tooltip/tooltip.css";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import App from "./App.vue";
import router from "./router";

View File

@ -0,0 +1,30 @@
import type Commit from "@/commit";
import { defineStore } from "pinia";
import { ref } from "vue";
export const useCommitsStore = defineStore("commits", () => {
const commits = ref<Commit[]>([]);
const commitsMap = ref<{ [id: string]: Commit }>({ });
function fetchCommits() {
const user = "HumanoidSandvichDispenser";
const repo = "availabili.tf";
if (commits.value.length == 0) {
fetch(`https://api.github.com/repos/${user}/${repo}/commits`)
.then((response) => response.json())
.then((response: Commit[]) => {
commits.value = response;
response.forEach((commit) => {
commitsMap.value[commit.sha] = commit;
});
});
}
}
return {
commits,
commitsMap,
fetchCommits,
};
});

View File

@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { reactive, type Reactive } from "vue";
import { useClientStore } from "./client";
import { useAuthStore } from "./auth";
import { type TeamSchema, type RoleSchema, type ViewTeamMembersResponse, type CreateTeamJson } from "@/client";
import { type TeamSchema, type RoleSchema, type ViewTeamMembersResponse, type CreateTeamJson, type TeamWithRoleSchema } from "@/client";
export type TeamMap = { [id: number]: TeamSchema };
@ -11,8 +11,14 @@ export const useTeamsStore = defineStore("teams", () => {
const clientStore = useClientStore();
const client = clientStore.client;
const teams: Reactive<{ [id: number]: TeamSchema }> = reactive({});
const teamMembers: Reactive<{ [id: number]: ViewTeamMembersResponse[] }> = reactive({});
const teams = reactive<{ [id: number]: TeamSchema }>({});
const teamsWithRole = reactive<{ [id: number]: TeamWithRoleSchema }>({});
const teamMembers = reactive<{ [id: number]: ViewTeamMembersResponse[] }>({});
const roleNames: { [key: string]: string } = {
"Player": "Player",
"CoachMentor": "Coach/Mentor",
};
async function fetchTeams() {
const response = await clientStore.call(
@ -21,6 +27,7 @@ export const useTeamsStore = defineStore("teams", () => {
);
response.teams.forEach((team) => {
teams[team.id] = team;
teamsWithRole[team.id] = team;
});
return response;
}
@ -70,6 +77,8 @@ export const useTeamsStore = defineStore("teams", () => {
return {
teams,
teamsWithRole,
roleNames,
teamMembers,
fetchTeams,
fetchTeam,

View File

@ -1,13 +1,38 @@
<script setup lang="ts">
import GithubCommitHistory from "@/components/GithubCommitHistory.vue";
import TeamsListSidebar from "../components/TeamsListSidebar.vue";
</script>
<template>
<TeamsListSidebar />
<main>
<h2>JustGetAHouse</h2>
<div>
test
<div class="home-view-container">
<div class="left">
<TeamsListSidebar />
</div>
<div class="right">
<GithubCommitHistory />
</div>
</div>
</main>
</template>
<style scoped>
.home-view-container {
display: flex;
gap: 2rem;
}
.left {
flex: 2;
}
.right {
flex: 1;
}
@media (max-width: 1024px) {
.home-view-container {
flex-direction: column;
}
}
</style>

View File

@ -8,6 +8,7 @@ import MembersList from "@/components/MembersList.vue";
import moment from "moment";
import EventList from "@/components/EventList.vue";
import { useTeamsEventsStore } from "@/stores/teams/events";
import MatchCard from "@/components/MatchCard.vue";
const route = useRoute();
const teamsStore = useTeamsStore();
@ -81,7 +82,8 @@ onMounted(() => {
</button>
</RouterLink>
</h2>
<em class="subtext">No recent matches.</em>
<em class="subtext" v-if="false">No recent matches.</em>
<MatchCard v-else />
</div>
</div>
</template>
@ -135,4 +137,10 @@ onMounted(() => {
.icons button:hover {
color: var(--text);
}
@media (max-width: 1024px) {
.content-container {
flex-direction: column;
}
}
</style>

View File

@ -38,12 +38,49 @@ class Event(app_db.BaseModel):
UniqueConstraint("team_id", "name", "start_time"),
)
def get_maximum_matching(self):
players_teams_roles = app_db.db.session.query(
PlayerTeamRole
).join(
PlayerTeam
).join(
PlayerEvent,
PlayerTeam.player_id == PlayerEvent.player_id
).where(
PlayerTeam.team_id == self.team_id
).where(
PlayerTeam.player_id == PlayerEvent.player_id
).where(
PlayerEvent.event_id == self.id
).all()
role_map = {}
for roles in players_teams_roles:
if roles.player_team_id not in role_map:
role_map[roles.player_team_id] = []
role_map[roles.player_team_id].append(roles.role)
import sys
print(role_map, file=sys.stderr)
required_roles = [
PlayerTeamRole.Role.PocketScout,
PlayerTeamRole.Role.FlankScout,
PlayerTeamRole.Role.PocketSoldier,
PlayerTeamRole.Role.Roamer,
PlayerTeamRole.Role.Demoman,
PlayerTeamRole.Role.Medic,
]
graph = BipartiteGraph(role_map, required_roles)
return graph.hopcroft_karp()
def get_discord_content(self):
start_timestamp = int(self.start_time.timestamp())
players = list(self.players)
# players with a role should be sorted first
players.sort(key=lambda p: p.role is not None, reverse=True)
players_info = []
matchings = self.get_maximum_matching()
ringers_needed = 6 - matchings
for player in players:
player_info = "- "
@ -60,6 +97,10 @@ class Event(app_db.BaseModel):
players_info.append(player_info)
ringers_needed_msg = ""
if ringers_needed > 0:
ringers_needed_msg = f" **({ringers_needed} ringer(s) needed)**"
return "\n".join([
f"# {self.name}",
"",
@ -67,6 +108,7 @@ class Event(app_db.BaseModel):
"",
f"<t:{start_timestamp}:f>",
"\n".join(players_info),
f"Max bipartite matching size: {matchings}" + ringers_needed_msg,
"",
"[Confirm attendance here]" +
f"(https://availabili.tf/team/id/{self.team.id}/events/{self.id})",
@ -130,17 +172,20 @@ class EventSchema(spec.BaseModel):
created_at=model.created_at,
)
class EventPlayersSchema(spec.BaseModel):
players: list["PlayerEventRolesSchema"]
@classmethod
def from_model(cls, model: Event) -> "EventPlayersSchema":
return cls(
players=[PlayerEventRolesSchema.from_model(player) for player in model.players],
roles=[RoleSchema.from_model(player.role.role) for player in model.players if player.role],
)
#class EventPlayersSchema(spec.BaseModel):
# players: list["PlayerEventRolesSchema"]
#
# @classmethod
# def from_model(cls, model: Event) -> "EventPlayersSchema":
# return cls(
# players=[PlayerEventRolesSchema.from_model(player) for player in model.players],
# roles=[RoleSchema.from_model(player.role.role) for player in model.players if player.role],
# )
from models.team import Team
from models.player_event import PlayerEvent
from models.player_event import PlayerEvent, PlayerEventRolesSchema
from models.team_integration import TeamDiscordIntegration
from models.player_team import PlayerTeam
from models.player_team_role import PlayerTeamRole
from utils.bipartite_graph import BipartiteGraph

View File

@ -102,6 +102,24 @@ class TeamSchema(spec.BaseModel):
created_at=team.created_at,
)
class TeamWithRoleSchema(TeamSchema):
role: str
is_team_leader: bool
player_count: int
@classmethod
def from_player_team(cls, player_team: "PlayerTeam"):
return cls(
id=player_team.team.id,
team_name=player_team.team.team_name,
tz_timezone=player_team.team.tz_timezone,
minute_offset=player_team.team.minute_offset,
created_at=player_team.team.created_at,
role=player_team.team_role.name,
is_team_leader=player_team.is_team_leader,
player_count=len(player_team.team.players),
)
from models.player_team import PlayerTeam
from models.team_invite import TeamInvite
from models.team_integration import (