TickSimple v1.0.0 — Dead-simple time tracker for freelancers. One-tap timer, PDF reports, invoice generation, Chrome extension MV3.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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 = '<p class="empty">No entries match the selected criteria.</p>';
|
||||
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 `
|
||||
<div class="entry">
|
||||
<div class="entry-info">
|
||||
<div class="entry-desc">${escapeHtml(entry.description || 'Untitled')}</div>
|
||||
<div class="entry-meta">${formatDate(entry.startTime)} • ${formatDuration(entry.duration)}</div>
|
||||
</div>
|
||||
<div class="entry-amount">${settings.currency}${amount.toFixed(2)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).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);
|
||||
@@ -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 = `<img src="${currentLogo}" alt="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 = `<img src="${currentLogo}" alt="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);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 = '<p class="empty">No time tracked yet today.</p>';
|
||||
els.todayTotal.textContent = '';
|
||||
return;
|
||||
}
|
||||
const totalMs = today.reduce((sum, e) => sum + e.duration, 0);
|
||||
els.entriesList.innerHTML = today.map((entry) => `
|
||||
<div class="entry">
|
||||
<div class="entry-info">
|
||||
<div class="entry-desc">${escapeHtml(entry.description || 'Untitled')}</div>
|
||||
<div class="entry-meta">${escapeHtml(entry.client)} • ${formatTime(entry.startTime)}</div>
|
||||
</div>
|
||||
<div class="entry-duration">${formatDurationShort(entry.duration)}</div>
|
||||
</div>
|
||||
`).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);
|
||||
@@ -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 = '<p class="empty">No entries for this period.</p>';
|
||||
return;
|
||||
}
|
||||
els.entriesContainer.innerHTML = currentEntries.map((entry) => `
|
||||
<div class="entry">
|
||||
<div class="entry-info">
|
||||
<div class="entry-desc">${escapeHtml(entry.description || 'Untitled')}</div>
|
||||
<div class="entry-meta">${escapeHtml(entry.client)} • ${formatDate(entry.startTime)}</div>
|
||||
</div>
|
||||
<div class="entry-duration">${formatDuration(entry.duration)}</div>
|
||||
</div>
|
||||
`).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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DEFAULT_SETTINGS = {
|
||||
hourlyRate: 50,
|
||||
defaultClient: 'Client',
|
||||
defaultProject: 'Project',
|
||||
userName: 'Your Name',
|
||||
userEmail: 'you@example.com',
|
||||
logoDataUrl: '',
|
||||
currency: '$',
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user