Add events in frontend

master
John Montagu, the 4th Earl of Sandvich 2024-11-24 10:47:45 -08:00
parent 2c5cf3f4ca
commit eee3241cae
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
15 changed files with 443 additions and 15 deletions

View File

@ -13,8 +13,11 @@ export type { OpenAPIConfig } from './core/OpenAPI';
export type { AbstractTeamIntegrationSchema } from './models/AbstractTeamIntegrationSchema'; export type { AbstractTeamIntegrationSchema } from './models/AbstractTeamIntegrationSchema';
export type { AddPlayerJson } from './models/AddPlayerJson'; export type { AddPlayerJson } from './models/AddPlayerJson';
export type { AvailabilitySchema } from './models/AvailabilitySchema'; export type { AvailabilitySchema } from './models/AvailabilitySchema';
export type { CreateEventJson } from './models/CreateEventJson';
export type { CreateTeamJson } from './models/CreateTeamJson'; export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson'; export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
export type { EventSchema } from './models/EventSchema';
export type { EventSchemaList } from './models/EventSchemaList';
export type { PlayerSchema } from './models/PlayerSchema'; export type { PlayerSchema } from './models/PlayerSchema';
export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvailabilityRoleSchema'; export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvailabilityRoleSchema';
export type { PutScheduleForm } from './models/PutScheduleForm'; export type { PutScheduleForm } from './models/PutScheduleForm';

