Skip to content

Instantly share code, notes, and snippets.

@borisschapira
Last active April 21, 2026 11:06
Show Gist options
  • Select an option

  • Save borisschapira/a3e5726429dd4fabe33d67143a658561 to your computer and use it in GitHub Desktop.

Select an option

Save borisschapira/a3e5726429dd4fabe33d67143a658561 to your computer and use it in GitHub Desktop.
Go to /timesheets/. Use the arrow to navigate to a past week. Open the DevTools console, paste this script, and press "enter". The script collects billable and non-billable values, advances to the next week, and repeats until it reaches the current week. Expect an execution time of 5 seconds per week.
// ─────────────────────────────────────────────────────────────────
// Timesheet exporter – with billable/non-billable split support
// ─────────────────────────────────────────────────────────────────
// Checks whether the billable/non-billable split toggle is present and
// clickable, activates it, then waits for the DOM to re-render.
async function enableBillableSplit() {
const splitButton = document.querySelector(
'[data-cy="timesheets.buttons.split-billable-button"]:has([class*="secondary-bar__BillableNonSplitIcon"])'
);
if (!splitButton) {
console.log('Split-billable button not found – skipping (single-mode export).');
return;
}
if (splitButton.disabled) {
console.log('Split-billable button is disabled – skipping.');
return;
}
console.log('Clicking "Split billable" button...');
splitButton.click();
await new Promise(resolve => setTimeout(resolve, 1500));
console.log('Billable/non-billable split enabled.');
}
// ─────────────────────────────────────────────────────────────────
// Iterates through all available weeks starting from the currently displayed
// one, collects each week's entries, and returns the full dataset.
// Stops when the "this week" button disappears (last week reached)
// or when any navigation step fails.
async function exportAllTimesheetData() {
const allData = [];
let weekCount = 0;
console.log('Starting timesheet data export...');
while (true) {
weekCount++;
console.log(`Processing week ${weekCount}...`);
const weekData = exportCurrentWeekData();
allData.push(...weekData);
console.log(`Found ${weekData.length} entries for this week.`);
// ── Stop condition ──────────────────────────────────────────
// The absence of the "this week" button marks the end of the export
const thisWeekButton = document.querySelector(
'[data-cy="timesheets.buttons.this_week_button"]'
);
if (thisWeekButton == null) {
console.log('"This week" button absent – this is the last week. Stopping.');
break;
}
// ── Navigate to next week ───────────────────────────────────
const nextWeekButton = document.querySelector(
'[class*=week-range-picker__Wrapper] button:nth-of-type(2)'
);
if (!nextWeekButton) {
console.log('Next week button not found. Stopping.');
break;
}
if (nextWeekButton.disabled) {
console.log('Next week button is disabled. Stopping.');
break;
}
nextWeekButton.click();
console.log('Clicked next week button, waiting 2 seconds...');
await new Promise(resolve => setTimeout(resolve, 2000));
// Sanity-check: date range still visible
const calendarButton = document.querySelector(
'[data-cy-button="week-range-picker-trigger"]'
);
if (!calendarButton || !calendarButton.textContent.trim()) {
console.log('Date range not found after navigation. Stopping.');
break;
}
}
console.log(
`Export complete! Processed ${weekCount} week(s) with ${allData.length} total entries.`
);
return allData;
}
// ─────────────────────────────────────────────────────────────────
// Reads the timesheet table for the currently displayed week and returns
// one entry per (row × day): each entry contains the date, project, task,
// billable and non-billable hours, and week metadata.
// Day cells with no logged hours are silently skipped.
//
// ► RULE: any row whose title starts with "Non-project activity" is treated
// as entirely non-billable, regardless of how the split toggle classified it.
function exportCurrentWeekData() {
const exportedData = [];
const { dateRange, isoDate } = getDateInfo();
if (!dateRange || !isoDate) {
console.warn('Could not read date info for the current week. Skipping.');
return [];
}
const workingDays = getWorkingDaysCount();
const weekCapacity = getWeekCapacity(workingDays);
const rows = document.querySelectorAll(
'[data-cy-table="my-timesheet"] .ant-table-tbody tr.ant-table-row:not([aria-hidden])'
);
rows.forEach(row => {
// ── Title: project + task ───────────────────────────────────
// Each row has two [data-cy="timesheet.primary.name"] elements:
// [0] → Project name (column "Projects")
// [1] → Task name (column "Work")
const nameCells = row.querySelectorAll('[data-cy="timesheet.primary.name"]');
const projectName = nameCells[0] ? nameCells[0].textContent.trim() : '';
const taskName = nameCells[1] ? nameCells[1].textContent.trim() : '';
const title = taskName ? `${projectName} - ${taskName}` : projectName;
// ── One entry per day cell ──────────────────────────────────
// The data-time-cell attribute already holds the ISO date ("YYYY-MM-DD")
// for that column, so no further date parsing is needed here.
row.querySelectorAll('[data-time-cell]').forEach(cellWrapper => {
const date = cellWrapper.getAttribute('data-time-cell');
const { billable, nonBillable } = parseTimeCell(cellWrapper);
let billableHours = billable;
let nonBillableHours = nonBillable;
// ── Non-project activity override ─────────────────────────
// Any entry whose title starts with "Non-project activity" must be
// recorded as entirely non-billable, even if the split toggle assigned
// some hours to the billable bucket.
if (title.startsWith('Non-project activity')) {
nonBillableHours += billableHours;
billableHours = 0;
}
// Skip day cells with no logged hours
if (billableHours > 0 || nonBillableHours > 0) {
exportedData.push({
dateRange,
weekStartDate: isoDate,
date,
title,
billableHours,
nonBillableHours,
workingDays,
weekCapacity
});
}
});
});
return exportedData;
}
// ─────────────────────────────────────────────────────────────────
/**
* Extracts billable and non-billable hours from a single day cell wrapper
* ([data-time-cell] element).
*
* Split mode (after clicking the toggle):
* Active input (not .ant-input-disabled) → billable hours
* Disabled input (.ant-input-disabled) → non-billable hours
*
* Single mode (toggle not available / not clicked):
* Only one active input → billable, non-billable = 0.
*/
function parseTimeCell(cellWrapper) {
const billableInput = cellWrapper.querySelector(
'[class*="time-entry-cell"] input:not(.ant-input-disabled)'
);
const nonBillableInput = cellWrapper.querySelector(
'[class*="time-entry-cell"] input.ant-input-disabled'
);
return {
billable: billableInput ? convertTimeToDecimal(billableInput.value) : 0,
nonBillable: nonBillableInput ? convertTimeToDecimal(nonBillableInput.value) : 0
};
}
// ─────────────────────────────────────────────────────────────────
// Reads the week range picker in the UI to extract both the human-readable
// date range string and the week start date in ISO 8601 format.
function getDateInfo() {
const calendarButton = document.querySelector(
'[data-cy-button="week-range-picker-trigger"]'
);
if (!calendarButton) return { dateRange: '', isoDate: '' };
let dateRange = '';
calendarButton.querySelectorAll('span').forEach(span => {
const text = span.textContent.trim();
if (/\d{1,2}\s+[A-Za-z]{3}\s+\d{2}\s+-/.test(text)) {
dateRange = text;
}
});
if (!dateRange) dateRange = calendarButton.textContent.trim();
return { dateRange, isoDate: parseDateRangeToISO(dateRange) };
}
// ─────────────────────────────────────────────────────────────────
// Counts non-weekend column headers in the timesheet table to determine
// the number of working days in the currently displayed week.
function getWorkingDaysCount() {
// Header: [0] Projects | [1] Work | [2..8] Mon–Sun | [9] Total | [10] actions
// Weekend columns carry the class "weekend-cell".
const ths = document.querySelectorAll(
'[data-cy-table="my-timesheet"] thead th'
);
let workingDays = 0;
for (let i = 2; i < ths.length - 2; i++) {
if (ths[i] && !ths[i].classList.contains('weekend-cell')) workingDays++;
}
return workingDays;
}
// Computes the theoretical weekly capacity in hours, assuming 7 h per working
// day. Adjust the multiplier to match your contract if needed.
function getWeekCapacity(workingDays) {
return workingDays * 7;
}
// ─────────────────────────────────────────────────────────────────
// Parses a human-readable date range string into an ISO 8601 date (YYYY-MM-DD)
// representing the first day of the week. Supports two formats:
// - New : "13 Apr 26 - 19 Apr 26"
// - Old : "Feb 02 - Feb 08, 2025"
function parseDateRangeToISO(dateRange) {
if (!dateRange) return '';
const monthMap = {
Jan:'01', Feb:'02', Mar:'03', Apr:'04', May:'05', Jun:'06',
Jul:'07', Aug:'08', Sep:'09', Oct:'10', Nov:'11', Dec:'12'
};
try {
const newMatch = dateRange.match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{2})\s+-/);
if (newMatch) {
const [, day, month, year2] = newMatch;
const monthNum = monthMap[month];
if (!monthNum) return '';
return `${'20' + year2}-${monthNum}-${day.padStart(2, '0')}`;
}
const oldMatch = dateRange.match(/^([A-Za-z]{3})\s+(\d{1,2})\s+-.*?,\s+(\d{4})$/);
if (oldMatch) {
const [, month, day, year] = oldMatch;
const monthNum = monthMap[month];
if (!monthNum) return '';
return `${year}-${monthNum}-${day.padStart(2, '0')}`;
}
return '';
} catch (error) {
console.error('Error parsing date range:', error);
return '';
}
}
// ─────────────────────────────────────────────────────────────────
// Converts a time string such as "2h 30m" or "45m" into a decimal number
// of hours (e.g. 2.5). Returns 0 for any empty, null, or zero-value input.
function convertTimeToDecimal(timeString) {
if (
!timeString ||
timeString === '—' ||
timeString === '--' ||
timeString === '0h 0m' ||
timeString === '0m'
) {
return 0;
}
const hoursMatch = timeString.match(/(\d+)h/);
const minutesMatch = timeString.match(/(\d+)m/);
const hours = hoursMatch ? parseInt(hoursMatch[1], 10) : 0;
const minutes = minutesMatch ? parseInt(minutesMatch[1], 10) : 0;
return hours + minutes / 60;
}
// ─────────────────────────────────────────────────────────────────
// Serialises the entry list into a TSV string (tab-separated values) with a
// header row. Tabs within text fields are replaced with spaces to preserve
// the format integrity.
function exportAsTSV(data) {
const headers = [
'Date Range', 'Week Start Date', 'Date', 'Title',
'Billable Hours', 'Non-Billable Hours', 'Working Days', 'Week Capacity'
];
const tsvRows = [headers.join('\t')];
data.forEach(row => {
tsvRows.push([
row.dateRange.replace(/\t/g, ' '),
row.weekStartDate,
row.date,
row.title.replace(/\t/g, ' '),
row.billableHours,
row.nonBillableHours,
row.workingDays,
row.weekCapacity
].join('\t'));
});
return tsvRows.join('\n');
}
// Triggers a file download for an already-serialised TSV string by creating
// a temporary anchor element in the DOM, simulating a click, then cleaning
// up the created resources.
function downloadTSV(tsvContent, filename = 'timesheet_export_all_weeks.tsv') {
const blob = new Blob([tsvContent], { type: 'text/tab-separated-values' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
// ─────────────────────────────────────────────────────────────────
// Main entry point. Enables the split mode if available, collects all weeks,
// serialises the data to TSV, and triggers the file download.
async function runFullExport() {
try {
console.log('Starting full timesheet export...');
await enableBillableSplit();
const allData = await exportAllTimesheetData();
const tsvContent = exportAsTSV(allData);
console.log(`Export completed successfully! Total entries: ${allData.length}`);
downloadTSV(tsvContent);
return allData;
} catch (error) {
console.error('Error during export:', error);
return null;
}
}
// Navigate to the EARLIEST week you want to export, then run:
runFullExport();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment