fix/spa-reconnect-no-demo-data #1

Merged
Erik merged 2 commits from fix/spa-reconnect-no-demo-data into main 2026-07-09 10:57:31 +00:00
10 changed files with 140 additions and 51 deletions
Showing only changes of commit 07c6789e03 - Show all commits

1
dist/assets/index-CU7fLO-K.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title> <title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script> <script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css"> <link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { WindowCode } from '@/types/station' import type { WindowCode } from '@/types/station'
defineProps<{ window: WindowCode; sample: boolean }>() defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>() const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
const { t } = useI18n() const { t } = useI18n()
@ -32,7 +32,8 @@ onUnmounted(() => {
</span> </span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</span> <span v-if="sample" class="demo">{{ t('app.demo') }}</span>
<span class="spacer"></span> <span class="spacer"></span>
<span class="live"><span class="led"></span>{{ t('app.live') }}</span> <span v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span>
<span v-else class="live"><span class="led"></span>{{ t('app.live') }}</span>
<span class="clock num">{{ clock }}</span> <span class="clock num">{{ clock }}</span>
<span class="rangep" role="group" :aria-label="t('window.label')"> <span class="rangep" role="group" :aria-label="t('window.label')">
<span class="rl">{{ t('window.label') }}</span> <span class="rl">{{ t('window.label') }}</span>
@ -117,6 +118,16 @@ onUnmounted(() => {
background: var(--done); background: var(--done);
animation: blink 2.4s ease-in-out infinite; animation: blink 2.4s ease-in-out infinite;
} }
.reconnect {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--transit);
}
.clock { .clock {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--ink-dim); color: var(--ink-dim);

View File

@ -6,6 +6,11 @@ export default {
live: 'Live', live: 'Live',
demo: 'Demo-Daten', demo: 'Demo-Daten',
}, },
conn: {
connecting: 'Verbinde mit Server …',
lost: 'Verbindung verloren neuer Versuch …',
reconnecting: 'Verbinde neu …',
},
filter: { filter: {
title: 'Maschinen', title: 'Maschinen',
all: 'Alle', all: 'Alle',

View File

@ -7,6 +7,11 @@ export default {
live: 'Live', live: 'Live',
demo: 'Sample data', demo: 'Sample data',
}, },
conn: {
connecting: 'Connecting to server …',
lost: 'Connection lost retrying …',
reconnecting: 'Reconnecting …',
},
filter: { filter: {
title: 'Presses', title: 'Presses',
all: 'All', all: 'All',

View File

@ -6,7 +6,12 @@ import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station' import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling(). // Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
// Falls back to bundled sample data when the API is unreachable (dev / no gateway). //
// Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps
// the last known REAL payload (stale > fake — an operator must never book against a
// demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling
// retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) —
// gated behind `import.meta.env.DEV`, so `vite build` strips it from production.
const CACHE_KEY = 'temper_rejects_payload' const CACHE_KEY = 'temper_rejects_payload'
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usingSample = ref(false) const usingSample = ref(false)
const disconnected = ref(false)
const lastUpdated = ref<number | null>(null) const lastUpdated = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let pollMs = 20000
const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly
let filterInitialized = false let filterInitialized = false
const hasData = computed(() => payload.value !== null) const hasData = computed(() => payload.value !== null)
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
} }
payload.value = res.data payload.value = res.data
usingSample.value = res.data.sample === true usingSample.value = res.data.sample === true
disconnected.value = false
} catch (e) { } catch (e) {
// Connection lost. Keep the last known REAL data (stale > fabricated): never
// replace live carts with demo carts on a live terminal, or an operator could
// book against a fabricated entry. Flag `disconnected` so the UI shows a
// reconnect spinner (cold start) or a header indicator (stale data) and retries.
disconnected.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable' error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Keep the last known REAL data on a transient failure (stale > fabricated): never // Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
// replace live carts with bundled demo carts on a live terminal, or an operator if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
// could book against a fabricated entry. Only fall back to sample on a COLD start
// with nothing to show (dev standalone, where there is no gateway).
if (!payload.value || usingSample.value) {
payload.value = sampleStation(window.value) payload.value = sampleStation(window.value)
usingSample.value = true usingSample.value = true
} }
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
async function fetchReasons() { async function fetchReasons() {
try { try {
const res = await api.get<{ reasons: Reason[] }>('/reasons') const res = await api.get<{ reasons: Reason[] }>('/reasons')
reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS if (Array.isArray(res.data?.reasons) && res.data.reasons.length) {
reasons.value = res.data.reasons
return
}
throw new Error('no reasons')
} catch { } catch {
reasons.value = SAMPLE_REASONS // Keep any reasons already loaded; only seed the bundled sample reasons under
// `npm run dev` — production must never offer fabricated reason codes.
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
} }
} }
@ -152,13 +169,22 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
} }
} }
// Self-scheduling poll: next tick is the normal cadence when connected, a faster
// retry while disconnected so the terminal recovers as soon as the gateway is back.
function scheduleNext() {
timer = setTimeout(async () => {
await refresh()
scheduleNext()
}, disconnected.value ? RETRY_MS : pollMs)
}
function startPolling(ms = 20000) { function startPolling(ms = 20000) {
if (timer) return if (timer) return
timer = setInterval(refresh, ms) pollMs = ms
scheduleNext()
} }
function stopPolling() { function stopPolling() {
if (timer) { if (timer) {
clearInterval(timer) clearTimeout(timer)
timer = null timer = null
} }
} }
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
loading, loading,
error, error,
usingSample, usingSample,
disconnected,
lastUpdated, lastUpdated,
hasData, hasData,
allPresses, allPresses,

