Add viewing teammate schedules

master
John Montagu, the 4th Earl of Sandvich 2024-11-13 16:05:15 -08:00
parent 104282da30
commit 5f45a8ebda
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
14 changed files with 370 additions and 67 deletions

View File

@ -11,6 +11,7 @@ export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { AddPlayerJson } from './models/AddPlayerJson';
export type { AvailabilitySchema } from './models/AvailabilitySchema';
export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
export type { PlayerSchema } from './models/PlayerSchema';
@ -30,6 +31,7 @@ export type { ViewScheduleResponse } from './models/ViewScheduleResponse';
export type { ViewTeamMembersResponse } from './models/ViewTeamMembersResponse';
export type { ViewTeamMembersResponseList } from './models/ViewTeamMembersResponseList';
export type { ViewTeamResponse } from './models/ViewTeamResponse';
export type { ViewTeamScheduleResponse } from './models/ViewTeamScheduleResponse';
export type { ViewTeamsResponse } from './models/ViewTeamsResponse';
export { DefaultService } from './services/DefaultService';

View File

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type AvailabilitySchema = {
availability?: Array<number>;
steamId: string;
username: string;
};

View File

@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AvailabilitySchema } from './AvailabilitySchema';
export type ViewTeamScheduleResponse = {
playerAvailability: Record<string, AvailabilitySchema>;
};

View File

