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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css">
<script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
</head>
<body>
<div id="app"></div>

View File

@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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 { t } = useI18n()
@ -32,7 +32,8 @@ onUnmounted(() => {
</span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</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="rangep" role="group" :aria-label="t('window.label')">
<span class="rl">{{ t('window.label') }}</span>
@ -117,6 +118,16 @@ onUnmounted(() => {
background: var(--done);
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 {
font-size: 0.95rem;
color: var(--ink-dim);

View File

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

View File

@ -7,6 +7,11 @@ export default {
live: 'Live',
demo: 'Sample data',
},
conn: {
connecting: 'Connecting to server …',
lost: 'Connection lost retrying …',
reconnecting: 'Reconnecting …',
},
filter: {
title: 'Presses',
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'
// 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'
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const usingSample = ref(false)
const disconnected = ref(false)
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
const hasData = computed(() => payload.value !== null)
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
}
payload.value = res.data
usingSample.value = res.data.sample === true
disconnected.value = false
} 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'
// Keep the last known REAL data on a transient failure (stale > fabricated): never
// replace live carts with bundled demo carts on a live terminal, or an operator
// 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) {
// Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
payload.value = sampleStation(window.value)
usingSample.value = true
}
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
async function fetchReasons() {
try {
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 {
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) {
if (timer) return
timer = setInterval(refresh, ms)
pollMs = ms
scheduleNext()
}
function stopPolling() {
if (timer) {
clearInterval(timer)
clearTimeout(timer)
timer = null
}
}
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
loading,
error,
usingSample,
disconnected,
lastUpdated,
hasData,
allPresses,

View File

@ -114,6 +114,27 @@ html:not(.dark) body {
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) {
* {

View File

@ -5,8 +5,10 @@
5-station temper belt and an "Ausschuss buchen" action booking popover
(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
bundled sample data so `npm run dev` renders + the booking flow is demoable.
Login-free by design. Data from the store (polled). On connection loss the terminal
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">
import { onMounted, onUnmounted, ref } from 'vue'
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
const { t } = useI18n()
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 bookingEntry = ref<RejectEntry | null>(null)
@ -80,9 +82,15 @@ onUnmounted(() => {
<template>
<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
:presses="allPresses"
:checked="checkedPresses"
@ -140,6 +148,18 @@ onUnmounted(() => {
border: 1px dashed var(--line-2);
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 {
position: fixed;
left: 50%;