Compare commits

...

5 commits

Author SHA1 Message Date
9013e85ff0 Scroll the now line into view on navigation
All checks were successful
/ build (push) Successful in 1m34s
/ deploy (push) Successful in 16s
When displaying the schedule, scroll it such that the now line is on the
left to make what is displayed by default the most immediately useful
information.
2025-07-16 19:58:01 +02:00
ae1c653af6 Separate event dipslay from event slot display
Pull out the list of events into its own page sorted by name and show
the event slots in chronological order on the schedule page, with past
slots hidden by default.  This makes the content underneath the schedule
the most immediately useful to have in the moment, while the full list
is kept separately and in a predictable order.
2025-07-16 19:37:23 +02:00
848a330f3a Add editing and display of event host
Display and allow editing of the host field of events.
2025-07-16 19:07:36 +02:00
085e348aa8 Hide empty crew list in EventCard
It used to be that the assigned property was not present for clients who
are not crew, but this changed with the client state refactor.  It makes
more sense to only show the crew field if there are any crew present.
2025-07-16 19:02:55 +02:00
4ff3dcb3fe Remove use of async components
When async components are added dynamically to the tree via v-for list
that change their order and position gets messed up.  I am not sure what
causes this, so I will just work around the issue for now and not use
async components.

Components that need async data loaded will instead depend on the parent
page fetching this data during its setup.
2025-07-16 18:59:11 +02:00
14 changed files with 233 additions and 14 deletions

View file

@ -29,7 +29,6 @@ defineProps<{
edit?: boolean
}>();
const usersStore = useUsersStore();
await usersStore.fetch();
const assignedIds = defineModel<Set<number>>({ required: true });
const assigned = computed(
() => [...assignedIds.value].map(

View file

@ -5,6 +5,9 @@
<template>
<section class="event">
<h3>{{ event.name }}</h3>
<p v-if=event.host>
Host: {{ event.host }}
</p>
<p>{{ event.description ?? "No description provided" }}</p>
<p v-if="event.interested">
{{ event.interested }} interested
@ -35,7 +38,7 @@
<template v-if="slot.interested">
({{ slot.interested }} interested)
</template>
<p v-if="slot.assigned">
<p v-if="slot.assigned.size">
Crew:
{{ [...slot.assigned].map(id => usersStore.users.get(id)?.name).join(", ") }}
</p>
@ -53,7 +56,6 @@ defineProps<{
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
function formatTime(time: DateTime) {
return time.toFormat("yyyy-LL-dd HH:mm");

View file

@ -0,0 +1,68 @@
<!--
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>{{ 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>

View file

@ -27,9 +27,7 @@ defineProps<{
shift: ClientScheduleShift
}>()
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
function formatTime(time: DateTime) {
return time.toFormat("yyyy-LL-dd HH:mm");

View file

@ -11,6 +11,12 @@
:after="event.name"
:state
/>
<DiffFieldString
title="Host"
:before="event.serverHost"
:after="event.host"
:state
/>
<DiffFieldString
title="Public"
:before='event.serverCrew ? "No" : "Yes"'

View file

@ -9,6 +9,9 @@
<li>
<NuxtLink to="/">Home</NuxtLink>
</li>
<li>
<NuxtLink to="/events">Events</NuxtLink>
</li>
<li>
<NuxtLink to="/schedule">Schedule</NuxtLink>
</li>

View file

@ -215,7 +215,6 @@ interface Gap {
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
const schedule = await useSchedule();
const oneDayMs = 24 * 60 * 60 * 1000;

View file

@ -9,6 +9,7 @@
<tr>
<th>id</th>
<th>name</th>
<th>host</th>
<th>description</th>
<th>p</th>
<th>s</th>
@ -30,6 +31,13 @@
v-model="event.name"
>
</td>
<td>
<input
type="text"
:disabled="!canEdit(event)"
v-model="event.host"
>
</td>
<td>
<input
type="text"
@ -101,6 +109,7 @@
>
<td>{{ event.id }}</td>
<td>{{ event.name }}</td>
<td>{{ event.host }}</td>
<td>{{ event.description }}</td>
<td>{{ event.crew ? "" : "Yes"}}</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td>

View file

@ -209,7 +209,6 @@ interface Gap {
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
const schedule = await useSchedule();
const oneDayMs = 24 * 60 * 60 * 1000;

View file

@ -55,7 +55,6 @@
<script lang="ts" setup>
useEventSource();
const usersStore = useUsersStore();
await usersStore.fetch();
async function saveUser(user: ClientUser) {
try {

View file

@ -77,6 +77,7 @@
<td :colSpan="totalColumns">
<div
v-if="nowOffset !== undefined"
ref="nowLine"
class="now"
:style="` --now-offset: ${nowOffset}`"
>
@ -677,6 +678,16 @@ const nowOffset = computed(() => {
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>
<style scoped>

89
pages/events.vue Normal file
View 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"
>&lt;All events&gt;</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>

View file

@ -4,7 +4,7 @@
-->
<template>
<main>
<h1>Schedule & Events</h1>
<h1>Schedule</h1>
<p>
Study carefully, we only hold these events once a year.
</p>
@ -44,12 +44,17 @@
</optgroup>
</select>
</label>
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
<Timetable ref="timetable" :schedule :eventSlotFilter :shiftSlotFilter />
<h2>Events</h2>
<CardEvent
v-for="event in [...schedule.events.values()].filter(e => !e.deleted && [...e.slots.values()].some(eventSlotFilter))"
:key="event.id"
:event
<label>
Hide past events
<input type="checkbox" v-model="hidePastEvents">
</label>
<CardEventSlot
v-for="eventSlot in eventSlots"
:key="eventSlot.slot.id"
:event="eventSlot.event"
:slot="eventSlot.slot"
/>
<template v-if="accountStore.isCrew">
<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(() => {
if (filter.value === undefined || !accountStore.valid || schedule.value.deleted) {
return () => true;
@ -130,4 +146,19 @@ const shiftSlotFilter = computed(() => {
}
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>

View file

@ -21,6 +21,12 @@ export function queryToNumber(item?: null | LocationQueryValue | LocationQueryVa
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[]) {
return new Map(entities.map(entity => [entity.id, entity]));
}