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
+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);
});
});