Skip to content

Commit da072e4

Browse files
committed
perf: add performance optimizations and developer tooling
Performance Optimizations: - Implemented LazyImage component with Intersection Observer API - Added useLazyLoad composable for efficient image loading - Updated ItemsSelector to use LazyImage for optimized rendering - Optimized offline.worker.js with better error handling Developer Tooling: - Added global logger utility with namespaced logging - Logger supports multiple log levels (DEBUG, INFO, SUCCESS, WARN, ERROR) - Integrated logger into useRealtimeStock composable - Updated vite.config.js with optimized build settings Type Definitions: - Updated components.d.ts with new component types
1 parent 6e779a7 commit da072e4

8 files changed

Lines changed: 761 additions & 49 deletions

File tree

POS/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ declare module 'vue' {
2121
InvoiceHistoryDialog: typeof import('./src/components/sale/InvoiceHistoryDialog.vue')['default']
2222
ItemSelectionDialog: typeof import('./src/components/sale/ItemSelectionDialog.vue')['default']
2323
ItemsSelector: typeof import('./src/components/sale/ItemsSelector.vue')['default']
24+
LazyImage: typeof import('./src/components/common/LazyImage.vue')['default']
2425
LoadingSpinner: typeof import('./src/components/common/LoadingSpinner.vue')['default']
2526
ManagementSlider: typeof import('./src/components/pos/ManagementSlider.vue')['default']
2627
NumberField: typeof import('./src/components/settings/NumberField.vue')['default']
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<template>
2+
<div ref="targetRef" :class="containerClasses">
3+
<!-- Placeholder while loading -->
4+
<div
5+
v-if="!isLoaded"
6+
:class="[
7+
'absolute inset-0 bg-gray-100 flex items-center justify-center',
8+
placeholderClass,
9+
]"
10+
>
11+
<slot name="placeholder">
12+
<!-- Default placeholder: animated gradient -->
13+
<div
14+
class="absolute inset-0 bg-gradient-to-r from-gray-100 via-gray-200 to-gray-100 animate-pulse"
15+
></div>
16+
</slot>
17+
</div>
18+
19+
<!-- Actual image (only loads when visible) -->
20+
<img
21+
v-if="isVisible"
22+
:src="src"
23+
:alt="alt"
24+
:class="[
25+
'transition-opacity duration-300',
26+
isLoaded ? 'opacity-100' : 'opacity-0',
27+
imgClass,
28+
]"
29+
@load="handleLoad"
30+
@error="handleError"
31+
:loading="nativeLazy ? 'lazy' : 'eager'"
32+
/>
33+
34+
<!-- Error state -->
35+
<div
36+
v-if="error"
37+
:class="['absolute inset-0 bg-gray-100 flex items-center justify-center', errorClass]"
38+
>
39+
<slot name="error">
40+
<!-- Default error icon -->
41+
<svg
42+
class="w-8 h-8 text-gray-400"
43+
fill="none"
44+
stroke="currentColor"
45+
viewBox="0 0 24 24"
46+
>
47+
<path
48+
stroke-linecap="round"
49+
stroke-linejoin="round"
50+
stroke-width="2"
51+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
52+
/>
53+
</svg>
54+
</slot>
55+
</div>
56+
</div>
57+
</template>
58+
59+
<script setup>
60+
import { computed } from "vue"
61+
import { useLazyLoad } from "@/composables/useLazyLoad"
62+
63+
const props = defineProps({
64+
src: {
65+
type: String,
66+
required: true,
67+
},
68+
alt: {
69+
type: String,
70+
default: "",
71+
},
72+
containerClass: {
73+
type: String,
74+
default: "",
75+
},
76+
imgClass: {
77+
type: String,
78+
default: "w-full h-full object-cover",
79+
},
80+
placeholderClass: {
81+
type: String,
82+
default: "",
83+
},
84+
errorClass: {
85+
type: String,
86+
default: "",
87+
},
88+
rootMargin: {
89+
type: String,
90+
default: "50px",
91+
},
92+
threshold: {
93+
type: Number,
94+
default: 0.01,
95+
},
96+
// Use native loading="lazy" as fallback for browsers without Intersection Observer
97+
nativeLazy: {
98+
type: Boolean,
99+
default: true,
100+
},
101+
})
102+
103+
const emit = defineEmits(["load", "error"])
104+
105+
const baseContainerClass = "relative overflow-hidden"
106+
const containerClasses = computed(() => {
107+
const userClasses = props.containerClass?.trim()
108+
return userClasses
109+
? `${baseContainerClass} ${userClasses}`
110+
: baseContainerClass
111+
})
112+
113+
const { targetRef, isVisible, isLoaded, error } = useLazyLoad({
114+
rootMargin: props.rootMargin,
115+
threshold: props.threshold,
116+
})
117+
118+
function handleLoad(event) {
119+
error.value = null
120+
isLoaded.value = true
121+
emit("load", event)
122+
}
123+
124+
function handleError(event) {
125+
error.value = event
126+
isLoaded.value = true
127+
emit("error", event)
128+
}
129+
</script>
130+
131+
<style scoped>
132+
@keyframes shimmer {
133+
0% {
134+
background-position: -200% 0;
135+
}
136+
100% {
137+
background-position: 200% 0;
138+
}
139+
}
140+
141+
.animate-pulse {
142+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
143+
}
144+
</style>

