Implement AvailabilityGrid component

master
John Montagu, the 4th Earl of Sandvich 2024-10-26 15:36:52 -07:00
parent 280e02c15a
commit a11b6f454f
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
9 changed files with 508 additions and 20 deletions

View File

@ -40,7 +40,10 @@
--green: #a6e3a1;
--lavender: #7287fd;
--accent: var(--lavender);
--accent-transparent: color-mix(in srgb, var(--accent), transparent 80%);
--accent-transparent-80: color-mix(in srgb, var(--accent), transparent 80%);
--accent-transparent-50: color-mix(in srgb, var(--accent), transparent 50%);
--accent-transparent: var(--accent-transparent-80);
--shadow: color-mix(in srgb, var(--text), transparent 50%);
}

View File

@ -25,10 +25,23 @@ a,
button {
font-weight: 700;
color: var(--text);
background-color: var(--surface-0);
background-color: var(--crust);
border: none;
padding: 8px;
padding: 8px 16px;
border-radius: 4px;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
}
button.accent {
@ -37,6 +50,10 @@ button.accent {
text-transform: uppercase;
}
button.accent {
}
h1 {
font-weight: 800;
font-size: 200%;
@ -46,3 +63,7 @@ h1 {
em.aside {
color: var(--overlay-0);
}
select {
appearance: none;
}

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, defineModel, defineProps, ref } from "vue";
const model = defineModel();
const props = defineProps({
options: Array<String>,
});
const isOpen = ref(false);
const selectedOption = computed(() => props.options[model.value]);
function selectOption(index) {
model.value = index;
isOpen.value = false;
}
</script>
<template>
<div :class="{ 'dropdown-container': true, 'is-open': isOpen }">
<button @click="isOpen = !isOpen">
{{ selectedOption }}
</button>
<ul class="dropdown" v-if="isOpen" @blur="isOpen = false">
<li v-for="(option, i) in options" :key="i" @click="selectOption(i)">
<button :class="{ 'is-selected': i == model }">
{{ option }}
</button>
</li>
</ul>
</div>
</template>
<style scoped>
.dropdown-container {
display: inline-block;
border-radius: 8px;
}
.dropdown-container button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
font-weight: 700;
font-size: 16px;
padding: 4px;
transition-duration: 200ms;
background-color: transparent;
cursor: pointer;
}
.dropdown-container button:hover {
background-color: var(--crust);
}
.dropdown-container.is-open ul.dropdown {
box-shadow: 1px 1px 8px var(--shadow);
}
ul.dropdown {
display: block;
background-color: var(--base);
position: absolute;
margin-top: 8px;
padding: 0;
z-index: 2;
border-radius: 8px;
overflow: none;
}
ul.dropdown > li {
list-style-type: none;
}
.dropdown li > button {
padding: 8px 16px;
font-weight: 500;
font-size: 14px;
border-radius: 0;
}
.dropdown li > button.is-selected {
background-color: var(--accent-transparent);
color: var(--accent);
}
</style>

View File

@ -1,11 +1,268 @@
<script setup lang="ts">
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted } from "vue";
const model = defineModel();
const firstHour = 15;
const lastHour = 23;
const windowStart = new Date(2024, 9, 21);
const props = defineProps({
selectionMode: Number,
});
const selectionStart = reactive({ x: undefined, y: undefined });
const selectionEnd = reactive({ x: undefined, y: undefined });
const isCtrlDown = ref(false);
const isShiftDown = ref(false);
const lowerBoundX = computed(() => {
return isShiftDown.value ? 0 :
Math.min(selectionStart.x, selectionEnd.x)
});
const upperBoundX = computed(() => {
return isShiftDown.value ? 7 :
Math.max(selectionStart.x, selectionEnd.x)
});
const lowerBoundY = computed(() => {
return isCtrlDown.value ? firstHour :
Math.min(selectionStart.y, selectionEnd.y)
});
const upperBoundY = computed(() => {
return isCtrlDown.value ? lastHour :
Math.max(selectionStart.y, selectionEnd.y)
});
function selectionInside(dayIndex, hour) {
if (selectionStart.x != undefined) {
return (dayIndex >= lowerBoundX.value && dayIndex <= upperBoundX.value) &&
(hour >= lowerBoundY.value && hour <= upperBoundY.value);
}
return false;
}
const days = computed(() => {
let ret = [];
for (let i = 0; i < 7; i++) {
const date = new Date(windowStart);
date.setDate(windowStart.getDate() + i);
ret.push(date);
}
return ret;
});
const hours = computed(() => {
return Array.from(Array(lastHour - firstHour + 1).keys())
.map(x => x + firstHour);
});
const daysOfWeek = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
];
const isMouseDown = ref(false);
const selectionValue = ref(0);
function onSlotMouseDown($event, x, y) {
selectionValue.value = model.value[24 * x + y] == props.selectionMode ?
0 : props.selectionMode;
selectionStart.x = x;
selectionStart.y = y;
selectionEnd.x = x;
selectionEnd.y = y;
console.log("selected " + x + " " + y);
}
function onSlotMouseOver($event, x, y) {
if ($event.buttons & 1 == 1) {
//if (isAdding.value) {
// model.value[24 * x + y] = props.selectionMode;
//} else {
// model.value[24 * x + y] = 0;
//}
console.log("selected " + (24 * x + y));
selectionEnd.x = x;
selectionEnd.y = y;
}
}
function onSlotMouseUp($event) {
console.log("mouseup");
console.log(selectionStart);
if (selectionStart.x == undefined) {
return;
}
for (let x = lowerBoundX.value; x <= upperBoundX.value; x++) {
for (let y = lowerBoundY.value; y <= upperBoundY.value; y++) {
model.value[24 * x + y] = selectionValue.value;
}
}
selectionStart.x = undefined;
}
function onKeyUp($event) {
switch ($event.key) {
case "Shift":
isShiftDown.value = false;
break;
case "Control":
isCtrlDown.value = false;
break;
}
}
function onKeyDown($event) {
switch ($event.key) {
case "Shift":
isShiftDown.value = true;
break;
case "Control":
isCtrlDown.value = true;
break;
}
}
onMounted(() => {
window.addEventListener("mouseup", onSlotMouseUp);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
});
onUnmounted(() => {
console.log("removing");
window.removeEventListener("mouseup", onSlotMouseUp);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
});
</script>
<template>
<div class="grid">
<div>
<div class="height-48px"></div>
<div class="height-24px hour-marker-container" v-for="hour, i in hours" :key="i">
<span class="hour-marker" v-if="i % 2 == 0 || i == hours.length">
{{ hour % 24 }}:30 ({{ (hour + 3) % 24 }}:30 EST)
</span>
</div>
<div class="height-24px hour-marker-container">
<span class="hour-marker">
{{ (lastHour + 1) % 24 }}:30 ({{ (lastHour + 4) % 24 }}:30 EST)
</span>
</div>
</div>
<div v-for="(day, dayIndex) in days" :key="dayIndex" class="column">
<div class="date">
<div class="day-of-week">{{ daysOfWeek[day.getDay()] }}</div>
<div class="day">{{ day.getDate() }}</div>
</div>
<div class="column-time-slots">
<div
:class="{
'time-slot': true,
'height-24px': true,
}"
:selection="
selectionInside(dayIndex, hour) ? selectionValue
: model[24 * dayIndex + hour]
"
v-for="hour in hours"
@mousedown="onSlotMouseDown($event, dayIndex, hour)"
@mouseover="onSlotMouseOver($event, dayIndex, hour)"
>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.height-24px {
height: 24px;
}
.height-32px {
height: 32px;
}
.height-48px {
height: 48px;
}
.hour-marker-container {
text-align: right;
}
.hour-marker {
font-size: 12px;
line-height: 0;
position: relative;
top: -0.75rem;
margin-right: 8px;
color: var(--subtext-0);
}
.grid {
display: flex;
user-select: none;
}
.grid > .column > .column-time-slots {
width: 72px;
border: 4px;
border: 1px solid var(--text);
border-left: none;
}
.grid > .column:nth-child(2) > .column-time-slots {
border-left: 1px solid var(--text);
}
.date {
display: flex;
flex-direction: column;
justify-content: end;
text-align: center;
height: 48px;
}
.date .day-of-week {
color: var(--subtext-0);
font-size: 12px;
text-transform: uppercase;
line-height: 0;
margin-bottom: 2px;
}
.date .day {
font-size: 20px;
font-weight: 700;
}
.time-slot:hover {
background-color: var(--crust);
outline: 2px inset var(--subtext-0);
}
.time-slot:nth-child(2n):not(:last-child) {
border-bottom: 1px dashed var(--text);
}
.time-slot[selection="1"] {
background-color: var(--accent-transparent-80);
}
.time-slot[selection="2"] {
background-color: var(--accent);
}
</style>

