This userscript enhances the Swap.work platform by adding a time tracking panel that allows freelancers to easily record hours, manage time entries, and export costs directly to the project. Built using MUI styles to seamlessly integrate with the existing UI, this script leverages GraphQL API calls for efficient data handling. Time entries are saved within the project's comment field and can be exported as individual cost entries, making it easier to track and bill for time spent on tasks.
Last active
September 20, 2024 16:41
-
-
Save Yukaii/e0c40a3de4e6bb3d08c3fb4598f420f9 to your computer and use it in GitHub Desktop.
Enhance Swap.work with a time tracking panel to record hours, manage entries, and export costs, using MUI styles and GraphQL API calls.
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 Swap.work Time Tracking | |
// @namespace http://tampermonkey.net/ | |
// @version 0.7 | |
// @description Enhance Swap.work with a time tracking panel to record hours, manage entries, and export costs, using MUI styles and GraphQL API calls. | |
// @match https://swap.work/* | |
// @updateURL https://gist.github.com/Yukaii/e0c40a3de4e6bb3d08c3fb4598f420f9/raw/swap-panel.user.js | |
// @grant GM_addStyle | |
// @grant GM_getValue | |
// @grant GM_setValue | |
// @grant GM_xmlhttpRequest | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// Add necessary styles | |
GM_addStyle(` | |
#time-tracking-panel { | |
position: fixed; | |
right: -320px; | |
top: 45px; | |
width: 320px; | |
height: calc(100% - 45px); | |
background-color: #f5f5f5; | |
box-shadow: -2px 0 5px rgba(0,0,0,0.1); | |
transition: right 0.3s; | |
z-index: 1000; | |
padding: 20px; | |
box-sizing: border-box; | |
overflow-y: auto; | |
} | |
#time-tracking-panel.open { | |
right: 0; | |
} | |
#time-tracking-toggle { | |
position: fixed; | |
right: 0; | |
top: 45px; | |
width: 10px; | |
height: calc(100% - 45px); | |
background-color: rgba(0, 0, 0, 0.1); | |
z-index: 1001; | |
cursor: pointer; | |
transition: background-color 0.3s; | |
} | |
#time-tracking-toggle:hover { | |
background-color: rgba(0, 0, 0, 0.2); | |
} | |
.time-entry { | |
margin-bottom: 16px; | |
} | |
`); | |
// Helper function to get JWT token from localStorage | |
function getJWTToken() { | |
return localStorage.getItem('account'); | |
} | |
// Helper function to make GraphQL requests | |
function graphqlRequest(query, variables = {}) { | |
return new Promise((resolve, reject) => { | |
GM_xmlhttpRequest({ | |
method: 'POST', | |
url: 'https://api.swap.work', | |
headers: { | |
'Content-Type': 'application/json', | |
'X-Authorization': getJWTToken() | |
}, | |
data: JSON.stringify({ query, variables }), | |
onload: function(response) { | |
if (response.status >= 200 && response.status < 300) { | |
resolve(JSON.parse(response.responseText)); | |
} else { | |
reject(new Error(`HTTP ${response.status}: ${response.statusText}`)); | |
} | |
}, | |
onerror: function(error) { | |
reject(error); | |
} | |
}); | |
}); | |
} | |
// Time tracking data structure | |
let timeTrackingData = GM_getValue('timeTrackingData', {}); | |
let costPerHour = GM_getValue('costPerHour', 0); | |
// Create UI elements | |
const panel = document.createElement('div'); | |
panel.id = 'time-tracking-panel'; | |
panel.innerHTML = ` | |
<h2 class="MuiTypography-root jss122 MuiTypography-h6">Time Tracking</h2> | |
<div class="MuiFormControl-root MuiTextField-root jss520 MuiFormControl-fullWidth" style="margin-bottom: 16px;"> | |
<label class="MuiFormLabel-root MuiInputLabel-root jss525 MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink jss526 MuiInputLabel-outlined" data-shrink="true">Cost per hour</label> | |
<div class="MuiInputBase-root MuiOutlinedInput-root jss521 MuiInputBase-fullWidth MuiInputBase-formControl"> | |
<input id="cost-per-hour" type="number" class="MuiInputBase-input MuiOutlinedInput-input jss522" value="${costPerHour}"> | |
<fieldset aria-hidden="true" class="jss190 MuiOutlinedInput-notchedOutline"> | |
<legend class="jss192 jss193"><span>Cost per hour</span></legend> | |
</fieldset> | |
</div> | |
</div> | |
<div id="time-entries"></div> | |
<button id="add-time-entry" class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary" style="margin-right: 8px;">Add Time Entry</button> | |
<button id="export-to-costs" class="MuiButtonBase-root MuiButton-root MuiButton-contained">Export to Costs</button> | |
`; | |
const toggleButton = document.createElement('div'); | |
toggleButton.id = 'time-tracking-toggle'; | |
// Add elements to the page | |
document.body.appendChild(panel); | |
document.body.appendChild(toggleButton); | |
// Toggle panel visibility | |
toggleButton.addEventListener('click', () => { | |
panel.classList.toggle('open'); | |
}); | |
// Update cost per hour | |
document.getElementById('cost-per-hour').addEventListener('change', (e) => { | |
costPerHour = parseFloat(e.target.value); | |
GM_setValue('costPerHour', costPerHour); | |
}); | |
// Add time entry | |
document.getElementById('add-time-entry').addEventListener('click', () => { | |
const projectId = getCurrentProjectId(); | |
if (!projectId) { | |
alert('Please open a project first.'); | |
return; | |
} | |
if (!timeTrackingData[projectId]) { | |
timeTrackingData[projectId] = []; | |
} | |
const newEntry = { | |
id: Date.now(), | |
date: new Date().toISOString().split('T')[0], | |
duration: 0, | |
description: '' | |
}; | |
timeTrackingData[projectId].push(newEntry); | |
saveTimeTrackingData(projectId); | |
renderTimeEntries(projectId); | |
}); | |
// Render time entries for the current project | |
function renderTimeEntries(projectId) { | |
const entriesContainer = document.getElementById('time-entries'); | |
entriesContainer.innerHTML = ''; | |
const entries = timeTrackingData[projectId] || []; | |
entries.forEach(entry => { | |
const entryElement = document.createElement('div'); | |
entryElement.className = 'time-entry'; | |
entryElement.innerHTML = ` | |
<div class="MuiFormControl-root MuiTextField-root jss520 MuiFormControl-fullWidth" style="margin-bottom: 8px;"> | |
<label class="MuiFormLabel-root MuiInputLabel-root jss525 MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink jss526 MuiInputLabel-outlined" data-shrink="true">Date</label> | |
<div class="MuiInputBase-root MuiOutlinedInput-root jss521 MuiInputBase-fullWidth MuiInputBase-formControl"> | |
<input type="date" class="MuiInputBase-input MuiOutlinedInput-input jss522" value="${entry.date}"> | |
<fieldset aria-hidden="true" class="jss190 MuiOutlinedInput-notchedOutline"> | |
<legend class="jss192 jss193"><span>Date</span></legend> | |
</fieldset> | |
</div> | |
</div> | |
<div class="MuiFormControl-root MuiTextField-root jss520 MuiFormControl-fullWidth" style="margin-bottom: 8px;"> | |
<label class="MuiFormLabel-root MuiInputLabel-root jss525 MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink jss526 MuiInputLabel-outlined" data-shrink="true">Duration (hours)</label> | |
<div class="MuiInputBase-root MuiOutlinedInput-root jss521 MuiInputBase-fullWidth MuiInputBase-formControl"> | |
<input type="number" step="0.5" min="0" class="MuiInputBase-input MuiOutlinedInput-input jss522" value="${entry.duration}"> | |
<fieldset aria-hidden="true" class="jss190 MuiOutlinedInput-notchedOutline"> | |
<legend class="jss192 jss193"><span>Duration (hours)</span></legend> | |
</fieldset> | |
</div> | |
</div> | |
<div class="MuiFormControl-root MuiTextField-root jss520 MuiFormControl-fullWidth" style="margin-bottom: 8px;"> | |
<label class="MuiFormLabel-root MuiInputLabel-root jss525 MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink jss526 MuiInputLabel-outlined" data-shrink="true">Description</label> | |
<div class="MuiInputBase-root MuiOutlinedInput-root jss521 MuiInputBase-fullWidth MuiInputBase-formControl"> | |
<input type="text" class="MuiInputBase-input MuiOutlinedInput-input jss522" value="${entry.description}"> | |
<fieldset aria-hidden="true" class="jss190 MuiOutlinedInput-notchedOutline"> | |
<legend class="jss192 jss193"><span>Description</span></legend> | |
</fieldset> | |
</div> | |
</div> | |
<button class="delete-entry MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textSecondary">Delete</button> | |
`; | |
entryElement.querySelector('input[type="date"]').addEventListener('change', (e) => { | |
entry.date = e.target.value; | |
saveTimeTrackingData(projectId); | |
}); | |
entryElement.querySelector('input[type="number"]').addEventListener('change', (e) => { | |
entry.duration = parseFloat(e.target.value); | |
saveTimeTrackingData(projectId); | |
}); | |
entryElement.querySelector('input[type="text"]').addEventListener('change', (e) => { | |
entry.description = e.target.value.replace(/\n/g, ' '); // Remove line breaks | |
saveTimeTrackingData(projectId); | |
}); | |
entryElement.querySelector('.delete-entry').addEventListener('click', () => { | |
timeTrackingData[projectId] = entries.filter(e => e.id !== entry.id); | |
saveTimeTrackingData(projectId); | |
renderTimeEntries(projectId); | |
}); | |
entriesContainer.appendChild(entryElement); | |
}); | |
} | |
// Export time entries to costs | |
document.getElementById('export-to-costs').addEventListener('click', async () => { | |
const projectId = getCurrentProjectId(); | |
if (!projectId) { | |
alert('Please open a project first.'); | |
return; | |
} | |
const entries = timeTrackingData[projectId] || []; | |
try { | |
for (const entry of entries) { | |
const totalCost = entry.duration * costPerHour; | |
const content = `[TT] (${entry.duration}h) - ${entry.description}`; | |
const result = await graphqlRequest(` | |
mutation { | |
createProjectCostFromSOHO(inputData: { | |
project_id: "${projectId}" | |
content: "${content}" | |
value: ${totalCost} | |
}) { | |
message | |
errors | |
} | |
} | |
`); | |
if (result.data && result.data.createProjectCostFromSOHO.errors) { | |
throw new Error(result.data.createProjectCostFromSOHO.errors[0]); | |
} | |
} | |
alert('Time tracking data exported to costs successfully.'); | |
} catch (error) { | |
console.error('Error exporting time tracking data:', error); | |
alert('Failed to export time tracking data. Please try again.'); | |
} | |
}); | |
// Helper function to get the current project ID from the URL | |
function getCurrentProjectId() { | |
const match = window.location.pathname.match(/\/member\/projects\/([^\/]+)/); | |
return match ? match[1] : null; | |
} | |
// Save time tracking data to project comment | |
async function saveTimeTrackingData(projectId) { | |
const data = JSON.stringify(timeTrackingData[projectId]); | |
try { | |
await graphqlRequest(` | |
mutation { | |
updateProjectFromSOHO(inputData: { | |
project_id: "${projectId}" | |
comment: "${data}" | |
}) { | |
status | |
message | |
errors | |
} | |
} | |
`); | |
GM_setValue('timeTrackingData', timeTrackingData); | |
} catch (error) { | |
console.error('Error saving time tracking data:', error); | |
} | |
} | |
// Load time tracking data from project comment | |
async function loadTimeTrackingData(projectId) { | |
try { | |
const result = await graphqlRequest(` | |
query { | |
getProjectsFromSOHO(filter: { id: "${projectId}" }) { | |
payload { | |
id | |
comment | |
} | |
} | |
} | |
`); | |
const project = result.data.getProjectsFromSOHO.payload[0]; | |
if (project && project.comment) { | |
timeTrackingData[projectId] = JSON.parse(project.comment); | |
GM_setValue('timeTrackingData', timeTrackingData); | |
} | |
} catch (error) { | |
console.error('Error loading time tracking data:', error); | |
} | |
} | |
// Update time tracking panel when navigating to a project page | |
let currentProjectId = null; | |
async function checkForProjectChange() { | |
const projectId = getCurrentProjectId(); | |
if (projectId && projectId !== currentProjectId) { | |
currentProjectId = projectId; | |
await loadTimeTrackingData(projectId); | |
renderTimeEntries(projectId); | |
} | |
} | |
// Check for project changes every second | |
setInterval(checkForProjectChange, 1000); | |
// Initial check | |
checkForProjectChange(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Prompt for Claude 3.5 & gpt-4o
https://hackmd.io/@yukai/swap-panel-prompt