From c02d5ef2aee671437f649dece631ac56f533bd83 Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Sat, 20 Jun 2026 00:30:24 +0000 Subject: [PATCH] =?UTF-8?q?TickSimple=20v1.0.0=20=E2=80=94=20Dead-simple?= =?UTF-8?q?=20time=20tracker=20for=20freelancers.=20One-tap=20timer,=20PDF?= =?UTF-8?q?=20reports,=20invoice=20generation,=20Chrome=20extension=20MV3.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + dist-test/src/background.js | 127 +++ dist-test/src/invoice/invoice.js | 103 ++ dist-test/src/options/options.js | 73 ++ dist-test/src/pdf.js | 119 +++ dist-test/src/popup/popup.js | 144 +++ dist-test/src/report/report.js | 76 ++ dist-test/src/storage.js | 87 ++ dist-test/src/types.js | 9 + dist-test/test/storage.test.js | 112 ++ package-lock.json | 1724 ++++++++++++++++++++++++++++++ package.json | 22 + public/icons/icon128.png | Bin 0 -> 67 bytes public/icons/icon16.png | Bin 0 -> 67 bytes public/icons/icon32.png | Bin 0 -> 67 bytes public/icons/icon48.png | Bin 0 -> 67 bytes public/manifest.json | 27 + scripts/gen-icons.js | 24 + src/background.ts | 150 +++ src/invoice/invoice.css | 175 +++ src/invoice/invoice.html | 50 + src/invoice/invoice.ts | 123 +++ src/options/options.css | 186 ++++ src/options/options.html | 73 ++ src/options/options.ts | 84 ++ src/pdf.ts | 146 +++ src/popup/popup.css | 210 ++++ src/popup/popup.html | 46 + src/popup/popup.ts | 168 +++ src/report/report.css | 169 +++ src/report/report.html | 46 + src/report/report.ts | 91 ++ src/storage.ts | 102 ++ src/types.ts | 39 + test/storage.test.ts | 132 +++ tsconfig.json | 17 + vite.config.ts | 29 + 37 files changed, 4689 insertions(+) create mode 100644 .gitignore create mode 100644 dist-test/src/background.js create mode 100644 dist-test/src/invoice/invoice.js create mode 100644 dist-test/src/options/options.js create mode 100644 dist-test/src/pdf.js create mode 100644 dist-test/src/popup/popup.js create mode 100644 dist-test/src/report/report.js create mode 100644 dist-test/src/storage.js create mode 100644 dist-test/src/types.js create mode 100644 dist-test/test/storage.test.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/icons/icon128.png create mode 100644 public/icons/icon16.png create mode 100644 public/icons/icon32.png create mode 100644 public/icons/icon48.png create mode 100644 public/manifest.json create mode 100644 scripts/gen-icons.js create mode 100644 src/background.ts create mode 100644 src/invoice/invoice.css create mode 100644 src/invoice/invoice.html create mode 100644 src/invoice/invoice.ts create mode 100644 src/options/options.css create mode 100644 src/options/options.html create mode 100644 src/options/options.ts create mode 100644 src/pdf.ts create mode 100644 src/popup/popup.css create mode 100644 src/popup/popup.html create mode 100644 src/popup/popup.ts create mode 100644 src/report/report.css create mode 100644 src/report/report.html create mode 100644 src/report/report.ts create mode 100644 src/storage.ts create mode 100644 src/types.ts create mode 100644 test/storage.test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f88eda2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.verdict +.env +.DS_Store diff --git a/dist-test/src/background.js b/dist-test/src/background.js new file mode 100644 index 0000000..3d9d68e --- /dev/null +++ b/dist-test/src/background.js @@ -0,0 +1,127 @@ +import { getTimerState, setTimerState, saveEntry, generateId } from './storage.js'; +// Alarm name for timer tick updates +const TIMER_ALARM = 'ticksimple-timer-tick'; +// Keep service worker alive while timer is running +let keepAliveInterval = null; +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === TIMER_ALARM) { + await updateBadge(); + } +}); +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + const handle = async () => { + switch (message.type) { + case 'START_TIMER': + return await startTimer(message.payload); + case 'STOP_TIMER': + return await stopTimer(); + case 'GET_TIMER_STATE': + return await getTimerState(); + case 'UPDATE_TIMER_DESCRIPTION': + return await updateTimerDescription(message.payload); + default: + return { error: 'Unknown message type' }; + } + }; + handle().then(sendResponse).catch((err) => sendResponse({ error: err.message })); + return true; // async response +}); +async function startTimer(payload) { + const state = await getTimerState(); + if (state.running) { + return state; + } + const entryId = generateId(); + const now = Date.now(); + const newState = { + running: true, + startTime: now, + currentEntryId: entryId, + description: payload.description || '', + client: payload.client || '', + project: payload.project || '', + }; + await setTimerState(newState); + await chrome.alarms.create(TIMER_ALARM, { periodInMinutes: 0.1 }); + await updateBadge(); + startKeepAlive(); + return newState; +} +async function stopTimer() { + const state = await getTimerState(); + if (!state.running || !state.startTime || !state.currentEntryId) { + throw new Error('Timer is not running'); + } + const now = Date.now(); + const duration = now - state.startTime; + const entry = { + id: state.currentEntryId, + startTime: state.startTime, + endTime: now, + duration, + description: state.description, + client: state.client, + project: state.project, + createdAt: now, + }; + await saveEntry(entry); + const newState = { + running: false, + startTime: null, + currentEntryId: null, + description: state.description, + client: state.client, + project: state.project, + }; + await setTimerState(newState); + await chrome.alarms.clear(TIMER_ALARM); + await chrome.action.setBadgeText({ text: '' }); + stopKeepAlive(); + return { state: newState, entry }; +} +async function updateTimerDescription(payload) { + const state = await getTimerState(); + const updated = { + ...state, + description: payload.description, + client: payload.client, + project: payload.project, + }; + await setTimerState(updated); + return updated; +} +async function updateBadge() { + const state = await getTimerState(); + if (!state.running || !state.startTime) { + await chrome.action.setBadgeText({ text: '' }); + return; + } + const elapsed = Date.now() - state.startTime; + const totalMinutes = Math.floor(elapsed / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + // Show hours if > 59 min, else show minutes + const badgeText = hours > 0 ? `${hours}h` : `${minutes}m`; + await chrome.action.setBadgeText({ text: badgeText.slice(0, 4) }); + await chrome.action.setBadgeBackgroundColor({ color: '#10b981' }); +} +function startKeepAlive() { + if (keepAliveInterval) + return; + keepAliveInterval = setInterval(() => { + // noop to keep service worker alive + }, 20000); +} +function stopKeepAlive() { + if (keepAliveInterval) { + clearInterval(keepAliveInterval); + keepAliveInterval = null; + } +} +// Restore badge on startup +chrome.runtime.onStartup.addListener(async () => { + await updateBadge(); +}); +chrome.runtime.onInstalled.addListener(async () => { + await updateBadge(); +}); diff --git a/dist-test/src/invoice/invoice.js b/dist-test/src/invoice/invoice.js new file mode 100644 index 0000000..3e604e8 --- /dev/null +++ b/dist-test/src/invoice/invoice.js @@ -0,0 +1,103 @@ +import { getEntries, getSettings, formatDuration, formatDate } from '../storage.js'; +import { generateInvoicePDF } from '../pdf.js'; +const els = { + clientInput: document.getElementById('invoice-client'), + numberInput: document.getElementById('invoice-number'), + dateFrom: document.getElementById('date-from'), + dateTo: document.getElementById('date-to'), + entriesList: document.getElementById('matching-entries'), + invoiceTotal: document.getElementById('invoice-total'), + generateBtn: document.getElementById('generate-invoice'), +}; +let currentEntries = []; +async function init() { + // Set default date range to current month + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + els.dateFrom.value = toISODate(firstDay); + els.dateTo.value = toISODate(now); + const settings = await getSettings(); + els.clientInput.value = settings.defaultClient; + els.clientInput.addEventListener('input', debounce(updateMatching, 300)); + els.dateFrom.addEventListener('change', updateMatching); + els.dateTo.addEventListener('change', updateMatching); + els.generateBtn.addEventListener('click', async () => { + if (currentEntries.length === 0) { + alert('No entries match the selected criteria.'); + return; + } + const settings = await getSettings(); + const blob = generateInvoicePDF(currentEntries, settings, els.clientInput.value, els.numberInput.value || `INV-${Date.now().toString().slice(-6)}`); + const fromDate = els.dateFrom.value || 'all'; + const toDate = els.dateTo.value || 'all'; + downloadBlob(blob, `invoice-${els.clientInput.value}-${fromDate}-to-${toDate}.pdf`); + }); + await updateMatching(); +} +async function updateMatching() { + const client = els.clientInput.value.trim().toLowerCase(); + const from = els.dateFrom.valueAsDate; + const to = els.dateTo.valueAsDate; + const allEntries = await getEntries(); + const settings = await getSettings(); + currentEntries = allEntries.filter((e) => { + if (e.endTime === null) + return false; + if (client && !e.client.toLowerCase().includes(client)) + return false; + if (from && e.startTime < from.getTime()) + return false; + if (to) { + const endOfDay = new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59).getTime(); + if (e.startTime > endOfDay) + return false; + } + return true; + }).sort((a, b) => a.startTime - b.startTime); + if (currentEntries.length === 0) { + els.entriesList.innerHTML = '