View File

@ -52,6 +52,11 @@ function onClick() {
}
}
};
const playtime = computed(() => {
let hours = props.player?.playtime / 3600 ?? 0;
return hours.toFixed(1);
});
</script>
<template>
@ -67,19 +72,26 @@ function onClick() {
<div v-if="player" class="role-info">
<span>
<h4 class="player-name">{{ player.name }}</h4>
<span v-if="roleTitle != player.role">
Subbing in as
</span>
{{ player.role }}
<span v-if="!player.main && isRoster">
(alternate)
</span>
<div class="subtitle">
<span>
{{ player.role }}
<span v-if="!player.main && isRoster">
(alternate)
</span>
</span>
<span v-if="playtime > 0">
{{ playtime }} hours
</span>
</div>
</span>
</div>
<div v-else-if="isRinger" class="role-info">
<span>
<h4 class="player-name">Ringer</h4>
{{ roleTitle }}
<div class="subtitle">
<span>{{ roleTitle }}</span>
<span>nobody likes to play {{ roleTitle }}</span>
</div>
</span>
</div>
<div v-else class="role-info">
@ -116,6 +128,13 @@ function onClick() {
.player-card .role-info {
text-align: left;
flex-grow: 1;
}
.role-info .subtitle {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.player-card:hover {

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import { defineModel } from "vue";
const model = defineModel();
</script>
<template>
<div class="scroll-box">
</div>
</template>
<style>
.scroll-box {
display: flex;
justify-content: space-between;
}
</style>

View File

@ -65,6 +65,14 @@ export const useRosterStore = defineStore("roster", () => {
availability: 2,
playtime: 47324,
},
{
steamId: 2083,
name: "IF_YOU_READ_THIS_",
role: "Demoman",
main: true,
availability: 1,
playtime: 32812,
},
{
steamId: 2842,
name: "BossOfThisGym",
@ -142,14 +150,24 @@ export const useRosterStore = defineStore("roster", () => {
return availablePlayerRoles.value.filter((player) => player.availability == 1);
});
function comparator(p1: PlayerTeamRole, p2: PlayerTeamRole) {
// definitely available > can be available
let availabilityDiff = p1.availability - p2.availability;
// less playtime is preferred
let playtimeDiff = p1.playtime - p2.playtime;
return availabilityDiff || playtimeDiff;
}
const mainRoles = computed(() => {
return availablePlayerRoles.value.filter((player) => player.main)
.sort((a, b) => b.playtime - a.playtime);
.sort(comparator);
});
const alternateRoles = computed(() => {
return availablePlayerRoles.value.filter((player) => !player.main)
.sort((a, b) => b.playtime - a.playtime);
.sort(comparator);
});
const roleIcons = reactive({

View File

@ -24,11 +24,8 @@ const hasAlternates = computed(() => {
<em class="aside date">Aug. 13, 2036 @ 11:30 PM EST</em>
</h1>
<div class="button-group">
<button>
<i class="bi bi-box-arrow-left"></i>
Back
</button>
<button class="accent">Submit</button>
<button>Cancel</button>
<button class="accent">Save Roster</button>
</div>
</div>
<div class="columns">

View File

@ -1,8 +1,75 @@
<script setup lang="ts">
import AvailabilityGrid from "../components/AvailabilityGrid.vue";
import AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
import WeekSelectionBox from "../components/WeekSelectionBox.vue";
import { reactive, ref } from "vue";
const options = reactive([
"Team pepeja",
"Snus Brotherhood",
]);
const comboBoxIndex = ref(0);
const availability = reactive(new Array(168));
const selectionMode = ref(1);
</script>
<template>
<main>
<h1>Master Schedule</h1>
<div v-if="options.length > 0">
<div>
Availability for
<AvailabilityComboBox :options="options" v-model="comboBoxIndex" />
<WeekSelectionBox />
</div>
<AvailabilityGrid v-model="availability" :selection-mode="selectionMode" />
<div class="radio-group">
<button
:class="{ 'radio': true, 'selected': selectionMode == 1, 'left': true }"
@click="selectionMode = 1"
>
Available if needed
</button>
<button
:class="{ 'radio': true, 'selected': selectionMode == 2, 'right': true }"
@click="selectionMode = 2"
>
Definitely available
</button>
</div>
</div>
<div v-else>
You currently are not in any team to schedule for.
</div>
</main>
</template>
<style scoped>
.radio-group {
display: flex;
gap: 2px;
}
button.radio {
font-weight: 500;
}
button.radio:hover {
font-weight: 500;
}
button.radio.selected {
color: var(--accent);
background-color: var(--accent-transparent);
}
button.left {
border-radius: 8px 0 0 8px;
}
button.right {
border-radius: 0 8px 8px 0;
}
</style>