Files
basicswap/basicswap/static/js/pages/bid-page.js
2026-01-29 16:08:28 +01:00

615 lines
25 KiB
JavaScript

const BidPage = {
bidId: null,
bidStateInd: null,
createdAtTimestamp: null,
autoRefreshInterval: null,
elapsedTimeInterval: null,
AUTO_REFRESH_SECONDS: 60,
refreshPaused: false,
swapType: null,
coinFrom: null,
coinTo: null,
previousStateInd: null,
INACTIVE_STATES: [8, 17, 18, 19, 21, 22, 23, 25, 31], // Completed, Failed variants, Timed-out, Abandoned, Error, Rejected, Expired
DELAYING_STATE: 20,
STATE_TOOLTIPS: {
'Bid Sent': 'Your bid has been broadcast to the network',
'Bid Receiving': 'Receiving partial bid message from the network',
'Bid Received': 'Bid received and waiting for decision to accept or reject',
'Bid Receiving accept': 'Receiving acceptance message from the other party',
'Bid Accepted': 'Bid accepted. The atomic swap process is starting',
'Bid Initiated': 'Swap initiated. First lock transaction is being created',
'Bid Participating': 'Participating in the swap. Second lock transaction is being created',
'Bid Completed': 'Swap completed successfully! Both parties received their coins',
'Bid Script coin locked': null,
'Bid Script coin spend tx valid': null,
'Bid Scriptless coin locked': null,
'Bid Script coin lock released': 'Adaptor signature revealed. The script coin can now be claimed',
'Bid Script tx redeemed': null,
'Bid Script pre-refund tx in chain': 'Pre-refund transaction detected. Swap may be failing',
'Bid Scriptless tx redeemed': null,
'Bid Scriptless tx recovered': null,
'Bid Failed, refunded': 'Swap failed but your coins have been refunded',
'Bid Failed, swiped': 'Swap failed due to an unexpected issue. Please check the event log for details',
'Bid Failed': 'Swap failed. Check events for details',
'Bid Delaying': 'Brief delay between swap steps to ensure network propagation',
'Bid Timed-out': 'Swap timed out waiting for the other party',
'Bid Abandoned': 'Swap was manually abandoned. Locked coins will be refunded after timelock',
'Bid Error': 'An error occurred. Check events for details',
'Bid Rejected': 'Bid was rejected by the offer owner',
'Bid Stalled (debug)': 'Debug mode: swap intentionally stalled for testing',
'Bid Exchanged script lock tx sigs msg': 'Exchanging adaptor signatures needed for lock transactions',
'Bid Exchanged script lock spend tx msg': 'Exchanging signed spend transaction for locked coins',
'Bid Request sent': 'Connection request sent to the other party',
'Bid Request accepted': 'Connection request accepted',
'Bid Expired': 'Bid expired before being accepted',
'Bid Auto accept delay': 'Waiting for automation delay before auto-accepting',
'Bid Auto accept failed': 'Automation failed to accept this bid',
'Bid Connect request sent': 'Sent connection request to peer',
'Bid Unknown bid state': 'Unknown state - please check the swap details',
'ITX Sent': 'Initiate transaction has been broadcast to the network',
'ITX Confirmed': 'Initiate transaction has been confirmed by miners',
'ITX Redeemed': 'Initiate transaction has been successfully claimed',
'ITX Refunded': 'Initiate transaction has been refunded',
'ITX In Mempool': 'Initiate transaction is in the mempool (unconfirmed)',
'ITX In Chain': 'Initiate transaction is included in a block',
'PTX Sent': 'Participate transaction has been broadcast to the network',
'PTX Confirmed': 'Participate transaction has been confirmed by miners',
'PTX Redeemed': 'Participate transaction has been successfully claimed',
'PTX Refunded': 'Participate transaction has been refunded',
'PTX In Mempool': 'Participate transaction is in the mempool (unconfirmed)',
'PTX In Chain': 'Participate transaction is included in a block'
},
getStateTooltip: function(stateText) {
const staticTooltip = this.STATE_TOOLTIPS[stateText];
if (staticTooltip !== null && staticTooltip !== undefined) {
return staticTooltip;
}
const scriptlessCoins = ['XMR', 'WOW'];
let scriptCoin, scriptlessCoin;
if (scriptlessCoins.includes(this.coinFrom)) {
scriptlessCoin = this.coinFrom;
scriptCoin = this.coinTo;
} else if (scriptlessCoins.includes(this.coinTo)) {
scriptlessCoin = this.coinTo;
scriptCoin = this.coinFrom;
} else {
scriptCoin = this.coinFrom;
scriptlessCoin = this.coinTo;
}
const dynamicTooltips = {
'Bid Script coin locked': `${scriptCoin} is locked in the atomic swap contract`,
'Bid Script coin spend tx valid': `The ${scriptCoin} spend transaction has been validated and is ready`,
'Bid Scriptless coin locked': `${scriptlessCoin} is locked using adaptor signatures`,
'Bid Script tx redeemed': `${scriptCoin} has been successfully claimed`,
'Bid Scriptless tx redeemed': `${scriptlessCoin} has been successfully claimed`,
'Bid Scriptless tx recovered': `${scriptlessCoin} recovered after swap failure`,
};
return dynamicTooltips[stateText] || null;
},
EVENT_TOOLTIPS: {
'Lock tx A published': 'First lock transaction broadcast to the blockchain network',
'Lock tx A seen in mempool': 'First lock transaction detected in mempool (unconfirmed)',
'Lock tx A seen in chain': 'First lock transaction included in a block',
'Lock tx A confirmed in chain': 'First lock transaction has enough confirmations',
'Lock tx B published': 'Second lock transaction broadcast to the blockchain network',
'Lock tx B seen in mempool': 'Second lock transaction detected in mempool (unconfirmed)',
'Lock tx B seen in chain': 'Second lock transaction included in a block',
'Lock tx B confirmed in chain': 'Second lock transaction has enough confirmations',
'Lock tx A spend tx published': 'Transaction to claim coins from first lock has been broadcast',
'Lock tx A spend tx seen in chain': 'First lock spend transaction included in a block',
'Lock tx B spend tx published': 'Transaction to claim coins from second lock has been broadcast',
'Lock tx B spend tx seen in chain': 'Second lock spend transaction included in a block',
'Failed to publish lock tx B': 'ERROR: Could not broadcast second lock transaction',
'Failed to publish lock tx B spend': 'ERROR: Could not broadcast spend transaction for second lock',
'Failed to publish lock tx B refund': 'ERROR: Could not broadcast refund transaction',
'Detected invalid lock Tx B': 'ERROR: Second lock transaction is invalid or malformed',
'Lock tx A pre-refund tx published': 'Pre-refund transaction broadcast. Swap is being cancelled',
'Lock tx A refund spend tx published': 'Refund transaction for first lock has been broadcast',
'Lock tx A refund swipe tx published': 'Other party claimed your refund (swiped)',
'Lock tx B refund tx published': 'Refund transaction for second lock has been broadcast',
'Lock tx A conflicting txn/s': 'WARNING: Conflicting transaction detected for first lock',
'Lock tx A pre-refund tx seen in chain': 'Pre-refund transaction detected in blockchain',
'Lock tx A refund spend tx seen in chain': 'Refund spend transaction detected in blockchain',
'Initiate tx published': 'Secret-hash swap: Initiate transaction broadcast',
'Initiate tx redeem tx published': 'Secret-hash swap: Initiate transaction claimed',
'Initiate tx refund tx published': 'Secret-hash swap: Initiate transaction refunded',
'Participate tx published': 'Secret-hash swap: Participate transaction broadcast',
'Participate tx redeem tx published': 'Secret-hash swap: Participate transaction claimed',
'Participate tx refund tx published': 'Secret-hash swap: Participate transaction refunded',
'BCH mercy tx found': 'BCH specific: Mercy transaction detected',
'Lock tx B mercy tx published': 'BCH specific: Mercy transaction broadcast',
'Auto accepting': 'Automation is accepting this bid',
'Failed auto accepting': 'Automation constraints prevented accepting this bid',
'Debug tweak applied': 'Debug mode: A test tweak was applied'
},
STATE_PHASES: {
1: { phase: 'negotiation', order: 1, label: 'Negotiation' }, // BID_SENT
2: { phase: 'negotiation', order: 2, label: 'Negotiation' }, // BID_RECEIVING
3: { phase: 'negotiation', order: 3, label: 'Negotiation' }, // BID_RECEIVED
4: { phase: 'negotiation', order: 4, label: 'Negotiation' }, // BID_RECEIVING_ACC
5: { phase: 'accepted', order: 5, label: 'Accepted' }, // BID_ACCEPTED
6: { phase: 'locking', order: 6, label: 'Locking' }, // SWAP_INITIATED
7: { phase: 'locking', order: 7, label: 'Locking' }, // SWAP_PARTICIPATING
8: { phase: 'complete', order: 100, label: 'Complete' }, // SWAP_COMPLETED
9: { phase: 'locking', order: 8, label: 'Locking' }, // XMR_SWAP_SCRIPT_COIN_LOCKED
10: { phase: 'locking', order: 9, label: 'Locking' }, // XMR_SWAP_HAVE_SCRIPT_COIN_SPEND_TX
11: { phase: 'locking', order: 10, label: 'Locking' }, // XMR_SWAP_NOSCRIPT_COIN_LOCKED
12: { phase: 'redemption', order: 11, label: 'Redemption' }, // XMR_SWAP_LOCK_RELEASED
13: { phase: 'redemption', order: 12, label: 'Redemption' }, // XMR_SWAP_SCRIPT_TX_REDEEMED
14: { phase: 'failed', order: 90, label: 'Failed' }, // XMR_SWAP_SCRIPT_TX_PREREFUND
15: { phase: 'redemption', order: 13, label: 'Redemption' }, // XMR_SWAP_NOSCRIPT_TX_REDEEMED
16: { phase: 'failed', order: 91, label: 'Recovered' }, // XMR_SWAP_NOSCRIPT_TX_RECOVERED
17: { phase: 'failed', order: 92, label: 'Failed' }, // XMR_SWAP_FAILED_REFUNDED
18: { phase: 'failed', order: 93, label: 'Failed' }, // XMR_SWAP_FAILED_SWIPED
19: { phase: 'failed', order: 94, label: 'Failed' }, // XMR_SWAP_FAILED
20: { phase: 'locking', order: 7.5, label: 'Locking' }, // SWAP_DELAYING
21: { phase: 'failed', order: 95, label: 'Failed' }, // SWAP_TIMEDOUT
22: { phase: 'failed', order: 96, label: 'Abandoned' }, // BID_ABANDONED
23: { phase: 'failed', order: 97, label: 'Error' }, // BID_ERROR
25: { phase: 'failed', order: 98, label: 'Rejected' }, // BID_REJECTED
27: { phase: 'accepted', order: 5.5, label: 'Accepted' }, // XMR_SWAP_MSG_SCRIPT_LOCK_TX_SIGS
28: { phase: 'accepted', order: 5.6, label: 'Accepted' }, // XMR_SWAP_MSG_SCRIPT_LOCK_SPEND_TX
29: { phase: 'negotiation', order: 0.5, label: 'Negotiation' }, // BID_REQUEST_SENT
30: { phase: 'negotiation', order: 0.6, label: 'Negotiation' }, // BID_REQUEST_ACCEPTED
31: { phase: 'failed', order: 99, label: 'Expired' }, // BID_EXPIRED
32: { phase: 'negotiation', order: 3.5, label: 'Negotiation' }, // BID_AACCEPT_DELAY
33: { phase: 'failed', order: 89, label: 'Failed' }, // BID_AACCEPT_FAIL
34: { phase: 'negotiation', order: 0.4, label: 'Negotiation' } // CONNECT_REQ_SENT
},
init: function(bidId, bidStateInd, createdAtTimestamp, stateTimeTimestamp, options) {
this.bidId = bidId;
this.bidStateInd = bidStateInd;
this.createdAtTimestamp = createdAtTimestamp;
this.stateTimeTimestamp = stateTimeTimestamp;
this.tooltipCounter = 0;
options = options || {};
this.swapType = options.swapType || 'secret-hash';
this.coinFrom = options.coinFrom || '';
this.coinTo = options.coinTo || '';
if (this.bidStateInd === this.DELAYING_STATE) {
this.previousStateInd = this.findPreviousState();
}
this.applyStateTooltips();
this.applyEventTooltips();
this.createProgressBar();
this.startElapsedTimeUpdater();
this.setupAutoRefresh();
},
findPreviousState: function() {
const sections = document.querySelectorAll('section');
let oldStatesSection = null;
sections.forEach(section => {
const h4 = section.querySelector('h4');
if (h4 && h4.textContent.includes('Old states')) {
oldStatesSection = section.nextElementSibling;
}
});
if (oldStatesSection) {
const table = oldStatesSection.querySelector('table');
if (table) {
const rows = table.querySelectorAll('tr');
for (let i = rows.length - 1; i >= 0; i--) {
const cells = rows[i].querySelectorAll('td');
if (cells.length >= 2) {
const stateText = cells[cells.length - 1].textContent.trim();
if (!stateText.includes('Delaying')) {
return this.stateTextToIndex(stateText);
}
}
}
}
}
return null;
},
stateTextToIndex: function(stateText) {
const stateMap = {
'Sent': 1, 'Receiving': 2, 'Received': 3, 'Receiving accept': 4,
'Accepted': 5, 'Initiated': 6, 'Participating': 7, 'Completed': 8,
'Script coin locked': 9, 'Script coin spend tx valid': 10,
'Scriptless coin locked': 11, 'Script coin lock released': 12,
'Script tx redeemed': 13, 'Script pre-refund tx in chain': 14,
'Scriptless tx redeemed': 15, 'Scriptless tx recovered': 16,
'Failed, refunded': 17, 'Failed, swiped': 18, 'Failed': 19,
'Delaying': 20, 'Timed-out': 21, 'Abandoned': 22, 'Error': 23,
'Rejected': 25, 'Exchanged script lock tx sigs msg': 27,
'Exchanged script lock spend tx msg': 28, 'Request sent': 29,
'Request accepted': 30, 'Expired': 31
};
for (const [key, value] of Object.entries(stateMap)) {
if (stateText.includes(key)) {
return value;
}
}
return null;
},
isActiveState: function() {
return !this.INACTIVE_STATES.includes(this.bidStateInd);
},
setupAutoRefresh: function() {
const refreshBtn = document.getElementById('refresh');
if (!refreshBtn) return;
if (!this.isActiveState()) {
refreshBtn.style.display = 'none';
return;
}
const originalSpan = refreshBtn.querySelector('span');
if (!originalSpan) return;
let countdown = this.AUTO_REFRESH_SECONDS;
let isRefreshing = false;
let isPersistentlyPaused = false;
const updateCountdown = () => {
if (this.refreshPaused || isPersistentlyPaused || isRefreshing) return;
originalSpan.textContent = `Auto-refresh in ${countdown}s`;
countdown--;
if (countdown < 0 && !isRefreshing) {
isRefreshing = true;
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
window.location.href = window.location.pathname + window.location.search;
}
};
updateCountdown();
this.autoRefreshInterval = setInterval(updateCountdown, 1000);
refreshBtn.addEventListener('click', (e) => {
e.preventDefault();
if (isPersistentlyPaused) {
window.location.href = window.location.pathname + window.location.search;
} else {
isPersistentlyPaused = true;
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
originalSpan.textContent = 'Paused (click to refresh)';
}
});
refreshBtn.addEventListener('mouseenter', () => {
if (!isPersistentlyPaused) {
this.refreshPaused = true;
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
originalSpan.textContent = 'Click to pause';
}
});
refreshBtn.addEventListener('mouseleave', () => {
if (!isPersistentlyPaused) {
this.refreshPaused = false;
countdown = this.AUTO_REFRESH_SECONDS;
if (!this.autoRefreshInterval) {
updateCountdown();
this.autoRefreshInterval = setInterval(updateCountdown, 1000);
}
}
});
},
createTooltip: function(element, tooltipText) {
if (window.TooltipManager && typeof window.TooltipManager.create === 'function') {
try {
const tooltipContent = `
<div class="py-1 px-2 text-sm text-white">
${tooltipText}
</div>
`;
window.TooltipManager.create(element, tooltipContent, {
placement: 'top'
});
element.classList.add('cursor-help');
} catch (e) {
element.setAttribute('title', tooltipText);
element.classList.add('cursor-help');
}
} else {
element.setAttribute('title', tooltipText);
element.classList.add('cursor-help');
}
},
applyStateTooltips: function() {
const sections = document.querySelectorAll('section');
let oldStatesSection = null;
sections.forEach(section => {
const h4 = section.querySelector('h4');
if (h4 && h4.textContent.includes('Old states')) {
oldStatesSection = section.nextElementSibling;
}
});
if (oldStatesSection) {
const table = oldStatesSection.querySelector('table');
if (table) {
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
const stateCell = cells[cells.length - 1];
const stateText = stateCell.textContent.trim();
const tooltip = this.getStateTooltip(stateText) || this.getStateTooltip('Bid ' + stateText);
if (tooltip) {
this.addHelpIcon(stateCell, tooltip);
}
}
});
}
}
const allRows = document.querySelectorAll('table tr');
allRows.forEach(row => {
const firstCell = row.querySelector('td');
if (firstCell) {
const labelText = firstCell.textContent.trim();
if (labelText === 'Bid State') {
const valueCell = row.querySelectorAll('td')[1];
if (valueCell) {
const stateText = valueCell.textContent.trim();
const tooltip = this.getStateTooltip(stateText) || this.getStateTooltip('Bid ' + stateText);
if (tooltip) {
this.addHelpIcon(valueCell, tooltip);
}
}
}
}
});
},
addHelpIcon: function(cell, tooltipText) {
if (cell.querySelector('.help-icon')) return;
const helpIcon = document.createElement('span');
helpIcon.className = 'help-icon cursor-help inline-flex items-center justify-center w-4 h-4 ml-2 text-xs font-medium text-white bg-blue-500 dark:bg-blue-600 rounded-full hover:bg-blue-600 dark:hover:bg-blue-500';
helpIcon.textContent = '?';
helpIcon.style.fontSize = '10px';
helpIcon.style.verticalAlign = 'middle';
helpIcon.style.flexShrink = '0';
cell.appendChild(helpIcon);
setTimeout(() => {
this.createTooltip(helpIcon, tooltipText);
}, 50);
},
applyEventTooltips: function() {
const sections = document.querySelectorAll('section');
let eventsSection = null;
sections.forEach(section => {
const h4 = section.querySelector('h4');
if (h4 && h4.textContent.includes('Events')) {
eventsSection = section.nextElementSibling;
}
});
if (eventsSection) {
const table = eventsSection.querySelector('table');
if (table) {
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
const eventCell = cells[cells.length - 1];
const eventText = eventCell.textContent.trim();
let tooltip = this.EVENT_TOOLTIPS[eventText];
if (!tooltip) {
for (const [key, value] of Object.entries(this.EVENT_TOOLTIPS)) {
if (eventText.startsWith(key.replace(':', ''))) {
tooltip = value;
break;
}
}
}
if (!tooltip && eventText.startsWith('Warning:')) {
tooltip = 'System warning - check message for details';
}
if (!tooltip && eventText.startsWith('Error:')) {
tooltip = 'Error occurred - check message for details';
}
if (!tooltip && eventText.startsWith('Temporary RPC error')) {
tooltip = 'Temporary error checking transaction. Will retry automatically';
}
if (tooltip) {
this.addHelpIcon(eventCell, tooltip);
}
}
});
}
}
},
createProgressBar: function() {
let stateForProgress = this.bidStateInd;
let isDelaying = false;
if (this.bidStateInd === this.DELAYING_STATE && this.previousStateInd) {
stateForProgress = this.previousStateInd;
isDelaying = true;
}
const phaseInfo = this.STATE_PHASES[stateForProgress];
if (!phaseInfo) return;
let progressPercent = 0;
const phase = phaseInfo.phase;
if (phase === 'negotiation') progressPercent = 15;
else if (phase === 'accepted') progressPercent = 30;
else if (phase === 'locking') progressPercent = 55;
else if (phase === 'redemption') progressPercent = 80;
else if (phase === 'complete') progressPercent = 100;
else if (phase === 'failed' || phase === 'error') progressPercent = 100;
const bidStateRow = document.querySelector('td.bold');
if (!bidStateRow) return;
let targetRow = null;
const rows = document.querySelectorAll('table tr');
rows.forEach(row => {
const firstTd = row.querySelector('td.bold');
if (firstTd && firstTd.textContent.trim() === 'Bid State') {
targetRow = row;
}
});
if (!targetRow) return;
const progressRow = document.createElement('tr');
progressRow.className = 'opacity-100 text-gray-500 dark:text-gray-100';
const isError = ['failed', 'error'].includes(phase);
const isComplete = phase === 'complete';
const barColor = isError ? 'bg-red-500' : (isComplete ? 'bg-green-500' : 'bg-blue-500');
let phaseLabel;
if (isError) {
phaseLabel = phaseInfo.label;
} else if (isComplete) {
phaseLabel = 'Complete';
} else if (isDelaying) {
phaseLabel = `${phaseInfo.label} (${progressPercent}%) - Delaying`;
} else {
phaseLabel = `${phaseInfo.label} (${progressPercent}%)`;
}
progressRow.innerHTML = `
<td class="py-3 px-6 bold">Swap Progress</td>
<td class="py-3 px-6">
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 dark:bg-gray-600 rounded-full h-2.5 max-w-xs">
<div class="${barColor} h-2.5 rounded-full transition-all duration-500" style="width: ${progressPercent}%"></div>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">${phaseLabel}</span>
</div>
</td>
`;
targetRow.parentNode.insertBefore(progressRow, targetRow.nextSibling);
},
startElapsedTimeUpdater: function() {
if (!this.createdAtTimestamp) return;
let createdAtRow = null;
const rows = document.querySelectorAll('table tr');
rows.forEach(row => {
const firstTd = row.querySelector('td');
if (firstTd && firstTd.textContent.includes('Created At')) {
createdAtRow = row;
}
});
if (!createdAtRow) return;
const isCompleted = !this.isActiveState() && this.stateTimeTimestamp;
const elapsedRow = document.createElement('tr');
elapsedRow.className = 'opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600';
const labelText = isCompleted ? 'Swap Duration' : 'Time Elapsed';
const iconColor = isCompleted ? '#10B981' : '#3B82F6';
elapsedRow.innerHTML = `
<td class="flex items-center px-46 whitespace-nowrap">
<svg alt="" class="w-5 h-5 rounded-full ml-5" xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 24 24">
<g stroke-linecap="round" stroke-width="2" fill="none" stroke="${iconColor}" stroke-linejoin="round">
<circle cx="12" cy="12" r="11"></circle>
<polyline points="12,6 12,12 18,12" stroke="${iconColor}"></polyline>
</g>
</svg>
<div class="py-3 pl-2 bold">
<div>${labelText}</div>
</div>
</td>
<td class="py-3 px-6" id="elapsed-time-display">Calculating...</td>
`;
createdAtRow.parentNode.insertBefore(elapsedRow, createdAtRow.nextSibling);
const elapsedDisplay = document.getElementById('elapsed-time-display');
if (isCompleted) {
const duration = this.stateTimeTimestamp - this.createdAtTimestamp;
elapsedDisplay.textContent = this.formatDuration(duration);
} else {
const updateElapsed = () => {
const now = Math.floor(Date.now() / 1000);
const elapsed = now - this.createdAtTimestamp;
elapsedDisplay.textContent = this.formatDuration(elapsed);
};
updateElapsed();
this.elapsedTimeInterval = setInterval(updateElapsed, 1000);
}
},
formatDuration: function(seconds) {
if (seconds < 60) {
return `${seconds} second${seconds !== 1 ? 's' : ''}`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
const remainingSeconds = seconds % 60;
if (remainingSeconds > 0) {
return `${minutes} min ${remainingSeconds} sec`;
}
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours < 24) {
if (remainingMinutes > 0) {
return `${hours} hr ${remainingMinutes} min`;
}
return `${hours} hour${hours !== 1 ? 's' : ''}`;
}
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (remainingHours > 0) {
return `${days} day${days !== 1 ? 's' : ''} ${remainingHours} hr`;
}
return `${days} day${days !== 1 ? 's' : ''}`;
}
};