mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 20:12:10 +00:00
Implement instant websocket room updates
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user