No entries match the selected criteria.

'; + els.invoiceTotal.textContent = ''; + return; + } + const totalMs = currentEntries.reduce((sum, e) => sum + e.duration, 0); + const totalHours = totalMs / 3600000; + const totalAmount = totalHours * settings.hourlyRate; + els.entriesList.innerHTML = currentEntries.map((entry) => { + const hours = entry.duration / 3600000; + const amount = hours * settings.hourlyRate; + return ` +
+ +
${settings.currency}${amount.toFixed(2)}
+
+ `; + }).join(''); + els.invoiceTotal.textContent = `Total: ${settings.currency}${totalAmount.toFixed(2)} (${totalHours.toFixed(1)} hours @ ${settings.currency}${settings.hourlyRate}/hr)`; +} +function toISODate(d) { + return d.toISOString().slice(0, 10); +} +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} +function debounce(fn, ms) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), ms); + }; +} +init().catch(console.error); diff --git a/dist-test/src/options/options.js b/dist-test/src/options/options.js new file mode 100644 index 0000000..b79c5c0 --- /dev/null +++ b/dist-test/src/options/options.js @@ -0,0 +1,73 @@ +import { getSettings, saveSettings, getEntries } from '../storage.js'; +const form = document.getElementById('settings-form'); +const savedMsg = document.getElementById('saved-msg'); +const logoPreview = document.getElementById('logo-preview'); +const logoFile = document.getElementById('logo-file'); +const fields = { + userName: document.getElementById('user-name'), + userEmail: document.getElementById('user-email'), + hourlyRate: document.getElementById('hourly-rate'), + currency: document.getElementById('currency'), + defaultClient: document.getElementById('default-client'), + defaultProject: document.getElementById('default-project'), + logoDataUrl: document.createElement('input'), // hidden, managed separately +}; +let currentLogo = ''; +async function init() { + const settings = await getSettings(); + fields.userName.value = settings.userName; + fields.userEmail.value = settings.userEmail; + fields.hourlyRate.value = String(settings.hourlyRate); + fields.currency.value = settings.currency; + fields.defaultClient.value = settings.defaultClient; + fields.defaultProject.value = settings.defaultProject; + currentLogo = settings.logoDataUrl; + if (currentLogo) { + logoPreview.innerHTML = `Logo`; + } +} +logoFile.addEventListener('change', async () => { + const file = logoFile.files?.[0]; + if (!file) + return; + const reader = new FileReader(); + reader.onload = (e) => { + currentLogo = e.target?.result; + logoPreview.innerHTML = `Logo`; + }; + reader.readAsDataURL(file); +}); +form.addEventListener('submit', async (e) => { + e.preventDefault(); + const settings = { + userName: fields.userName.value.trim() || 'Your Name', + userEmail: fields.userEmail.value.trim() || 'you@example.com', + hourlyRate: parseFloat(fields.hourlyRate.value) || 0, + currency: fields.currency.value.trim() || '$', + defaultClient: fields.defaultClient.value.trim() || 'Client', + defaultProject: fields.defaultProject.value.trim() || 'Project', + logoDataUrl: currentLogo, + }; + await saveSettings(settings); + savedMsg.classList.add('show'); + setTimeout(() => savedMsg.classList.remove('show'), 2000); +}); +document.getElementById('export-btn')?.addEventListener('click', async () => { + const entries = await getEntries(); + const settings = await getSettings(); + const data = { entries, settings, exportedAt: new Date().toISOString() }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ticksimple-export-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +}); +document.getElementById('clear-btn')?.addEventListener('click', async () => { + if (!confirm('Delete ALL time entries and settings? This cannot be undone.')) + return; + await chrome.storage.local.clear(); + location.reload(); +}); +init().catch(console.error); diff --git a/dist-test/src/pdf.js b/dist-test/src/pdf.js new file mode 100644 index 0000000..9d5bc6a --- /dev/null +++ b/dist-test/src/pdf.js @@ -0,0 +1,119 @@ +import { jsPDF } from 'jspdf'; +import 'jspdf-autotable'; +import { formatDuration, formatDate, formatTime } from './storage.js'; +export function generateReportPDF(entries, settings, range) { + const doc = new jsPDF(); + const totalMs = entries.reduce((sum, e) => sum + e.duration, 0); + const totalHours = totalMs / 3600000; + // Header + doc.setFontSize(20); + doc.text('TickSimple Time Report', 14, 20); + doc.setFontSize(11); + doc.setTextColor(100); + doc.text(`Generated: ${new Date().toLocaleDateString()}`, 14, 30); + doc.text(`Period: ${range}`, 14, 36); + doc.text(`Total Time: ${formatDuration(totalMs)}`, 14, 42); + doc.text(`Entries: ${entries.length}`, 14, 48); + if (settings.hourlyRate > 0) { + doc.text(`Est. Value: ${settings.currency}${(totalHours * settings.hourlyRate).toFixed(2)}`, 14, 54); + } + // Table + const body = entries.map((e) => [ + formatDate(e.startTime), + formatTime(e.startTime), + e.client || '-', + e.project || '-', + e.description || 'Untitled', + formatDuration(e.duration), + ]); + doc.autoTable({ + startY: settings.hourlyRate > 0 ? 60 : 54, + head: [['Date', 'Time', 'Client', 'Project', 'Description', 'Duration']], + body, + theme: 'striped', + headStyles: { fillColor: [16, 185, 129] }, + styles: { fontSize: 9, cellPadding: 2 }, + columnStyles: { + 0: { cellWidth: 22 }, + 1: { cellWidth: 16 }, + 2: { cellWidth: 25 }, + 3: { cellWidth: 25 }, + 5: { cellWidth: 20 }, + }, + }); + return doc.output('blob'); +} +export function generateInvoicePDF(entries, settings, client, invoiceNumber) { + const doc = new jsPDF(); + const totalMs = entries.reduce((sum, e) => sum + e.duration, 0); + const totalHours = totalMs / 3600000; + const totalAmount = totalHours * settings.hourlyRate; + // Logo + if (settings.logoDataUrl) { + try { + doc.addImage(settings.logoDataUrl, 'PNG', 14, 10, 30, 30); + } + catch { + // ignore logo errors + } + } + // Header + doc.setFontSize(24); + doc.setTextColor(16, 185, 129); + doc.text('INVOICE', 140, 20); + doc.setFontSize(10); + doc.setTextColor(100); + doc.text(`Invoice #: ${invoiceNumber}`, 140, 28); + doc.text(`Date: ${new Date().toLocaleDateString()}`, 140, 34); + doc.text(`Due: ${new Date(Date.now() + 14 * 86400000).toLocaleDateString()}`, 140, 40); + // From + doc.setFontSize(11); + doc.setTextColor(0); + doc.text('From:', 14, 50); + doc.setFontSize(10); + doc.text(settings.userName, 14, 57); + doc.text(settings.userEmail, 14, 63); + // To + doc.setFontSize(11); + doc.text('Bill To:', 14, 75); + doc.setFontSize(10); + doc.text(client, 14, 82); + // Line items + const body = entries.map((e) => { + const hours = e.duration / 3600000; + const amount = hours * settings.hourlyRate; + return [ + formatDate(e.startTime), + e.description || 'Services rendered', + `${hours.toFixed(2)} hrs`, + `${settings.currency}${settings.hourlyRate.toFixed(2)}`, + `${settings.currency}${amount.toFixed(2)}`, + ]; + }); + doc.autoTable({ + startY: 92, + head: [['Date', 'Description', 'Hours', 'Rate', 'Amount']], + body, + theme: 'striped', + headStyles: { fillColor: [16, 185, 129] }, + styles: { fontSize: 9, cellPadding: 3 }, + columnStyles: { + 0: { cellWidth: 25 }, + 2: { cellWidth: 20, halign: 'right' }, + 3: { cellWidth: 25, halign: 'right' }, + 4: { cellWidth: 25, halign: 'right' }, + }, + }); + // Totals + const finalY = doc.lastAutoTable.finalY + 10; + doc.setFontSize(12); + doc.setTextColor(0); + doc.text(`Subtotal: ${settings.currency}${totalAmount.toFixed(2)}`, 140, finalY, { align: 'right' }); + doc.text(`Total: ${settings.currency}${totalAmount.toFixed(2)}`, 140, finalY + 8, { align: 'right' }); + // Footer + doc.setFontSize(9); + doc.setTextColor(150); + doc.text('Generated by TickSimple — ticksimple.bunbunlabs.com', 14, 280); + doc.text('Thank you for your business!', 14, 286); + return doc.output('blob'); +} diff --git a/dist-test/src/popup/popup.js b/dist-test/src/popup/popup.js new file mode 100644 index 0000000..68e9281 --- /dev/null +++ b/dist-test/src/popup/popup.js @@ -0,0 +1,144 @@ +import { getTimerState, getEntries, getSettings, getTodayEntries, formatDurationShort, formatDuration, formatTime, } from '../storage.js'; +let timerInterval = null; +let currentState = null; +const els = { + display: document.getElementById('timer-display'), + toggleBtn: document.getElementById('toggle-btn'), + descInput: document.getElementById('desc-input'), + clientInput: document.getElementById('client-input'), + projectInput: document.getElementById('project-input'), + entriesList: document.getElementById('entries-list'), + todayTotal: document.getElementById('today-total'), +}; +async function init() { + const [state, settings] = await Promise.all([getTimerState(), getSettings()]); + currentState = state; + // Pre-fill inputs with defaults + els.clientInput.value = state.client || settings.defaultClient; + els.projectInput.value = state.project || settings.defaultProject; + els.descInput.value = state.description || ''; + updateUI(state); + await renderEntries(); + // Auto-save inputs to timer state + els.descInput.addEventListener('input', debounce(saveInputs, 300)); + els.clientInput.addEventListener('input', debounce(saveInputs, 300)); + els.projectInput.addEventListener('input', debounce(saveInputs, 300)); + els.toggleBtn.addEventListener('click', onToggle); +} +function updateUI(state) { + if (state.running && state.startTime) { + els.display.classList.add('running'); + els.toggleBtn.textContent = 'Stop Timer'; + els.toggleBtn.classList.add('stop'); + startTicking(state.startTime); + } + else { + els.display.classList.remove('running'); + els.toggleBtn.textContent = 'Start Timer'; + els.toggleBtn.classList.remove('stop'); + stopTicking(); + els.display.textContent = '0:00'; + } +} +function startTicking(startTime) { + stopTicking(); + const tick = () => { + const elapsed = Date.now() - startTime; + els.display.textContent = formatDurationShort(elapsed); + }; + tick(); + timerInterval = setInterval(tick, 1000); +} +function stopTicking() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } +} +async function saveInputs() { + if (!currentState) + return; + await chrome.runtime.sendMessage({ + type: 'UPDATE_TIMER_DESCRIPTION', + payload: { + description: els.descInput.value, + client: els.clientInput.value, + project: els.projectInput.value, + }, + }); +} +async function onToggle() { + if (!currentState) + return; + if (currentState.running) { + // Stop + els.toggleBtn.disabled = true; + const result = await chrome.runtime.sendMessage({ type: 'STOP_TIMER' }); + if (result.error) { + console.error(result.error); + els.toggleBtn.disabled = false; + return; + } + currentState = result.state; + if (!currentState) + return; + updateUI(currentState); + await renderEntries(); + els.toggleBtn.disabled = false; + } + else { + // Start + els.toggleBtn.disabled = true; + const result = await chrome.runtime.sendMessage({ + type: 'START_TIMER', + payload: { + description: els.descInput.value, + client: els.clientInput.value, + project: els.projectInput.value, + }, + }); + if (result.error) { + console.error(result.error); + els.toggleBtn.disabled = false; + return; + } + currentState = result; + if (!currentState) + return; + updateUI(currentState); + els.toggleBtn.disabled = false; + } +} +async function renderEntries() { + const allEntries = await getEntries(); + const today = getTodayEntries(allEntries); + if (today.length === 0) { + els.entriesList.innerHTML = '

