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

feat: track + notify user signups #929

Merged
merged 16 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
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
33 changes: 32 additions & 1 deletion client/src/api/tournament/request.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { url } from "@port-of-mars/client/util";
import { TournamentRoundInviteStatus, TournamentStatus } from "@port-of-mars/shared/types";
import {
TournamentRoundInviteStatus,
TournamentRoundScheduleDate,
TournamentStatus,
} from "@port-of-mars/shared/types";
import { TStore } from "@port-of-mars/client/plugins/tstore";
import { AjaxRequest } from "@port-of-mars/client/plugins/ajax";

Expand All @@ -16,6 +20,16 @@ export class TournamentAPI {
}
}

async getTournamentRoundSchedule(): Promise<Array<TournamentRoundScheduleDate>> {
try {
return this.ajax.get(url("/tournament/schedule"), ({ data }) => data);
} catch (e) {
console.log("Unable to get tournament round schedule");
console.log(e);
throw e;
}
}

async getInviteStatus(): Promise<TournamentRoundInviteStatus> {
try {
return this.ajax.get(url("/tournament/invite-status"), ({ data }) => data);
Expand All @@ -25,4 +39,21 @@ export class TournamentAPI {
throw e;
}
}

async addOrRemoveSignup(
action: "add" | "remove",
tournamentRoundDateId: number,
inviteId: number
) {
try {
const params = `?tournamentRoundDateId=${tournamentRoundDateId}&inviteId=${inviteId}`;
return this.ajax.post(url(`/tournament/signup/${action}${params}`), ({ data }) => {
this.store.commit("SET_TOURNAMENT_ROUND_SCHEDULE", data);
});
} catch (e) {
console.log(`Unable to ${action} signup`);
console.log(e);
throw e;
}
}
}
154 changes: 109 additions & 45 deletions client/src/components/global/Schedule.vue
Original file line number Diff line number Diff line change
@@ -1,73 +1,129 @@
<template>
<div>
<b-list-group class="p-1">
<template v-for="(times, date) in launchTimes">
<template v-for="(launchTimes, date) in groupedLaunchTimes">
<b-list-group-item class="text-center bg-primary border-0 my-1" :key="date">
<b>{{ date }}</b>
</b-list-group-item>
<b-list-group-item
class="p-3 text-center bg-dark border-0 my-1 d-flex justify-content-between"
v-for="game in times"
:key="game.date.getTime()"
class="p-0 bg-dark border-0 my-1 d-flex flex-column"
v-for="launchTime in launchTimes"
:key="launchTime.date.getTime()"
>
<div class="launch-date">
<b>{{ formatTime(game.date) }}</b>
<div class="p-3 text-center d-flex justify-content-between align-items-center">
<div class="launch-date">
<b>{{ formatTime(launchTime.date) }}</b>
</div>
<div class="d-flex align-items-between">
<div v-if="!invite?.hasParticipated" class="d-flex align-items-center mr-3">
<label class="mb-0 mr-2" :for="`signup-toggle-${launchTime.tournamentRoundDateId}`">
<small v-if="launchTime.isSignedUp" class="text-success">Signed up</small>
<small v-else>Sign up to get notified *</small>
</label>
<Toggle
:id="`signup-toggle-${launchTime.tournamentRoundDateId}`"
v-model="launchTime.isSignedUp"
@click="handleSignupClicked(launchTime)"
/>
</div>
<b-button-group>
<a
id="add-to-gcal"
class="btn btn-dark py-0"
:href="launchTime.googleInviteURL"
title="add to Google Calendar"
target="_blank"
>
<b-icon-google scale=".8"></b-icon-google>
</a>
<b-popover target="add-to-gcal" placement="bottom" triggers="hover focus">
<template #title></template>
add to Google Calendar
</b-popover>
<a
id="download-ics"
class="btn btn-dark py-0"
:href="launchTime.icsInviteURL"
title="download as ics"
target="_blank"
>
<b-icon-calendar-plus-fill scale=".8"></b-icon-calendar-plus-fill>
</a>
<b-popover target="download-ics" placement="bottom" triggers="hover focus">
<template #title></template>
download as .ics
</b-popover>
</b-button-group>
</div>
</div>
<div>
<b-progress
id="interest-bar"
:value="launchTime.signupCount + 0.5"
:max="signupsPopularityThreshold"
class="bg-dark"
height="0.5rem"
:variant="getInterestBarVariant(launchTime.signupCount)"
title="interest level"
style="opacity: 0.5"
></b-progress>
</div>
<b-button-group>
<a
class="btn btn-secondary py-0"
:href="game.googleInviteURL"
title="add to Google Calendar"
target="_blank"
>
<b-icon-google scale=".8"></b-icon-google>
</a>
<a
class="btn btn-primary py-0"
:href="game.icsInviteURL"
title="download as ics"
target="_blank"
>
<b-icon-calendar-plus-fill scale=".8"></b-icon-calendar-plus-fill>
</a>
</b-button-group>
</b-list-group-item>
</template>
</b-list-group>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Component, Inject, Prop, Vue } from "vue-property-decorator";
import { google, ics } from "calendar-link";
import {
TournamentRoundInviteStatus,
TournamentRoundScheduleDate,
} from "@port-of-mars/shared/types";
import { TournamentAPI } from "@port-of-mars/client/api/tournament/request";
import Toggle from "@port-of-mars/client/components/global/Toggle.vue";