View File

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateEventJson = {
description: string;
name: string;
playerIds: Array<number>;
startTime: string;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type EventSchema = {
createdAt: string;
description: string;
id: number;
name: string;
startTime: string;
teamId: number;
};

View File

@ -0,0 +1,6 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { EventSchema } from './EventSchema';
export type EventSchemaList = Array<EventSchema>;

View File

@ -4,8 +4,11 @@
/* eslint-disable */ /* eslint-disable */
import type { AbstractTeamIntegrationSchema } from '../models/AbstractTeamIntegrationSchema'; import type { AbstractTeamIntegrationSchema } from '../models/AbstractTeamIntegrationSchema';
import type { AddPlayerJson } from '../models/AddPlayerJson'; import type { AddPlayerJson } from '../models/AddPlayerJson';
import type { CreateEventJson } from '../models/CreateEventJson';
import type { CreateTeamJson } from '../models/CreateTeamJson'; import type { CreateTeamJson } from '../models/CreateTeamJson';
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson'; import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
import type { EventSchema } from '../models/EventSchema';
import type { EventSchemaList } from '../models/EventSchemaList';
import type { PlayerSchema } from '../models/PlayerSchema'; import type { PlayerSchema } from '../models/PlayerSchema';
import type { PutScheduleForm } from '../models/PutScheduleForm'; import type { PutScheduleForm } from '../models/PutScheduleForm';
import type { SetUsernameJson } from '../models/SetUsernameJson'; import type { SetUsernameJson } from '../models/SetUsernameJson';
@ -45,6 +48,104 @@ export class DefaultService {
url: '/api/debug/set-cookie', url: '/api/debug/set-cookie',
}); });
} }
/**
* get_team_events <GET>
* @param teamId
* @returns EventSchemaList OK
* @throws ApiError
*/
public getTeamEvents(
teamId: number,
): CancelablePromise<EventSchemaList> {
return this.httpRequest.request({
method: 'GET',
url: '/api/events/team/id/{team_id}',
path: {
'team_id': teamId,
},
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* create_event <POST>
* @param teamId
* @param requestBody
* @returns EventSchema OK
* @throws ApiError
*/
public postApiEventsTeamIdTeamId(
teamId: number,
requestBody?: CreateEventJson,
): CancelablePromise<EventSchema> {
return this.httpRequest.request({
method: 'POST',
url: '/api/events/team/id/{team_id}',
path: {
'team_id': teamId,
},
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* get_user_events <GET>
* @param userId
* @returns void
* @throws ApiError
*/
public getApiEventsUserIdUserId(
userId: number,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'GET',
url: '/api/events/user/id/{user_id}',
path: {
'user_id': userId,
},
});
}
/**
* get_event <GET>
* @param eventId
* @returns EventSchema OK
* @throws ApiError
*/
public getEvent(
eventId: number,
): CancelablePromise<EventSchema> {
return this.httpRequest.request({
method: 'GET',
url: '/api/events/{event_id}',
path: {
'event_id': eventId,
},
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* set_event_players <PATCH>
* @param eventId
* @returns void
* @throws ApiError
*/
public patchApiEventsEventIdPlayers(
eventId: number,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'PATCH',
url: '/api/events/{event_id}/players',
path: {
'event_id': eventId,
},
});
}
/** /**
* logout <DELETE> * logout <DELETE>
* @returns void * @returns void

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import type { EventSchema } from "@/client";
import { useTeamsStore } from "@/stores/teams";
import moment from "moment";
import { computed } from "vue";
const teamsStore = useTeamsStore();
const date = computed(() => moment(props.event.startTime));
const formattedTime = computed(() => {
const team = teamsStore.teams[props.event.teamId];
const offsetDate = date.value.clone().tz(team.tzTimezone);
return `${date.value.format("LT")} (${offsetDate.format("LT z")})`;
});
const day = computed(() => {
return date.value.format("D");
});
const shortMonth = computed(() => {
return date.value.format("MMM");
});
const props = defineProps<{
event: EventSchema;
}>();
</script>
<template>
<div class="event-card">
<div class="date">
<span class="month">
{{ shortMonth }}
</span>
<span class="day">
{{ day }}
</span>
</div>
<div class="details">
<div>
<h3>{{ event.name }}</h3>
<div>
<span v-if="event.description">{{ event.description }}</span>
<em v-else class="subtext">No description provided.</em>
</div>
</div>
<div class="subdetails">
<span>
<i class="bi bi-clock-fill margin" />
{{ formattedTime }}
</span>
<span class="class-info">
<i class="tf2class tf2-PocketScout margin" />
Pocket Scout
</span>
</div>
</div>
</div>
</template>
<style scoped>
.event-card {
display: flex;
padding: 1rem;
align-items: center;
/*background-color: white;*/
border: 1px solid var(--text);
border-radius: 8px;
align-items: stretch;
}
.date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1;
flex-basis: 4rem;
}
.date .month {
text-transform: uppercase;
font-weight: 600;
font-size: 0.8rem;
}
.date .day {
font-size: 1.5rem;
font-weight: 700;
}
.details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.subdetails {
display: flex;
flex-direction: column;
}
.subdetails .margin {
margin-right: 0.25em;
}
.subdetails i.tf2class {
line-height: 1em;
font-size: 1.2rem;
vertical-align: middle;
}
</style>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { EventSchema } from "@/client";
import EventCard from "./EventCard.vue";
const props = defineProps<{
events: EventSchema[];
}>();
</script>
<template>
<h2>Upcoming Events</h2>
<div class="events-list">
<EventCard v-for="event in props.events" :key="event.id" :event="event" />
</div>
</template>
<style scoped>
h2 {
margin-bottom: 1rem;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@ -34,9 +34,7 @@ function leaveTeam() {
Members Members
</h2> </h2>
<em class="aside" v-if="teamMembers"> <em class="aside" v-if="teamMembers">
{{ teamMembers?.length }} member(s), {{ teamMembers?.length }} member(s)
{{ availableMembers?.length }} currently available,
{{ availableMembersNextHour?.length }} available in the next hour
</em> </em>
<div class="team-details-button-group"> <div class="team-details-button-group">
</div> </div>

View File

@ -0,0 +1,31 @@
import type { EventSchema } from "@/client";
import { defineStore } from "pinia";
import { computed, reactive, ref } from "vue";
import { useClientStore } from "./client";
export const useEventsStore = defineStore("events", () => {
const clientStore = useClientStore();
const client = clientStore.client;
const events = ref<EventSchema[]>([ ]);
const eventsById = computed(() => {
return events.value
.reduce((acc, event) => {
return { ...acc, [event.id]: event };
}, { } as { [id: number]: EventSchema });
});
function fetchEvent(id: number) {
return clientStore.call(
fetchEvent.name,
() => client.default.getEvent(id),
);
}
return {
events,
eventsById,
fetchEvent,
}
});

View File

@ -0,0 +1,38 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import { useEventsStore } from "../events";
import { useTeamsEventsStore } from "./events";
describe("Team events store", () => {
beforeEach(() => {
setActivePinia(createPinia());
})
it("should reflect the same events as the events store", () => {
const eventsStore = useEventsStore();
eventsStore.events = [
{
createdAt: "",
description: "",
id: 0,
name: "test",
startTime: "",
teamId: 5,
},
{
createdAt: "",
description: "",
id: 2,
name: "test",
startTime: "",
teamId: 5,
}
];
const teamEventsStore = useTeamsEventsStore();
const teamEvents = teamEventsStore.teamEvents[5];
expect(teamEvents.length).toEqual(eventsStore.events.length);
expect(teamEvents).toEqual(eventsStore.events);
});
});

View File

@ -0,0 +1,45 @@
import { defineStore } from "pinia";
import { useClientStore } from "../client";
import type { EventSchema, EventSchemaList } from "@/client";
import { useEventsStore } from "../events";
import { computed } from "vue";
export const useTeamsEventsStore = defineStore("teamsEvents", () => {
const clientStore = useClientStore();
const client = clientStore.client;
const eventsStore = useEventsStore();
const teamEvents = computed(() => {
console.log("Recomputing teamEvents");
// map events to objects with teamId as key, and array of events as value
return eventsStore.events
.reduce((acc, event) => {
if (!acc[event.teamId]) {
acc[event.teamId] = [];
}
acc[event.teamId].push(event);
return acc;
}, { } as { [teamId: number]: EventSchema[] });
});
function fetchTeamEvents(teamId: number) {
return clientStore.call(
fetchTeamEvents.name,
() => client.default.getTeamEvents(teamId),
(result: EventSchemaList) => {
result.forEach((event) => {
// insert into event store
//eventsStore.events[event.id] = event;
eventsStore.events.push(event);
});
return result;
}
);
}
return {
teamEvents,
fetchTeamEvents,
}
});

View File

@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute, useRouter, RouterLink, RouterView } from "vue-router"; import { useRoute, useRouter, RouterLink, RouterView } from "vue-router";
import { useTeamsStore } from "../stores/teams"; import { useTeamsStore } from "@/stores/teams";
import { useInvitesStore } from "@/stores/teams/invites";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useTeamDetails } from "../composables/team-details"; import { useTeamDetails } from "@/composables/team-details";
import MembersList from "../components/MembersList.vue"; import MembersList from "@/components/MembersList.vue";
import moment from "moment"; import moment from "moment";
import EventList from "@/components/EventList.vue";
import { useTeamsEventsStore } from "@/stores/teams/events";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const teamsStore = useTeamsStore(); const teamsStore = useTeamsStore();
const invitesStore = useInvitesStore();
const { team, teamId } = useTeamDetails(); const { team, teamId } = useTeamDetails();
const creationDate = computed(() => { const creationDate = computed(() => {
@ -19,15 +22,20 @@ const creationDate = computed(() => {
const key = computed(() => route.query.key); const key = computed(() => route.query.key);
const teamsEventsStore = useTeamsEventsStore();
const events = computed(() => teamsEventsStore.teamEvents[teamId.value]);
onMounted(() => { onMounted(() => {
let doFetchTeam = () => { let doFetchTeam = () => {
teamsStore.fetchTeam(teamId.value) teamsStore.fetchTeam(teamId.value)
.then(() => teamsStore.fetchTeamMembers(teamId.value)) .then(() => {
.then(() => teamsStore.getInvites(teamId.value)); teamsStore.fetchTeamMembers(teamId.value);
teamsEventsStore.fetchTeamEvents(teamId.value);
});
}; };
if (key.value) { if (key.value) {
teamsStore.consumeInvite(teamId.value, key.value.toString()) invitesStore.consumeInvite(teamId.value, key.value.toString())
.finally(doFetchTeam); .finally(doFetchTeam);
} else { } else {
doFetchTeam(); doFetchTeam();
@ -58,12 +66,32 @@ onMounted(() => {
</RouterLink> </RouterLink>
</div> </div>
</center> </center>
<MembersList /> <div class="content-container">
<div class="left">
<MembersList />
</div>
<div class="right">
<EventList :events="events" />
</div>
</div>
</template> </template>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.content-container {
display: flex;
justify-content: space-between;
}
.content-container > div.left {
flex: 2;
}
.content-container > div.right {
flex: 1;
}
.margin { .margin {
margin: 4em; margin: 4em;
} }

View File

@ -6,6 +6,7 @@ import schedule
import team import team
from spec import spec from spec import spec
import user import user
import events
connect_db_with_app() connect_db_with_app()
@ -14,6 +15,7 @@ api.register_blueprint(login.api_login)
api.register_blueprint(schedule.api_schedule) api.register_blueprint(schedule.api_schedule)
api.register_blueprint(team.api_team) api.register_blueprint(team.api_team)
api.register_blueprint(user.api_user) api.register_blueprint(user.api_user)
api.register_blueprint(events.api_events)
@api.get("/debug/set-cookie") @api.get("/debug/set-cookie")
@api.post("/debug/set-cookie") @api.post("/debug/set-cookie")

View File

@ -48,6 +48,8 @@ def get_team_events(team_id: int):
Event Event
).filter( ).filter(
Event.team_id == team_id Event.team_id == team_id
).order_by(
Event.start_time
).all() ).all()
def map_to_schema(event: Event): def map_to_schema(event: Event):

View File

@ -2,6 +2,7 @@ from datetime import datetime
from sqlalchemy.orm import mapped_column, relationship from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm.attributes import Mapped from sqlalchemy.orm.attributes import Mapped
from sqlalchemy.orm.properties import ForeignKey from sqlalchemy.orm.properties import ForeignKey
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.types import TIMESTAMP, Integer, String, Text from sqlalchemy.types import TIMESTAMP, Integer, String, Text
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy_utc import UtcDateTime from sqlalchemy_utc import UtcDateTime
@ -12,21 +13,29 @@ import spec
class Event(app_db.BaseModel): class Event(app_db.BaseModel):
__tablename__ = "events" __tablename__ = "events"
# surrogate key
id: Mapped[int] = mapped_column(Integer, autoincrement=True, primary_key=True) id: Mapped[int] = mapped_column(Integer, autoincrement=True, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=True) # primary key
start_time: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False) team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
start_time: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
team: Mapped["Team"] = relationship("Team", back_populates="events") team: Mapped["Team"] = relationship("Team", back_populates="events")
players: Mapped["PlayerEvent"] = relationship("PlayerEvent", back_populates="event") players: Mapped["PlayerEvent"] = relationship("PlayerEvent", back_populates="event")
__table_args__ = (
UniqueConstraint("team_id", "name", "start_time"),
)
class EventSchema(spec.BaseModel): class EventSchema(spec.BaseModel):
id: int id: int
team_id: int team_id: int
name: str name: str
description: str description: str | None
start_time: datetime start_time: datetime
created_at: datetime created_at: datetime
@ -41,5 +50,6 @@ class EventSchema(spec.BaseModel):
created_at=model.created_at, created_at=model.created_at,
) )
from models.team import Team from models.team import Team
from models.player_event import PlayerEvent from models.player_event import PlayerEvent