Sep 15 - Update the attendance records table to update every 20 min

This commit is contained in:
2026-09-15 12:32:35 -04:00
parent 4786f34f4d
commit e5410d5141
4 changed files with 742 additions and 131 deletions
+318 -50
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// 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 &amp; 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() + ".";
}
}