feat(property): replace polling autosave with change-driven debounce and compact status indicator
deploy-chabokan / deploy (push) Canceled after 0s

Autosave previously ran on a fixed setInterval regardless of activity. It now
debounces a save shortly after the user stops editing, with a max-wait safety
net for long uninterrupted typing sessions, so it only does work when there
are actual changes. Also replaces the bulky bottom-right status popup with a
very small fixed pill (idle/pending/saving/saved/error) that stays visible
while scrolling.
This commit is contained in:
2026-08-19 08:12:25 +03:30
parent a155cdd481
commit b0980f1448
+87 -26
View File
@@ -48,7 +48,15 @@ const autoSaveIntervalSeconds = ref(60);
const lastSavedTime = ref(null);
const isAutoSaving = ref(false);
const isDirty = ref(false);
let autoSaveTimer = null;
const autoSaveFailed = ref(false);
// Change-driven auto-save: instead of polling on a fixed interval regardless of
// activity, we debounce a save shortly after the user stops typing (cheap - only
// ever one pending timer, reset on each change) and fall back to a max-wait timer
// so long, uninterrupted editing sessions still get saved periodically.
const AUTOSAVE_DEBOUNCE_MS = 2500;
let autoSaveDebounceTimer = null;
let autoSaveMaxWaitTimer = null;
// Registration Date Fields (Jalali: Year, Month, Day)
const regYear = ref('');
@@ -157,6 +165,40 @@ const computedPropertyName = computed(() => {
return '';
});
// Auto-save indicator status: derived, no extra reactive bookkeeping needed
const autoSaveStatus = computed(() => {
if (isAutoSaving.value || isSaving.value) return 'saving';
if (autoSaveFailed.value) return 'error';
if (isDirty.value) return 'pending';
if (lastSavedTime.value) return 'saved';
return 'idle';
});
const AUTOSAVE_STATUS_META = {
saving: { label: 'در حال ذخیره', icon: 'pi pi-spin pi-sync', dot: 'bg-primary-500', badge: 'bg-primary-50/95 dark:bg-primary-950/70 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-300' },
pending: { label: 'تغییرات ذخیره‌نشده', icon: 'pi pi-circle-fill', dot: 'bg-amber-500 animate-pulse', badge: 'bg-amber-50/95 dark:bg-amber-950/70 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300' },
saved: { label: 'ذخیره شد', icon: 'pi pi-check-circle', dot: 'bg-emerald-500', badge: 'bg-surface-0/95 dark:bg-surface-900/95 border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-300' },
error: { label: 'خطا در ذخیره', icon: 'pi pi-exclamation-triangle', dot: 'bg-red-500', badge: 'bg-red-50/95 dark:bg-red-950/70 border-red-200 dark:border-red-800 text-red-600 dark:text-red-300' },
idle: { label: 'ذخیره خودکار', icon: 'pi pi-circle', dot: 'bg-surface-400', badge: 'bg-surface-0/80 dark:bg-surface-900/80 border-surface-200 dark:border-surface-700 text-surface-400 dark:text-surface-500' }
};
const autoSaveMeta = computed(() => AUTOSAVE_STATUS_META[autoSaveStatus.value]);
const autoSaveTooltip = computed(() => {
switch (autoSaveStatus.value) {
case 'saving':
return 'در حال ذخیره خودکار تغییرات...';
case 'pending':
return 'تغییراتی ثبت شده که به‌زودی به‌صورت خودکار ذخیره می‌شود.';
case 'error':
return 'ذخیره خودکار ناموفق بود. لطفاً به‌صورت دستی ذخیره کنید.';
case 'saved':
return lastSavedTime.value ? `آخرین ذخیره خودکار: ${lastSavedTime.value}` : 'ذخیره شد';
default:
return 'ذخیره خودکار فعال است';
}
});
// Dong options with fractional description
const dongOptions = [
{ value: 1, label: '۱ دانگ', fraction: '۱⁏۶' },
@@ -339,6 +381,7 @@ watch(
() => {
if (!pageLoading.value) {
isDirty.value = true;
scheduleAutoSave();
}
},
{ deep: true }
@@ -443,10 +486,31 @@ const getStatusSeverity = (st) => {
return valid.includes(st.color) ? st.color : 'secondary';
};
const initAutoSaveTimer = () => {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (autoSaveEnabled.value && autoSaveIntervalSeconds.value > 0) {
autoSaveTimer = setInterval(performAutoSave, autoSaveIntervalSeconds.value * 1000);
const clearAutoSaveTimers = () => {
if (autoSaveDebounceTimer) {
clearTimeout(autoSaveDebounceTimer);
autoSaveDebounceTimer = null;
}
if (autoSaveMaxWaitTimer) {
clearTimeout(autoSaveMaxWaitTimer);
autoSaveMaxWaitTimer = null;
}
};
const triggerAutoSave = () => {
clearAutoSaveTimers();
performAutoSave();
};
const scheduleAutoSave = () => {
if (!autoSaveEnabled.value || pageLoading.value) return;
if (autoSaveDebounceTimer) clearTimeout(autoSaveDebounceTimer);
autoSaveDebounceTimer = setTimeout(triggerAutoSave, AUTOSAVE_DEBOUNCE_MS);
if (!autoSaveMaxWaitTimer) {
const maxWaitMs = Math.max(AUTOSAVE_DEBOUNCE_MS, (autoSaveIntervalSeconds.value || 60) * 1000);
autoSaveMaxWaitTimer = setTimeout(triggerAutoSave, maxWaitMs);
}
};
@@ -482,9 +546,13 @@ const performAutoSave = async () => {
const status = await PropertyService.updateProperty(property.value._id, payload);
if (status === 'success') {
isDirty.value = false;
autoSaveFailed.value = false;
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
} else {
autoSaveFailed.value = true;
}
} catch (e) {
autoSaveFailed.value = true;
console.warn('Auto-save background exception:', e);
} finally {
isAutoSaving.value = false;
@@ -502,8 +570,11 @@ const performAutoSave = async () => {
};
localStorage.setItem('bm_property_draft', JSON.stringify(draft));
isDirty.value = false;
autoSaveFailed.value = false;
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
} catch (e) {}
} catch (e) {
autoSaveFailed.value = true;
}
}
};
@@ -610,12 +681,11 @@ onBeforeMount(async () => {
} finally {
pageLoading.value = false;
isDirty.value = false;
initAutoSaveTimer();
}
});
onBeforeUnmount(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer);
clearAutoSaveTimers();
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
});
@@ -674,7 +744,9 @@ const saveProperty = async () => {
if (status === 'success') {
isDirty.value = false;
autoSaveFailed.value = false;
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
clearAutoSaveTimers();
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
@@ -1153,27 +1225,16 @@ const saveProperty = async () => {
</div>
</div>
<!-- Floating Auto-Save Status Popup (Bottom-Right) -->
<!-- Tiny Fixed Auto-Save Indicator (Bottom-Right, stays visible while scrolling) -->
<Transition name="fade">
<div
v-if="autoSaveEnabled && (lastSavedTime || isAutoSaving)"
class="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-0/95 dark:bg-surface-900/95 backdrop-blur-md shadow-xl border border-surface-200 dark:border-surface-700 select-none text-xs text-surface-700 dark:text-surface-200 animate-fade-in"
style="box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);"
v-if="autoSaveEnabled && !pageLoading"
class="fixed bottom-4 right-4 z-50 flex items-center gap-1.5 h-6 px-2 rounded-full border shadow-sm backdrop-blur-md select-none transition-colors duration-300"
:class="autoSaveMeta.badge"
:title="autoSaveTooltip"
>
<div class="flex items-center gap-2">
<span v-if="isAutoSaving" class="flex items-center text-primary gap-1.5 font-semibold">
<i class="pi pi-spin pi-sync text-sm"></i>
<span>در حال ذخیره خودکار...</span>
</span>
<span v-else class="flex items-center text-emerald-600 dark:text-emerald-400 gap-1.5 font-semibold">
<i class="pi pi-check-circle text-sm"></i>
<span>آخرین ذخیره: {{ lastSavedTime }}</span>
</span>
</div>
<span class="w-px h-3.5 bg-surface-200 dark:bg-surface-700"></span>
<span class="text-[10px] text-surface-400 font-mono" :title="`بازه زمانی ذخیره خودکار: ${autoSaveIntervalSeconds} ثانیه`">
{{ autoSaveIntervalSeconds }}s
</span>
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="autoSaveMeta.dot"></span>
<span class="text-[10px] font-medium leading-none whitespace-nowrap">{{ autoSaveMeta.label }}</span>
</div>
</Transition>
</div>