@necrolab/dashboard 0.5.32 → 0.5.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@ const tasksOpen = async (data) => {
38
38
  return {
39
39
  error: "task does not exist"
40
40
  };
41
- Bot.Tasks[taskId].openBrowser(); // TODO use cookies from task in subprocess
41
+ Bot.Tasks[taskId].openBrowser();
42
42
  return {};
43
43
  };
44
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@necrolab/dashboard",
3
- "version": "0.5.32",
3
+ "version": "0.5.34",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "node scripts/build.mjs",
@@ -1,73 +1,396 @@
1
1
  <template>
2
- <div class="flex-gap-1 items-center font-bold text-white lg:gap-3" v-if="ui.queueStats.show" :key="key">
2
+ <div
3
+ class="queue-stats"
4
+ :class="{ 'queue-stats--full': visibleCards === 4 }"
5
+ :style="statsGridStyle"
6
+ v-if="ui.queueStats.show"
7
+ :key="key">
3
8
  <div
4
9
  v-if="ui.queueStats.total"
5
- class="stat-badge">
6
- <h2 class="stat-header">
10
+ class="stat-badge queue-stats__card queue-stats__card--metric">
11
+ <h2 class="queue-stats__header">
7
12
  <img width="14px" src="@/assets/img/wildcard.svg" />
8
- <span class="hidden md:inline">Total</span>
9
13
  </h2>
10
- <span class="stat-value">
14
+ <span class="queue-stats__value">
11
15
  {{ ui.queueStats.total }}
12
16
  </span>
13
17
  </div>
14
18
  <div
15
19
  v-if="ui.queueStats.queued"
16
- class="stat-badge">
17
- <h2 class="stat-header">
20
+ class="stat-badge queue-stats__card queue-stats__card--metric">
21
+ <h2 class="queue-stats__header">
18
22
  <SkiIcon />
19
- <span class="hidden md:inline">Queued</span>
20
23
  </h2>
21
- <span class="stat-value">
24
+ <span class="queue-stats__value">
22
25
  {{ ui.queueStats.queued }}
23
26
  </span>
24
27
  </div>
25
28
  <div
26
29
  v-if="ui.queueStats.sleeping"
27
- class="stat-badge">
28
- <h2 class="stat-header">
30
+ class="stat-badge queue-stats__card queue-stats__card--metric">
31
+ <h2 class="queue-stats__header">
29
32
  <TimerIcon />
30
- <span class="hidden md:inline">Sleeping</span>
31
33
  </h2>
32
- <span class="stat-value">
34
+ <span class="queue-stats__value">
33
35
  {{ ui.queueStats.sleeping }}
34
36
  </span>
35
37
  </div>
36
38
  <div
37
39
  v-if="ui.queueStats.nextQueuePasses.length > 0"
38
- class="mb-2 flex h-8 min-w-0 items-center justify-between gap-2 rounded-xl p-2 text-sm lg:mb-5 lg:gap-3 lg:p-3 bg-dark-400 border-2 border-dark-550">
39
- <h2 class="stat-header">
40
- <CartIcon />
41
- <span class="hidden sm:block">Next Passes</span>
42
- <span class="block sm:hidden">Pass</span>
40
+ class="stat-badge queue-stats__card queue-stats__card--passes"
41
+ :title="ui.queueStats.nextQueuePasses.join(', ')">
42
+ <h2 class="queue-stats__header">
43
+ <SandclockIcon />
43
44
  </h2>
44
- <span class="flex items-center truncate text-right text-xs font-bold text-light-300">
45
- {{ ui.queueStats.nextQueuePasses.slice(0, queuePassAmount).join(", ") }}
45
+ <span class="queue-stats__value queue-stats__value--passes">
46
+ <span class="queue-stats__pass-text">{{ queuePassPreviewText }}</span>
47
+ <button
48
+ v-if="hiddenPassCount > 0"
49
+ type="button"
50
+ class="queue-stats__more-btn"
51
+ @click.stop="openPassesOverlay">
52
+ +{{ hiddenPassCount }}
53
+ </button>
46
54
  </span>
47
55
  </div>
48
56
  </div>
57
+
58
+ <Teleport to="body">
59
+ <div
60
+ v-if="showPassesOverlay"
61
+ class="queue-pass-overlay"
62
+ @click.self="closePassesOverlay">
63
+ <div class="queue-pass-modal">
64
+ <div class="queue-pass-modal__header">
65
+ <h3>Next Queue Passes</h3>
66
+ <button
67
+ type="button"
68
+ class="queue-pass-modal__close"
69
+ @click="closePassesOverlay">
70
+ Close
71
+ </button>
72
+ </div>
73
+ <div class="queue-pass-modal__body">
74
+ <ul class="queue-pass-modal__list">
75
+ <li
76
+ v-for="(pass, index) in ui.queueStats.nextQueuePasses"
77
+ :key="`${pass}-${index}`"
78
+ class="queue-pass-modal__item">
79
+ <span>#{{ index + 1 }}</span>
80
+ <span>{{ pass }}</span>
81
+ </li>
82
+ </ul>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </Teleport>
49
87
  </template>
50
88
 
51
89
  <script setup>
52
- import { SkiIcon, TimerIcon, CartIcon } from "@/components/icons";
90
+ import { SkiIcon, TimerIcon, SandclockIcon } from "@/components/icons";
53
91
  import { useUIStore } from "@/stores/ui";
54
- import { ref, onUnmounted } from "vue";
92
+ import { ref, onUnmounted, computed, onMounted } from "vue";
55
93
 
56
94
  const ui = useUIStore();
57
95
 
58
96
  const getQueuePassAmount = (width) => {
59
- if (width > 1024) return 8;
60
- if (width > 640) return 3;
97
+ if (width >= 1536) return 10;
98
+ if (width >= 1280) return 8;
99
+ if (width >= 1024) return 6;
100
+ if (width >= 768) return 4;
101
+ if (width >= 640) return 2;
61
102
  return 1;
62
103
  };
104
+ const getQueuePassCharBudget = (width) => {
105
+ if (width >= 1536) return 56;
106
+ if (width >= 1280) return 42;
107
+ if (width >= 1024) return 32;
108
+ if (width >= 768) return 24;
109
+ if (width >= 640) return 16;
110
+ return 10;
111
+ };
112
+ const buildQueuePassPreview = (passes, maxItems, maxChars) => {
113
+ const values = passes.slice(0, maxItems).map((value) => String(value));
114
+ if (!values.length) return "";
115
+
116
+ let currentLength = 0;
117
+ const output = [];
118
+
119
+ for (const value of values) {
120
+ const separator = output.length ? 2 : 0;
121
+ const nextLength = currentLength + separator + value.length;
122
+ if (nextLength > maxChars) break;
123
+ output.push(value);
124
+ currentLength = nextLength;
125
+ }
126
+
127
+ if (output.length === 0) output.push(values[0]);
128
+
129
+ return {
130
+ visible: output,
131
+ hiddenCount: Math.max(passes.length - output.length, 0)
132
+ };
133
+ };
63
134
 
64
135
  let key = ref(0);
136
+ const width = ref(window.innerWidth);
65
137
  let queuePassAmount = ref(getQueuePassAmount(window.innerWidth));
138
+ const queuePassCharBudget = ref(getQueuePassCharBudget(window.innerWidth));
139
+ const showPassesOverlay = ref(false);
140
+ const visibleCards = computed(
141
+ () =>
142
+ Number(Boolean(ui.queueStats.total)) +
143
+ Number(Boolean(ui.queueStats.queued)) +
144
+ Number(Boolean(ui.queueStats.sleeping)) +
145
+ Number(Boolean(ui.queueStats.nextQueuePasses.length))
146
+ );
147
+ const statsGridStyle = computed(() => ({
148
+ "--stats-card-count": `${Math.max(visibleCards.value, 1)}`
149
+ }));
150
+ const queuePassPreviewData = computed(() =>
151
+ buildQueuePassPreview(ui.queueStats.nextQueuePasses, queuePassAmount.value, queuePassCharBudget.value)
152
+ );
153
+ const queuePassPreviewText = computed(() => queuePassPreviewData.value.visible.join(", "));
154
+ const hiddenPassCount = computed(() => queuePassPreviewData.value.hiddenCount);
155
+
156
+ const openPassesOverlay = () => {
157
+ if (!hiddenPassCount.value) return;
158
+ showPassesOverlay.value = true;
159
+ };
160
+
161
+ const closePassesOverlay = () => {
162
+ showPassesOverlay.value = false;
163
+ };
66
164
 
67
- const handleResize = () => (queuePassAmount.value = getQueuePassAmount(window.innerWidth));
165
+ const handleResize = () => {
166
+ width.value = window.innerWidth;
167
+ queuePassAmount.value = getQueuePassAmount(width.value);
168
+ queuePassCharBudget.value = getQueuePassCharBudget(width.value);
169
+ };
68
170
  window.addEventListener("resize", handleResize);
69
171
 
172
+ const handleKeyDown = (event) => {
173
+ if (event.key === "Escape" && showPassesOverlay.value) {
174
+ closePassesOverlay();
175
+ }
176
+ };
177
+
178
+ onMounted(() => {
179
+ window.addEventListener("keydown", handleKeyDown);
180
+ });
181
+
70
182
  onUnmounted(() => {
71
183
  window.removeEventListener("resize", handleResize);
184
+ window.removeEventListener("keydown", handleKeyDown);
72
185
  });
73
186
  </script>
187
+
188
+ <style scoped>
189
+ .queue-stats {
190
+ display: grid;
191
+ width: 100%;
192
+ min-width: 0;
193
+ grid-template-columns: repeat(var(--stats-card-count), minmax(0, 1fr));
194
+ gap: 0.35rem;
195
+ }
196
+
197
+ .queue-stats__card {
198
+ @apply bg-dark-400 border-2 border-dark-550;
199
+ height: 2.5rem;
200
+ min-width: 0;
201
+ gap: 0.35rem;
202
+ border-radius: 0.55rem;
203
+ padding: 0 0.5rem;
204
+ }
205
+
206
+ .queue-stats__header {
207
+ min-width: 1rem;
208
+ flex: 0 0 auto;
209
+ display: flex;
210
+ align-items: center;
211
+ gap: 0.25rem;
212
+ color: rgb(212 212 219 / 1);
213
+ font-weight: 600;
214
+ font-size: 0.7rem;
215
+ line-height: 1.1;
216
+ white-space: nowrap;
217
+ }
218
+
219
+ .queue-stats__value {
220
+ min-width: 0;
221
+ flex: 1 1 auto;
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: flex-end;
225
+ overflow: hidden;
226
+ text-align: right;
227
+ text-overflow: clip;
228
+ white-space: nowrap;
229
+ color: rgb(212 212 219 / 1);
230
+ font-weight: 700;
231
+ font-size: 0.7rem;
232
+ font-variant-numeric: tabular-nums;
233
+ line-height: 1.1;
234
+ margin-left: 0.25rem;
235
+ }
236
+
237
+ .queue-stats__card--metric .queue-stats__header {
238
+ min-width: 1rem;
239
+ }
240
+
241
+ .queue-stats__card--metric .queue-stats__value {
242
+ flex: 0 0 auto;
243
+ min-width: 3.4rem;
244
+ margin-left: 0.35rem;
245
+ }
246
+
247
+ .queue-stats__header :deep(svg),
248
+ .queue-stats__header img {
249
+ width: 12px;
250
+ height: 12px;
251
+ flex-shrink: 0;
252
+ }
253
+
254
+ .queue-stats__card--passes {
255
+ padding-right: 0.4rem;
256
+ gap: 0.3rem;
257
+ }
258
+
259
+ .queue-stats__card--passes .queue-stats__header {
260
+ min-width: 1.1rem;
261
+ }
262
+
263
+ .queue-stats__value--passes {
264
+ font-size: 0.67rem;
265
+ gap: 0.35rem;
266
+ }
267
+
268
+ .queue-stats__pass-text {
269
+ min-width: 0;
270
+ flex: 1 1 auto;
271
+ overflow: hidden;
272
+ white-space: nowrap;
273
+ text-overflow: ellipsis;
274
+ }
275
+
276
+ .queue-stats__more-btn {
277
+ @apply bg-dark-550 border border-dark-650 text-light-300;
278
+ flex-shrink: 0;
279
+ border-radius: 9999px;
280
+ padding: 0.1rem 0.4rem;
281
+ font-size: 0.62rem;
282
+ font-weight: 700;
283
+ line-height: 1.2;
284
+ }
285
+
286
+ .queue-pass-overlay {
287
+ position: fixed;
288
+ inset: 0;
289
+ z-index: 90;
290
+ display: flex;
291
+ align-items: center;
292
+ justify-content: center;
293
+ background: rgba(0, 0, 0, 0.45);
294
+ padding: 1rem;
295
+ }
296
+
297
+ .queue-pass-modal {
298
+ @apply bg-dark-400 border border-dark-550;
299
+ width: min(34rem, 95vw);
300
+ border-radius: 0.75rem;
301
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
302
+ }
303
+
304
+ .queue-pass-modal__header {
305
+ @apply border-b border-dark-550 text-light-200;
306
+ display: flex;
307
+ align-items: center;
308
+ justify-content: space-between;
309
+ gap: 0.5rem;
310
+ padding: 0.75rem 0.9rem;
311
+ }
312
+
313
+ .queue-pass-modal__header h3 {
314
+ font-size: 0.9rem;
315
+ font-weight: 700;
316
+ }
317
+
318
+ .queue-pass-modal__close {
319
+ @apply bg-dark-500 border border-dark-650 text-light-300;
320
+ border-radius: 0.45rem;
321
+ padding: 0.25rem 0.5rem;
322
+ font-size: 0.72rem;
323
+ font-weight: 600;
324
+ }
325
+
326
+ .queue-pass-modal__body {
327
+ max-height: min(60vh, 28rem);
328
+ overflow-y: auto;
329
+ padding: 0.55rem;
330
+ }
331
+
332
+ .queue-pass-modal__list {
333
+ display: grid;
334
+ gap: 0.35rem;
335
+ }
336
+
337
+ .queue-pass-modal__item {
338
+ @apply bg-dark-500 border border-dark-650 text-light-300;
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: space-between;
342
+ border-radius: 0.5rem;
343
+ padding: 0.35rem 0.55rem;
344
+ font-size: 0.78rem;
345
+ font-variant-numeric: tabular-nums;
346
+ }
347
+
348
+ @media (min-width: 768px) {
349
+ .queue-stats {
350
+ gap: 0.45rem;
351
+ }
352
+
353
+ .queue-stats__card {
354
+ padding: 0 0.7rem;
355
+ }
356
+
357
+ .queue-stats__header {
358
+ gap: 0.35rem;
359
+ font-size: 0.72rem;
360
+ }
361
+
362
+ .queue-stats__value {
363
+ font-size: 0.72rem;
364
+ }
365
+
366
+ .queue-stats__value--passes {
367
+ font-size: 0.7rem;
368
+ }
369
+
370
+ .queue-stats__card--metric .queue-stats__value {
371
+ min-width: 3.7rem;
372
+ }
373
+ }
374
+
375
+ @media (min-width: 960px) {
376
+ .queue-stats--full {
377
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.35fr);
378
+ }
379
+ }
380
+
381
+ @media (min-width: 1280px) {
382
+ .queue-stats--full {
383
+ grid-template-columns: repeat(5, minmax(0, 1fr));
384
+ }
385
+
386
+ .queue-stats--full .queue-stats__card--passes {
387
+ grid-column: span 2;
388
+ }
389
+ }
390
+
391
+ @media (min-width: 1280px) {
392
+ .queue-stats__value--passes {
393
+ font-size: 0.75rem;
394
+ }
395
+ }
396
+ </style>
@@ -270,16 +270,26 @@ const maxTableHeight = computed(() => {
270
270
  const headerHeight = windowWidth.value >= 1024 ? 80 : 64; // Navbar padding (lg:pt-20 vs pt-16)
271
271
  const titleHeight = 45; // Tasks title and mobile controls
272
272
  const controlsHeight = windowWidth.value >= 650 ? 55 : 0; // Desktop controls
273
- // On desktop: stats + filters all in one row (45px)
274
- // On mobile: stats row (40px) + filters stacked (90px) = 130px
275
- const filtersAndStatsHeight =
276
- windowWidth.value >= 768
277
- ? ui.queueStats.show
278
- ? 50
279
- : 45 // Desktop: single row
280
- : ui.queueStats.show
281
- ? 130
282
- : 90; // Mobile: stats row + stacked filters
273
+ // Keep this in sync with Tasks.vue controls layout:
274
+ // - >= 1280px: stats stays in same row as filters
275
+ // - 768px..1279px: stats takes its own wrapped row when visible
276
+ // - < 768px: mobile has dedicated stats row + controls/search rows
277
+ const isDesktopInlineStats = windowWidth.value >= 1280;
278
+ const isTabletWrappedStats = windowWidth.value >= 768 && windowWidth.value < 1280;
279
+
280
+ const filtersAndStatsHeight = (() => {
281
+ if (isDesktopInlineStats) {
282
+ return ui.queueStats.show ? 50 : 45;
283
+ }
284
+
285
+ if (isTabletWrappedStats) {
286
+ const filterRowHeight = 45;
287
+ const wrappedStatsRowHeight = ui.queueStats.show ? 54 : 0;
288
+ return filterRowHeight + wrappedStatsRowHeight;
289
+ }
290
+
291
+ return ui.queueStats.show ? 155 : 110;
292
+ })();
283
293
 
284
294
  // Reserve more space for UTILS on iPhone PWA to prevent overflow
285
295
  const utilitiesHeight = isPWA && windowWidth.value <= 768
@@ -37,7 +37,7 @@
37
37
 
38
38
  <script setup>
39
39
  import { ref, onMounted, onUnmounted } from "vue";
40
- import logoIcon from "/img/reconnect-logo.png";
40
+ import logoIcon from "@/assets/img/reconnect-logo.png?inline";
41
41
 
42
42
  const dotIndex = ref(0);
43
43
  let dotInterval;
@@ -3,6 +3,7 @@ import WebsocketHeartbeatJs from "websocket-heartbeat-js";
3
3
  import { decode } from "@msgpack/msgpack";
4
4
  import router from "@/router/index";
5
5
  import { sortTaskIds } from "@/libs/utils/array";
6
+ import { DEBUG } from "@/utils/debug";
6
7
 
7
8
  const TASK_UPDATE_EVENTS = new Set(["task-update", "task-setstatus", "task-setinfo"]);
8
9
  const QUEUE_STAT_FIELDS = new Set(["siteId", "eventId", "inQueue", "status", "queuePosition"]);
@@ -568,6 +569,7 @@ export class ConnectionHandler {
568
569
  if (msg?.data) msg.data.module = this.ui.currentModule;
569
570
  this.socket.send(JSON.stringify(msg));
570
571
  } catch {
572
+ if (DEBUG) return;
571
573
  this.ui.startSpinner("Reconnecting");
572
574
  }
573
575
  }
@@ -604,13 +606,13 @@ export class ConnectionHandler {
604
606
  };
605
607
 
606
608
  this.socket.onclose = () => {
607
- this.ui.startSpinner("Reconnecting");
609
+ if (!DEBUG) this.ui.startSpinner("Reconnecting");
608
610
  hasShownDisconnect = true;
609
611
  this.clearTaskUpdateQueue();
610
612
  };
611
613
 
612
614
  this.socket.onerror = () => {
613
- this.ui.startSpinner("Reconnecting");
615
+ if (!DEBUG) this.ui.startSpinner("Reconnecting");
614
616
  hasShownDisconnect = true;
615
617
  this.clearTaskUpdateQueue();
616
618
  };
package/src/stores/ui.js CHANGED
@@ -24,6 +24,16 @@ import { DEBUG } from "@/utils/debug";
24
24
  const ALL_SELECTED = 0;
25
25
  const SOME_SELECTED = 1;
26
26
  const NO_SELECTED = 2;
27
+ const DEV_QUEUE_STATS_STORAGE_KEY = "dev-queue-stats-mock";
28
+ const DEV_QUEUE_STATS_PRESETS = {
29
+ heavy: {
30
+ show: true,
31
+ total: 10000,
32
+ queued: 5000,
33
+ sleeping: 3000,
34
+ nextQueuePasses: [11, 18, 26, 35, 49, 57, 72, 88, 103, 121, 147, 169]
35
+ }
36
+ };
27
37
 
28
38
  export const useUIStore = defineStore("ui", () => {
29
39
  const logger = createLogger("UI");
@@ -98,6 +108,55 @@ export const useUIStore = defineStore("ui", () => {
98
108
  sleeping: 0,
99
109
  nextQueuePasses: []
100
110
  });
111
+ const devQueueStatsMock = ref(null);
112
+
113
+ const normalizeQueueStats = (input = {}) => {
114
+ const total = Number(input.total) || 0;
115
+ const queued = Number(input.queued) || 0;
116
+ const sleeping = Number(input.sleeping) || 0;
117
+ const nextQueuePasses = Array.isArray(input.nextQueuePasses)
118
+ ? input.nextQueuePasses.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0)
119
+ : [];
120
+ const show = Boolean(input.show ?? (queued > 0 || sleeping > 0 || nextQueuePasses.length > 0));
121
+
122
+ return {
123
+ show,
124
+ total,
125
+ queued,
126
+ sleeping,
127
+ nextQueuePasses: nextQueuePasses.sort((a, b) => a - b)
128
+ };
129
+ };
130
+
131
+ const applyQueueStats = (stats) => {
132
+ queueStats.value.show = stats.show;
133
+ queueStats.value.total = stats.total;
134
+ queueStats.value.queued = stats.queued;
135
+ queueStats.value.sleeping = stats.sleeping;
136
+ queueStats.value.nextQueuePasses = stats.nextQueuePasses;
137
+ };
138
+
139
+ const setDevQueueStatsMock = (stats) => {
140
+ if (!DEBUG) return;
141
+ if (!stats) {
142
+ devQueueStatsMock.value = null;
143
+ localStorage.removeItem(DEV_QUEUE_STATS_STORAGE_KEY);
144
+ refreshQueueStats();
145
+ return;
146
+ }
147
+
148
+ const normalized = normalizeQueueStats(stats);
149
+ devQueueStatsMock.value = normalized;
150
+ localStorage.setItem(DEV_QUEUE_STATS_STORAGE_KEY, JSON.stringify(normalized));
151
+ applyQueueStats(normalized);
152
+ };
153
+
154
+ const setDevQueueStatsPreset = (preset = "heavy") => {
155
+ if (!DEBUG) return;
156
+ const data = DEV_QUEUE_STATS_PRESETS[preset];
157
+ if (!data) return;
158
+ setDevQueueStatsMock(data);
159
+ };
101
160
 
102
161
  const router = useRouter();
103
162
  const openContextMenu = ref("");
@@ -246,6 +305,11 @@ export const useUIStore = defineStore("ui", () => {
246
305
  };
247
306
 
248
307
  const refreshQueueStats = () => {
308
+ if (DEBUG && devQueueStatsMock.value) {
309
+ applyQueueStats(devQueueStatsMock.value);
310
+ return;
311
+ }
312
+
249
313
  const siteId = currentCountry.value.siteId;
250
314
  const eventId = currentEvent.value;
251
315
  const sleepingStatuses = new Set([
@@ -284,6 +348,23 @@ export const useUIStore = defineStore("ui", () => {
284
348
  queueStats.value.show = queued > 0 || sleeping > 0 || positions.length > 0;
285
349
  };
286
350
 
351
+ if (DEBUG) {
352
+ try {
353
+ const saved = localStorage.getItem(DEV_QUEUE_STATS_STORAGE_KEY);
354
+ if (saved) {
355
+ const parsed = JSON.parse(saved);
356
+ devQueueStatsMock.value = normalizeQueueStats(parsed);
357
+ applyQueueStats(devQueueStatsMock.value);
358
+ }
359
+ } catch {
360
+ localStorage.removeItem(DEV_QUEUE_STATS_STORAGE_KEY);
361
+ }
362
+
363
+ window.__setQueueStatsMock = (stats) => setDevQueueStatsMock(stats);
364
+ window.__setQueueStatsPreset = (preset = "heavy") => setDevQueueStatsPreset(preset);
365
+ window.__clearQueueStatsMock = () => setDevQueueStatsMock(null);
366
+ }
367
+
287
368
  const toggleModal = (name, clearValue = false) => {
288
369
  if (disabledButtons.value["add-tasks"] && name === "create-task") return;
289
370
  if (clearValue) currentlyEditing.value = {};
@@ -551,6 +632,8 @@ export const useUIStore = defineStore("ui", () => {
551
632
 
552
633
  disabledButtons,
553
634
  queueStats,
635
+ setDevQueueStatsMock,
636
+ setDevQueueStatsPreset,
554
637
 
555
638
  refreshQueueStats,
556
639
 
@@ -37,9 +37,9 @@
37
37
  @deleteAll="ui.deleteTasks()" />
38
38
  </div>
39
39
 
40
- <div class="mb-2 flex items-center justify-between gap-2 md:hidden">
41
- <Stats class="stats-component flex-1" />
42
- <div class="flex-gap-2 items-center">
40
+ <div class="mb-2 flex flex-col gap-2 md:hidden">
41
+ <Stats class="stats-component w-full" />
42
+ <div class="flex-gap-2 items-center justify-end">
43
43
  <div
44
44
  class="flex h-8 items-center justify-between rounded-md border border-dark-650 bg-dark-400 px-2 text-xs font-medium text-white">
45
45
  <p class="text-2xs">Name</p>
@@ -54,7 +54,7 @@
54
54
  </div>
55
55
  </div>
56
56
 
57
- <div class="mb-1 hidden items-center gap-2 md:flex lg:mb-2">
57
+ <div class="mb-1 hidden flex-wrap items-center gap-2 md:flex xl:flex-nowrap lg:mb-2">
58
58
  <div v-if="uniqEventIds.length > 1" class="w-52 flex-shrink-0">
59
59
  <Dropdown
60
60
  :onClick="(f) => ui.setCurrentEvent(f)"
@@ -87,7 +87,9 @@
87
87
  :darker="true"
88
88
  :current="ui.taskFilter"
89
89
  @change="(e) => ui.setTaskFilter(e)" />
90
- <Stats class="stats-component ml-auto" />
90
+ <div class="stats-component min-w-0 w-full md:basis-full xl:ml-auto xl:basis-auto xl:flex-1 xl:max-w-2xl">
91
+ <Stats class="w-full" />
92
+ </div>
91
93
  </div>
92
94
 
93
95
  <div class="mb-1 flex flex-col gap-2 md:hidden">
@@ -50,6 +50,18 @@ module.exports = {
50
50
  }
51
51
  }
52
52
  },
53
+ // Critical UI images used during reconnect/offline states
54
+ {
55
+ urlPattern: /\/img\/(?:reconnect-logo\.png|logo_trans\.png|pwa\/.+\.png)$/,
56
+ handler: "CacheFirst",
57
+ options: {
58
+ cacheName: "critical-ui-images",
59
+ expiration: {
60
+ maxEntries: 20,
61
+ maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
62
+ }
63
+ }
64
+ },
53
65
  // Images - Cache first for best performance
54
66
  {
55
67
  urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,