@ -13,6 +13,7 @@ import type { ViewAvailablePlayersResponse } from '../models/ViewAvailablePlayer
import type { ViewScheduleResponse } from '../models/ViewScheduleResponse';
import type { ViewTeamMembersResponseList } from '../models/ViewTeamMembersResponseList';
import type { ViewTeamResponse } from '../models/ViewTeamResponse';
import type { ViewTeamScheduleResponse } from '../models/ViewTeamScheduleResponse';
import type { ViewTeamsResponse } from '../models/ViewTeamsResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import type { BaseHttpRequest } from '../core/BaseHttpRequest';
@ -130,6 +131,32 @@ export class DefaultService {
mediaType: 'application/json',
});
}
/**
* get_team_availability <GET>
* @param windowStart
* @param teamId
* @param windowSizeDays
* @returns ViewTeamScheduleResponse OK
* @throws ApiError
*/
public getApiScheduleTeam(
windowStart: string,
teamId: number,
windowSizeDays: number = 7,
): CancelablePromise<ViewTeamScheduleResponse> {
return this.httpRequest.request({
method: 'GET',
url: '/api/schedule/team',
query: {
'windowStart': windowStart,
'teamId': teamId,
'windowSizeDays': windowSizeDays,
},
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* view_available_at_time <GET>
* @param startTime

View File

@ -1,12 +1,18 @@
<script setup lang="ts">
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted } from "vue";
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted, type PropType } from "vue";
import moment, { type Moment } from "moment";
const model = defineModel();
const selectedTime = defineModel("selectedTime");
const hoveredIndex = defineModel("hoveredIndex");
const props = defineProps({
selectionMode: Number,
isDisabled: Boolean,
dateStart: Date,
overlay: Array,
dateStart: Object as PropType<Moment>,
firstHour: {
type: Number,
default: 14
@ -17,6 +23,8 @@ const props = defineProps({
},
});
const isEditing = computed(() => !props.isDisabled);
const selectionStart = reactive({ x: undefined, y: undefined });
const selectionEnd = reactive({ x: undefined, y: undefined });
const isCtrlDown = ref(false);
@ -74,11 +82,40 @@ const daysOfWeek = [
"Sat"
];
function getTimeAtCell(dayIndex: number, hour: number) {
return props.dateStart.clone()
.add(dayIndex, "days")
.add(hour, "hours");
}
function onSlotMouseOver($event, x, y) {
hoveredIndex.value = 24 * x + y;
if (!isEditing.value) {
return;
}
if ($event.buttons & 1 == 1) {
isShiftDown.value = $event.shiftKey;
isCtrlDown.value = $event.ctrlKey;
selectionEnd.x = x;
selectionEnd.y = y;
}
}
function onSlotMouseLeave($event, x, y) {
let index = 24 * x + y;
if (hoveredIndex.value == index) {
hoveredIndex.value = undefined;
}
}
const isMouseDown = ref(false);
const selectionValue = ref(0);
function onSlotMouseDown($event, x, y) {
if (props.isDisabled) {
if (!isEditing.value) {
return;
}
@ -96,22 +133,8 @@ function onSlotMouseDown($event, x, y) {
console.log("selected " + x + " " + y);
}
function onSlotMouseOver($event, x, y) {
if (props.isDisabled) {
return;
}
if ($event.buttons & 1 == 1) {
isShiftDown.value = $event.shiftKey;
isCtrlDown.value = $event.ctrlKey;
selectionEnd.x = x;
selectionEnd.y = y;
}
}
function onSlotMouseUp($event) {
if (props.isDisabled || selectionStart.x == undefined) {
if (!isEditing.value || selectionStart.x == undefined) {
return;
}
@ -124,6 +147,14 @@ function onSlotMouseUp($event) {
selectionStart.x = undefined;
}
function onSlotClick(dayIndex, hour) {
if (isEditing.value) {
return;
}
selectedTime.value = getTimeAtCell(dayIndex, hour);
}
function onKeyUp($event) {
switch ($event.key) {
case "Shift":
@ -158,6 +189,13 @@ onUnmounted(() => {
window.removeEventListener("keyup", onKeyUp);
});
function getAvailabilityCell(day: number, hour: number) {
let index = day * 24 + hour;
if (props.overlay && props.overlay[index] != undefined) {
return props.overlay[index]
}
return model.value[index];
}
</script>
<template>
@ -188,11 +226,13 @@ onUnmounted(() => {
}"
:selection="
selectionInside(dayIndex, hour) ? selectionValue
: model[24 * dayIndex + hour]
: getAvailabilityCell(dayIndex, hour)
"
v-for="hour in hours"
@mousedown="onSlotMouseDown($event, dayIndex, hour)"
@mouseover="onSlotMouseOver($event, dayIndex, hour)"
@mouseleave="onSlotMouseLeave($event, dayIndex, hour)"
@click="onSlotClick(dayIndex, hour)"
>
</div>
</div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { PlayerTeamRole } from "../player";
import type { PlayerTeamRoleFlat } from "../player";
import { computed, type PropType } from "vue";
import { useRosterStore } from "../stores/roster";
@ -7,7 +7,7 @@ const rosterStore = useRosterStore();
const props = defineProps({
roleTitle: String,
player: Object as PropType<PlayerTeamRole>,
player: Object as PropType<PlayerTeamRoleFlat>,
isRoster: Boolean,
isRinger: Boolean,
});
@ -64,7 +64,7 @@ const playtime = computed(() => {
'player-card': true,
'no-player': !player && !isRinger,
'selected': isSelected,
'can-be-available': player?.availability == 2
'can-be-available': player?.availability == 1
}" @click="onClick">
<div class="role-icon">
<i :class="rosterStore.roleIcons[roleTitle]" />
@ -74,8 +74,8 @@ const playtime = computed(() => {
<h4 class="player-name">{{ player.name }}</h4>
<div class="subtitle">
<span>
{{ player.role }}
<span v-if="!player.main && isRoster">
{{ rosterStore.roleNames[player.role] }}
<span v-if="!player.isMain && isRoster">
(alternate)
</span>
</span>
@ -89,14 +89,14 @@ const playtime = computed(() => {
<span>
<h4 class="player-name">Ringer</h4>
<div class="subtitle">
<span>{{ roleTitle }}</span>
<span>nobody likes to play {{ roleTitle }}</span>
<span>{{ rosterStore.roleNames[roleTitle] }}</span>
<!--span>nobody likes to play {{ roleTitle }}</span-->
</div>
</span>
</div>
<div v-else class="role-info">
<span>
{{ roleTitle }}
{{ rosterStore.roleNames[roleTitle] }}
</span>
</div>
</button>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useScheduleStore } from "../stores/schedule";
import SchedulePlayerListItem from "./SchedulePlayerListItem.vue";
const scheduleStore = useScheduleStore();
</script>
<template>
<div class="schedule-player-list">
<h3>{{ scheduleStore.team?.teamName }}</h3>
<SchedulePlayerListItem
v-for="record in scheduleStore.playerAvailability"
:player="record"
/>
</div>
</template>
<style scoped>
h3 {
font-weight: 700;
}
.player:hover {
background-color: var(--mantle);
}
</style>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { useScheduleStore } from "../stores/schedule";
import { computed, type PropType } from "vue";
import { type AvailabilitySchema } from "@/client";
const scheduleStore = useScheduleStore();
const hoveredIndex = computed(() => scheduleStore.hoveredIndex);
const availabilityAtHoveredIndex = computed(() => {
if (hoveredIndex.value) {
return props.player?.availability[hoveredIndex.value] ?? 0;
}
return undefined;
});
const props = defineProps({
player: Object as PropType<AvailabilitySchema>,
});
function onMouseOver() {
scheduleStore.overlay = props.player;
}
function onMouseLeave() {
if (scheduleStore.overlay == props.player) {
scheduleStore.overlay = undefined;
}
}
</script>
<template>
<div
class="player"
@mouseover="onMouseOver(player)"
@mouseleave="onMouseLeave"
>
<span v-if="availabilityAtHoveredIndex > 0">
<span v-if="availabilityAtHoveredIndex == 1" class="can-be-available">
{{ player.username }}
</span>
<span v-else class="available">
{{ player.username }}
</span>
</span>
<s v-else-if="availabilityAtHoveredIndex == 0">
{{ player.username }}
</s>
<span v-else>
{{ player.username }}
</span>
</div>
</template>
<style scoped>
.player:hover {
background-color: var(--mantle);
}
.player span.can-be-available {
background-color: var(--yellow-transparent);
}
.player span.available {
background-color: var(--green-transparent);
}
.player s {
color: var(--overlay-0);
}
</style>

View File

@ -49,7 +49,11 @@ router
const authStore = useAuthStore();
console.log("test");
if (!authStore.isLoggedIn && !authStore.hasCheckedAuth) {
await authStore.getUser();
try {
await authStore.getUser();
} catch (exception) {
}
}
});

View File

@ -1,30 +1,38 @@
import { computed } from "@vue/reactivity";
import { defineStore } from "pinia";
import { reactive, ref, watch } from "vue";
import { reactive, ref, type Ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useClientStore } from "./client";
import type { TeamSchema } from "@/client";
import type { AvailabilitySchema, TeamSchema } from "@/client";
import moment, { type Moment } from "moment";
import "moment-timezone";
import { useAuthStore } from "./auth";
export const useScheduleStore = defineStore("schedule", () => {
const client = useClientStore().client;
const authStore = useAuthStore();
const dateStart = ref(moment());
const windowStart = computed(() => Math.floor(dateStart.value.unix()));
const availability = reactive(new Array(168));
availability.fill(0);
const route = useRoute();
const router = useRouter();
watch(availability, () => {
// TODO: maybe do not sync these values so that we can cancel editing
// availability
let index = playerAvailability.value
.findIndex((v) => v.steamId == authStore.steamId);
playerAvailability.value[index].availability = availability;
});
const playerAvailability: Ref<AvailabilitySchema[]> = ref([ ]);
const overlay: Ref<AvailabilitySchema[] | undefined> = ref();
const hoveredIndex: Ref<number | undefined> = ref();
//const teamId = computed({
// get: () => Number(route?.query?.teamId),
// set: (value) => router.push({ query: { teamId: value } }),
//});
const team = ref();
function getWindowStart(team: TeamSchema) {
@ -46,7 +54,7 @@ export const useScheduleStore = defineStore("schedule", () => {
}
watch(dateStart, () => {
fetchSchedule();
fetchTeamSchedule();
});
watch(team, () => {
@ -66,19 +74,29 @@ export const useScheduleStore = defineStore("schedule", () => {
});
return response;
});
//return fetch(import.meta.env.VITE_API_BASE_URL + "/schedule?" + new URLSearchParams({
// window_start: windowStart.value.toString(),
// team_id: teamId.toString(),
//}).toString(),{
// credentials: "include",
// })
// .then((response) => response.json())
// .then((response) => {
// response.availability.forEach((value: number, i: number) => {
// availability[i] = value;
// });
// return response;
// });
}
async function fetchTeamSchedule(dateStartOverride?: Moment) {
dateStartOverride = dateStartOverride ?? dateStart.value;
return client.default.getApiScheduleTeam(
Math.floor(dateStartOverride.unix()).toString(),
team.value.id,
)
.then((response) => {
const values = Object.values(response.playerAvailability);
playerAvailability.value = values;
let record = values.find((value) => value.steamId == authStore.steamId);
if (record?.availability) {
record.availability
.forEach((value, i) => {
availability[i] = value;
});
}
return response;
});
}
async function saveSchedule() {
@ -87,25 +105,17 @@ export const useScheduleStore = defineStore("schedule", () => {
teamId: team.value.id,
availability,
});
//return fetch(import.meta.env.VITE_API_BASE_URL + "/schedule", {
// method: "PUT",
// credentials: "include",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({
// window_start: Math.floor(dateStart.value.getTime() / 1000),
// team_id: teamId.toString(),
// availability: availability,
// })
//});
}
return {
dateStart,
windowStart,
availability,
playerAvailability,
overlay,
hoveredIndex,
fetchSchedule,
fetchTeamSchedule,
saveSchedule,
team,
getWindowStart,

View File

@ -31,7 +31,7 @@ onMounted(() => {
Roster for Snus Brotherhood
<em class="aside date">
@
{{ moment(startTime).format("L LT") }}
{{ moment.unix(route.params.startTime).format("L LT") }}
</em>
</h1>
<div class="button-group">

View File

@ -2,6 +2,7 @@
import AvailabilityGrid from "../components/AvailabilityGrid.vue";
import AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
import WeekSelectionBox from "../components/WeekSelectionBox.vue";
import SchedulePlayerList from "../components/SchedulePlayerList.vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useTeamsStore } from "../stores/teams";
import { useScheduleStore } from "../stores/schedule";
@ -24,6 +25,10 @@ const availability = schedule.availability;
const selectionMode = ref(1);
const selectedTime = ref(undefined);
const availabilityOverlay = computed(() => schedule.overlay?.availability);
const isEditing = ref(false);
const selectedTeam = ref();
@ -49,6 +54,16 @@ function copyPreviousWeek() {
});
}
function scheduleRoster() {
router.push({
name: "roster-builder",
params: {
teamId: selectedTeam.value.id,
startTime: selectedTime.value.unix(),
}
});
}
onMounted(() => {
teamsStore.fetchTeams()
.then((teamsList) => {
@ -59,7 +74,7 @@ onMounted(() => {
if (queryTeam) {
selectedTeam.value = queryTeam;
schedule.team = queryTeam;
schedule.fetchSchedule();
schedule.fetchTeamSchedule();
} else {
selectedTeam.value = options.value[0];
}
@ -87,6 +102,9 @@ onMounted(() => {
</div>
<div class="grid-container">
<AvailabilityGrid v-model="availability"
v-model:selectedTime="selectedTime"
v-model:hoveredIndex="schedule.hoveredIndex"
:overlay="availabilityOverlay"
:selection-mode="selectionMode"
:is-disabled="!isEditing"
:date-start="schedule.dateStart"
@ -123,6 +141,9 @@ onMounted(() => {
<button @click="copyPreviousWeek">
Copy previous week
</button>
<button @click="scheduleRoster" v-if="selectedTime">
Schedule for {{ selectedTime.format("L LT") }}
</button>
<button class="accent" @click="isEditing = true">
<i class="bi bi-pencil-fill"></i>
</button>
@ -133,15 +154,24 @@ onMounted(() => {
<div v-else>
You currently are not in any team to schedule for.
</div>
<div class="player-list">
<SchedulePlayerList />
</div>
</main>
</template>
<style scoped>
main {
flex-direction: row;
gap: 8px;
display: flex;
justify-content: space-evenly;
}
.schedule-view-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.top-menu {

View File

@ -30,6 +30,28 @@ class PlayerTeamAvailability(app_db.BaseModel):
),
)
class AvailabilitySchema(spec.BaseModel):
steam_id: str
username: str
availability: list[int] = [0] * 168
def add_availability_region(
self,
region: PlayerTeamAvailability,
window_start: datetime,
):
relative_start_time = region.start_time - window_start
relative_start_hour = int(relative_start_time.total_seconds() // 3600)
relative_end_time = region.end_time - window_start
relative_end_hour = int(relative_end_time.total_seconds() // 3600)
window_size_hours = 168 # TODO: change me if window_size is variable
i = max(0, relative_start_hour)
while i < window_size_hours and i < relative_end_hour:
print(i, "=", region.availability)
self.availability[i] = region.availability
i += 1
class PlayerTeamAvailabilityRoleSchema(spec.BaseModel):
from models.player import PlayerSchema
from models.player_team_role import RoleSchema

View File

@ -3,10 +3,11 @@ from typing import cast
from flask import Blueprint, abort, jsonify, make_response, request
from spectree import Response
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import and_
from app_db import db
from models.player import Player, PlayerSchema
from models.player_team import PlayerTeam
from models.player_team_availability import PlayerTeamAvailability, PlayerTeamAvailabilityRoleSchema
from models.player_team_availability import AvailabilitySchema, PlayerTeamAvailability, PlayerTeamAvailabilityRoleSchema
from models.player_team_role import PlayerTeamRole, RoleSchema
from middleware import requires_authentication
from spec import spec, BaseModel
@ -70,6 +71,7 @@ def get(query: ViewScheduleForm, player: Player, **kwargs):
print(i, "=", region.availability)
availability[i] = region.availability
i += 1
return {
"availability": availability
}
@ -185,6 +187,56 @@ def put(json: PutScheduleForm, player: Player, **kwargs):
db.session.commit()
return make_response({ }, 200)
class ViewTeamScheduleResponse(BaseModel):
player_availability: dict[str, AvailabilitySchema]
@api_schedule.get("/team")
@spec.validate(
resp=Response(
HTTP_200=ViewTeamScheduleResponse
)
)
@requires_authentication
def get_team_availability(query: ViewScheduleForm, player: Player, **kwargs):
window_start = query.window_start
window_end = window_start + datetime.timedelta(days=query.window_size_days)
players_teams = db.session.query(
PlayerTeam
).outerjoin(
PlayerTeamAvailability,
and_(
PlayerTeamAvailability.start_time.between(window_start, window_end) |
PlayerTeamAvailability.end_time.between(window_start, window_end) |
# handle edge case where someone for some reason might list their
# availability spanning more than a week total
((PlayerTeamAvailability.start_time < window_start) &
(PlayerTeamAvailability.end_time > window_end))
)
).join(
Player
).where(
PlayerTeam.team_id == query.team_id
).all()
ret: dict[str, AvailabilitySchema] = { }
for player_team in players_teams:
player_id = str(player_team.player_id)
ret[player_id] = AvailabilitySchema(
steam_id=player_id,
username=player_team.player.username,
)
for region in player_team.availability:
ret[player_id].add_availability_region(region, window_start)
return ViewTeamScheduleResponse(
player_availability=ret,
).dict(by_alias=True)
class ViewAvailablePlayersQuery(BaseModel):
start_time: datetime.datetime
team_id: int