View File

@ -114,6 +114,27 @@ html:not(.dark) body {
background-position: 11px 0; background-position: 11px 0;
} }
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Reconnect spinner (connection-loss states). `.sm` is the inline header variant. */
.spinner {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid var(--line-2);
border-top-color: var(--transit);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner.sm {
width: 13px;
height: 13px;
border-width: 2px;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { * {

View File

@ -5,8 +5,10 @@
5-station temper belt and an "Ausschuss buchen" action booking popover 5-station temper belt and an "Ausschuss buchen" action booking popover
(a tooltip-style panel anchored to the button, not a centered modal). (a tooltip-style panel anchored to the button, not a centered modal).
Login-free by design. Data from the store (polled); no-backend falls back to Login-free by design. Data from the store (polled). On connection loss the terminal
bundled sample data so `npm run dev` renders + the booking flow is demoable. keeps the last real carts (or shows a reconnect spinner on a cold start) and retries
it never shows demo carts in production. Sample data renders only under
`npm run dev` (no gateway), where the booking flow is demoable.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
const { t } = useI18n() const { t } = useI18n()
const store = useTemperRejectsStore() const store = useTemperRejectsStore()
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store) const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
const pop = ref<InstanceType<typeof RejectPopover> | null>(null) const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
const bookingEntry = ref<RejectEntry | null>(null) const bookingEntry = ref<RejectEntry | null>(null)
@ -80,9 +82,15 @@ onUnmounted(() => {
<template> <template>
<div class="wrap"> <div class="wrap">
<StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" /> <StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" />
<div class="layout"> <!-- Cold start / no data yet: never blank, never fake spinner that keeps retrying. -->
<div v-if="!hasData" class="state connect">
<span class="spinner" aria-hidden="true"></span>
<span class="msg">{{ disconnected ? t('conn.lost') : t('conn.connecting') }}</span>
</div>
<div v-else class="layout">
<PressFilter <PressFilter
:presses="allPresses" :presses="allPresses"
:checked="checkedPresses" :checked="checkedPresses"
@ -140,6 +148,18 @@ onUnmounted(() => {
border: 1px dashed var(--line-2); border: 1px dashed var(--line-2);
border-radius: var(--r); border-radius: var(--r);
} }
/* Connection spinner state (cold start / reconnecting): full-width, no dashed box. */
.state.connect {
flex-direction: column;
gap: 16px;
min-height: 60vh;
margin-top: 14px;
border: 0;
}
.state.connect .msg {
font-size: 0.85rem;
letter-spacing: 0.04em;
}
.toast { .toast {
position: fixed; position: fixed;
left: 50%; left: 50%;