interface LaunchTime extends TournamentRoundScheduleDate {
date: Date;
googleInviteURL: string;
icsInviteURL: string;
}

interface LaunchTimes {
[date: string]: {
date: Date;
googleInviteURL: string;
icsInviteURL: string;
}[];
interface GroupedLaunchTimes {
[date: string]: Array<LaunchTime>;
}

@Component({})
@Component({
components: {
Toggle,
},
})
export default class Schedule extends Vue {
@Prop({ default: "schedule-header" })
scheduleId!: string;
@Prop({ default: "schedule-header" }) scheduleId!: string;
@Prop() schedule!: Array<TournamentRoundScheduleDate>;
@Prop() invite!: TournamentRoundInviteStatus | null;

@Prop()
schedule!: Array<number>;
@Inject() readonly api!: TournamentAPI;

static readonly SITE_URL = "https://portofmars.asu.edu";

get launchTimes() {
get groupedLaunchTimes() {
return this.groupLaunchTimesByDate(this.schedule);
}

groupLaunchTimesByDate(launchTimes: number[]) {
// returns an object with date strings mapped to individual launch times and invite links
// could use a Map<string, object> also
const grouped: LaunchTimes = {};
for (const time of launchTimes) {
const launchDate = new Date(time);
get signupsPopularityThreshold() {
return this.$store.getters.tournamentStatus.signupsPopularityThreshold;
}

getInterestBarVariant(signupCount: number) {
return signupCount < this.signupsPopularityThreshold / 2 ? "warning" : "success";
}

groupLaunchTimesByDate(schedule: Array<TournamentRoundScheduleDate>): GroupedLaunchTimes {
// returns an object with date strings mapped to individual scheduled dates with invite links
const grouped: GroupedLaunchTimes = {};
for (const scheduleDate of schedule) {
const launchDate = new Date(scheduleDate.timestamp);
const dateStr = launchDate.toLocaleDateString([], {
weekday: "long",
month: "long",
Expand All @@ -80,14 +136,22 @@ export default class Schedule extends Vue {
const googleInviteURL = google(calendarEvent);
const icsInviteURL = ics(calendarEvent);
grouped[dateStr].push({
date: new Date(time),
...scheduleDate,
date: launchDate,
googleInviteURL,
icsInviteURL,
});
}
return grouped;
}

async handleSignupClicked(launchTime: LaunchTime) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toggleSignup might help convey the toggliness of this method more though I like handleSignupClicked perfectly fine as well.

How about

await this.api.setSignup(..., launchTime.isSignedUp);

This pushes the add|remove based on the value of the isSignedUp boolean logic into the API method and better encapsulates API endpoint specific things

const tournamentRoundDateId = launchTime.tournamentRoundDateId;
if (!this.invite) return;
const action = launchTime.isSignedUp ? "remove" : "add";
await this.api.addOrRemoveSignup(action, tournamentRoundDateId, this.invite.id);
}

get calendarEventDescription() {
return (
`Register and complete all Port of Mars onboarding tasks at ${Schedule.SITE_URL} ASAP. \n\n` +
Expand All @@ -102,7 +166,7 @@ export default class Schedule extends Vue {
location: Schedule.SITE_URL,
start,
description: this.calendarEventDescription,
duration: [1, "hour"],
duration: [1, "hour"] as any,
};
}

Expand Down
58 changes: 58 additions & 0 deletions client/src/components/global/Toggle.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<input v-bind="$attrs" v-on="$listeners" type="checkbox" class="toggle-input" />
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component({
inheritAttrs: false,
})
export default class Toggle extends Vue {}
</script>

<style lang="scss" scoped>
.switch.square label .lever {
width: 54px;
height: 34px;
border-radius: 0px;
}
.switch.square label .lever:after {
width: 26px;
height: 26px;
border-radius: 0px;
left: 4px;
top: 4px;
}

.toggle-input {
position: relative;
appearance: none;
width: 45px; /* Adjust the width as needed */
height: 25px; /* Adjust the height as needed */
background-color: #a49ca6; /* Background color when the toggle is off */
border-radius: 2px; /* Square corners */
cursor: pointer;
outline: none;
}

.toggle-input:checked {
background-color: rgb(95, 141, 75); /* Background color when the toggle is on */
}

.toggle-input::before {
content: "";
position: absolute;
width: 20px; /* Width of the toggle button */
height: 20px; /* Height of the toggle button */
background-color: #fff; /* Color of the toggle button */
border-radius: 5%; /* Make it a rounded square*/
top: 3px; /* Adjust the vertical position */
left: 3px; /* Adjust the horizontal position */
transition: 0.3s; /* Transition for smooth animation */
}

.toggle-input:checked::before {
transform: translateX(20px); /* Move the toggle button to the right when checked */
}
</style>
3 changes: 3 additions & 0 deletions client/src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export default {
if (data.tournamentStatus) {
context.commit("SET_TOURNAMENT_STATUS", data.tournamentStatus);
}
if (data.tournamentRoundSchedule) {
context.commit("SET_TOURNAMENT_ROUND_SCHEDULE", data.tournamentRoundSchedule);
}
context.commit("SET_FREE_PLAY_ENABLED", data.isFreePlayEnabled);
context.commit("SET_TOURNAMENT_ENABLED", data.isTournamentEnabled);
context.commit("SET_ANNOUNCEMENT_BANNER_TEXT", data.announcementBannerText);
Expand Down
12 changes: 7 additions & 5 deletions client/src/store/getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,24 +207,26 @@ export default {
},

tournamentRoundHasUpcomingLaunch(state: State) {
return !!state.tournamentStatus?.currentRound.schedule.length;
return !!state.tournamentRoundSchedule?.length;
},

tournamentSchedule(state: State) {
return state.tournamentStatus?.currentRound.schedule;
return state.tournamentRoundSchedule;
},

nextLaunchTime(state: State) {
return state.tournamentStatus?.currentRound.schedule[0];
if (state.tournamentRoundSchedule?.length) {
return state.tournamentRoundSchedule[0].timestamp;
}
},

isTournamentLobbyOpen(state: State) {
if (!state.tournamentStatus) {
if (!state.tournamentStatus || !state.tournamentRoundSchedule?.length) {
return false;
}
const beforeOffset = state.tournamentStatus.lobbyOpenBeforeOffset;
const afterOffset = state.tournamentStatus.lobbyOpenAfterOffset;
const nextLaunchTime = state.tournamentStatus.currentRound.schedule[0];
const nextLaunchTime = state.tournamentRoundSchedule[0].timestamp;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could use this.nextLaunchTime(state) too? duplicates the tournamentRoundSchedule length check though

const timeNow = new Date().getTime();
return timeNow >= nextLaunchTime - beforeOffset && timeNow <= nextLaunchTime + afterOffset;
},
Expand Down
5 changes: 5 additions & 0 deletions client/src/store/mutations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DashboardMessage,
Role,
SystemHealthChangesData,
TournamentRoundScheduleDate,
TournamentStatus,
} from "@port-of-mars/shared/types";
import _ from "lodash";
Expand All @@ -41,6 +42,10 @@ export default {
state.tournamentStatus = status;
},

SET_TOURNAMENT_ROUND_SCHEDULE(state: State, schedule: Array<TournamentRoundScheduleDate>) {
state.tournamentRoundSchedule = schedule;
},

SET_ANNOUNCEMENT_BANNER_TEXT(state: State, text: string) {
state.announcementBannerText = text;
},
Expand Down
Loading
Loading