Last active
April 21, 2026 11:06
-
-
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.
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
| // ───────────────────────────────────────────────────────────────── | |
| // 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