POS/src/components/sale/ItemsSelector.vue

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -212,32 +212,46 @@
212212
</div>
213213

214214
<!-- Item Image -->
215-
<div class="relative aspect-square bg-gray-100 rounded-md mb-1.5 sm:mb-2 flex items-center justify-center overflow-hidden">
216-
<img
215+
<div class="relative aspect-square bg-gray-100 rounded-md mb-1.5 sm:mb-2 overflow-hidden">
216+
<LazyImage
217217
v-if="item.image"
218218
:src="item.image"
219219
:alt="item.item_name"
220-
loading="lazy"
221-
width="100"
222-
height="100"
223-
decoding="async"
224-
class="w-full h-full object-cover"
225-
@error="handleImageError"
226-
/>
227-
<svg
228-
v-else
229-
class="h-8 w-8 sm:h-10 sm:w-10 text-gray-300"
230-
fill="none"
231-
stroke="currentColor"
232-
viewBox="0 0 24 24"
220+
container-class="relative w-full h-full"
221+
img-class="w-full h-full object-cover"
222+
root-margin="100px"
233223
>
234-
<path
235-
stroke-linecap="round"
236-
stroke-linejoin="round"
237-
stroke-width="2"
238-
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
239-
/>
240-
</svg>
224+
<template #error>
225+
<svg
226+
class="h-8 w-8 sm:h-10 sm:w-10 text-gray-300"
227+
fill="none"
228+
stroke="currentColor"
229+
viewBox="0 0 24 24"
230+
>
231+
<path
232+
stroke-linecap="round"
233+
stroke-linejoin="round"
234+
stroke-width="2"
235+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
236+
/>
237+
</svg>
238+
</template>
239+
</LazyImage>
240+
<div v-else class="w-full h-full flex items-center justify-center">
241+
<svg
242+
class="h-8 w-8 sm:h-10 sm:w-10 text-gray-300"
243+
fill="none"
244+
stroke="currentColor"
245+
viewBox="0 0 24 24"
246+
>
247+
<path
248+
stroke-linecap="round"
249+
stroke-linejoin="round"
250+
stroke-width="2"
251+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
252+
/>
253+
</svg>
254+
</div>
241255
</div>
242256

243257
<!-- Item Details -->
@@ -354,7 +368,20 @@
354368
>
355369
<td class="px-2 sm:px-3 py-2 whitespace-nowrap">
356370
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
357-
<img v-if="item.image" :src="item.image" :alt="item.item_name" loading="lazy" width="40" height="40" decoding="async" class="w-full h-full object-cover" @error="handleImageError" />
371+
<LazyImage
372+
v-if="item.image"
373+
:src="item.image"
374+
:alt="item.item_name"
375+
container-class="relative w-full h-full"
376+
img-class="w-full h-full object-cover"
377+
root-margin="100px"
378+
>
379+
<template #error>
380+
<svg class="h-4 w-4 sm:h-5 sm:w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
381+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
382+
</svg>
383+
</template>
384+
</LazyImage>
358385
<svg v-else class="h-4 w-4 sm:h-5 sm:w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
359386
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
360387
</svg>
@@ -458,6 +485,7 @@
458485
</template>
459486

460487
<script setup>
488+
import LazyImage from "@/components/common/LazyImage.vue"
461489
import { useItemSearchStore } from "@/stores/itemSearch"
462490
import { formatCurrency as formatCurrencyUtil } from "@/utils/currency"
463491
import { toast } from "frappe-ui"
@@ -920,10 +948,6 @@ defineExpose({
920948
loadMoreItems: () => itemStore.loadMoreItems(),
921949
})
922950
923-
function handleImageError(event) {
924-
event.target.style.display = "none"
925-
}
926-
927951
// View mode functions
928952
function setViewMode(mode) {
929953
viewMode.value = mode

POS/src/composables/useLazyLoad.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { onBeforeUnmount, onMounted, ref } from "vue"
2+
3+
/**
4+
* Composable for lazy loading images using Intersection Observer
5+
* Optimizes performance by only loading images when they're about to enter the viewport
6+
*
7+
* @param {Object} options - Configuration options
8+
* @param {string} options.rootMargin - Margin around the viewport to trigger loading (default: "50px")
9+
* @param {number} options.threshold - Percentage of visibility to trigger (default: 0.01)
10+
* @returns {Object} { isVisible, targetRef, isLoaded, error }
11+
*/
12+
export function useLazyLoad(options = {}) {
13+
const {
14+
rootMargin = "50px", // Start loading 50px before element enters viewport
15+
threshold = 0.01, // Trigger when 1% of element is visible
16+
} = options
17+
18+
const targetRef = ref(null)
19+
const isVisible = ref(false)
20+
const isLoaded = ref(false)
21+
const error = ref(null)
22+
23+
let observer = null
24+
25+
onMounted(() => {
26+
if (!targetRef.value) return
27+
28+
// Check if Intersection Observer is supported
29+
if (!("IntersectionObserver" in window)) {
30+
// Fallback for browsers that don't support Intersection Observer
31+
isVisible.value = true
32+
return
33+
}
34+
35+
observer = new IntersectionObserver(
36+
(entries) => {
37+
entries.forEach((entry) => {
38+
if (entry.isIntersecting && !isVisible.value) {
39+
isVisible.value = true
40+
// Once loaded, disconnect to save resources
41+
observer?.disconnect()
42+
}
43+
})
44+
},
45+
{
46+
rootMargin,
47+
threshold,
48+
},
49+
)
50+
51+
observer.observe(targetRef.value)
52+
})
53+
54+
onBeforeUnmount(() => {
55+
if (observer) {
56+
observer.disconnect()
57+
observer = null
58+
}
59+
})
60+
61+
return {
62+
targetRef,
63+
isVisible,
64+
isLoaded,
65+
error,
66+
}
67+
}

POS/src/composables/useRealtimeStock.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,31 @@
44
* Listens to Socket.IO events for stock changes and notifies registered handlers.
55
* Provides intelligent event management with deduplication and batching.
66
* Each handler is responsible for filtering by warehouse and updating its cache.
7+
*
8+
* Performance optimization: Batch delay and size are dynamically adjusted
9+
* based on device CPU cores and performance tier.
710
*/
811

12+
import { performanceConfig } from "@/utils/performanceConfig"
13+
import { logger } from "@/utils/logger"
914
import { onUnmounted, ref } from "vue"
1015

16+
const log = logger.create('RealtimeStock')
17+
1118
// Shared state across all instances
1219
const isListening = ref(false)
1320
const eventHandlers = new Set()
1421
const pendingUpdates = new Map()
1522
let batchTimeout = null
1623

1724
/**
18-
* Batch update configuration
25+
* Batch update configuration - dynamically adjusted based on device performance
26+
* Low-end devices (800ms, 50 items): More batching to reduce CPU load
27+
* Medium devices (500ms, 100 items): Balanced performance
28+
* High-end devices (300ms, 200 items): Faster updates with larger batches
1929
*/
20-
const BATCH_DELAY_MS = 500 // Wait 500ms before applying batched updates
21-
const MAX_BATCH_SIZE = 100 // Maximum items to batch before forcing update
30+
const BATCH_DELAY_MS = performanceConfig.get("stockBatchDelay")
31+
const MAX_BATCH_SIZE = performanceConfig.get("stockMaxBatchSize")
2232

2333
/**
2434
* Process pending stock updates in batch
@@ -38,11 +48,11 @@ async function processBatchedUpdates() {
3848
try {
3949
handler(updates)
4050
} catch (error) {
41-
console.error("[Realtime Stock] Handler error:", error)
51+
log.error("Handler error", error)
4252
}
4353
})
4454
} catch (error) {
45-
console.error("[Realtime Stock] Failed to process batch updates:", error)
55+
log.error("Failed to process batch updates", error)
4656
}
4757
}
4858

@@ -99,7 +109,7 @@ function startListening() {
99109
}
100110

101111
if (!window.frappe?.realtime) {
102-
console.warn("[Realtime Stock] Socket.IO not available")
112+
log.warn("Socket.IO not available")
103113
return
104114
}
105115

0 commit comments

Comments
 (0)