/** @odoo-module **/
/**
* Fusion Clock - Portal Clock-In/Out Interaction (Odoo 19)
*
* Handles: GPS verification, clock in/out actions, live timer,
* sound effects, persistent state, location selection, and UI animations.
*/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class FusionClockPortal extends Interaction {
static selector = "#fusion-clock-app";
setup() {
this.isCheckedIn = this.el.dataset.checkedIn === "true";
this.enableSounds = this.el.dataset.enableSounds === "true";
this.checkInTime = null;
this.timerInterval = null;
this.selectedLocationId = null;
if (this.el.dataset.checkInTime) {
this.checkInTime = new Date(this.el.dataset.checkInTime + "Z");
}
// If server didn't provide check-in data, try localStorage
if (!this.isCheckedIn || !this.checkInTime) {
this._restoreState();
} else {
this._saveState();
}
// Load locations
const locDataEl = document.getElementById("fclk-locations-data");
this.locations = [];
if (locDataEl) {
try { this.locations = JSON.parse(locDataEl.textContent); } catch (e) {}
}
if (this.locations.length > 0) {
this.selectedLocationId = this.locations[0].id;
this._restoreSelectedLocation();
}
// Start live clock
this._updateCurrentTime();
this.clockInterval = setInterval(() => this._updateCurrentTime(), 1000);
// Start timer if checked in
if (this.isCheckedIn && this.checkInTime) {
this._startTimer();
this._updateUIForClockIn({
location_name: this.el.dataset.locationName || "",
location_address: this.el.dataset.locationAddress || "",
});
const locId = parseInt(this.el.dataset.locationId || "0");
if (locId) {
this.selectedLocationId = locId;
this._saveSelectedLocation(locId);
}
} else if (this.locations.length > 1) {
this._detectNearestLocation();
}
this._updateDateDisplay();
// Event listeners
this._setupEventListeners();
// Visibility sync -- also sync on load to verify with server
this._onVisibilityChange = () => this._syncOnVisibilityChange();
document.addEventListener("visibilitychange", this._onVisibilityChange);
this._syncTimeout = setTimeout(() => this._syncOnVisibilityChange(), 3000);
}
destroy() {
this._stopTimer();
if (this.clockInterval) clearInterval(this.clockInterval);
if (this._syncTimeout) clearTimeout(this._syncTimeout);
if (this._onVisibilityChange) {
document.removeEventListener("visibilitychange", this._onVisibilityChange);
}
}
_setupEventListeners() {
const clockBtn = document.getElementById("fclk-clock-btn");
if (clockBtn) {
this._onClockClick = (e) => this._onClockButtonClick(e);
clockBtn.addEventListener("click", this._onClockClick);
}
const locationCard = document.getElementById("fclk-location-card");
if (locationCard && this.locations.length > 1) {
locationCard.addEventListener("click", () => {
const modal = document.getElementById("fclk-location-modal");
if (modal) modal.style.display = "flex";
});
}
const reasonSubmitBtn = document.getElementById("fclk-reason-submit");
if (reasonSubmitBtn) {
reasonSubmitBtn.addEventListener("click", () => this._submitReason());
}
const leaveBtn = document.getElementById("fclk-leave-btn");
if (leaveBtn) {
leaveBtn.addEventListener("click", () => {
const modal = document.getElementById("fclk-leave-modal");
if (modal) modal.style.display = "flex";
});
}
const leaveSubmitBtn = document.getElementById("fclk-leave-submit");
if (leaveSubmitBtn) {
leaveSubmitBtn.addEventListener("click", () => this._submitLeave());
}
const clockoutConfirmBtn = document.getElementById("fclk-clockout-confirm-btn");
if (clockoutConfirmBtn) {
clockoutConfirmBtn.addEventListener("click", () => this._confirmClockOut());
}
document.querySelectorAll("[data-dismiss]").forEach((btn) => {
btn.addEventListener("click", () => {
const targetId = btn.dataset.dismiss;
const modal = document.getElementById(targetId);
if (modal) modal.style.display = "none";
});
});
document.querySelectorAll(".fclk-modal-item").forEach((item) => {
item.addEventListener("click", () => {
const locId = parseInt(item.dataset.id);
this.selectedLocationId = locId;
this._saveSelectedLocation(locId);
const nameEl = document.getElementById("fclk-location-name");
const addrEl = document.getElementById("fclk-location-address");
if (nameEl) nameEl.textContent = item.dataset.name;
if (addrEl) addrEl.textContent = item.dataset.address;
const modal = document.getElementById("fclk-location-modal");
if (modal) modal.style.display = "none";
});
});
}
// =========================================================================
// Nearest Location Detection
// =========================================================================
_detectNearestLocation() {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => this._selectNearestFromCoords(pos.coords.latitude, pos.coords.longitude),
async () => {
try {
const resp = await fetch("https://ipapi.co/json/");
if (resp.ok) {
const data = await resp.json();
if (data.latitude && data.longitude) {
this._selectNearestFromCoords(data.latitude, data.longitude);
}
}
} catch { /* silent */ }
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
_selectNearestFromCoords(lat, lng) {
let nearest = null;
let minDist = Infinity;
for (const loc of this.locations) {
if (!loc.latitude || !loc.longitude) continue;
const dist = this._haversine(lat, lng, loc.latitude, loc.longitude);
if (dist < minDist) {
minDist = dist;
nearest = loc;
}
}
if (!nearest) return;
this.selectedLocationId = nearest.id;
const nameEl = document.getElementById("fclk-location-name");
const addrEl = document.getElementById("fclk-location-address");
if (nameEl) nameEl.textContent = nearest.name;
if (addrEl) addrEl.textContent = nearest.address || "";
}
_haversine(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = (v) => (v * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// =========================================================================
// Clock Action
// =========================================================================
_onClockButtonClick(e) {
e.preventDefault();
const btn = document.getElementById("fclk-clock-btn");
if (!btn || btn.disabled) return;
if (this.isCheckedIn) {
this._showClockOutConfirmation();
return;
}
this._beginClockAction();
}
_showClockOutConfirmation() {
const modal = document.getElementById("fclk-clockout-confirm-modal");
if (!modal) {
this._beginClockAction();
return;
}
const checkinEl = document.getElementById("fclk-confirm-checkin-time");
const durationEl = document.getElementById("fclk-confirm-duration");
if (checkinEl && this.checkInTime) {
const h = this.checkInTime.getHours();
const m = this.checkInTime.getMinutes();
const ampm = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 || 12;
checkinEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
}
if (durationEl && this.checkInTime) {
const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000));
const dh = Math.floor(diff / 3600);
const dm = Math.floor((diff % 3600) / 60);
durationEl.textContent = dh + "h " + dm + "m";
}
modal.style.display = "flex";
}
_confirmClockOut() {
const modal = document.getElementById("fclk-clockout-confirm-modal");
if (modal) modal.style.display = "none";
this._beginClockAction();
}
_beginClockAction() {
const btn = document.getElementById("fclk-clock-btn");
if (!btn || btn.disabled) return;
btn.disabled = true;
const ripple = btn.querySelector(".fclk-btn-ripple");
if (ripple) {
ripple.classList.remove("fclk-ripple-active");
void ripple.offsetWidth;
ripple.classList.add("fclk-ripple-active");
}
this._showGPSOverlay();
if (!navigator.geolocation) {
this._hideGPSOverlay();
this._showToast("Geolocation is not supported by your browser.", "error");
btn.disabled = false;
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
this._performClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
},
async () => {
let lat = 0, lng = 0;
try {
const ipResp = await fetch("https://ipapi.co/json/");
if (ipResp.ok) {
const ipData = await ipResp.json();
if (ipData.latitude && ipData.longitude) {
lat = ipData.latitude;
lng = ipData.longitude;
}
}
} catch {
// IP geolocation also unavailable
}
this._hideGPSOverlay();
this._performClockAction(lat, lng, lat ? 5000 : 0);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
}
async _performClockAction(lat, lng, accuracy) {
const btn = document.getElementById("fclk-clock-btn");
try {
const result = await rpc("/fusion_clock/clock_action", {
latitude: lat,
longitude: lng,
accuracy: accuracy,
source: "portal",
});
this._hideGPSOverlay();
if (btn) btn.disabled = false;
if (result.requires_reason) {
this._showReasonModal();
return;
}
if (result.error) {
this._showToast(result.error, "error");
this._shakeButton();
return;
}
if (result.action === "clock_in") {
this.isCheckedIn = true;
this.checkInTime = new Date(result.check_in + "Z");
this.el.dataset.locationName = result.location_name || "";
this._updateUIForClockIn(result);
this._startTimer();
this._playSound("in");
this._showToast(result.message, "success");
this._saveState();
if (this.selectedLocationId) this._saveSelectedLocation(this.selectedLocationId);
} else if (result.action === "clock_out") {
this.isCheckedIn = false;
this._updateUIForClockOut(result);
this._stopTimer();
this._playSound("out");
this._showToast(result.message, "success");
this._clearState();
}
} catch (err) {
this._hideGPSOverlay();
this._showToast("Network error. Please try again.", "error");
if (btn) btn.disabled = false;
}
}
// =========================================================================
// UI Updates
// =========================================================================
_updateUIForClockIn(data) {
const dot = document.getElementById("fclk-status-dot");
const statusText = document.getElementById("fclk-status-text");
const timerLabel = document.getElementById("fclk-timer-label");
const btnLabel = document.getElementById("fclk-btn-label");
const btn = document.getElementById("fclk-clock-btn");
const playIcon = document.getElementById("fclk-btn-icon-play");
const stopIcon = document.getElementById("fclk-btn-icon-stop");
if (dot) dot.classList.add("fclk-dot-active");
if (statusText) statusText.textContent = "Clocked In";
if (timerLabel) timerLabel.textContent = "Time Elapsed";
if (btnLabel) btnLabel.textContent = "Tap to Clock Out";
if (btn) btn.classList.add("fclk-clock-btn-out");
if (playIcon) playIcon.style.display = "none";
if (stopIcon) stopIcon.style.display = "block";
if (data.location_name) {
const locEl = document.getElementById("fclk-location-name");
if (locEl) locEl.textContent = data.location_name;
}
if (data.location_address) {
const addrEl = document.getElementById("fclk-location-address");
if (addrEl) addrEl.textContent = data.location_address;
}
}
_updateUIForClockOut(data) {
const dot = document.getElementById("fclk-status-dot");
const statusText = document.getElementById("fclk-status-text");
const timerLabel = document.getElementById("fclk-timer-label");
const btnLabel = document.getElementById("fclk-btn-label");
const btn = document.getElementById("fclk-clock-btn");
const playIcon = document.getElementById("fclk-btn-icon-play");
const stopIcon = document.getElementById("fclk-btn-icon-stop");
const timer = document.getElementById("fclk-timer");
if (dot) dot.classList.remove("fclk-dot-active");
if (statusText) statusText.textContent = "Not Clocked In";
if (timerLabel) timerLabel.textContent = "Ready to Clock In";
if (btnLabel) btnLabel.textContent = "Tap to Clock In";
if (btn) btn.classList.remove("fclk-clock-btn-out");
if (playIcon) playIcon.style.display = "block";
if (stopIcon) stopIcon.style.display = "none";
if (timer) timer.textContent = "00 : 00 : 00";
if (data.net_hours !== undefined) {
const todayEl = document.getElementById("fclk-today-hours");
if (todayEl) {
const current = parseFloat(todayEl.textContent) || 0;
todayEl.textContent = (current + data.net_hours).toFixed(1) + "h";
}
const weekEl = document.getElementById("fclk-week-hours");
if (weekEl) {
const currentW = parseFloat(weekEl.textContent) || 0;
weekEl.textContent = (currentW + data.net_hours).toFixed(1) + "h";
}
}
}
// =========================================================================
// Timer
// =========================================================================
_startTimer() {
this._stopTimer();
this._updateTimer();
this.timerInterval = setInterval(() => this._updateTimer(), 1000);
}
_stopTimer() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
_updateTimer() {
if (!this.checkInTime) return;
const now = new Date();
let diff = Math.max(0, Math.floor((now - this.checkInTime) / 1000));
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
const pad = (n) => (n < 10 ? "0" + n : "" + n);
const timerEl = document.getElementById("fclk-timer");
if (timerEl) {
timerEl.textContent = pad(h) + " : " + pad(m) + " : " + pad(s);
}
}
// =========================================================================
// Date & Time Display
// =========================================================================
_updateDateDisplay() {
const el = document.getElementById("fclk-date-display");
if (!el) return;
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const months = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
const now = new Date();
el.textContent = days[now.getDay()] + ", " + months[now.getMonth()] + " " + now.getDate();
}
_updateCurrentTime() {
const el = document.getElementById("fclk-current-time");
if (!el) return;
const now = new Date();
let h = now.getHours();
const m = now.getMinutes();
const ampm = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
el.textContent = h + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
}
// =========================================================================
// Sound Effects
// =========================================================================
_playSound(type) {
if (!this.enableSounds) return;
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
if (type === "in") {
osc.type = "sine";
osc.frequency.setValueAtTime(523, ctx.currentTime);
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1);
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.5);
} else {
osc.type = "sine";
osc.frequency.setValueAtTime(784, ctx.currentTime);
osc.frequency.setValueAtTime(523, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.25, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.4);
}
} catch (e) {
// Sounds are non-critical
}
}
// =========================================================================
// Toast Notifications
// =========================================================================
_showToast(msg, type) {
const toast = document.getElementById("fclk-toast");
const toastMsg = document.getElementById("fclk-toast-msg");
const toastIcon = document.getElementById("fclk-toast-icon");
if (!toast) return;
toast.className = "fclk-toast fclk-toast-" + (type || "success");
if (toastMsg) toastMsg.textContent = msg;
if (toastIcon) {
toastIcon.innerHTML = type === "error"
? ''
: '';
}
toast.style.display = "flex";
toast.style.animation = "fclk-toast-in 0.3s ease-out";
setTimeout(() => {
toast.style.animation = "fclk-toast-out 0.3s ease-in forwards";
setTimeout(() => { toast.style.display = "none"; }, 300);
}, 3000);
}
// =========================================================================
// GPS Overlay
// =========================================================================
_showGPSOverlay() {
const el = document.getElementById("fclk-gps-overlay");
if (el) el.style.display = "flex";
}
_hideGPSOverlay() {
const el = document.getElementById("fclk-gps-overlay");
if (el) el.style.display = "none";
}
// =========================================================================
// Shake Animation
// =========================================================================
_shakeButton() {
const btn = document.getElementById("fclk-clock-btn");
if (!btn) return;
btn.classList.add("fclk-shake");
setTimeout(() => { btn.classList.remove("fclk-shake"); }, 500);
}
// =========================================================================
// Persistent State
// =========================================================================
_saveState() {
try {
localStorage.setItem("fclk_checked_in", "true");
if (this.checkInTime) {
localStorage.setItem("fclk_check_in_time", this.checkInTime.toISOString());
}
if (this.el.dataset.locationName) {
localStorage.setItem("fclk_location_name", this.el.dataset.locationName);
}
} catch (e) {}
}
_clearState() {
try {
localStorage.removeItem("fclk_checked_in");
localStorage.removeItem("fclk_check_in_time");
localStorage.removeItem("fclk_location_name");
} catch (e) {}
}
_restoreState() {
try {
const wasCheckedIn = localStorage.getItem("fclk_checked_in") === "true";
const savedTime = localStorage.getItem("fclk_check_in_time");
if (wasCheckedIn && savedTime) {
const t = new Date(savedTime);
if (!isNaN(t.getTime()) && (Date.now() - t.getTime()) < 24 * 60 * 60 * 1000) {
this.isCheckedIn = true;
this.checkInTime = t;
this.el.dataset.locationName = localStorage.getItem("fclk_location_name") || "";
}
}
} catch (e) {}
}
_saveSelectedLocation(locId) {
try {
localStorage.setItem("fclk_selected_location", String(locId));
} catch (e) {}
}
_restoreSelectedLocation() {
try {
const saved = localStorage.getItem("fclk_selected_location");
if (!saved) return;
const savedId = parseInt(saved);
const loc = this.locations.find((l) => l.id === savedId);
if (!loc) return;
this.selectedLocationId = savedId;
const nameEl = document.getElementById("fclk-location-name");
const addrEl = document.getElementById("fclk-location-address");
if (nameEl) nameEl.textContent = loc.name;
if (addrEl) addrEl.textContent = loc.address || "";
} catch (e) {}
}
// =========================================================================
// Reason Modal & Leave Request
// =========================================================================
_showReasonModal() {
const modal = document.getElementById("fclk-reason-modal");
if (modal) modal.style.display = "flex";
}
async _submitReason() {
const reasonEl = document.getElementById("fclk-reason-text");
const timeEl = document.getElementById("fclk-reason-time");
const reason = reasonEl ? reasonEl.value.trim() : "";
const rawTime = timeEl ? timeEl.value.trim() : "";
const depTime = rawTime ? new Date(rawTime).toISOString() : "";
if (!reason) {
this._showToast("Please provide a reason.", "error");
return;
}
try {
const result = await rpc("/fusion_clock/submit_reason", {
reason: reason,
departure_time: depTime,
});
if (result.success) {
const modal = document.getElementById("fclk-reason-modal");
if (modal) modal.style.display = "none";
this._showToast(result.message, "success");
if (reasonEl) reasonEl.value = "";
if (timeEl) timeEl.value = "";
} else {
this._showToast(result.error || "Failed to submit.", "error");
}
} catch (e) {
this._showToast("Network error.", "error");
}
}
async _submitLeave() {
const fromEl = document.getElementById("fclk-leave-date");
const toEl = document.getElementById("fclk-leave-date-to");
const reasonEl = document.getElementById("fclk-leave-reason");
const dateFrom = fromEl ? fromEl.value : "";
let dateTo = toEl && toEl.value ? toEl.value : dateFrom; // single day if "To" left blank
const reason = reasonEl ? reasonEl.value.trim() : "";
if (!dateFrom || !reason) {
this._showToast("Please provide a start date and a reason.", "error");
return;
}
if (dateTo < dateFrom) {
this._showToast("End date can't be before the start date.", "error");
return;
}
try {
const result = await rpc("/fusion_clock/request_leave", {
date_from: dateFrom,
date_to: dateTo,
reason: reason,
});
if (result.success) {
const modal = document.getElementById("fclk-leave-modal");
if (modal) modal.style.display = "none";
this._showToast(result.message, "success");
if (fromEl) fromEl.value = "";
if (toEl) toEl.value = "";
if (reasonEl) reasonEl.value = "";
} else {
this._showToast(result.error || "Failed to submit.", "error");
}
} catch (e) {
this._showToast("Network error.", "error");
}
}
// =========================================================================
// Sync on visibility change
// =========================================================================
async _syncOnVisibilityChange() {
if (document.visibilityState !== "visible") return;
try {
const result = await rpc("/fusion_clock/get_status", {});
if (result.error) return;
if (result.is_checked_in && !this.isCheckedIn) {
this.isCheckedIn = true;
this.checkInTime = new Date(result.check_in + "Z");
this._updateUIForClockIn({ location_name: result.location_name, location_address: result.location_address || "" });
this._startTimer();
this._saveState();
} else if (result.is_checked_in && this.isCheckedIn) {
const serverTime = new Date(result.check_in + "Z");
if (Math.abs(serverTime - this.checkInTime) > 5000) {
this.checkInTime = serverTime;
this._saveState();
}
} else if (!result.is_checked_in && this.isCheckedIn) {
this.isCheckedIn = false;
this._updateUIForClockOut({});
this._stopTimer();
this._clearState();
}
const todayEl = document.getElementById("fclk-today-hours");
if (todayEl && result.today_hours !== undefined) {
todayEl.textContent = result.today_hours.toFixed(1) + "h";
}
const weekEl = document.getElementById("fclk-week-hours");
if (weekEl && result.week_hours !== undefined) {
weekEl.textContent = result.week_hours.toFixed(1) + "h";
}
} catch {
// Server unavailable (restarting) -- keep timer running from localStorage
}
}
}
registry.category("public.interactions").add("fusion_clock.portal", FusionClockPortal);