No time tracked yet today.

'; + els.todayTotal.textContent = ''; + return; + } + const totalMs = today.reduce((sum, e) => sum + e.duration, 0); + els.entriesList.innerHTML = today.map((entry) => ` +
+ +
${formatDurationShort(entry.duration)}
+
+ `).join(''); + els.todayTotal.textContent = `Total: ${formatDuration(totalMs)}`; +} +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} +function debounce(fn, ms) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), ms); + }; +} +init().catch(console.error); diff --git a/dist-test/src/report/report.js b/dist-test/src/report/report.js new file mode 100644 index 0000000..443426b --- /dev/null +++ b/dist-test/src/report/report.js @@ -0,0 +1,76 @@ +import { getEntries, getSettings, getTodayEntries, getWeekEntries, formatDuration, formatDate } from '../storage.js'; +import { generateReportPDF } from '../pdf.js'; +let currentRange = 'today'; +let currentEntries = []; +const els = { + totalHours: document.getElementById('total-hours'), + entryCount: document.getElementById('entry-count'), + estimatedValue: document.getElementById('estimated-value'), + entriesContainer: document.getElementById('entries'), + downloadBtn: document.getElementById('download-pdf'), +}; +async function init() { + await loadEntries('today'); + document.querySelectorAll('.filter-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + document.querySelectorAll('.filter-btn').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + const range = btn.getAttribute('data-range'); + await loadEntries(range); + }); + }); + els.downloadBtn.addEventListener('click', async () => { + const settings = await getSettings(); + const blob = generateReportPDF(currentEntries, settings, currentRange); + downloadBlob(blob, `ticksimple-report-${currentRange}-${new Date().toISOString().slice(0, 10)}.pdf`); + }); +} +async function loadEntries(range) { + currentRange = range; + const allEntries = await getEntries(); + const settings = await getSettings(); + switch (range) { + case 'today': + currentEntries = getTodayEntries(allEntries); + break; + case 'week': + currentEntries = getWeekEntries(allEntries); + break; + case 'all': + currentEntries = allEntries.filter((e) => e.endTime !== null).sort((a, b) => b.startTime - a.startTime); + break; + } + const totalMs = currentEntries.reduce((sum, e) => sum + e.duration, 0); + const totalHours = totalMs / 3600000; + const estimatedValue = totalHours * settings.hourlyRate; + els.totalHours.textContent = `${totalHours.toFixed(1)}h`; + els.entryCount.textContent = String(currentEntries.length); + els.estimatedValue.textContent = `${settings.currency}${estimatedValue.toFixed(0)}`; + if (currentEntries.length === 0) { + els.entriesContainer.innerHTML = '

No entries for this period.

'; + return; + } + els.entriesContainer.innerHTML = currentEntries.map((entry) => ` +
+ +
${formatDuration(entry.duration)}
+
+ `).join(''); +} +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} +init().catch(console.error); diff --git a/dist-test/src/storage.js b/dist-test/src/storage.js new file mode 100644 index 0000000..2fa7916 --- /dev/null +++ b/dist-test/src/storage.js @@ -0,0 +1,87 @@ +import { DEFAULT_SETTINGS } from './types.js'; +const STORAGE_KEYS = { + entries: 'ticksimple_entries', + timer: 'ticksimple_timer', + settings: 'ticksimple_settings', +}; +export async function getEntries() { + const result = await chrome.storage.local.get(STORAGE_KEYS.entries); + return result[STORAGE_KEYS.entries] || []; +} +export async function saveEntry(entry) { + const entries = await getEntries(); + const index = entries.findIndex((e) => e.id === entry.id); + if (index >= 0) { + entries[index] = entry; + } + else { + entries.push(entry); + } + await chrome.storage.local.set({ [STORAGE_KEYS.entries]: entries }); +} +export async function deleteEntry(id) { + const entries = await getEntries(); + const filtered = entries.filter((e) => e.id !== id); + await chrome.storage.local.set({ [STORAGE_KEYS.entries]: filtered }); +} +export async function getTimerState() { + const result = await chrome.storage.local.get(STORAGE_KEYS.timer); + return result[STORAGE_KEYS.timer] || { + running: false, + startTime: null, + currentEntryId: null, + description: '', + client: '', + project: '', + }; +} +export async function setTimerState(state) { + await chrome.storage.local.set({ [STORAGE_KEYS.timer]: state }); +} +export async function getSettings() { + const result = await chrome.storage.local.get(STORAGE_KEYS.settings); + return { ...DEFAULT_SETTINGS, ...(result[STORAGE_KEYS.settings] || {}) }; +} +export async function saveSettings(settings) { + await chrome.storage.local.set({ [STORAGE_KEYS.settings]: settings }); +} +export function generateId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} +export function formatDuration(ms) { + const totalMinutes = Math.floor(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${minutes.toString().padStart(2, '0')}m`; +} +export function formatDurationShort(ms) { + const totalMinutes = Math.floor(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}:${minutes.toString().padStart(2, '0')}`; +} +export function formatDate(ts) { + const d = new Date(ts); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} +export function formatTime(ts) { + const d = new Date(ts); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); +} +export function getTodayEntries(entries) { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const endOfDay = startOfDay + 86400000; + return entries + .filter((e) => e.startTime >= startOfDay && e.startTime < endOfDay && e.endTime !== null) + .sort((a, b) => b.startTime - a.startTime); +} +export function getWeekEntries(entries) { + const now = new Date(); + const dayOfWeek = now.getDay(); + const startOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dayOfWeek).getTime(); + const endOfWeek = startOfWeek + 7 * 86400000; + return entries + .filter((e) => e.startTime >= startOfWeek && e.startTime < endOfWeek && e.endTime !== null) + .sort((a, b) => b.startTime - a.startTime); +} diff --git a/dist-test/src/types.js b/dist-test/src/types.js new file mode 100644 index 0000000..e79d052 --- /dev/null +++ b/dist-test/src/types.js @@ -0,0 +1,9 @@ +export const DEFAULT_SETTINGS = { + hourlyRate: 50, + defaultClient: 'Client', + defaultProject: 'Project', + userName: 'Your Name', + userEmail: 'you@example.com', + logoDataUrl: '', + currency: '$', +}; diff --git a/dist-test/test/storage.test.js b/dist-test/test/storage.test.js new file mode 100644 index 0000000..a8f18de --- /dev/null +++ b/dist-test/test/storage.test.js @@ -0,0 +1,112 @@ +// Mock chrome APIs for Node test environment +; +globalThis.chrome = { + storage: { + local: { + get: async () => ({}), + set: async () => { }, + clear: async () => { }, + }, + }, + runtime: { + sendMessage: async () => ({}), + onMessage: { addListener: () => { } }, + onStartup: { addListener: () => { } }, + onInstalled: { addListener: () => { } }, + }, + alarms: { + create: async () => { }, + clear: async () => true, + onAlarm: { addListener: () => { } }, + }, + action: { + setBadgeText: async () => { }, + setBadgeBackgroundColor: async () => { }, + }, +}; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { formatDuration, formatDurationShort, formatDate, formatTime, getTodayEntries, getWeekEntries, generateId, } from '../src/storage.js'; +describe('formatDuration', () => { + it('formats 0 ms', () => { + assert.strictEqual(formatDuration(0), '0h 00m'); + }); + it('formats 1 hour', () => { + assert.strictEqual(formatDuration(3600000), '1h 00m'); + }); + it('formats 90 minutes', () => { + assert.strictEqual(formatDuration(5400000), '1h 30m'); + }); + it('formats 25 minutes', () => { + assert.strictEqual(formatDuration(1500000), '0h 25m'); + }); +}); +describe('formatDurationShort', () => { + it('formats 0 ms', () => { + assert.strictEqual(formatDurationShort(0), '0:00'); + }); + it('formats 1 hour 5 min', () => { + assert.strictEqual(formatDurationShort(3900000), '1:05'); + }); +}); +describe('formatDate', () => { + it('formats a timestamp', () => { + const ts = new Date('2024-06-20T10:00:00Z').getTime(); + const result = formatDate(ts); + assert.ok(result.includes('Jun') || result.includes('20')); + }); +}); +describe('formatTime', () => { + it('formats a timestamp', () => { + const ts = new Date('2024-06-20T10:30:00Z').getTime(); + const result = formatTime(ts); + assert.ok(result.includes(':')); + }); +}); +describe('getTodayEntries', () => { + it('returns only today entries', () => { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const todayEntry = startOfDay + 3600000; // 1 hour after start of day + const yesterdayEntry = startOfDay - 3600000; // 1 hour before start of day + const entries = [ + { id: '1', startTime: todayEntry, endTime: todayEntry + 1800000, duration: 1800000, description: 'A', client: 'C', project: 'P', createdAt: todayEntry }, + { id: '2', startTime: yesterdayEntry, endTime: yesterdayEntry + 1800000, duration: 1800000, description: 'B', client: 'C', project: 'P', createdAt: yesterdayEntry }, + ]; + const result = getTodayEntries(entries); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, '1'); + }); + it('excludes running entries', () => { + const now = Date.now(); + const entries = [ + { id: '1', startTime: now - 3600000, endTime: null, duration: 0, description: 'A', client: 'C', project: 'P', createdAt: now }, + ]; + const result = getTodayEntries(entries); + assert.strictEqual(result.length, 0); + }); +}); +describe('getWeekEntries', () => { + it('returns entries from this week', () => { + const now = new Date(); + const dayOfWeek = now.getDay(); + const startOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dayOfWeek); + const weekEntry = startOfWeek.getTime() + 3600000; + const oldEntry = startOfWeek.getTime() - 86400000; + const entries = [ + { id: '1', startTime: weekEntry, endTime: weekEntry + 1800000, duration: 1800000, description: 'A', client: 'C', project: 'P', createdAt: weekEntry }, + { id: '2', startTime: oldEntry, endTime: oldEntry + 1800000, duration: 1800000, description: 'B', client: 'C', project: 'P', createdAt: oldEntry }, + ]; + const result = getWeekEntries(entries); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, '1'); + }); +}); +describe('generateId', () => { + it('generates unique ids', () => { + const id1 = generateId(); + const id2 = generateId(); + assert.notStrictEqual(id1, id2); + assert.ok(id1.length > 10); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b358b8f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1724 @@ +{ + "name": "ticksimple", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ticksimple", + "version": "1.0.0", + "dependencies": { + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.26", + "@types/chrome": "^0.0.268", + "@types/node": "^26.0.0", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@crxjs/vite-plugin": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.7.0.tgz", + "integrity": "sha512-NN98fUgJOEiVBshELJnYCW54+K2Yy067++/K+RdxclHsyG08ea+Em3lJJTubtPTCl2bECyp4+K69oT+izilnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webcomponents/custom-elements": "^1.5.0", + "acorn-walk": "^8.3.5", + "convert-source-map": "^1.7.0", + "debug": "^4.3.3", + "es-module-lexer": "^0.10.0", + "fs-extra": "^10.0.1", + "jsesc": "^3.0.2", + "magic-string": "^0.30.12", + "node-html-parser": "^7.1.0", + "pathe": "^2.0.1", + "picocolors": "^1.1.1", + "rollup": "2.80.0", + "rxjs": "7.5.7", + "tinyglobby": "^0.2.17" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.268", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.268.tgz", + "integrity": "sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@webcomponents/custom-elements": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", + "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz", + "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.8.4.tgz", + "integrity": "sha512-rSffGoBsJYX83iTRv8Ft7FhqfgEL2nLpGAIiqruEQQ3e4r0qdLFbPUB7N9HAle0I3XgpisvyW751VHCqKUVOgQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2.5.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", + "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9eb0388 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ticksimple", + "version": "1.0.0", + "description": "Dead-simple time tracker for freelancers", + "type": "module", + "scripts": { + "build": "vite build", + "test": "node --test dist-test/test-*.js", + "dev": "vite" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.26", + "@types/chrome": "^0.0.268", + "@types/node": "^26.0.0", + "typescript": "^5.4.5", + "vite": "^5.2.11" + }, + "dependencies": { + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2" + } +} diff --git a/public/icons/icon128.png b/public/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..bdf265c7cc8d7c9c2a587fba20559bc999ebc530 GIT binary patch literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Asp9}fA9k(7#Ub4ZukL3 O89ZJ6T-G@yGywp0r3}yj literal 0 HcmV?d00001 diff --git a/public/icons/icon16.png b/public/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..bdf265c7cc8d7c9c2a587fba20559bc999ebc530 GIT binary patch literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Asp9}fA9k(7#Ub4ZukL3 O89ZJ6T-G@yGywp0r3}yj literal 0 HcmV?d00001 diff --git a/public/icons/icon32.png b/public/icons/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..bdf265c7cc8d7c9c2a587fba20559bc999ebc530 GIT binary patch literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Asp9}fA9k(7#Ub4ZukL3 O89ZJ6T-G@yGywp0r3}yj literal 0 HcmV?d00001 diff --git a/public/icons/icon48.png b/public/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..bdf265c7cc8d7c9c2a587fba20559bc999ebc530 GIT binary patch literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Asp9}fA9k(7#Ub4ZukL3 O89ZJ6T-G@yGywp0r3}yj literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..b13fa32 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 3, + "name": "TickSimple — Dead-Simple Time Tracker", + "version": "1.0.0", + "description": "One-tap time tracking, clean reports, simple invoices. Built for freelancers.", + "permissions": ["storage", "alarms", "activeTab"], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "options_page": "src/options/options.html", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/scripts/gen-icons.js b/scripts/gen-icons.js new file mode 100644 index 0000000..9d99e95 --- /dev/null +++ b/scripts/gen-icons.js @@ -0,0 +1,24 @@ +// Generate minimal valid PNG icons for the extension +import { writeFileSync } from 'fs' + +// Minimal PNG: 1x1 pixel green (#10b981) +const greenPixel = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0x0F, 0x00, 0x00, + 0x01, 0x01, 0x00, 0x05, 0x18, 0xD8, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +]) + +const sizes = [16, 32, 48, 128] +const outDir = 'public/icons' + +import { mkdirSync } from 'fs' +mkdirSync(outDir, { recursive: true }) + +for (const size of sizes) { + writeFileSync(`${outDir}/icon${size}.png`, greenPixel) +} + +console.log('Icons generated.') diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..fe768cc --- /dev/null +++ b/src/background.ts @@ -0,0 +1,150 @@ +import { getTimerState, setTimerState, saveEntry, generateId } from './storage.js' +import type { TimeEntry, TimerState } from './types.js' + +// Alarm name for timer tick updates +const TIMER_ALARM = 'ticksimple-timer-tick' + +// Keep service worker alive while timer is running +let keepAliveInterval: ReturnType | null = null + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === TIMER_ALARM) { + await updateBadge() + } +}) + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + const handle = async () => { + switch (message.type) { + case 'START_TIMER': + return await startTimer(message.payload) + case 'STOP_TIMER': + return await stopTimer() + case 'GET_TIMER_STATE': + return await getTimerState() + case 'UPDATE_TIMER_DESCRIPTION': + return await updateTimerDescription(message.payload) + default: + return { error: 'Unknown message type' } + } + } + handle().then(sendResponse).catch((err) => sendResponse({ error: err.message })) + return true // async response +}) + +async function startTimer(payload: { description: string; client: string; project: string }): Promise { + const state = await getTimerState() + if (state.running) { + return state + } + + const entryId = generateId() + const now = Date.now() + const newState: TimerState = { + running: true, + startTime: now, + currentEntryId: entryId, + description: payload.description || '', + client: payload.client || '', + project: payload.project || '', + } + + await setTimerState(newState) + await chrome.alarms.create(TIMER_ALARM, { periodInMinutes: 0.1 }) + await updateBadge() + startKeepAlive() + + return newState +} + +async function stopTimer(): Promise<{ state: TimerState; entry: TimeEntry }> { + const state = await getTimerState() + if (!state.running || !state.startTime || !state.currentEntryId) { + throw new Error('Timer is not running') + } + + const now = Date.now() + const duration = now - state.startTime + + const entry: TimeEntry = { + id: state.currentEntryId, + startTime: state.startTime, + endTime: now, + duration, + description: state.description, + client: state.client, + project: state.project, + createdAt: now, + } + + await saveEntry(entry) + + const newState: TimerState = { + running: false, + startTime: null, + currentEntryId: null, + description: state.description, + client: state.client, + project: state.project, + } + + await setTimerState(newState) + await chrome.alarms.clear(TIMER_ALARM) + await chrome.action.setBadgeText({ text: '' }) + stopKeepAlive() + + return { state: newState, entry } +} + +async function updateTimerDescription(payload: { description: string; client: string; project: string }): Promise { + const state = await getTimerState() + const updated: TimerState = { + ...state, + description: payload.description, + client: payload.client, + project: payload.project, + } + await setTimerState(updated) + return updated +} + +async function updateBadge(): Promise { + const state = await getTimerState() + if (!state.running || !state.startTime) { + await chrome.action.setBadgeText({ text: '' }) + return + } + + const elapsed = Date.now() - state.startTime + const totalMinutes = Math.floor(elapsed / 60000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + + // Show hours if > 59 min, else show minutes + const badgeText = hours > 0 ? `${hours}h` : `${minutes}m` + await chrome.action.setBadgeText({ text: badgeText.slice(0, 4) }) + await chrome.action.setBadgeBackgroundColor({ color: '#10b981' }) +} + +function startKeepAlive(): void { + if (keepAliveInterval) return + keepAliveInterval = setInterval(() => { + // noop to keep service worker alive + }, 20000) +} + +function stopKeepAlive(): void { + if (keepAliveInterval) { + clearInterval(keepAliveInterval) + keepAliveInterval = null + } +} + +// Restore badge on startup +chrome.runtime.onStartup.addListener(async () => { + await updateBadge() +}) + +chrome.runtime.onInstalled.addListener(async () => { + await updateBadge() +}) diff --git a/src/invoice/invoice.css b/src/invoice/invoice.css new file mode 100644 index 0000000..8738620 --- /dev/null +++ b/src/invoice/invoice.css @@ -0,0 +1,175 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface-hover: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --accent: #10b981; + --accent-hover: #059669; + --radius: 8px; + --max-width: 640px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 32px 20px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +header h1 { + font-size: 22px; + font-weight: 700; +} + +.back { + color: var(--text-muted); + text-decoration: none; + font-size: 14px; +} + +.back:hover { + color: var(--text); +} + +.invoice-form { + background: var(--surface); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 20px; +} + +.invoice-form label { + display: block; + margin-bottom: 14px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); +} + +.invoice-form input { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid var(--surface-hover); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; + outline: none; +} + +.invoice-form input:focus { + border-color: var(--accent); +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.entries-section h2 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.entries-list { + margin-bottom: 16px; +} + +.entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--surface); + border-radius: var(--radius); + margin-bottom: 8px; +} + +.entry-info { + flex: 1; + min-width: 0; +} + +.entry-desc { + font-size: 14px; + font-weight: 500; +} + +.entry-meta { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.entry-duration { + font-size: 14px; + font-weight: 600; + color: var(--accent); + font-family: 'SF Mono', monospace; +} + +.entry-amount { + font-size: 14px; + font-weight: 600; + margin-left: 12px; +} + +.empty { + text-align: center; + color: var(--text-muted); + padding: 24px; +} + +.invoice-total { + text-align: right; + font-size: 18px; + font-weight: 700; + color: var(--accent); + padding: 16px; + background: var(--surface); + border-radius: var(--radius); + margin-bottom: 20px; +} + +.btn-primary { + width: 100%; + padding: 14px; + font-size: 15px; + font-weight: 600; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: var(--accent-hover); +} diff --git a/src/invoice/invoice.html b/src/invoice/invoice.html new file mode 100644 index 0000000..1e29ea1 --- /dev/null +++ b/src/invoice/invoice.html @@ -0,0 +1,50 @@ + + + + + + TickSimple Invoices + + + +
+
+

🧾 Invoices

+ ← Back +
+ +
+ + +
+ + +
+
+ +
+

Matching Entries

+
+

Select a client and date range to see entries.

+
+
+
+ + +
+ + + + diff --git a/src/invoice/invoice.ts b/src/invoice/invoice.ts new file mode 100644 index 0000000..17dd83f --- /dev/null +++ b/src/invoice/invoice.ts @@ -0,0 +1,123 @@ +import { getEntries, getSettings, formatDuration, formatDate } from '../storage.js' +import type { TimeEntry } from '../types.js' +import { generateInvoicePDF } from '../pdf.js' + +const els = { + clientInput: document.getElementById('invoice-client') as HTMLInputElement, + numberInput: document.getElementById('invoice-number') as HTMLInputElement, + dateFrom: document.getElementById('date-from') as HTMLInputElement, + dateTo: document.getElementById('date-to') as HTMLInputElement, + entriesList: document.getElementById('matching-entries') as HTMLDivElement, + invoiceTotal: document.getElementById('invoice-total') as HTMLDivElement, + generateBtn: document.getElementById('generate-invoice') as HTMLButtonElement, +} + +let currentEntries: TimeEntry[] = [] + +async function init(): Promise { + // Set default date range to current month + const now = new Date() + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1) + els.dateFrom.value = toISODate(firstDay) + els.dateTo.value = toISODate(now) + + const settings = await getSettings() + els.clientInput.value = settings.defaultClient + + els.clientInput.addEventListener('input', debounce(updateMatching, 300)) + els.dateFrom.addEventListener('change', updateMatching) + els.dateTo.addEventListener('change', updateMatching) + + els.generateBtn.addEventListener('click', async () => { + if (currentEntries.length === 0) { + alert('No entries match the selected criteria.') + return + } + const settings = await getSettings() + const blob = generateInvoicePDF( + currentEntries, + settings, + els.clientInput.value, + els.numberInput.value || `INV-${Date.now().toString().slice(-6)}`, + ) + const fromDate = els.dateFrom.value || 'all' + const toDate = els.dateTo.value || 'all' + downloadBlob(blob, `invoice-${els.clientInput.value}-${fromDate}-to-${toDate}.pdf`) + }) + + await updateMatching() +} + +async function updateMatching(): Promise { + const client = els.clientInput.value.trim().toLowerCase() + const from = els.dateFrom.valueAsDate + const to = els.dateTo.valueAsDate + const allEntries = await getEntries() + const settings = await getSettings() + + currentEntries = allEntries.filter((e) => { + if (e.endTime === null) return false + if (client && !e.client.toLowerCase().includes(client)) return false + if (from && e.startTime < from.getTime()) return false + if (to) { + const endOfDay = new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59).getTime() + if (e.startTime > endOfDay) return false + } + return true + }).sort((a, b) => a.startTime - b.startTime) + + if (currentEntries.length === 0) { + els.entriesList.innerHTML = '

No entries match the selected criteria.

' + els.invoiceTotal.textContent = '' + return + } + + const totalMs = currentEntries.reduce((sum, e) => sum + e.duration, 0) + const totalHours = totalMs / 3600000 + const totalAmount = totalHours * settings.hourlyRate + + els.entriesList.innerHTML = currentEntries.map((entry) => { + const hours = entry.duration / 3600000 + const amount = hours * settings.hourlyRate + return ` +
+ +
${settings.currency}${amount.toFixed(2)}
+
+ ` + }).join('') + + els.invoiceTotal.textContent = `Total: ${settings.currency}${totalAmount.toFixed(2)} (${totalHours.toFixed(1)} hours @ ${settings.currency}${settings.hourlyRate}/hr)` +} + +function toISODate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function escapeHtml(text: string): string { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function debounce void>(fn: T, ms: number): (...args: Parameters) => void { + let timeout: ReturnType + return (...args: Parameters) => { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), ms) + } +} + +init().catch(console.error) diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..d4747e4 --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,186 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface-hover: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --accent: #10b981; + --accent-hover: #059669; + --danger: #ef4444; + --danger-hover: #dc2626; + --radius: 8px; + --max-width: 480px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 32px 20px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +header h1 { + font-size: 24px; + font-weight: 700; +} + +.back { + color: var(--text-muted); + text-decoration: none; + font-size: 14px; +} + +.back:hover { + color: var(--text); +} + +section { + background: var(--surface); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; +} + +section h2 { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 16px; +} + +label { + display: block; + margin-bottom: 14px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); +} + +label input { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid var(--surface-hover); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; + outline: none; +} + +label input:focus { + border-color: var(--accent); +} + +.preview { + margin-top: 8px; + max-width: 120px; + max-height: 120px; + border-radius: var(--radius); + overflow: hidden; +} + +.preview img { + width: 100%; + height: auto; + display: block; +} + +.actions { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.btn-primary { + padding: 12px 24px; + font-size: 15px; + font-weight: 600; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.saved { + font-size: 14px; + color: var(--accent); + opacity: 0; + transition: opacity 0.3s; +} + +.saved.show { + opacity: 1; +} + +.danger-zone { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.danger-zone h2 { + width: 100%; + margin-bottom: 12px; +} + +.btn-secondary { + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--surface-hover); + border-radius: var(--radius); + background: transparent; + color: var(--text); + cursor: pointer; + transition: background 0.15s; +} + +.btn-secondary:hover { + background: var(--surface-hover); +} + +.btn-danger { + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + border: none; + border-radius: var(--radius); + background: var(--danger); + color: #fff; + cursor: pointer; + transition: background 0.15s; +} + +.btn-danger:hover { + background: var(--danger-hover); +} diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..93005fe --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,73 @@ + + + + + + TickSimple Settings + + + +
+
+

⚙️ Settings

+ ← Back +
+ +
+
+

Your Info

+ + +
+ +
+

Billing Defaults

+ + + + +
+ +
+

Branding

+ +
+ +
+ + Saved! +
+
+ +
+

Data

+ + +
+
+ + + + diff --git a/src/options/options.ts b/src/options/options.ts new file mode 100644 index 0000000..6bc5bab --- /dev/null +++ b/src/options/options.ts @@ -0,0 +1,84 @@ +import { getSettings, saveSettings, getEntries, generateId } from '../storage.js' +import type { Settings } from '../types.js' + +const form = document.getElementById('settings-form') as HTMLFormElement +const savedMsg = document.getElementById('saved-msg') as HTMLSpanElement +const logoPreview = document.getElementById('logo-preview') as HTMLDivElement +const logoFile = document.getElementById('logo-file') as HTMLInputElement + +const fields: Record = { + userName: document.getElementById('user-name') as HTMLInputElement, + userEmail: document.getElementById('user-email') as HTMLInputElement, + hourlyRate: document.getElementById('hourly-rate') as HTMLInputElement, + currency: document.getElementById('currency') as HTMLInputElement, + defaultClient: document.getElementById('default-client') as HTMLInputElement, + defaultProject: document.getElementById('default-project') as HTMLInputElement, + logoDataUrl: document.createElement('input'), // hidden, managed separately +} + +let currentLogo = '' + +async function init(): Promise { + const settings = await getSettings() + fields.userName.value = settings.userName + fields.userEmail.value = settings.userEmail + fields.hourlyRate.value = String(settings.hourlyRate) + fields.currency.value = settings.currency + fields.defaultClient.value = settings.defaultClient + fields.defaultProject.value = settings.defaultProject + currentLogo = settings.logoDataUrl + + if (currentLogo) { + logoPreview.innerHTML = `Logo` + } +} + +logoFile.addEventListener('change', async () => { + const file = logoFile.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (e) => { + currentLogo = e.target?.result as string + logoPreview.innerHTML = `Logo` + } + reader.readAsDataURL(file) +}) + +form.addEventListener('submit', async (e) => { + e.preventDefault() + + const settings: Settings = { + userName: fields.userName.value.trim() || 'Your Name', + userEmail: fields.userEmail.value.trim() || 'you@example.com', + hourlyRate: parseFloat(fields.hourlyRate.value) || 0, + currency: fields.currency.value.trim() || '$', + defaultClient: fields.defaultClient.value.trim() || 'Client', + defaultProject: fields.defaultProject.value.trim() || 'Project', + logoDataUrl: currentLogo, + } + + await saveSettings(settings) + savedMsg.classList.add('show') + setTimeout(() => savedMsg.classList.remove('show'), 2000) +}) + +document.getElementById('export-btn')?.addEventListener('click', async () => { + const entries = await getEntries() + const settings = await getSettings() + const data = { entries, settings, exportedAt: new Date().toISOString() } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `ticksimple-export-${new Date().toISOString().slice(0, 10)}.json` + a.click() + URL.revokeObjectURL(url) +}) + +document.getElementById('clear-btn')?.addEventListener('click', async () => { + if (!confirm('Delete ALL time entries and settings? This cannot be undone.')) return + await chrome.storage.local.clear() + location.reload() +}) + +init().catch(console.error) diff --git a/src/pdf.ts b/src/pdf.ts new file mode 100644 index 0000000..7113e9d --- /dev/null +++ b/src/pdf.ts @@ -0,0 +1,146 @@ +import { jsPDF } from 'jspdf' +import 'jspdf-autotable' +import type { TimeEntry, Settings } from './types.js' +import { formatDuration, formatDate, formatTime } from './storage.js' + +export function generateReportPDF( + entries: TimeEntry[], + settings: Settings, + range: string, +): Blob { + const doc = new jsPDF() + const totalMs = entries.reduce((sum, e) => sum + e.duration, 0) + const totalHours = totalMs / 3600000 + + // Header + doc.setFontSize(20) + doc.text('TickSimple Time Report', 14, 20) + + doc.setFontSize(11) + doc.setTextColor(100) + doc.text(`Generated: ${new Date().toLocaleDateString()}`, 14, 30) + doc.text(`Period: ${range}`, 14, 36) + doc.text(`Total Time: ${formatDuration(totalMs)}`, 14, 42) + doc.text(`Entries: ${entries.length}`, 14, 48) + + if (settings.hourlyRate > 0) { + doc.text(`Est. Value: ${settings.currency}${(totalHours * settings.hourlyRate).toFixed(2)}`, 14, 54) + } + + // Table + const body = entries.map((e) => [ + formatDate(e.startTime), + formatTime(e.startTime), + e.client || '-', + e.project || '-', + e.description || 'Untitled', + formatDuration(e.duration), + ]) + + ;(doc as any).autoTable({ + startY: settings.hourlyRate > 0 ? 60 : 54, + head: [['Date', 'Time', 'Client', 'Project', 'Description', 'Duration']], + body, + theme: 'striped', + headStyles: { fillColor: [16, 185, 129] }, + styles: { fontSize: 9, cellPadding: 2 }, + columnStyles: { + 0: { cellWidth: 22 }, + 1: { cellWidth: 16 }, + 2: { cellWidth: 25 }, + 3: { cellWidth: 25 }, + 5: { cellWidth: 20 }, + }, + }) + + return doc.output('blob') +} + +export function generateInvoicePDF( + entries: TimeEntry[], + settings: Settings, + client: string, + invoiceNumber: string, +): Blob { + const doc = new jsPDF() + const totalMs = entries.reduce((sum, e) => sum + e.duration, 0) + const totalHours = totalMs / 3600000 + const totalAmount = totalHours * settings.hourlyRate + + // Logo + if (settings.logoDataUrl) { + try { + doc.addImage(settings.logoDataUrl, 'PNG', 14, 10, 30, 30) + } catch { + // ignore logo errors + } + } + + // Header + doc.setFontSize(24) + doc.setTextColor(16, 185, 129) + doc.text('INVOICE', 140, 20) + + doc.setFontSize(10) + doc.setTextColor(100) + doc.text(`Invoice #: ${invoiceNumber}`, 140, 28) + doc.text(`Date: ${new Date().toLocaleDateString()}`, 140, 34) + doc.text(`Due: ${new Date(Date.now() + 14 * 86400000).toLocaleDateString()}`, 140, 40) + + // From + doc.setFontSize(11) + doc.setTextColor(0) + doc.text('From:', 14, 50) + doc.setFontSize(10) + doc.text(settings.userName, 14, 57) + doc.text(settings.userEmail, 14, 63) + + // To + doc.setFontSize(11) + doc.text('Bill To:', 14, 75) + doc.setFontSize(10) + doc.text(client, 14, 82) + + // Line items + const body = entries.map((e) => { + const hours = e.duration / 3600000 + const amount = hours * settings.hourlyRate + return [ + formatDate(e.startTime), + e.description || 'Services rendered', + `${hours.toFixed(2)} hrs`, + `${settings.currency}${settings.hourlyRate.toFixed(2)}`, + `${settings.currency}${amount.toFixed(2)}`, + ] + }) + + ;(doc as any).autoTable({ + startY: 92, + head: [['Date', 'Description', 'Hours', 'Rate', 'Amount']], + body, + theme: 'striped', + headStyles: { fillColor: [16, 185, 129] }, + styles: { fontSize: 9, cellPadding: 3 }, + columnStyles: { + 0: { cellWidth: 25 }, + 2: { cellWidth: 20, halign: 'right' }, + 3: { cellWidth: 25, halign: 'right' }, + 4: { cellWidth: 25, halign: 'right' }, + }, + }) + + // Totals + const finalY = (doc as any).lastAutoTable.finalY + 10 + doc.setFontSize(12) + doc.setTextColor(0) + doc.text(`Subtotal: ${settings.currency}${totalAmount.toFixed(2)}`, 140, finalY, { align: 'right' }) + doc.text(`Total: ${settings.currency}${totalAmount.toFixed(2)}`, 140, finalY + 8, { align: 'right' }) + + // Footer + doc.setFontSize(9) + doc.setTextColor(150) + doc.text('Generated by TickSimple — ticksimple.bunbunlabs.com', 14, 280) + doc.text('Thank you for your business!', 14, 286) + + return doc.output('blob') +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..a4b661e --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,210 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface-hover: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --accent: #10b981; + --accent-hover: #059669; + --danger: #ef4444; + --radius: 8px; + --width: 360px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + width: var(--width); + min-height: 400px; +} + +.container { + padding: 16px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +header h1 { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.tag { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--accent); + border: 1px solid var(--accent); + padding: 2px 8px; + border-radius: 12px; +} + +.timer-section { + text-align: center; + margin-bottom: 16px; +} + +.timer-display { + font-size: 48px; + font-weight: 300; + font-variant-numeric: tabular-nums; + letter-spacing: -1px; + margin-bottom: 12px; + font-family: 'SF Mono', Monaco, monospace; +} + +.timer-display.running { + color: var(--accent); +} + +.btn-primary { + width: 100%; + padding: 12px 20px; + font-size: 16px; + font-weight: 600; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-primary.stop { + background: var(--danger); +} + +.inputs { + margin-bottom: 12px; +} + +.inputs input { + width: 100%; + padding: 10px 12px; + margin-bottom: 8px; + border: 1px solid var(--surface-hover); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + font-size: 14px; + outline: none; +} + +.inputs input:focus { + border-color: var(--accent); +} + +.inputs .row { + display: flex; + gap: 8px; +} + +.inputs .row input { + flex: 1; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.btn-link { + flex: 1; + text-align: center; + padding: 8px; + font-size: 12px; + color: var(--text-muted); + text-decoration: none; + background: var(--surface); + border-radius: var(--radius); + transition: background 0.15s; +} + +.btn-link:hover { + background: var(--surface-hover); + color: var(--text); +} + +.entries-section h2 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 8px; +} + +.entries-list { + max-height: 180px; + overflow-y: auto; +} + +.entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: var(--surface); + border-radius: var(--radius); + margin-bottom: 6px; +} + +.entry-info { + flex: 1; + min-width: 0; +} + +.entry-desc { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.entry-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.entry-duration { + font-size: 13px; + font-weight: 600; + color: var(--accent); + font-family: 'SF Mono', monospace; +} + +.empty { + font-size: 13px; + color: var(--text-muted); + text-align: center; + padding: 16px; +} + +.total { + text-align: right; + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--surface-hover); +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..9697b1d --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,46 @@ + + + + + + TickSimple + + + +
+
+

TickSimple

+ Time Tracker +
+ +
+
0:00
+ +
+ +
+ +
+ + +
+
+ + + +
+

Today

+
+

No time tracked yet today.

+
+
+
+
+ + + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..4c063c3 --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,168 @@ +import { + getTimerState, + getEntries, + getSettings, + getTodayEntries, + formatDurationShort, + formatDuration, + formatTime, +} from '../storage.js' +import type { TimerState } from '../types.js' + +let timerInterval: ReturnType | null = null +let currentState: TimerState | null = null + +const els = { + display: document.getElementById('timer-display') as HTMLDivElement, + toggleBtn: document.getElementById('toggle-btn') as HTMLButtonElement, + descInput: document.getElementById('desc-input') as HTMLInputElement, + clientInput: document.getElementById('client-input') as HTMLInputElement, + projectInput: document.getElementById('project-input') as HTMLInputElement, + entriesList: document.getElementById('entries-list') as HTMLDivElement, + todayTotal: document.getElementById('today-total') as HTMLDivElement, +} + +async function init(): Promise { + const [state, settings] = await Promise.all([getTimerState(), getSettings()]) + currentState = state + + // Pre-fill inputs with defaults + els.clientInput.value = state.client || settings.defaultClient + els.projectInput.value = state.project || settings.defaultProject + els.descInput.value = state.description || '' + + updateUI(state) + await renderEntries() + + // Auto-save inputs to timer state + els.descInput.addEventListener('input', debounce(saveInputs, 300)) + els.clientInput.addEventListener('input', debounce(saveInputs, 300)) + els.projectInput.addEventListener('input', debounce(saveInputs, 300)) + + els.toggleBtn.addEventListener('click', onToggle) +} + +function updateUI(state: TimerState): void { + if (state.running && state.startTime) { + els.display.classList.add('running') + els.toggleBtn.textContent = 'Stop Timer' + els.toggleBtn.classList.add('stop') + startTicking(state.startTime) + } else { + els.display.classList.remove('running') + els.toggleBtn.textContent = 'Start Timer' + els.toggleBtn.classList.remove('stop') + stopTicking() + els.display.textContent = '0:00' + } +} + +function startTicking(startTime: number): void { + stopTicking() + const tick = () => { + const elapsed = Date.now() - startTime + els.display.textContent = formatDurationShort(elapsed) + } + tick() + timerInterval = setInterval(tick, 1000) +} + +function stopTicking(): void { + if (timerInterval) { + clearInterval(timerInterval) + timerInterval = null + } +} + +async function saveInputs(): Promise { + if (!currentState) return + await chrome.runtime.sendMessage({ + type: 'UPDATE_TIMER_DESCRIPTION', + payload: { + description: els.descInput.value, + client: els.clientInput.value, + project: els.projectInput.value, + }, + }) +} + +async function onToggle(): Promise { + if (!currentState) return + + if (currentState.running) { + // Stop + els.toggleBtn.disabled = true + const result = await chrome.runtime.sendMessage({ type: 'STOP_TIMER' }) + if (result.error) { + console.error(result.error) + els.toggleBtn.disabled = false + return + } + currentState = result.state + if (!currentState) return + updateUI(currentState) + await renderEntries() + els.toggleBtn.disabled = false + } else { + // Start + els.toggleBtn.disabled = true + const result = await chrome.runtime.sendMessage({ + type: 'START_TIMER', + payload: { + description: els.descInput.value, + client: els.clientInput.value, + project: els.projectInput.value, + }, + }) + if (result.error) { + console.error(result.error) + els.toggleBtn.disabled = false + return + } + currentState = result + if (!currentState) return + updateUI(currentState) + els.toggleBtn.disabled = false + } +} + +async function renderEntries(): Promise { + const allEntries = await getEntries() + const today = getTodayEntries(allEntries) + + if (today.length === 0) { + els.entriesList.innerHTML = '

No time tracked yet today.

' + els.todayTotal.textContent = '' + return + } + + const totalMs = today.reduce((sum, e) => sum + e.duration, 0) + + els.entriesList.innerHTML = today.map((entry) => ` +
+ +
${formatDurationShort(entry.duration)}
+
+ `).join('') + + els.todayTotal.textContent = `Total: ${formatDuration(totalMs)}` +} + +function escapeHtml(text: string): string { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +function debounce void>(fn: T, ms: number): (...args: Parameters) => void { + let timeout: ReturnType + return (...args: Parameters) => { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), ms) + } +} + +init().catch(console.error) diff --git a/src/report/report.css b/src/report/report.css new file mode 100644 index 0000000..653dee6 --- /dev/null +++ b/src/report/report.css @@ -0,0 +1,169 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface-hover: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --accent: #10b981; + --accent-hover: #059669; + --radius: 8px; + --max-width: 640px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 32px 20px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +header h1 { + font-size: 22px; + font-weight: 700; +} + +.back { + color: var(--text-muted); + text-decoration: none; + font-size: 14px; +} + +.back:hover { + color: var(--text); +} + +.filters { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.filter-btn { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--surface-hover); + border-radius: var(--radius); + background: var(--surface); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; +} + +.filter-btn:hover { + border-color: var(--accent); + color: var(--text); +} + +.filter-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.stat { + background: var(--surface); + border-radius: var(--radius); + padding: 16px; + text-align: center; +} + +.stat-value { + display: block; + font-size: 24px; + font-weight: 700; + color: var(--accent); +} + +.stat-label { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.entries { + margin-bottom: 24px; +} + +.entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--surface); + border-radius: var(--radius); + margin-bottom: 8px; +} + +.entry-info { + flex: 1; + min-width: 0; +} + +.entry-desc { + font-size: 14px; + font-weight: 500; +} + +.entry-meta { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.entry-duration { + font-size: 14px; + font-weight: 600; + color: var(--accent); + font-family: 'SF Mono', monospace; +} + +.empty { + text-align: center; + color: var(--text-muted); + padding: 32px; +} + +.btn-primary { + width: 100%; + padding: 14px; + font-size: 15px; + font-weight: 600; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { + background: var(--accent-hover); +} diff --git a/src/report/report.html b/src/report/report.html new file mode 100644 index 0000000..d4b51cb --- /dev/null +++ b/src/report/report.html @@ -0,0 +1,46 @@ + + + + + + TickSimple Reports + + + +
+
+

📊 Time Reports

+ ← Back +
+ +
+ + + +
+ +
+
+ 0h + Total +
+
+ 0 + Entries +
+
+ $0 + Est. Value +
+
+ +
+

No entries for this period.

+
+ + +
+ + + + diff --git a/src/report/report.ts b/src/report/report.ts new file mode 100644 index 0000000..452186e --- /dev/null +++ b/src/report/report.ts @@ -0,0 +1,91 @@ +import { getEntries, getSettings, getTodayEntries, getWeekEntries, formatDuration, formatDate } from '../storage.js' +import type { TimeEntry } from '../types.js' +import { generateReportPDF } from '../pdf.js' + +let currentRange: 'today' | 'week' | 'all' = 'today' +let currentEntries: TimeEntry[] = [] + +const els = { + totalHours: document.getElementById('total-hours') as HTMLSpanElement, + entryCount: document.getElementById('entry-count') as HTMLSpanElement, + estimatedValue: document.getElementById('estimated-value') as HTMLSpanElement, + entriesContainer: document.getElementById('entries') as HTMLDivElement, + downloadBtn: document.getElementById('download-pdf') as HTMLButtonElement, +} + +async function init(): Promise { + await loadEntries('today') + + document.querySelectorAll('.filter-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + document.querySelectorAll('.filter-btn').forEach((b) => b.classList.remove('active')) + btn.classList.add('active') + const range = btn.getAttribute('data-range') as 'today' | 'week' | 'all' + await loadEntries(range) + }) + }) + + els.downloadBtn.addEventListener('click', async () => { + const settings = await getSettings() + const blob = generateReportPDF(currentEntries, settings, currentRange) + downloadBlob(blob, `ticksimple-report-${currentRange}-${new Date().toISOString().slice(0, 10)}.pdf`) + }) +} + +async function loadEntries(range: 'today' | 'week' | 'all'): Promise { + currentRange = range + const allEntries = await getEntries() + const settings = await getSettings() + + switch (range) { + case 'today': + currentEntries = getTodayEntries(allEntries) + break + case 'week': + currentEntries = getWeekEntries(allEntries) + break + case 'all': + currentEntries = allEntries.filter((e) => e.endTime !== null).sort((a, b) => b.startTime - a.startTime) + break + } + + const totalMs = currentEntries.reduce((sum, e) => sum + e.duration, 0) + const totalHours = totalMs / 3600000 + const estimatedValue = totalHours * settings.hourlyRate + + els.totalHours.textContent = `${totalHours.toFixed(1)}h` + els.entryCount.textContent = String(currentEntries.length) + els.estimatedValue.textContent = `${settings.currency}${estimatedValue.toFixed(0)}` + + if (currentEntries.length === 0) { + els.entriesContainer.innerHTML = '

No entries for this period.

' + return + } + + els.entriesContainer.innerHTML = currentEntries.map((entry) => ` +
+ +
${formatDuration(entry.duration)}
+
+ `).join('') +} + +function escapeHtml(text: string): string { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +init().catch(console.error) diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..f970999 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,102 @@ +import type { TimeEntry, TimerState, Settings } from './types.js' +import { DEFAULT_SETTINGS } from './types.js' + +const STORAGE_KEYS = { + entries: 'ticksimple_entries', + timer: 'ticksimple_timer', + settings: 'ticksimple_settings', +} + +export async function getEntries(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.entries) + return (result[STORAGE_KEYS.entries] as TimeEntry[]) || [] +} + +export async function saveEntry(entry: TimeEntry): Promise { + const entries = await getEntries() + const index = entries.findIndex((e) => e.id === entry.id) + if (index >= 0) { + entries[index] = entry + } else { + entries.push(entry) + } + await chrome.storage.local.set({ [STORAGE_KEYS.entries]: entries }) +} + +export async function deleteEntry(id: string): Promise { + const entries = await getEntries() + const filtered = entries.filter((e) => e.id !== id) + await chrome.storage.local.set({ [STORAGE_KEYS.entries]: filtered }) +} + +export async function getTimerState(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.timer) + return (result[STORAGE_KEYS.timer] as TimerState) || { + running: false, + startTime: null, + currentEntryId: null, + description: '', + client: '', + project: '', + } +} + +export async function setTimerState(state: TimerState): Promise { + await chrome.storage.local.set({ [STORAGE_KEYS.timer]: state }) +} + +export async function getSettings(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.settings) + return { ...DEFAULT_SETTINGS, ...(result[STORAGE_KEYS.settings] as Settings || {}) } +} + +export async function saveSettings(settings: Settings): Promise { + await chrome.storage.local.set({ [STORAGE_KEYS.settings]: settings }) +} + +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +export function formatDuration(ms: number): string { + const totalMinutes = Math.floor(ms / 60000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return `${hours}h ${minutes.toString().padStart(2, '0')}m` +} + +export function formatDurationShort(ms: number): string { + const totalMinutes = Math.floor(ms / 60000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return `${hours}:${minutes.toString().padStart(2, '0')}` +} + +export function formatDate(ts: number): string { + const d = new Date(ts) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +export function formatTime(ts: number): string { + const d = new Date(ts) + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) +} + +export function getTodayEntries(entries: TimeEntry[]): TimeEntry[] { + const now = new Date() + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + const endOfDay = startOfDay + 86400000 + return entries + .filter((e) => e.startTime >= startOfDay && e.startTime < endOfDay && e.endTime !== null) + .sort((a, b) => b.startTime - a.startTime) +} + +export function getWeekEntries(entries: TimeEntry[]): TimeEntry[] { + const now = new Date() + const dayOfWeek = now.getDay() + const startOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dayOfWeek).getTime() + const endOfWeek = startOfWeek + 7 * 86400000 + return entries + .filter((e) => e.startTime >= startOfWeek && e.startTime < endOfWeek && e.endTime !== null) + .sort((a, b) => b.startTime - a.startTime) +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3570d7a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export interface TimeEntry { + id: string + startTime: number + endTime: number | null + duration: number + description: string + client: string + project: string + createdAt: number +} + +export interface TimerState { + running: boolean + startTime: number | null + currentEntryId: string | null + description: string + client: string + project: string +} + +export interface Settings { + hourlyRate: number + defaultClient: string + defaultProject: string + userName: string + userEmail: string + logoDataUrl: string + currency: string +} + +export const DEFAULT_SETTINGS: Settings = { + hourlyRate: 50, + defaultClient: 'Client', + defaultProject: 'Project', + userName: 'Your Name', + userEmail: 'you@example.com', + logoDataUrl: '', + currency: '$', +} diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..30813ae --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,132 @@ +// Mock chrome APIs for Node test environment +;(globalThis as any).chrome = { + storage: { + local: { + get: async () => ({}), + set: async () => {}, + clear: async () => {}, + }, + }, + runtime: { + sendMessage: async () => ({}), + onMessage: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + onInstalled: { addListener: () => {} }, + }, + alarms: { + create: async () => {}, + clear: async () => true, + onAlarm: { addListener: () => {} }, + }, + action: { + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + }, +} + +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { + formatDuration, + formatDurationShort, + formatDate, + formatTime, + getTodayEntries, + getWeekEntries, + generateId, +} from '../src/storage.js' + +describe('formatDuration', () => { + it('formats 0 ms', () => { + assert.strictEqual(formatDuration(0), '0h 00m') + }) + + it('formats 1 hour', () => { + assert.strictEqual(formatDuration(3600000), '1h 00m') + }) + + it('formats 90 minutes', () => { + assert.strictEqual(formatDuration(5400000), '1h 30m') + }) + + it('formats 25 minutes', () => { + assert.strictEqual(formatDuration(1500000), '0h 25m') + }) +}) + +describe('formatDurationShort', () => { + it('formats 0 ms', () => { + assert.strictEqual(formatDurationShort(0), '0:00') + }) + + it('formats 1 hour 5 min', () => { + assert.strictEqual(formatDurationShort(3900000), '1:05') + }) +}) + +describe('formatDate', () => { + it('formats a timestamp', () => { + const ts = new Date('2024-06-20T10:00:00Z').getTime() + const result = formatDate(ts) + assert.ok(result.includes('Jun') || result.includes('20')) + }) +}) + +describe('formatTime', () => { + it('formats a timestamp', () => { + const ts = new Date('2024-06-20T10:30:00Z').getTime() + const result = formatTime(ts) + assert.ok(result.includes(':')) + }) +}) + +describe('getTodayEntries', () => { + it('returns only today entries', () => { + const now = new Date() + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + const todayEntry = startOfDay + 3600000 // 1 hour after start of day + const yesterdayEntry = startOfDay - 3600000 // 1 hour before start of day + const entries = [ + { id: '1', startTime: todayEntry, endTime: todayEntry + 1800000, duration: 1800000, description: 'A', client: 'C', project: 'P', createdAt: todayEntry }, + { id: '2', startTime: yesterdayEntry, endTime: yesterdayEntry + 1800000, duration: 1800000, description: 'B', client: 'C', project: 'P', createdAt: yesterdayEntry }, + ] + const result = getTodayEntries(entries) + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].id, '1') + }) + + it('excludes running entries', () => { + const now = Date.now() + const entries = [ + { id: '1', startTime: now - 3600000, endTime: null, duration: 0, description: 'A', client: 'C', project: 'P', createdAt: now }, + ] + const result = getTodayEntries(entries) + assert.strictEqual(result.length, 0) + }) +}) + +describe('getWeekEntries', () => { + it('returns entries from this week', () => { + const now = new Date() + const dayOfWeek = now.getDay() + const startOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dayOfWeek) + const weekEntry = startOfWeek.getTime() + 3600000 + const oldEntry = startOfWeek.getTime() - 86400000 + const entries = [ + { id: '1', startTime: weekEntry, endTime: weekEntry + 1800000, duration: 1800000, description: 'A', client: 'C', project: 'P', createdAt: weekEntry }, + { id: '2', startTime: oldEntry, endTime: oldEntry + 1800000, duration: 1800000, description: 'B', client: 'C', project: 'P', createdAt: oldEntry }, + ] + const result = getWeekEntries(entries) + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].id, '1') + }) +}) + +describe('generateId', () => { + it('generates unique ids', () => { + const id1 = generateId() + const id2 = generateId() + assert.notStrictEqual(id1, id2) + assert.ok(id1.length > 10) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c6a3416 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist-test", + "types": ["chrome", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9988c9a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + popup: 'src/popup/popup.html', + options: 'src/options/options.html', + report: 'src/report/report.html', + invoice: 'src/invoice/invoice.html', + background: 'src/background.ts', + }, + output: { + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: (assetInfo) => { + const info = assetInfo.name || '' + if (info.endsWith('.html')) return '[name][extname]' + if (info.endsWith('.css')) return '[name][extname]' + return 'assets/[name][extname]' + }, + }, + }, + }, + publicDir: 'public', +})