Sep 15 - Update the attendance records table to update every 20 min
This commit is contained in:
+318
-50
@@ -24,6 +24,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeCharts();
|
||||
setupEventListeners();
|
||||
initializeDateRangeFilters();
|
||||
initializeLiveUpdates();
|
||||
});
|
||||
|
||||
function initializeReport() {
|
||||
@@ -164,6 +165,20 @@ function stripLeadingZeros(value) {
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
filteredData = filterRecords();
|
||||
|
||||
currentPage = 1;
|
||||
updateTable();
|
||||
updatePagination();
|
||||
updateFilterStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Records matching the on-page search / location / employee filters.
|
||||
* Shared by applyFilters() and refreshTableKeepingView() (live updates), which
|
||||
* must keep the user's current page and sort instead of jumping to page 1.
|
||||
*/
|
||||
function filterRecords() {
|
||||
const searchTerm =
|
||||
document.getElementById("searchInput")?.value.toLowerCase() || "";
|
||||
const locationFilter = document.getElementById("location")?.value || "";
|
||||
@@ -184,7 +199,7 @@ function applyFilters() {
|
||||
.map(parseEmployeeIdWorkType)
|
||||
: [];
|
||||
|
||||
filteredData = attendanceData.filter((record) => {
|
||||
return attendanceData.filter((record) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
record.employeeId.toLowerCase().includes(searchTerm) ||
|
||||
@@ -209,11 +224,6 @@ function applyFilters() {
|
||||
|
||||
return matchesSearch && matchesLocation && matchesEmployee;
|
||||
});
|
||||
|
||||
currentPage = 1;
|
||||
updateTable();
|
||||
updatePagination();
|
||||
updateFilterStats();
|
||||
}
|
||||
|
||||
function sortTable(columnIndex) {
|
||||
@@ -687,6 +697,10 @@ function createTableRow(record, displayIndex) {
|
||||
if (record.isDynamic) {
|
||||
row.classList.add('dynamic-qr-record');
|
||||
}
|
||||
// Brief highlight for rows delivered by live updates
|
||||
if (record.isLiveNew) {
|
||||
row.classList.add('live-new-record');
|
||||
}
|
||||
|
||||
// Debug logging for first few records
|
||||
if (displayIndex <= 3) {
|
||||
@@ -704,7 +718,7 @@ function createTableRow(record, displayIndex) {
|
||||
|
||||
if (record.verification_required && record.verification_status === 'pending') {
|
||||
// Show Review Needed badge for pending verification - LINK to review page
|
||||
locationAccuracyBadge = `<a href="/verification-review/${record.id}"
|
||||
locationAccuracyBadge = `<a href="/verification-review/${encodeURIComponent(record.id)}"
|
||||
class="location-accuracy-badge badge-review-needed"
|
||||
style="cursor: pointer; text-decoration: none;"
|
||||
title="Click to review verification photo - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles">
|
||||
@@ -782,12 +796,8 @@ function createTableRow(record, displayIndex) {
|
||||
addressDisplayHTML = `
|
||||
<i class="${addressIcon}" style="color: #059669; margin-right: 4px;"
|
||||
title="High accuracy - showing QR location"></i>
|
||||
<span title="${addressTitle}" class="${addressClass}">
|
||||
${
|
||||
addressToShow.length > 45
|
||||
? addressToShow.substring(0, 45) + "..."
|
||||
: addressToShow
|
||||
}
|
||||
<span title="${escapeHtml(addressTitle)}" class="${addressClass}">
|
||||
${escapeHtml(truncateText(addressToShow, 45))}
|
||||
</span>
|
||||
`;
|
||||
} else {
|
||||
@@ -799,14 +809,10 @@ function createTableRow(record, displayIndex) {
|
||||
addressDisplayHTML = `
|
||||
<i class="fas fa-exclamation-triangle" style="color: #f59e0b; margin-right: 4px;"
|
||||
title="Lower accuracy - showing actual check-in location"></i>
|
||||
<span title="${addressTitle} (Accuracy: ${accuracy.toFixed(
|
||||
<span title="${escapeHtml(addressTitle)} (Accuracy: ${accuracy.toFixed(
|
||||
3
|
||||
)} mi)" class="${addressClass}">
|
||||
${
|
||||
addressToShow.length > 45
|
||||
? addressToShow.substring(0, 45) + "..."
|
||||
: addressToShow
|
||||
}
|
||||
${escapeHtml(truncateText(addressToShow, 45))}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -820,12 +826,8 @@ function createTableRow(record, displayIndex) {
|
||||
|
||||
addressDisplayHTML = `
|
||||
<i class="${addressIcon}"></i>
|
||||
<span title="${addressTitle}" class="${addressClass}">
|
||||
${
|
||||
addressToShow.length > 45
|
||||
? addressToShow.substring(0, 45) + "..."
|
||||
: addressToShow
|
||||
}
|
||||
<span title="${escapeHtml(addressTitle)}" class="${addressClass}">
|
||||
${escapeHtml(truncateText(addressToShow, 45))}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -834,45 +836,41 @@ function createTableRow(record, displayIndex) {
|
||||
<td>${displayIndex}</td>
|
||||
<td>
|
||||
<div class="employee-info">
|
||||
<span class="employee-id">${record.employeeId}</span>
|
||||
<span class="employee-id">${escapeHtml(record.employeeId)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="employee-name">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>${record.employeeName || 'Unknown'}</span>
|
||||
<span>${escapeHtml(record.employeeName || 'Unknown')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="location-info">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
${record.location}
|
||||
${escapeHtml(record.location)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="event-info">
|
||||
${record.event}
|
||||
${escapeHtml(record.event)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="date-info">
|
||||
${record.date}
|
||||
${escapeHtml(record.date)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="time-info">
|
||||
${record.time}
|
||||
${escapeHtml(record.time)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="address-info qr-address">
|
||||
<i class="fas fa-qrcode" style="color: #6366f1; margin-right: 4px;" title="QR Code Address (Fixed)"></i>
|
||||
<span title="QR Address: ${record.qr_address}">
|
||||
${
|
||||
record.qr_address.length > 50
|
||||
? record.qr_address.substring(0, 50) + "..."
|
||||
: record.qr_address
|
||||
}
|
||||
<span title="QR Address: ${escapeHtml(record.qr_address)}">
|
||||
${escapeHtml(truncateText(record.qr_address, 50))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -889,12 +887,8 @@ function createTableRow(record, displayIndex) {
|
||||
<td>
|
||||
<div class="device-info">
|
||||
<i class="fas fa-mobile-alt"></i>
|
||||
<span title="${record.device}">
|
||||
${
|
||||
record.device.length > 20
|
||||
? record.device.substring(0, 20) + "..."
|
||||
: record.device
|
||||
}
|
||||
<span title="${escapeHtml(record.device)}">
|
||||
${escapeHtml(truncateText(record.device, 20))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -902,7 +896,7 @@ function createTableRow(record, displayIndex) {
|
||||
<div class="record-actions">
|
||||
${
|
||||
record.verification_required && record.verification_status === 'pending'
|
||||
? `<a href="/verification-review/${record.id}"
|
||||
? `<a href="/verification-review/${encodeURIComponent(record.id)}"
|
||||
class="action-btn btn-review"
|
||||
title="Review Verification Photo">
|
||||
<i class="fas fa-camera"></i>
|
||||
@@ -912,12 +906,12 @@ function createTableRow(record, displayIndex) {
|
||||
${
|
||||
hasEditPermission
|
||||
? `
|
||||
<button onclick="editRecord('${record.id}')"
|
||||
<button onclick="editRecord('${escapeHtml(escapeJsString(record.id))}')"
|
||||
class="action-btn btn-edit"
|
||||
title="Edit Record">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteRecord('${record.id}', '${record.employeeId}')"
|
||||
<button onclick="deleteRecord('${escapeHtml(escapeJsString(record.id))}', '${escapeHtml(escapeJsString(record.employeeId))}')"
|
||||
class="action-btn btn-delete"
|
||||
title="Delete Record">
|
||||
<i class="fas fa-trash"></i>
|
||||
@@ -961,6 +955,18 @@ function sortTable(columnIndex) {
|
||||
sortDirection = "asc";
|
||||
}
|
||||
|
||||
sortFilteredData();
|
||||
|
||||
updateTable();
|
||||
updateSortIndicators(columnIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort filteredData by the current sortColumn / sortDirection without toggling
|
||||
* the direction — reused when live updates re-apply the user's chosen sort.
|
||||
*/
|
||||
function sortFilteredData() {
|
||||
const columnIndex = sortColumn;
|
||||
const sortKey = getSortKey(columnIndex);
|
||||
|
||||
filteredData.sort((a, b) => {
|
||||
@@ -993,9 +999,6 @@ function sortTable(columnIndex) {
|
||||
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
});
|
||||
|
||||
updateTable();
|
||||
updateSortIndicators(columnIndex);
|
||||
}
|
||||
|
||||
// Enhanced statistics display for location accuracy
|
||||
@@ -1240,4 +1243,269 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
|
||||
console.log("Enhanced export functionality initialized");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// HTML SAFETY HELPERS
|
||||
// ============================================================
|
||||
// Row values are plain text (read with textContent, or JSON from the server)
|
||||
// but createTableRow() builds markup with innerHTML. Device and address text
|
||||
// come from the public check-in page, so every value is escaped first.
|
||||
function escapeHtml(value) {
|
||||
return String(value === null || value === undefined ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// For a value placed inside a single-quoted JS string in an onclick attribute:
|
||||
// escape for JS here, then escapeHtml() for the attribute.
|
||||
function escapeJsString(value) {
|
||||
return String(value === null || value === undefined ? "" : value)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
// Truncate BEFORE escaping, so an entity like & is never cut in half
|
||||
function truncateText(value, maxLength) {
|
||||
const text = String(value === null || value === undefined ? "" : value);
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LIVE UPDATES — new check-ins appear without reloading the page
|
||||
// ============================================================
|
||||
// Polls GET /api/attendance/live-updates (routes/attendance.py), which answers
|
||||
// from a primary-key MAX(id) lookup when nothing is new. To keep the load
|
||||
// negligible the poll:
|
||||
// - runs every LIVE_POLL_INTERVAL_MS and never overlaps itself
|
||||
// - pauses while the tab is hidden, and checks once as soon as it is shown
|
||||
// - backs off exponentially after errors, up to LIVE_MAX_BACKOFF_MS
|
||||
// - stops for good when the session has ended (redirect / 401 / 403)
|
||||
// New rows keep the user's current page, sort and search.
|
||||
const LIVE_POLL_INTERVAL_MS = 20000;
|
||||
const LIVE_MAX_BACKOFF_MS = 5 * 60 * 1000;
|
||||
const LIVE_HIGHLIGHT_MS = 8000;
|
||||
const LIVE_MAX_RECORDS = 5000; // rows kept in memory on a page left open for days
|
||||
const LIVE_FILTER_PARAMS = ["date_from", "date_to", "location", "employee", "project"];
|
||||
|
||||
const liveUpdates = {
|
||||
enabled: false,
|
||||
sinceId: 0,
|
||||
timer: null,
|
||||
inFlight: false,
|
||||
failures: 0,
|
||||
stopped: false,
|
||||
newSinceLoad: 0,
|
||||
statusEl: null,
|
||||
};
|
||||
|
||||
function initializeLiveUpdates() {
|
||||
const container = document.getElementById("attendanceReportContainer");
|
||||
const sinceId = container ? parseInt(container.dataset.liveSinceId, 10) : NaN;
|
||||
if (isNaN(sinceId)) {
|
||||
return; // rendered without a cursor (no access / id lookup failed): stay static
|
||||
}
|
||||
|
||||
liveUpdates.enabled = true;
|
||||
liveUpdates.sinceId = sinceId;
|
||||
createLiveStatusIndicator();
|
||||
setLiveStatus("live");
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!liveUpdates.enabled || liveUpdates.stopped) return;
|
||||
if (document.hidden) {
|
||||
clearTimeout(liveUpdates.timer); // no requests from background tabs
|
||||
} else {
|
||||
pollLiveUpdates(); // catch up straight away when the tab is shown again
|
||||
}
|
||||
});
|
||||
|
||||
scheduleLivePoll(LIVE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function scheduleLivePoll(delayMs) {
|
||||
clearTimeout(liveUpdates.timer);
|
||||
if (liveUpdates.stopped || document.hidden) return;
|
||||
liveUpdates.timer = setTimeout(pollLiveUpdates, delayMs);
|
||||
}
|
||||
|
||||
function pollLiveUpdates() {
|
||||
if (!liveUpdates.enabled || liveUpdates.stopped || liveUpdates.inFlight || document.hidden) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(liveUpdates.timer);
|
||||
liveUpdates.inFlight = true;
|
||||
|
||||
// The server applies the same filters the page was loaded with
|
||||
const pageParams = new URLSearchParams(window.location.search);
|
||||
const params = new URLSearchParams();
|
||||
LIVE_FILTER_PARAMS.forEach(function (name) {
|
||||
const value = pageParams.get(name);
|
||||
if (value) params.set(name, value);
|
||||
});
|
||||
params.set("since_id", String(liveUpdates.sinceId));
|
||||
|
||||
let nextDelay = LIVE_POLL_INTERVAL_MS;
|
||||
|
||||
fetch("/api/attendance/live-updates?" + params.toString(), {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest", Accept: "application/json" },
|
||||
})
|
||||
.then(function (response) {
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
if (
|
||||
response.redirected ||
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
(response.ok && contentType.indexOf("application/json") === -1)
|
||||
) {
|
||||
// login_required redirected to the login page: the session is over
|
||||
stopLiveUpdates();
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP " + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || "live update failed");
|
||||
}
|
||||
liveUpdates.failures = 0;
|
||||
if (Array.isArray(data.records) && data.records.length > 0) {
|
||||
insertLiveRecords(data.records);
|
||||
}
|
||||
const latestId = parseInt(data.latest_id, 10);
|
||||
if (!isNaN(latestId) && latestId > liveUpdates.sinceId) {
|
||||
liveUpdates.sinceId = latestId;
|
||||
}
|
||||
if (data.has_more) {
|
||||
nextDelay = 1000; // a large burst arrived: fetch the next batch promptly
|
||||
}
|
||||
if (!liveUpdates.stopped) setLiveStatus("live");
|
||||
})
|
||||
.catch(function (error) {
|
||||
liveUpdates.failures += 1;
|
||||
nextDelay = Math.min(
|
||||
LIVE_POLL_INTERVAL_MS * Math.pow(2, liveUpdates.failures),
|
||||
LIVE_MAX_BACKOFF_MS
|
||||
);
|
||||
console.log(
|
||||
"Live updates: check failed (" + error.message + "), retrying in " +
|
||||
Math.round(nextDelay / 1000) + "s"
|
||||
);
|
||||
setLiveStatus("retry");
|
||||
})
|
||||
.then(function () {
|
||||
liveUpdates.inFlight = false;
|
||||
if (!liveUpdates.stopped) scheduleLivePoll(nextDelay);
|
||||
});
|
||||
}
|
||||
|
||||
function stopLiveUpdates() {
|
||||
liveUpdates.stopped = true;
|
||||
clearTimeout(liveUpdates.timer);
|
||||
setLiveStatus("stopped");
|
||||
}
|
||||
|
||||
function insertLiveRecords(records) {
|
||||
// The page showed "No Attendance Records Found" (no table to add to):
|
||||
// reload once, keeping the filters, so the table renders normally.
|
||||
if (!document.getElementById("attendanceTable")) {
|
||||
stopLiveUpdates();
|
||||
if (typeof refreshReport === "function") {
|
||||
refreshReport();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const knownIds = new Set(attendanceData.map(function (r) { return String(r.id); }));
|
||||
const fresh = records
|
||||
.filter(function (r) { return !knownIds.has(String(r.id)); })
|
||||
.sort(function (a, b) { return Number(b.id) - Number(a.id); }); // newest first
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
fresh.forEach(function (r) { r.isLiveNew = true; });
|
||||
attendanceData = fresh.concat(attendanceData);
|
||||
if (attendanceData.length > LIVE_MAX_RECORDS) {
|
||||
attendanceData.length = LIVE_MAX_RECORDS;
|
||||
}
|
||||
attendanceData.forEach(function (r, i) { r.index = i + 1; });
|
||||
|
||||
liveUpdates.newSinceLoad += fresh.length;
|
||||
refreshTableKeepingView();
|
||||
|
||||
// Rows re-rendered after this (page change, sort) no longer highlight
|
||||
setTimeout(function () {
|
||||
fresh.forEach(function (r) { r.isLiveNew = false; });
|
||||
}, LIVE_HIGHLIGHT_MS);
|
||||
}
|
||||
|
||||
// Re-render after new rows arrive WITHOUT resetting the user's view:
|
||||
// same search/filters, same sort, same page (clamped if it no longer exists).
|
||||
function refreshTableKeepingView() {
|
||||
filteredData = filterRecords();
|
||||
if (sortColumn !== -1) {
|
||||
sortFilteredData();
|
||||
}
|
||||
if (entriesPerPage !== "all") {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredData.length / entriesPerPage));
|
||||
if (currentPage > totalPages) currentPage = totalPages;
|
||||
}
|
||||
updateTable();
|
||||
updatePagination();
|
||||
}
|
||||
|
||||
function createLiveStatusIndicator() {
|
||||
const heading = document.querySelector(".attendance-table-section .table-header h3");
|
||||
if (!heading) return;
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.id = "liveUpdateStatus";
|
||||
status.className = "live-status";
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "live-dot";
|
||||
const text = document.createElement("span");
|
||||
text.className = "live-text";
|
||||
|
||||
status.appendChild(dot);
|
||||
status.appendChild(text);
|
||||
heading.appendChild(status);
|
||||
liveUpdates.statusEl = status;
|
||||
}
|
||||
|
||||
function setLiveStatus(state) {
|
||||
const status = liveUpdates.statusEl;
|
||||
if (!status) return;
|
||||
const text = status.querySelector(".live-text");
|
||||
|
||||
status.classList.remove("live-status--retry", "live-status--stopped");
|
||||
if (state === "retry") {
|
||||
status.classList.add("live-status--retry");
|
||||
text.textContent = "Reconnecting…";
|
||||
status.title = "Could not check for new records. Retrying automatically.";
|
||||
} else if (state === "stopped") {
|
||||
status.classList.add("live-status--stopped");
|
||||
text.textContent = "Live updates paused";
|
||||
status.title = "Live updates stopped. Refresh the page to resume.";
|
||||
} else {
|
||||
text.textContent = liveUpdates.newSinceLoad > 0
|
||||
? "Live · " + liveUpdates.newSinceLoad + " new"
|
||||
: "Live";
|
||||
status.title =
|
||||
"New check-ins appear automatically (checked every " +
|
||||
LIVE_POLL_INTERVAL_MS / 1000 +
|
||||
" seconds, paused while this tab is hidden). Last checked " +
|
||||
new Date().toLocaleTimeString() + ".";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user