Last active
October 3, 2024 14:36
TimeJ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name TimeJ | |
// @namespace TimeJ | |
// @version 1.0 | |
// @description TimeJ | |
// @author Jim | |
// @match *://*/* | |
// @grant GM.setValue | |
// @grant GM.getValue | |
// @grant GM.deleteValue | |
// @grant GM.listValues | |
// @grant GM.setClipboard | |
// ==/UserScript== | |
(function () { | |
'use strict'; | |
if (window.self !== window.top) return; | |
let recordStartTimeMs = null; | |
const tabID = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(); | |
function startRecord() { | |
if (recordStartTimeMs === null) recordStartTimeMs = Date.now(); | |
} | |
async function stopRecord() { | |
if (recordStartTimeMs === null) return; | |
const recordStopTimeMs = Date.now(); | |
const recordStopTimeMin = Math.floor(recordStopTimeMs / 60000); // in minutes | |
// Extract domain name | |
const domain = window.location.host.split('.').slice(-2).join('.'); | |
const tabStoreKey = `${recordStopTimeMin}-${domain}-${tabID}`; | |
let records = JSON.parse(await GM.getValue(tabStoreKey, '[]')); | |
records.push([recordStartTimeMs, recordStopTimeMs]); | |
recordStartTimeMs = null; | |
await GM.setValue(tabStoreKey, JSON.stringify(records)); | |
} | |
if (document.visibilityState === 'visible') | |
startRecord(); | |
// Trigger recording on page visibility change | |
document.addEventListener('visibilitychange', () => { | |
document.visibilityState === 'visible' ? startRecord() : stopRecord(); | |
}); | |
// Stop recording before the page unloads | |
window.addEventListener('beforeunload', stopRecord); | |
// Epoch function for periodic tasks | |
async function epoch() { | |
if (!document.hidden) { | |
await stopRecord(); | |
await startRecord(); | |
} | |
const currTimeMin = Math.floor(Date.now() / 60000); | |
const r = currTimeMin % 4; | |
if (r === 0 || r === 2) { | |
return; | |
} | |
if (r === 1) { | |
await GM.setValue('leaderID', tabID); | |
return; | |
} | |
const leaderID = await GM.getValue('leaderID'); | |
if (leaderID !== tabID) return; | |
const keys = await GM.listValues(); | |
for (const k of keys) { | |
if (k !== 'leaderID') { | |
const recordStopTimeMin = parseInt(k.split('-')[0]); | |
if (currTimeMin - recordStopTimeMin > 4320) { // 3 days in minutes | |
await GM.deleteValue(k); | |
} | |
} | |
} | |
} | |
// Set epoch to run every minute | |
setInterval(epoch, 60000); | |
// Merge overlapping time ranges | |
function mergeTimeRanges(timeRanges) { | |
// Sort time ranges by start time | |
timeRanges.sort((a, b) => a[0] - b[0]); | |
const mergedRanges = [timeRanges[0]]; | |
for (let i = 1; i < timeRanges.length; i++) { | |
const lastMerged = mergedRanges[mergedRanges.length - 1]; | |
const current = timeRanges[i]; | |
// If current range overlaps or touches the last merged range, merge them | |
if (current[0] <= lastMerged[1]) { | |
lastMerged[1] = Math.max(lastMerged[1], current[1]); | |
} else { | |
mergedRanges.push(current); | |
} | |
} | |
return mergedRanges; | |
} | |
// Convert milliseconds to a more human-readable format | |
function formatDuration(ms) { | |
if (ms < 60000) { | |
const seconds = ms / 1000; | |
return `${seconds.toFixed(2)} seconds`; | |
} else { | |
const seconds = Math.floor(ms / 1000); | |
const minutes = Math.floor(seconds / 60); | |
const remainingSeconds = seconds % 60; | |
return `${minutes} minutes ${remainingSeconds} seconds`; | |
} | |
} | |
// Calculate time spent per hour | |
function calculateTimeSpentPerHour(data) { | |
const timeSpentPerHour = {}; | |
const domainTimeRanges = {}; | |
// Aggregate time ranges per domain | |
for (const shards of Object.values(data)) { | |
for (const [domain, timeRanges] of shards) { | |
domainTimeRanges[domain] = (domainTimeRanges[domain] || []).concat(timeRanges); | |
} | |
} | |
// Merge and calculate time per hour | |
for (const domain in domainTimeRanges) { | |
const mergedRanges = mergeTimeRanges(domainTimeRanges[domain]); | |
for (const [startTime, endTime] of mergedRanges) { | |
let start = new Date(startTime); | |
let end = new Date(endTime); | |
while (start < end) { | |
let hourStart = new Date(start); | |
hourStart.setMinutes(0, 0, 0); // Start of hour | |
let hourEnd = new Date(hourStart); | |
hourEnd.setHours(hourEnd.getHours() + 1); // End of hour | |
const actualEnd = (end < hourEnd) ? end : hourEnd; | |
const timeSpent = actualEnd - start; | |
const hourKey = hourStart.toLocaleString([], { | |
year: 'numeric', | |
month: '2-digit', | |
day: '2-digit', | |
hour: '2-digit', | |
hour12: false | |
}); | |
timeSpentPerHour[hourKey] = timeSpentPerHour[hourKey] || {}; | |
timeSpentPerHour[hourKey][domain] = (timeSpentPerHour[hourKey][domain] || 0) + timeSpent; | |
start = hourEnd; | |
} | |
} | |
} | |
// Format time durations, sort domains by time spent | |
for (const hour in timeSpentPerHour) { | |
const sortedDomains = Object.entries(timeSpentPerHour[hour]) | |
.sort(([, timeA], [, timeB]) => timeB - timeA) | |
.map(([domain, time]) => [domain, formatDuration(time)]); | |
// Convert sorted domains back to object | |
timeSpentPerHour[hour] = Object.fromEntries(sortedDomains); | |
} | |
return Object.fromEntries(Object.entries(timeSpentPerHour).sort()); | |
} | |
// Print stats on key combination (Ctrl + 1) | |
document.addEventListener('keydown', (event) => { | |
if (event.ctrlKey && event.key === '1') { | |
(async function () { | |
const keys = await GM.listValues(); | |
let rawData = {}; | |
for (const k of keys) { | |
if (k !== 'leaderID') { | |
const parts = k.split('-'); | |
const recordStopTimeMinStr = parts[0]; | |
const domain = parts.slice(1, -1).join('-'); | |
const records = JSON.parse(await GM.getValue(k)); | |
rawData[recordStopTimeMinStr] = rawData[recordStopTimeMinStr] || []; | |
rawData[recordStopTimeMinStr].push([domain, records]); | |
} | |
} | |
const res = JSON.stringify(calculateTimeSpentPerHour(rawData), null, 2); | |
await GM.setClipboard(res, 'text'); | |
})(); | |
} | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment