Compare commits
7 commits
Author | SHA1 | Date | |
---|---|---|---|
52973ffa9a | |||
31f80daa86 | |||
9013e85ff0 | |||
ae1c653af6 | |||
848a330f3a | |||
085e348aa8 | |||
4ff3dcb3fe |
15 changed files with 239 additions and 14 deletions
|
@ -29,7 +29,6 @@ defineProps<{
|
||||||
edit?: boolean
|
edit?: boolean
|
||||||
}>();
|
}>();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
await usersStore.fetch();
|
|
||||||
const assignedIds = defineModel<Set<number>>({ required: true });
|
const assignedIds = defineModel<Set<number>>({ required: true });
|
||||||
const assigned = computed(
|
const assigned = computed(
|
||||||
() => [...assignedIds.value].map(
|
() => [...assignedIds.value].map(
|
||||||
|
|
|
@ -5,6 +5,9 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="event">
|
<section class="event">
|
||||||
<h3>{{ event.name }}</h3>
|
<h3>{{ event.name }}</h3>
|
||||||
|
<p v-if=event.host>
|
||||||
|
Host: {{ event.host }}
|
||||||
|
</p>
|
||||||
<p>{{ event.description ?? "No description provided" }}</p>
|
<p>{{ event.description ?? "No description provided" }}</p>
|
||||||
<p v-if="event.interested">
|
<p v-if="event.interested">
|
||||||
{{ event.interested }} interested
|
{{ event.interested }} interested
|
||||||
|
@ -35,7 +38,7 @@
|
||||||
<template v-if="slot.interested">
|
<template v-if="slot.interested">
|
||||||
({{ slot.interested }} interested)
|
({{ slot.interested }} interested)
|
||||||
</template>
|
</template>
|
||||||
<p v-if="slot.assigned">
|
<p v-if="slot.assigned.size">
|
||||||
Crew:
|
Crew:
|
||||||
{{ [...slot.assigned].map(id => usersStore.users.get(id)?.name).join(", ") }}
|
{{ [...slot.assigned].map(id => usersStore.users.get(id)?.name).join(", ") }}
|
||||||
</p>
|
</p>
|
||||||
|
@ -53,7 +56,6 @@ defineProps<{
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
await usersStore.fetch();
|
|
||||||
|
|
||||||
function formatTime(time: DateTime) {
|
function formatTime(time: DateTime) {
|
||||||
return time.toFormat("yyyy-LL-dd HH:mm");
|
return time.toFormat("yyyy-LL-dd HH:mm");
|
||||||
|
|
71
components/CardEventSlot.vue
Normal file
71
components/CardEventSlot.vue
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<section class="eventSlot">
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{ event?.name }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
<p v-if=event?.host>
|
||||||
|
Host: {{ event.host }}
|
||||||
|
</p>
|
||||||
|
<p>{{ event?.description ?? "No description provided" }}</p>
|
||||||
|
<p v-if="locations.length">
|
||||||
|
At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }}
|
||||||
|
</p>
|
||||||
|
<p v-if="event?.interested">
|
||||||
|
{{ event?.interested }}
|
||||||
|
<template v-if="slot.interested">
|
||||||
|
+ {{ slot.interested }}
|
||||||
|
</template>
|
||||||
|
interested
|
||||||
|
</p>
|
||||||
|
<p v-if="slot.assigned.size">
|
||||||
|
Crew:
|
||||||
|
{{ [...slot.assigned].map(id => usersStore.users.get(id)?.name).join(", ") }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event?: ClientScheduleEvent,
|
||||||
|
slot: ClientScheduleEventSlot,
|
||||||
|
}>()
|
||||||
|
const scheduleStore = useSchedulesStore();
|
||||||
|
const schedule = scheduleStore.activeSchedule;
|
||||||
|
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
const locations = computed(() => [...props.slot.locationIds].map(id => schedule.value.locations.get(id)));
|
||||||
|
|
||||||
|
function formatTime(time: DateTime) {
|
||||||
|
return time.toFormat("yyyy-LL-dd HH:mm");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.eventSlot {
|
||||||
|
background: color-mix(in oklab, var(--background), grey 20%);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.eventSlot h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.eventSlot + .eventSlot {
|
||||||
|
margin-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding-inline: 0.2em;
|
||||||
|
}
|
||||||
|
button.active {
|
||||||
|
color: color-mix(in oklab, var(--foreground), green 50%);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -27,9 +27,7 @@ defineProps<{
|
||||||
shift: ClientScheduleShift
|
shift: ClientScheduleShift
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
await usersStore.fetch();
|
|
||||||
|
|
||||||
function formatTime(time: DateTime) {
|
function formatTime(time: DateTime) {
|
||||||
return time.toFormat("yyyy-LL-dd HH:mm");
|
return time.toFormat("yyyy-LL-dd HH:mm");
|
||||||
|
|
|
@ -11,6 +11,12 @@
|
||||||
:after="event.name"
|
:after="event.name"
|
||||||
:state
|
:state
|
||||||
/>
|
/>
|
||||||
|
<DiffFieldString
|
||||||
|
title="Host"
|
||||||
|
:before="event.serverHost"
|
||||||
|
:after="event.host"
|
||||||
|
:state
|
||||||
|
/>
|
||||||
<DiffFieldString
|
<DiffFieldString
|
||||||
title="Public"
|
title="Public"
|
||||||
:before='event.serverCrew ? "No" : "Yes"'
|
:before='event.serverCrew ? "No" : "Yes"'
|
||||||
|
|
|
@ -9,6 +9,9 @@
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/">Home</NuxtLink>
|
<NuxtLink to="/">Home</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/events">Events</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/schedule">Schedule</NuxtLink>
|
<NuxtLink to="/schedule">Schedule</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -215,7 +215,6 @@ interface Gap {
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
await usersStore.fetch();
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>id</th>
|
<th>id</th>
|
||||||
<th>name</th>
|
<th>name</th>
|
||||||
|
<th>host</th>
|
||||||
<th>description</th>
|
<th>description</th>
|
||||||
<th>p</th>
|
<th>p</th>
|
||||||
<th>s</th>
|
<th>s</th>
|
||||||
|
@ -30,6 +31,13 @@
|
||||||
v-model="event.name"
|
v-model="event.name"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:disabled="!canEdit(event)"
|
||||||
|
v-model="event.host"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -101,6 +109,7 @@
|
||||||
>
|
>
|
||||||
<td>{{ event.id }}</td>
|
<td>{{ event.id }}</td>
|
||||||
<td>{{ event.name }}</td>
|
<td>{{ event.name }}</td>
|
||||||
|
<td>{{ event.host }}</td>
|
||||||
<td>{{ event.description }}</td>
|
<td>{{ event.description }}</td>
|
||||||
<td>{{ event.crew ? "" : "Yes"}}</td>
|
<td>{{ event.crew ? "" : "Yes"}}</td>
|
||||||
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
|
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
|
||||||
|
|
|
@ -209,7 +209,6 @@ interface Gap {
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
await usersStore.fetch();
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
|
@ -55,7 +55,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
useEventSource();
|
useEventSource();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
await usersStore.fetch();
|
|
||||||
|
|
||||||
async function saveUser(user: ClientUser) {
|
async function saveUser(user: ClientUser) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -77,6 +77,7 @@
|
||||||
<td :colSpan="totalColumns">
|
<td :colSpan="totalColumns">
|
||||||
<div
|
<div
|
||||||
v-if="nowOffset !== undefined"
|
v-if="nowOffset !== undefined"
|
||||||
|
ref="nowLine"
|
||||||
class="now"
|
class="now"
|
||||||
:style="` --now-offset: ${nowOffset}`"
|
:style="` --now-offset: ${nowOffset}`"
|
||||||
>
|
>
|
||||||
|
@ -677,6 +678,16 @@ const nowOffset = computed(() => {
|
||||||
offset += group.width;
|
offset += group.width;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nowLine = useTemplateRef("nowLine"); // If I name the ref now, the element disappears?!
|
||||||
|
function scrollToNow() {
|
||||||
|
if (nowLine.value) {
|
||||||
|
nowLine.value.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defineExpose({
|
||||||
|
scrollToNow,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -101,6 +101,9 @@ useHead({
|
||||||
title: "Admin",
|
title: "Admin",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
await usersStore.fetch();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "users", title: "Users" },
|
{ id: "users", title: "Users" },
|
||||||
{ id: "database", title: "Database" },
|
{ id: "database", title: "Database" },
|
||||||
|
|
89
pages/events.vue
Normal file
89
pages/events.vue
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<h1>Events</h1>
|
||||||
|
<label v-if="accountStore.valid">
|
||||||
|
Filter:
|
||||||
|
<select
|
||||||
|
v-model="filter"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
:value="undefined"
|
||||||
|
:selected="filter === undefined"
|
||||||
|
><All events></option>
|
||||||
|
<option
|
||||||
|
value="my-schedule"
|
||||||
|
:selected='filter === "my-schedule"'
|
||||||
|
>My Schedule</option>
|
||||||
|
<option
|
||||||
|
v-if="accountStore.isCrew"
|
||||||
|
value="assigned"
|
||||||
|
:selected='filter === "assigned"'
|
||||||
|
>Assigned to Me</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<CardEvent
|
||||||
|
v-for="event in events"
|
||||||
|
:key="`my-${event.id}`"
|
||||||
|
:id="String(event.name)"
|
||||||
|
:event
|
||||||
|
></CardEvent>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: "Schedule",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stringSort = useStringSort();
|
||||||
|
const events = computed(() => {
|
||||||
|
return [...schedule.value.events.values()].filter(e => !e.deleted && [...e.slots.values()].some(eventSlotFilter.value)).sort((a, b) => stringSort(a.name, b.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
await usersStore.fetch();
|
||||||
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const filter = computed({
|
||||||
|
get: () => queryToString(route.query.filter),
|
||||||
|
set: (value: string | undefined) => navigateTo({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
filter: value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventSlotFilter = computed(() => {
|
||||||
|
if (filter.value === undefined || !accountStore.valid || schedule.value.deleted) {
|
||||||
|
return () => true;
|
||||||
|
}
|
||||||
|
const aid = accountStore.id;
|
||||||
|
if (filter.value === "my-schedule") {
|
||||||
|
const slotIds = new Set(accountStore.interestedEventSlotIds);
|
||||||
|
for (const event of schedule.value.events.values()) {
|
||||||
|
if (!event.deleted && accountStore.interestedEventIds.has(event.id)) {
|
||||||
|
for (const slot of event.slots.values()) {
|
||||||
|
slotIds.add(slot.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (slot: ClientScheduleEventSlot) => slotIds.has(slot.id) || slot.assigned.has(aid!) || false;
|
||||||
|
}
|
||||||
|
if (filter.value === "assigned") {
|
||||||
|
return (slot: ClientScheduleEventSlot) => slot.assigned.has(aid!) || false;
|
||||||
|
}
|
||||||
|
if (filter.value.startsWith("crew-")) {
|
||||||
|
const cid = parseInt(filter.value.slice(5));
|
||||||
|
return (slot: ClientScheduleEventSlot) => slot.assigned.has(cid) || false;
|
||||||
|
}
|
||||||
|
return () => false;
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -4,7 +4,7 @@
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<h1>Schedule & Events</h1>
|
<h1>Schedule</h1>
|
||||||
<p>
|
<p>
|
||||||
Study carefully, we only hold these events once a year.
|
Study carefully, we only hold these events once a year.
|
||||||
</p>
|
</p>
|
||||||
|
@ -44,12 +44,17 @@
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
<Timetable ref="timetable" :schedule :eventSlotFilter :shiftSlotFilter />
|
||||||
<h2>Events</h2>
|
<h2>Events</h2>
|
||||||
<CardEvent
|
<label>
|
||||||
v-for="event in [...schedule.events.values()].filter(e => !e.deleted && [...e.slots.values()].some(eventSlotFilter))"
|
Hide past events
|
||||||
:key="event.id"
|
<input type="checkbox" v-model="hidePastEvents">
|
||||||
:event
|
</label>
|
||||||
|
<CardEventSlot
|
||||||
|
v-for="eventSlot in eventSlots"
|
||||||
|
:key="eventSlot.slot.id"
|
||||||
|
:event="eventSlot.event"
|
||||||
|
:slot="eventSlot.slot"
|
||||||
/>
|
/>
|
||||||
<template v-if="accountStore.isCrew">
|
<template v-if="accountStore.isCrew">
|
||||||
<h2>Shifts</h2>
|
<h2>Shifts</h2>
|
||||||
|
@ -91,6 +96,17 @@ const filter = computed({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hidePastEvents = computed({
|
||||||
|
get: () => !queryToBoolean(route.query.showPast),
|
||||||
|
set: (value: boolean) => navigateTo({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
showPast: value ? undefined : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const eventSlotFilter = computed(() => {
|
const eventSlotFilter = computed(() => {
|
||||||
if (filter.value === undefined || !accountStore.valid || schedule.value.deleted) {
|
if (filter.value === undefined || !accountStore.valid || schedule.value.deleted) {
|
||||||
return () => true;
|
return () => true;
|
||||||
|
@ -130,4 +146,19 @@ const shiftSlotFilter = computed(() => {
|
||||||
}
|
}
|
||||||
return () => false;
|
return () => false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const eventSlots = computed(() => {
|
||||||
|
let slots = [...schedule.value.eventSlots.values()].filter(slot => !slot.deleted && eventSlotFilter.value(slot));
|
||||||
|
if (hidePastEvents.value) {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
slots = slots.filter(slot => slot.end.toMillis() >= nowMs);
|
||||||
|
}
|
||||||
|
slots.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
||||||
|
return slots.map(slot => ({ slot, event: schedule.value.events.get(slot.eventId!) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const timetable = useTemplateRef("timetable");
|
||||||
|
onMounted(() => {
|
||||||
|
timetable.value?.scrollToNow();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -21,6 +21,12 @@ export function queryToNumber(item?: null | LocationQueryValue | LocationQueryVa
|
||||||
return Number.parseInt(item, 10);
|
return Number.parseInt(item, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function queryToBoolean(item?: null | LocationQueryValue | LocationQueryValue[]) {
|
||||||
|
if (item === undefined)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function idMap<T extends { id: Id }>(entities: T[]) {
|
export function idMap<T extends { id: Id }>(entities: T[]) {
|
||||||
return new Map(entities.map(entity => [entity.id, entity]));
|
return new Map(entities.map(entity => [entity.id, entity]));
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue