Implement instant websocket room updates

This commit is contained in:
2026-08-25 22:23:38 +02:00
parent bfc93dceba
commit d6e5455e90
18 changed files with 303 additions and 130 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { useHttp } from '@inertiajs/vue3';
import { Eye, RotateCcw, Trash2 } from '@lucide/vue';
import { nextRound, reset as resetRound, reveal } from '@/routes/room';
@@ -7,26 +7,29 @@ import type { RoomModeratorControlsProps } from '@/types/room';
const props = defineProps<RoomModeratorControlsProps>();
const revealForm = useForm<{ room?: string }>({});
const nextRoundForm = useForm<{ room?: string }>({});
const resetForm = useForm<{ room?: string }>({});
const revealRequest = useHttp<{ room?: string }>(
() => reveal(props.roomId),
{},
);
const nextRoundRequest = useHttp<{ room?: string }>(
() => nextRound(props.roomId),
{},
);
const resetRequest = useHttp<{ room?: string }>(
() => resetRound(props.roomId),
{},
);
function revealCards(): void {
revealForm.submit(reveal(props.roomId), {
preserveScroll: true,
});
void revealRequest.submit();
}
function startNextRound(): void {
nextRoundForm.submit(nextRound(props.roomId), {
preserveScroll: true,
});
void nextRoundRequest.submit();
}
function resetVotes(): void {
resetForm.submit(resetRound(props.roomId), {
preserveScroll: true,
});
void resetRequest.submit();
}
</script>
@@ -35,7 +38,7 @@ function resetVotes(): void {
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-600 bg-slate-800 px-4 py-2.5 font-semibold text-slate-200 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="resetForm.processing || !hasVotes"
:disabled="resetRequest.processing || !hasVotes"
@click="resetVotes"
>
<Trash2 class="size-4" />
@@ -45,18 +48,18 @@ function resetVotes(): void {
<div class="moderator-controls">
<p
v-if="revealForm.errors.room || nextRoundForm.errors.room"
v-if="revealRequest.errors.room || nextRoundRequest.errors.room"
class="max-w-64 rounded-lg bg-rose-950/90 px-3 py-2 text-right text-sm text-rose-200 shadow-lg"
role="alert"
>
{{ revealForm.errors.room ?? nextRoundForm.errors.room }}
{{ revealRequest.errors.room ?? nextRoundRequest.errors.room }}
</p>
<button
v-if="!revealed"
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50"
:disabled="revealForm.processing"
:disabled="revealRequest.processing"
@click="revealCards"
>
<Eye class="size-4" />
@@ -67,7 +70,7 @@ function resetVotes(): void {
v-else
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50"
:disabled="nextRoundForm.processing"
:disabled="nextRoundRequest.processing"
@click="startNextRound"
>
<RotateCcw class="size-4" />
+20 -16
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { useHttp } from '@inertiajs/vue3';
import { Keyboard } from '@lucide/vue';
import { useEventListener } from '@vueuse/core';
import { watch } from 'vue';
import { store as storeVote } from '@/routes/room/vote';
import type { RoomVoteDeckProps } from '@/types/room';
import type { RoomVoteDeckEmits, RoomVoteDeckProps } from '@/types/room';
const props = defineProps<RoomVoteDeckProps>();
const emit = defineEmits<RoomVoteDeckEmits>();
const cardShortcuts = [
'1',
@@ -25,29 +26,32 @@ const cardShortcuts = [
),
];
const voteForm = useForm({
const voteRequest = useHttp(() => storeVote(props.roomId), {
value: props.currentVote ?? '',
});
watch(
() => props.currentVote,
(value) => {
if (!voteForm.processing) {
voteForm.value = value ?? '';
if (!voteRequest.processing) {
voteRequest.value = value ?? '';
}
},
);
function submitVote(value: string): void {
if (props.revealed || voteForm.processing) {
if (props.revealed || voteRequest.processing) {
return;
}
voteForm.value = value;
voteForm.submit(storeVote(props.roomId), {
preserveScroll: true,
const previousVote = props.currentVote;
voteRequest.value = value;
emit('vote-changed', value);
void voteRequest.submit({
onError: () => {
voteForm.value = props.currentVote ?? '';
voteRequest.value = previousVote ?? '';
emit('vote-changed', previousVote);
},
});
}
@@ -71,7 +75,7 @@ function handleKeyboardVote(event: KeyboardEvent): void {
if (
!props.presenterMode ||
props.revealed ||
voteForm.processing ||
voteRequest.processing ||
event.repeat ||
event.altKey ||
event.ctrlKey ||
@@ -118,10 +122,10 @@ useEventListener('keydown', handleKeyboardVote);
class="deck-card"
:class="{
'is-selected':
!presenterMode && voteForm.value === card.key,
!presenterMode && voteRequest.value === card.key,
}"
:disabled="voteForm.processing"
:aria-pressed="voteForm.value === card.key"
:disabled="voteRequest.processing"
:aria-pressed="voteRequest.value === card.key"
:aria-label="`Karte ${card.label} wählen`"
@click="submitVote(card.key)"
>
@@ -138,11 +142,11 @@ useEventListener('keydown', handleKeyboardVote);
</div>
<p
v-if="voteForm.errors.value"
v-if="voteRequest.errors.value"
class="mt-3 text-sm text-rose-300"
role="alert"
>
{{ voteForm.errors.value }}
{{ voteRequest.errors.value }}
</p>
</section>
</template>
+63 -51
View File
@@ -1,72 +1,73 @@
import { router } from '@inertiajs/vue3';
import { onScopeDispose, watch } from 'vue';
import { onScopeDispose, ref, shallowReadonly, watch } from 'vue';
import { getEcho } from '@/lib/echo';
import type { RoomStateChangedPayload } from '@/types/room';
import { home } from '@/routes';
import { join } from '@/routes/room';
import type {
CurrentRoomPlayer,
PokerRoom,
RoomStateChangedPayload,
} from '@/types/room';
const ROOM_EVENT = '.room.state.changed';
export function useRoomRealtime(
roomId: () => string,
stateVersion: () => number,
): void {
let reloadInProgress = false;
let pendingVersion = 0;
let disposed = false;
function reloadRoom(): void {
if (reloadInProgress || disposed) {
return;
}
reloadInProgress = true;
router.reload({
only: ['room', 'currentPlayer'],
onFinish: () => {
reloadInProgress = false;
if (disposed) {
return;
}
if (pendingVersion > stateVersion()) {
reloadRoom();
return;
}
pendingVersion = stateVersion();
},
});
}
initialRoom: () => PokerRoom,
initialCurrentPlayer: () => CurrentRoomPlayer,
) {
const room = ref<PokerRoom>({ ...initialRoom() });
const currentPlayer = ref<CurrentRoomPlayer>({ ...initialCurrentPlayer() });
function handleRoomChange(payload: RoomStateChangedPayload): void {
if (
typeof payload.version !== 'number' ||
payload.version <= Math.max(stateVersion(), pendingVersion)
payload.version <= room.value.state_version
) {
return;
}
pendingVersion = payload.version;
if (payload.change === 'room_deleted') {
router.visit('/', { replace: true });
router.visit(home().url, { replace: true });
return;
}
reloadRoom();
if (payload.change === 'room_closed') {
router.visit(
join({ query: { join_code: room.value.join_code } }).url,
{ replace: true },
);
return;
}
if (payload.state === null || payload.state === undefined) {
return;
}
room.value = {
...room.value,
...payload.state,
};
if (
payload.change === 'votes_reset' ||
payload.change === 'next_round'
) {
currentPlayer.value = {
...currentPlayer.value,
current_vote: null,
};
}
}
const stopWatchingRoom = watch(
roomId,
() => initialRoom().id,
(nextRoomId, _previousRoomId, onCleanup) => {
const echo = getEcho();
const channelName = `rooms.${nextRoomId}`;
pendingVersion = stateVersion();
echo.channel(channelName).listen(ROOM_EVENT, handleRoomChange);
onCleanup(() => {
@@ -76,19 +77,30 @@ export function useRoomRealtime(
{ immediate: true },
);
const stopWatchingVersion = watch(
stateVersion,
(version) => {
if (!reloadInProgress) {
pendingVersion = Math.max(pendingVersion, version);
}
const stopWatchingInitialState = watch(
[initialRoom, initialCurrentPlayer],
([nextRoom, nextCurrentPlayer]) => {
room.value = { ...nextRoom };
currentPlayer.value = { ...nextCurrentPlayer };
},
{ flush: 'sync' },
{ deep: true },
);
onScopeDispose(() => {
disposed = true;
stopWatchingRoom();
stopWatchingVersion();
stopWatchingInitialState();
});
function setCurrentVote(value: string | null): void {
currentPlayer.value = {
...currentPlayer.value,
current_vote: value,
};
}
return {
room: shallowReadonly(room),
currentPlayer: shallowReadonly(currentPlayer),
setCurrentVote,
};
}
+20 -19
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head, usePage, usePoll } from '@inertiajs/vue3';
import { Head, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import RoomHeader from '@/components/rooms/RoomHeader.vue';
@@ -18,18 +18,18 @@ const canFavorite = computed(
() => page.props.auth.user?.id != props.room.owner_id,
);
useRoomRealtime(
() => props.room.id,
() => props.room.state_version,
const {
room: realtimeRoom,
currentPlayer: realtimeCurrentPlayer,
setCurrentVote,
} = useRoomRealtime(
() => props.room,
() => props.currentPlayer,
);
usePoll(30_000, {
only: ['room', 'currentPlayer'],
});
</script>
<template>
<Head :title="room.title ?? 'Scrum Poker'" />
<Head :title="realtimeRoom.title ?? 'Scrum Poker'" />
<main
class="-m-6 min-h-[calc(100svh-4rem)] bg-slate-900 px-3 py-6 text-white sm:px-6 md:flex md:h-[calc(100dvh-4rem-1px)] md:min-h-0 md:flex-col md:overflow-hidden lg:px-10"
@@ -37,26 +37,27 @@ usePoll(30_000, {
<RoomRetentionNotice v-if="roomNotice" :notice="roomNotice" />
<RoomHeader
:id="room.id"
:title="room.title"
:join_code="room.join_code"
:is_favorite="room.is_favorite"
:id="realtimeRoom.id"
:title="realtimeRoom.title"
:join_code="realtimeRoom.join_code"
:is_favorite="realtimeRoom.is_favorite"
:can-favorite="canFavorite"
v-model:presenter-mode="presenterMode"
/>
<RoomTable
:room="room"
:current-player="currentPlayer"
:room="realtimeRoom"
:current-player="realtimeCurrentPlayer"
:presenter-mode="presenterMode"
/>
<RoomVoteDeck
:room-id="room.id"
:revealed="room.revealed"
:cards="room.deck.cards"
:current-vote="currentPlayer.current_vote"
:room-id="realtimeRoom.id"
:revealed="realtimeRoom.revealed"
:cards="realtimeRoom.deck.cards"
:current-vote="realtimeCurrentPlayer.current_vote"
:presenter-mode="presenterMode"
@vote-changed="setCurrentVote"
/>
</main>
</template>
+14
View File
@@ -132,6 +132,10 @@ export type RoomVoteDeckProps = {
presenterMode: boolean;
};
export type RoomVoteDeckEmits = {
'vote-changed': [value: string | null];
};
export type RoomStateChange =
| 'player_joined'
| 'vote_changed'
@@ -145,4 +149,14 @@ export type RoomStateChange =
export type RoomStateChangedPayload = {
version: number;
change: RoomStateChange;
state: RoomRealtimeState | null;
};
export type RoomRealtimeState = Pick<
PokerRoom,
| 'current_round'
| 'revealed'
| 'state_version'
| 'statistics'
| 'players'
>;