Add developer controls for page state previews

This commit is contained in:
Matt Isenhower
2026-09-27 16:07:44 -07:00
parent 0280104efb
commit 43ef8d7460
6 changed files with 156 additions and 7 deletions

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex flex-wrap justify-center items-center gap-2 text-xs">
<TimeOffsetSelector />
<label class="flex items-center gap-2 bg-zinc-700 p-1 rounded-sm">
<span class="bg-zinc-500 rounded-sm font-semibold px-1">Page state</span>
<select v-model="preview.mode" class="bg-zinc-800">
<option value="live">Live data</option>
<option value="loading">Loading</option>
<option value="empty">Empty</option>
<option value="error">Error</option>
</select>
</label>
</div>
</template>
<script setup>
import TimeOffsetSelector from './TimeOffsetSelector.vue';
import { usePagePreviewStore } from '@/stores/pagePreview.mjs';
const preview = usePagePreviewStore();
</script>

View File

@@ -27,16 +27,33 @@
<script setup>
import { computed } from 'vue';
import PageSkeleton from './PageSkeleton.vue';
import { usePagePreviewStore } from '@/stores/pagePreview.mjs';
const props = defineProps({
sources: { type: Array, required: true },
variant: { type: String, required: true },
});
const ready = computed(() => props.sources.every(source => source.data !== null));
const failed = computed(() => props.sources.some(source => source.data === null && source.error));
const preview = import.meta.env.DEV ? usePagePreviewStore() : null;
const ready = computed(() => {
if (import.meta.env.DEV && ['loading', 'error'].includes(preview.mode)) return false;
return props.sources.every(source => source.data !== null);
});
const failed = computed(() => {
if (import.meta.env.DEV && preview.mode === 'loading') return false;
if (import.meta.env.DEV && preview.mode === 'error') return true;
return props.sources.some(source => source.data === null && source.error);
});
function retry() {
if (import.meta.env.DEV && preview.mode === 'error') {
preview.mode = 'live';
}
for (const source of props.sources) {
if (source.data === null) source.update();
}

View File

@@ -50,20 +50,23 @@
</div>
</div>
<TimeOffsetSelector v-if="isDev" class="mb-4" />
<DevControls v-if="isDev" class="mb-4" />
</main>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
import NavButtons from '@/components/NavButtons.vue';
import LanguageButton from '@/components/LanguageButton.vue';
import TimeOffsetSelector from '@/components/Debug/TimeOffsetSelector.vue';
const props = defineProps({
title: String,
});
const isDev = import.meta.env.DEV;
const DevControls = import.meta.env.DEV
? defineAsyncComponent(() => import('@/components/Debug/DevControls.vue'))
: null;
</script>
<style scoped>

View File

@@ -1,10 +1,15 @@
import { acceptHMRUpdate, defineStore } from 'pinia';
import { computed, ref, shallowRef } from 'vue';
import { getEmptyPreviewData, usePagePreviewStore } from './pagePreview.mjs';
// Endpoint store definition (used for each individual data endpoint)
function defineEndpointStore(id, endpoint, transform = null) {
return defineStore(`data/${id}`, () => {
const data = shallowRef(null);
const loadedData = shallowRef(null);
const preview = import.meta.env.DEV ? usePagePreviewStore() : null;
const data = import.meta.env.DEV
? computed(() => preview.mode === 'empty' ? getEmptyPreviewData(id) : loadedData.value)
: loadedData;
const isUpdating = ref(false);
const error = ref(null);
let pending;
@@ -50,11 +55,11 @@ function defineEndpointStore(id, endpoint, transform = null) {
throw new Error('Missing data payload');
}
data.value = json;
loadedData.value = json;
error.value = null;
}
return { data, update, setData, isUpdating, error };
return { data, loadedData, update, setData, isUpdating, error };
});
}

View File

@@ -0,0 +1,31 @@
import { defineStore } from 'pinia';
export const usePagePreviewStore = import.meta.env.DEV
? defineStore('debug/pagePreview', {
state: () => ({ mode: 'live' }),
})
: null;
const emptyData = {
schedules: {
currentFest: null,
regularSchedules: { nodes: [] },
bankaraSchedules: { nodes: [] },
xSchedules: { nodes: [] },
festSchedules: { nodes: [] },
eventSchedules: { nodes: [] },
vsStages: { nodes: [] },
coopGroupingSchedule: {
regularSchedules: { nodes: [] },
bigRunSchedules: { nodes: [] },
teamContestSchedules: { nodes: [] },
},
},
gear: { gesotown: { pickupBrand: null, limitedGears: [] } },
coop: { coopResult: { monthlyGear: null } },
festivals: {},
};
export function getEmptyPreviewData(endpoint) {
return emptyData[endpoint];
}

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useGearDataStore, useSchedulesDataStore } from './data.mjs';
import { usePagePreviewStore } from './pagePreview.mjs';
import { useGearStore } from './gear.mjs';
import {
useRegularSchedulesStore,
useAnarchySeriesSchedulesStore,
useAnarchyOpenSchedulesStore,
useXSchedulesStore,
useEventSchedulesStore,
useSalmonRunSchedulesStore,
useEggstraWorkSchedulesStore,
} from './schedules.mjs';
import { useUSSplatfestsStore } from './splatfests.mjs';
describe('page state preview', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('provides valid empty results for every data page before requests complete', () => {
usePagePreviewStore().mode = 'empty';
expect(useGearDataStore().data).not.toBeNull();
expect(useSchedulesDataStore().data).not.toBeNull();
expect(useGearStore().dailyDropBrand).toBeNull();
expect(useGearStore().regularGear).toEqual([]);
expect(useUSSplatfestsStore().festivals).toEqual([]);
const scheduleStores = [
useRegularSchedulesStore,
useAnarchySeriesSchedulesStore,
useAnarchyOpenSchedulesStore,
useXSchedulesStore,
useEventSchedulesStore,
useSalmonRunSchedulesStore,
useEggstraWorkSchedulesStore,
];
for (const useStore of scheduleStores) {
expect(useStore().currentSchedules).toEqual([]);
expect(useStore().activeSchedule).toBeUndefined();
}
});
it('holds the empty preview through data updates and restores the latest live data', () => {
const preview = usePagePreviewStore();
const gear = useGearDataStore();
gear.setData({ data: { version: 1 } });
preview.mode = 'empty';
gear.setData({ data: { version: 2 } });
expect(gear.data.gesotown.limitedGears).toEqual([]);
preview.mode = 'live';
expect(gear.data).toEqual({ version: 2 });
});
it('restores the pending state when leaving an empty preview without live data', () => {
const preview = usePagePreviewStore();
const gear = useGearDataStore();
preview.mode = 'empty';
expect(gear.data).not.toBeNull();
preview.mode = 'live';
expect(gear.data).toBeNull();
expect(gear.error).toBeNull();
});
});