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,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.verdict
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+1724
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.')
|
||||||
@@ -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<typeof setInterval> | 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<TimerState> {
|
||||||
|
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<TimerState> {
|
||||||
|
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<void> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TickSimple Invoices</title>
|
||||||
|
<link rel="stylesheet" href="./invoice.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🧾 Invoices</h1>
|
||||||
|
<a href="../popup/popup.html" class="back">← Back</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="invoice-form">
|
||||||
|
<label>
|
||||||
|
Client
|
||||||
|
<input type="text" id="invoice-client" placeholder="Client name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Invoice #
|
||||||
|
<input type="text" id="invoice-number" placeholder="INV-001">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
From Date
|
||||||
|
<input type="date" id="date-from">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To Date
|
||||||
|
<input type="date" id="date-to">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entries-section">
|
||||||
|
<h2>Matching Entries</h2>
|
||||||
|
<div id="matching-entries" class="entries-list">
|
||||||
|
<p class="empty">Select a client and date range to see entries.</p>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-total" id="invoice-total"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" id="generate-invoice">Generate PDF Invoice</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./invoice.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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: 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<T extends (...args: unknown[]) => void>(fn: T, ms: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => fn(...args), ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init().catch(console.error)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TickSimple Settings</title>
|
||||||
|
<link rel="stylesheet" href="./options.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>⚙️ Settings</h1>
|
||||||
|
<a href="../popup/popup.html" class="back">← Back</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="settings-form">
|
||||||
|
<section>
|
||||||
|
<h2>Your Info</h2>
|
||||||
|
<label>
|
||||||
|
Your Name
|
||||||
|
<input type="text" id="user-name" placeholder="Your Name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Your Email
|
||||||
|
<input type="email" id="user-email" placeholder="you@example.com">
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Billing Defaults</h2>
|
||||||
|
<label>
|
||||||
|
Hourly Rate
|
||||||
|
<input type="number" id="hourly-rate" min="0" step="0.01" placeholder="50">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Currency Symbol
|
||||||
|
<input type="text" id="currency" maxlength="3" placeholder="$">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Default Client
|
||||||
|
<input type="text" id="default-client" placeholder="Client">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Default Project
|
||||||
|
<input type="text" id="default-project" placeholder="Project">
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Branding</h2>
|
||||||
|
<label>
|
||||||
|
Logo (optional)
|
||||||
|
<input type="file" id="logo-file" accept="image/*">
|
||||||
|
<div class="preview" id="logo-preview"></div>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn-primary">Save Settings</button>
|
||||||
|
<span class="saved" id="saved-msg">Saved!</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="danger-zone">
|
||||||
|
<h2>Data</h2>
|
||||||
|
<button type="button" class="btn-secondary" id="export-btn">Export JSON</button>
|
||||||
|
<button type="button" class="btn-danger" id="clear-btn">Clear All Data</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./options.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<keyof Settings, HTMLInputElement> = {
|
||||||
|
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<void> {
|
||||||
|
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 as string
|
||||||
|
logoPreview.innerHTML = `<img src="${currentLogo}" alt="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)
|
||||||
+146
@@ -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')
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TickSimple</title>
|
||||||
|
<link rel="stylesheet" href="./popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>TickSimple</h1>
|
||||||
|
<span class="tag">Time Tracker</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="timer-section">
|
||||||
|
<div class="timer-display" id="timer-display">0:00</div>
|
||||||
|
<button class="btn-primary" id="toggle-btn">Start Timer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inputs">
|
||||||
|
<input type="text" id="desc-input" placeholder="What are you working on?" maxlength="100">
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" id="client-input" placeholder="Client" maxlength="50">
|
||||||
|
<input type="text" id="project-input" placeholder="Project" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="../report/report.html" target="_blank" class="btn-link">📊 Reports</a>
|
||||||
|
<a href="../invoice/invoice.html" target="_blank" class="btn-link">🧾 Invoices</a>
|
||||||
|
<a href="../options/options.html" target="_blank" class="btn-link">⚙️ Settings</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entries-section">
|
||||||
|
<h2>Today</h2>
|
||||||
|
<div id="entries-list" class="entries-list">
|
||||||
|
<p class="empty">No time tracked yet today.</p>
|
||||||
|
</div>
|
||||||
|
<div class="total" id="today-total"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./popup.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
getTimerState,
|
||||||
|
getEntries,
|
||||||
|
getSettings,
|
||||||
|
getTodayEntries,
|
||||||
|
formatDurationShort,
|
||||||
|
formatDuration,
|
||||||
|
formatTime,
|
||||||
|
} from '../storage.js'
|
||||||
|
import type { TimerState } from '../types.js'
|
||||||
|
|
||||||
|
let timerInterval: ReturnType<typeof setInterval> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => fn(...args), ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init().catch(console.error)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TickSimple Reports</title>
|
||||||
|
<link rel="stylesheet" href="./report.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📊 Time Reports</h1>
|
||||||
|
<a href="../popup/popup.html" class="back">← Back</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<button class="filter-btn active" data-range="today">Today</button>
|
||||||
|
<button class="filter-btn" data-range="week">This Week</button>
|
||||||
|
<button class="filter-btn" data-range="all">All Time</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary" id="summary">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value" id="total-hours">0h</span>
|
||||||
|
<span class="stat-label">Total</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value" id="entry-count">0</span>
|
||||||
|
<span class="stat-label">Entries</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value" id="estimated-value">$0</span>
|
||||||
|
<span class="stat-label">Est. Value</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entries" id="entries">
|
||||||
|
<p class="empty">No entries for this period.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" id="download-pdf">Download PDF Report</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./report.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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: 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)
|
||||||
+102
@@ -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<TimeEntry[]> {
|
||||||
|
const result = await chrome.storage.local.get(STORAGE_KEYS.entries)
|
||||||
|
return (result[STORAGE_KEYS.entries] as TimeEntry[]) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntry(entry: TimeEntry): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<TimerState> {
|
||||||
|
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<void> {
|
||||||
|
await chrome.storage.local.set({ [STORAGE_KEYS.timer]: state })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<Settings> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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: '$',
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user