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:
Bun Bun
2026-06-20 00:30:24 +00:00
commit c02d5ef2ae
37 changed files with 4689 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.verdict
.env
.DS_Store
+127
View File
@@ -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();
});
+103
View File
@@ -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);
+73
View File
@@ -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);
+119
View File
@@ -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');
}
+144
View File
@@ -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);
+76
View File
@@ -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);
+87
View File
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
export const DEFAULT_SETTINGS = {
hourlyRate: 50,
defaultClient: 'Client',
defaultProject: 'Project',
userName: 'Your Name',
userEmail: 'you@example.com',
logoDataUrl: '',
currency: '$',
};
+112
View File
@@ -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);
});
});
+1724
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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

+27
View File
@@ -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"
}
}
+24
View File
@@ -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.')
+150
View File
@@ -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()
})
+175
View File
@@ -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);
}
+50
View File
@@ -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>
+123
View File
@@ -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)
+186
View File
@@ -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);
}
+73
View File
@@ -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>
+84
View File
@@ -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
View File
@@ -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')
}
+210
View File
@@ -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);
}
+46
View File
@@ -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>
+168
View File
@@ -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)
+169
View File
@@ -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);
}
+46
View File
@@ -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>
+91
View File
@@ -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
View File
@@ -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)
}
+39
View File
@@ -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: '$',
}
+132
View File
@@ -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)
})
})
+17
View File
@@ -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"]
}
+29
View File
@@ -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',
})