tooltips, two-column timer, font selector, tray behavior, icons, readme

- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- README with feature documentation
- Remove tracked files that belong in gitignore
This commit is contained in:
Your Name
2026-02-21 01:15:57 +02:00
parent 66d12e8c5c
commit c4703dfe98
144 changed files with 13351 additions and 3456 deletions

228
src/utils/audio.ts Normal file
View File

@@ -0,0 +1,228 @@
export type SoundEvent =
| 'timer_start'
| 'timer_stop'
| 'timer_pause'
| 'timer_resume'
| 'idle_alert'
| 'goal_reached'
| 'break_reminder'
export const SOUND_EVENTS: { key: SoundEvent; label: string }[] = [
{ key: 'timer_start', label: 'Timer start' },
{ key: 'timer_stop', label: 'Timer stop' },
{ key: 'timer_pause', label: 'Pause' },
{ key: 'timer_resume', label: 'Resume' },
{ key: 'idle_alert', label: 'Idle alert' },
{ key: 'goal_reached', label: 'Goal reached' },
{ key: 'break_reminder', label: 'Break reminder' },
]
export interface AudioSettings {
enabled: boolean
mode: 'synthesized' | 'system' | 'custom'
volume: number
events: Record<SoundEvent, boolean>
}
export const DEFAULT_EVENTS: Record<SoundEvent, boolean> = {
timer_start: true,
timer_stop: true,
timer_pause: true,
timer_resume: true,
idle_alert: true,
goal_reached: true,
break_reminder: true,
}
const DEFAULT_SETTINGS: AudioSettings = {
enabled: false,
mode: 'synthesized',
volume: 70,
events: { ...DEFAULT_EVENTS },
}
class AudioEngine {
private ctx: AudioContext | null = null
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext()
}
if (this.ctx.state === 'suspended') {
this.ctx.resume()
}
return this.ctx
}
private get gain(): number {
return this.settings.volume / 100
}
updateSettings(partial: Partial<AudioSettings>) {
if (partial.enabled !== undefined) this.settings.enabled = partial.enabled
if (partial.mode !== undefined) this.settings.mode = partial.mode
if (partial.volume !== undefined) this.settings.volume = partial.volume
if (partial.events !== undefined) this.settings.events = { ...partial.events }
}
getSettings(): AudioSettings {
return { ...this.settings, events: { ...this.settings.events } }
}
play(event: SoundEvent) {
if (!this.settings.enabled) return
if (!this.settings.events[event]) return
if (this.settings.mode !== 'synthesized') return
this.synthesize(event)
}
playTest(event: SoundEvent) {
this.synthesize(event)
}
private synthesize(event: SoundEvent) {
switch (event) {
case 'timer_start':
this.playTimerStart()
break
case 'timer_stop':
this.playTimerStop()
break
case 'timer_pause':
this.playTimerPause()
break
case 'timer_resume':
this.playTimerResume()
break
case 'idle_alert':
this.playIdleAlert()
break
case 'goal_reached':
this.playGoalReached()
break
case 'break_reminder':
this.playBreakReminder()
break
}
}
// Quick ascending two-note chime: C5 then E5
private playTimerStart() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
// Note 1: C5 (523Hz) for 100ms
this.playTone(ctx, t, 523, 0.100, vol, 0.010, 0.050, 3)
// Note 2: E5 (659Hz) for 150ms
this.playTone(ctx, t + 0.110, 659, 0.150, vol, 0.010, 0.050, 3)
}
// Descending resolve: G5 sliding to C5 over 250ms
private playTimerStop() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
const duration = 0.250
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(784, t)
osc.frequency.linearRampToValueAtTime(523, t + duration)
gain.gain.setValueAtTime(0, t)
gain.gain.linearRampToValueAtTime(vol, t + 0.010)
gain.gain.setValueAtTime(vol, t + duration - 0.100)
gain.gain.linearRampToValueAtTime(0, t + duration)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(t)
osc.stop(t + duration)
}
// Single soft tone: A4 (440Hz) for 120ms
private playTimerPause() {
const ctx = this.ensureContext()
const t = ctx.currentTime
this.playTone(ctx, t, 440, 0.120, this.gain, 0.005, 0.060)
}
// Single bright tone: C5 (523Hz) for 120ms
private playTimerResume() {
const ctx = this.ensureContext()
const t = ctx.currentTime
this.playTone(ctx, t, 523, 0.120, this.gain, 0.005, 0.060)
}
// Two quick pulses at A5 (880Hz), each 80ms with 60ms gap
private playIdleAlert() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
this.playTone(ctx, t, 880, 0.080, vol, 0.005, 0.030)
this.playTone(ctx, t + 0.140, 880, 0.080, vol, 0.005, 0.030)
}
// Ascending three-note fanfare: C5, E5, G5
private playGoalReached() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
this.playTone(ctx, t, 523, 0.120, vol, 0.010, 0.040, 3)
this.playTone(ctx, t + 0.130, 659, 0.120, vol, 0.010, 0.040, 3)
this.playTone(ctx, t + 0.260, 784, 0.120, vol, 0.010, 0.040, 3)
}
// Gentle single chime at E5 (659Hz), 200ms, long release
private playBreakReminder() {
const ctx = this.ensureContext()
const t = ctx.currentTime
this.playTone(ctx, t, 659, 0.200, this.gain, 0.020, 0.100)
}
// Helper: play a single tone with ADSR-style envelope
// Optional detuneCents adds a second oscillator slightly detuned for warmth
private playTone(
ctx: AudioContext,
startAt: number,
freq: number,
duration: number,
vol: number,
attack: number,
release: number,
detuneCents?: number
) {
const endAt = startAt + duration
const gainNode = ctx.createGain()
gainNode.gain.setValueAtTime(0, startAt)
gainNode.gain.linearRampToValueAtTime(vol, startAt + attack)
gainNode.gain.setValueAtTime(vol, endAt - release)
gainNode.gain.linearRampToValueAtTime(0, endAt)
gainNode.connect(ctx.destination)
const osc1 = ctx.createOscillator()
osc1.type = 'sine'
osc1.frequency.setValueAtTime(freq, startAt)
osc1.connect(gainNode)
osc1.start(startAt)
osc1.stop(endAt)
if (detuneCents) {
const osc2 = ctx.createOscillator()
osc2.type = 'sine'
osc2.frequency.setValueAtTime(freq, startAt)
osc2.detune.setValueAtTime(detuneCents, startAt)
osc2.connect(gainNode)
osc2.start(startAt)
osc2.stop(endAt)
}
}
}
export const audioEngine = new AudioEngine()

43
src/utils/chartTheme.ts Normal file
View File

@@ -0,0 +1,43 @@
export function getChartTheme() {
const style = getComputedStyle(document.documentElement)
return {
accent: style.getPropertyValue('--color-accent').trim(),
accentMuted: style.getPropertyValue('--color-accent-muted').trim(),
textPrimary: style.getPropertyValue('--color-text-primary').trim(),
textSecondary: style.getPropertyValue('--color-text-secondary').trim(),
textTertiary: style.getPropertyValue('--color-text-tertiary').trim(),
gridColor: style.getPropertyValue('--color-border-subtle').trim(),
bgSurface: style.getPropertyValue('--color-bg-surface').trim(),
}
}
export type ChartTheme = ReturnType<typeof getChartTheme>
export function buildBarChartOptions(theme: ChartTheme) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: theme.bgSurface,
titleColor: theme.textPrimary,
bodyColor: theme.textSecondary,
borderColor: theme.gridColor,
borderWidth: 1,
},
},
scales: {
x: {
ticks: { color: theme.textTertiary, font: { size: 11 } },
grid: { display: false },
border: { display: false },
},
y: {
ticks: { color: theme.textTertiary, font: { size: 11 } },
grid: { color: theme.gridColor },
border: { display: false },
},
},
}
}

44
src/utils/csvExport.ts Normal file
View File

@@ -0,0 +1,44 @@
import { save } from '@tauri-apps/plugin-dialog'
import { invoke } from '@tauri-apps/api/core'
function escapeCSV(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
}
function toCSVRow(values: string[]): string {
return values.map(escapeCSV).join(',')
}
export function entriesToCSV(
entries: { id?: number; start_time: string; duration: number; description?: string; billable?: number; project_id: number; task_id?: number }[],
getProjectName: (id: number) => string,
getClientName: (projectId: number) => string,
getTaskName: (id?: number) => string,
getTagNames: (entryId: number) => string
): string {
const headers = ['Date', 'Project', 'Client', 'Task', 'Description', 'Duration (hours)', 'Billable', 'Tags']
const rows = entries.map(e => toCSVRow([
e.start_time.split('T')[0],
getProjectName(e.project_id),
getClientName(e.project_id),
getTaskName(e.task_id),
e.description || '',
(e.duration / 3600).toFixed(2),
e.billable === 1 ? 'Yes' : 'No',
getTagNames(e.id || 0),
]))
return [toCSVRow(headers), ...rows].join('\n')
}
export async function downloadCSV(content: string, defaultName: string): Promise<boolean> {
const path = await save({
defaultPath: defaultName,
filters: [{ name: 'CSV', extensions: ['csv'] }],
})
if (!path) return false
await invoke('save_binary_file', { path, data: Array.from(new TextEncoder().encode(content)) })
return true
}

86
src/utils/dropdown.ts Normal file
View File

@@ -0,0 +1,86 @@
export function getZoomFactor(): number {
const app = document.getElementById('app')
if (!app) return 1
const zoom = (app.style as any).zoom
return zoom ? parseFloat(zoom) / 100 : 1
}
// Cached probe results for fixed-position coordinate mapping inside #app.
// CSS zoom behavior with position:fixed varies across Chromium versions,
// so we detect the actual behavior at runtime with a probe element.
let _probeCache: { scaleX: number; scaleY: number; offsetX: number; offsetY: number; zoomKey: string } | null = null
export function getFixedPositionMapping(): { scaleX: number; scaleY: number; offsetX: number; offsetY: number } {
const app = document.getElementById('app')
if (!app) return { scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }
const zoomKey = (app.style as any).zoom || ''
if (_probeCache && _probeCache.zoomKey === zoomKey) {
return _probeCache
}
const probe = document.createElement('div')
probe.style.cssText = 'position:fixed;top:0;left:0;width:100px;height:100px;visibility:hidden;pointer-events:none'
app.appendChild(probe)
const r0 = probe.getBoundingClientRect()
probe.style.top = '100px'
probe.style.left = '100px'
const r1 = probe.getBoundingClientRect()
app.removeChild(probe)
const result = {
scaleX: (r1.left - r0.left) / 100,
scaleY: (r1.top - r0.top) / 100,
offsetX: r0.left,
offsetY: r0.top,
zoomKey,
}
_probeCache = result
return result
}
export function resetPositionCache() {
_probeCache = null
}
export function computeDropdownPosition(
triggerEl: HTMLElement,
opts?: { minWidth?: number; estimatedHeight?: number; panelEl?: HTMLElement | null }
): Record<string, string> {
const rect = triggerEl.getBoundingClientRect()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
const minW = (opts?.minWidth ?? 0) * scaleX
const vpH = window.innerHeight
const vpW = window.innerWidth
// Default: position below trigger
let topVP = rect.bottom + gap
// Use offsetHeight (unaffected by CSS transition transforms like scale(0.95))
// and multiply by scaleY to get viewport pixels.
const panelEl = opts?.panelEl
if (panelEl) {
const panelH = panelEl.offsetHeight * scaleY
if (topVP + panelH > vpH && rect.top - gap - panelH >= 0) {
topVP = rect.top - gap - panelH
}
}
let leftVP = rect.left
const widthVP = Math.max(rect.width, minW)
if (leftVP + widthVP > vpW - gap) {
leftVP = vpW - gap - widthVP
}
if (leftVP < gap) leftVP = gap
// Convert viewport pixels to CSS pixels for position:fixed inside #app
return {
position: 'fixed',
top: `${(topVP - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
width: `${widthVP / scaleX}px`,
zIndex: '9999',
}
}

71
src/utils/focusTrap.ts Normal file
View File

@@ -0,0 +1,71 @@
import { ref, onUnmounted } from 'vue'
export function useFocusTrap() {
const trapElement = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
let isActive = false
function getFocusableElements(el: HTMLElement): HTMLElement[] {
const selectors = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
return Array.from(el.querySelectorAll<HTMLElement>(selectors)).filter(e => e.offsetParent !== null)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
deactivate()
return
}
if (e.key !== 'Tab' || !trapElement.value) return
const focusable = getFocusableElements(trapElement.value)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
let onDeactivate: (() => void) | null = null
function activate(el: HTMLElement, opts?: { onDeactivate?: () => void, initialFocus?: HTMLElement }) {
if (isActive) deactivate()
isActive = true
trapElement.value = el
previouslyFocused = document.activeElement as HTMLElement
onDeactivate = opts?.onDeactivate || null
document.addEventListener('keydown', onKeydown)
const target = opts?.initialFocus || getFocusableElements(el)[0]
if (target) {
requestAnimationFrame(() => {
if (trapElement.value === el) target.focus()
})
}
}
function deactivate() {
if (!isActive) return
isActive = false
document.removeEventListener('keydown', onKeydown)
trapElement.value = null
if (onDeactivate) onDeactivate()
if (previouslyFocused) {
previouslyFocused.focus()
previouslyFocused = null
}
}
onUnmounted(() => {
if (isActive) deactivate()
})
return { activate, deactivate }
}

47
src/utils/fonts.ts Normal file
View File

@@ -0,0 +1,47 @@
export interface TimerFont {
label: string
value: string
}
export const TIMER_FONTS: TimerFont[] = [
{ label: 'JetBrains Mono', value: 'JetBrains Mono' },
{ label: 'Fira Code', value: 'Fira Code' },
{ label: 'Source Code Pro', value: 'Source Code Pro' },
{ label: 'IBM Plex Mono', value: 'IBM Plex Mono' },
{ label: 'Roboto Mono', value: 'Roboto Mono' },
{ label: 'Space Mono', value: 'Space Mono' },
{ label: 'Ubuntu Mono', value: 'Ubuntu Mono' },
{ label: 'Inconsolata', value: 'Inconsolata' },
{ label: 'Red Hat Mono', value: 'Red Hat Mono' },
{ label: 'DM Mono', value: 'DM Mono' },
{ label: 'Azeret Mono', value: 'Azeret Mono' },
{ label: 'Martian Mono', value: 'Martian Mono' },
{ label: 'Share Tech Mono', value: 'Share Tech Mono' },
{ label: 'Anonymous Pro', value: 'Anonymous Pro' },
{ label: 'Victor Mono', value: 'Victor Mono' },
{ label: 'Overpass Mono', value: 'Overpass Mono' },
]
const loadedFonts = new Set<string>()
export function loadGoogleFont(fontName: string): void {
if (loadedFonts.has(fontName)) return
loadedFonts.add(fontName)
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600&display=swap`
document.head.appendChild(link)
}
export function applyTimerFont(fontName: string): void {
document.documentElement.style.setProperty(
'--font-timer',
`'${fontName}', monospace`
)
}
export function loadAndApplyTimerFont(fontName: string): void {
loadGoogleFont(fontName)
applyTimerFont(fontName)
}

33
src/utils/formGuard.ts Normal file
View File

@@ -0,0 +1,33 @@
import { ref } from 'vue'
export function useFormGuard() {
const _snapshot = ref('')
const showDiscardDialog = ref(false)
let _pendingClose: (() => void) | null = null
function snapshot(data: unknown) {
_snapshot.value = JSON.stringify(data)
}
function tryClose(data: unknown, closeFn: () => void) {
if (JSON.stringify(data) !== _snapshot.value) {
_pendingClose = closeFn
showDiscardDialog.value = true
} else {
closeFn()
}
}
function confirmDiscard() {
showDiscardDialog.value = false
_pendingClose?.()
_pendingClose = null
}
function cancelDiscard() {
showDiscardDialog.value = false
_pendingClose = null
}
return { showDiscardDialog, snapshot, tryClose, confirmDiscard, cancelDiscard }
}

View File

@@ -1,4 +1,5 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({
breaks: true,
@@ -7,7 +8,8 @@ marked.setOptions({
export function renderMarkdown(text: string): string {
if (!text) return ''
return marked.parseInline(text) as string
const raw = marked.parseInline(text) as string
return DOMPurify.sanitize(raw, { ALLOWED_TAGS: ['strong', 'em', 'code', 'a', 'br'], ALLOWED_ATTR: ['href', 'target', 'rel'] })
}
export function stripMarkdown(text: string): string {

481
src/utils/reportPdf.ts Normal file
View File

@@ -0,0 +1,481 @@
import { jsPDF } from 'jspdf'
import { formatCurrency, formatNumber } from './locale'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ReportProjectRow {
name: string
color: string
hours: number
rate: number
earnings: number
percentage: number
}
export interface HoursReportData {
dateRange: { start: string; end: string }
totalHours: number
totalEarnings: number
billableHours: number
nonBillableHours: number
projects: ReportProjectRow[]
}
export interface ProfitabilityReportRow {
project_name: string
client_name: string | null
total_hours: number
hourly_rate: number
revenue: number
expenses: number
net_profit: number
budget_hours: number | null
budget_used_pct: number | null
}
export interface ProfitabilityReportData {
dateRange: { start: string; end: string }
totalRevenue: number
totalExpenses: number
totalNet: number
totalHours: number
avgRate: number
rows: ProfitabilityReportRow[]
}
export interface ExpenseReportData {
dateRange: { start: string; end: string }
totalAmount: number
invoicedAmount: number
uninvoicedAmount: number
byCategory: { category: string; amount: number; percentage: number }[]
byProject: { name: string; color: string; amount: number; percentage: number }[]
}
export interface PatternsReportData {
dateRange: { start: string; end: string }
heatmap: number[][]
peakDay: string
peakHour: string
}
// ---------------------------------------------------------------------------
// Shared PDF helpers
// ---------------------------------------------------------------------------
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
]
}
function setText(doc: jsPDF, hex: string) {
const [r, g, b] = hexToRgb(hex)
doc.setTextColor(r, g, b)
}
function setFill(doc: jsPDF, hex: string) {
const [r, g, b] = hexToRgb(hex)
doc.setFillColor(r, g, b)
}
const PAGE_W = 210
const PAGE_H = 297
const MARGIN = 15
const CONTENT_W = PAGE_W - MARGIN * 2
function drawHeader(doc: jsPDF, title: string, dateRange: { start: string; end: string }): number {
let y = MARGIN
doc.setFontSize(18)
setText(doc, '#E5E7EB')
doc.text(title, MARGIN, y + 6)
y += 10
doc.setFontSize(9)
setText(doc, '#9CA3AF')
doc.text(`${dateRange.start} to ${dateRange.end}`, MARGIN, y + 4)
y += 10
setFill(doc, '#374151')
doc.rect(MARGIN, y, CONTENT_W, 0.3, 'F')
y += 6
return y
}
function drawStatBox(doc: jsPDF, x: number, y: number, w: number, label: string, value: string) {
setFill(doc, '#1F2937')
doc.roundedRect(x, y, w, 18, 2, 2, 'F')
doc.setFontSize(7)
setText(doc, '#9CA3AF')
doc.text(label.toUpperCase(), x + 4, y + 6)
doc.setFontSize(11)
setText(doc, '#F9FAFB')
doc.text(value, x + 4, y + 14)
}
function drawTableHeader(doc: jsPDF, y: number, cols: { label: string; x: number; w: number; align?: string }[]): number {
setFill(doc, '#1F2937')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
doc.setFontSize(6.5)
setText(doc, '#9CA3AF')
for (const col of cols) {
if (col.align === 'right') {
doc.text(col.label.toUpperCase(), col.x + col.w - 2, y + 5, { align: 'right' })
} else {
doc.text(col.label.toUpperCase(), col.x + 2, y + 5)
}
}
return y + 7
}
function checkPageBreak(doc: jsPDF, y: number, needed: number): number {
if (y + needed > PAGE_H - MARGIN) {
doc.addPage()
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
return MARGIN
}
return y
}
// ---------------------------------------------------------------------------
// Hours Report
// ---------------------------------------------------------------------------
function renderHoursReport(doc: jsPDF, data: HoursReportData) {
let y = drawHeader(doc, 'Hours Report', data.dateRange)
const boxW = (CONTENT_W - 6) / 4
drawStatBox(doc, MARGIN, y, boxW, 'Total Hours', formatNumber(data.totalHours, 1) + 'h')
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Earnings', formatCurrency(data.totalEarnings))
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Billable', formatNumber(data.billableHours, 1) + 'h')
drawStatBox(doc, MARGIN + (boxW + 2) * 3, y, boxW, 'Non-Billable', formatNumber(data.nonBillableHours, 1) + 'h')
y += 24
// Bar chart
if (data.projects.length > 0) {
const barAreaH = Math.min(data.projects.length * 10, 80)
y = checkPageBreak(doc, y, barAreaH + 10)
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('Hours by Project', MARGIN, y + 4)
y += 8
const maxHours = Math.max(...data.projects.map(p => p.hours))
const barMaxW = CONTENT_W - 50
for (const proj of data.projects) {
y = checkPageBreak(doc, y, 10)
const barW = maxHours > 0 ? (proj.hours / maxHours) * barMaxW : 0
doc.setFontSize(7)
setText(doc, '#D1D5DB')
doc.text(proj.name.substring(0, 25), MARGIN, y + 4)
setFill(doc, proj.color || '#F59E0B')
doc.roundedRect(MARGIN + 48, y, Math.max(barW, 1), 5, 1, 1, 'F')
setText(doc, '#9CA3AF')
doc.text(formatNumber(proj.hours, 1) + 'h', MARGIN + 50 + barW, y + 4)
y += 8
}
y += 4
}
// Project table
y = checkPageBreak(doc, y, 20)
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('Project Breakdown', MARGIN, y + 4)
y += 8
const cols = [
{ label: 'Project', x: MARGIN, w: 60 },
{ label: 'Hours', x: MARGIN + 60, w: 30, align: 'right' },
{ label: 'Rate', x: MARGIN + 90, w: 35, align: 'right' },
{ label: 'Earnings', x: MARGIN + 125, w: 35, align: 'right' },
{ label: '%', x: MARGIN + 160, w: 20, align: 'right' },
]
y = drawTableHeader(doc, y, cols)
doc.setFontSize(7)
for (let i = 0; i < data.projects.length; i++) {
y = checkPageBreak(doc, y, 7)
const p = data.projects[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setFill(doc, p.color || '#F59E0B')
doc.circle(MARGIN + 4, y + 3.5, 1.5, 'F')
setText(doc, '#E5E7EB')
doc.text(p.name.substring(0, 30), MARGIN + 8, y + 5)
setText(doc, '#D1D5DB')
doc.text(formatNumber(p.hours, 1), MARGIN + 60 + 30 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(p.rate), MARGIN + 90 + 35 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(p.earnings), MARGIN + 125 + 35 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(formatNumber(p.percentage, 0) + '%', MARGIN + 160 + 20 - 2, y + 5, { align: 'right' })
y += 7
}
// Totals row
y = checkPageBreak(doc, y, 8)
setFill(doc, '#1F2937')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
doc.setFontSize(7)
setText(doc, '#F9FAFB')
doc.text('Total', MARGIN + 8, y + 5)
doc.text(formatNumber(data.totalHours, 1), MARGIN + 60 + 30 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(data.totalEarnings), MARGIN + 125 + 35 - 2, y + 5, { align: 'right' })
}
// ---------------------------------------------------------------------------
// Profitability Report
// ---------------------------------------------------------------------------
function renderProfitabilityReport(doc: jsPDF, data: ProfitabilityReportData) {
let y = drawHeader(doc, 'Profitability Report', data.dateRange)
const boxW = (CONTENT_W - 8) / 5
drawStatBox(doc, MARGIN, y, boxW, 'Revenue', formatCurrency(data.totalRevenue))
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Expenses', formatCurrency(data.totalExpenses))
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Net Profit', formatCurrency(data.totalNet))
drawStatBox(doc, MARGIN + (boxW + 2) * 3, y, boxW, 'Hours', formatNumber(data.totalHours, 1) + 'h')
drawStatBox(doc, MARGIN + (boxW + 2) * 4, y, boxW, 'Avg Rate', formatCurrency(data.avgRate) + '/hr')
y += 24
const cols = [
{ label: 'Project', x: MARGIN, w: 35 },
{ label: 'Client', x: MARGIN + 35, w: 30 },
{ label: 'Hours', x: MARGIN + 65, w: 18, align: 'right' },
{ label: 'Rate', x: MARGIN + 83, w: 22, align: 'right' },
{ label: 'Revenue', x: MARGIN + 105, w: 25, align: 'right' },
{ label: 'Expenses', x: MARGIN + 130, w: 22, align: 'right' },
{ label: 'Net', x: MARGIN + 152, w: 22, align: 'right' },
{ label: 'Budget', x: MARGIN + 174, w: 16, align: 'right' },
]
y = drawTableHeader(doc, y, cols)
doc.setFontSize(6.5)
for (let i = 0; i < data.rows.length; i++) {
y = checkPageBreak(doc, y, 7)
const r = data.rows[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setText(doc, '#E5E7EB')
doc.text(r.project_name.substring(0, 18), MARGIN + 2, y + 5)
setText(doc, '#D1D5DB')
doc.text((r.client_name || '-').substring(0, 16), MARGIN + 37, y + 5)
doc.text(formatNumber(r.total_hours, 1), MARGIN + 65 + 18 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(r.hourly_rate), MARGIN + 83 + 22 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(r.revenue), MARGIN + 105 + 25 - 2, y + 5, { align: 'right' })
setText(doc, r.expenses > 0 ? '#FCA5A5' : '#D1D5DB')
doc.text(formatCurrency(r.expenses), MARGIN + 130 + 22 - 2, y + 5, { align: 'right' })
setText(doc, r.net_profit >= 0 ? '#86EFAC' : '#FCA5A5')
doc.text(formatCurrency(r.net_profit), MARGIN + 152 + 22 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(r.budget_used_pct != null ? formatNumber(r.budget_used_pct, 0) + '%' : '-', MARGIN + 174 + 16 - 2, y + 5, { align: 'right' })
y += 7
}
// Totals row
y = checkPageBreak(doc, y, 8)
setFill(doc, '#1F2937')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
doc.setFontSize(6.5)
setText(doc, '#F9FAFB')
doc.text('Total', MARGIN + 2, y + 5)
doc.text(formatNumber(data.totalHours, 1), MARGIN + 65 + 18 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(data.totalRevenue), MARGIN + 105 + 25 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(data.totalExpenses), MARGIN + 130 + 22 - 2, y + 5, { align: 'right' })
setText(doc, data.totalNet >= 0 ? '#86EFAC' : '#FCA5A5')
doc.text(formatCurrency(data.totalNet), MARGIN + 152 + 22 - 2, y + 5, { align: 'right' })
}
// ---------------------------------------------------------------------------
// Expenses Report
// ---------------------------------------------------------------------------
function renderExpensesReport(doc: jsPDF, data: ExpenseReportData) {
let y = drawHeader(doc, 'Expenses Report', data.dateRange)
const boxW = (CONTENT_W - 4) / 3
drawStatBox(doc, MARGIN, y, boxW, 'Total', formatCurrency(data.totalAmount))
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Invoiced', formatCurrency(data.invoicedAmount))
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Uninvoiced', formatCurrency(data.uninvoicedAmount))
y += 24
// By category
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('By Category', MARGIN, y + 4)
y += 8
const catCols = [
{ label: 'Category', x: MARGIN, w: 80 },
{ label: 'Amount', x: MARGIN + 80, w: 50, align: 'right' },
{ label: '%', x: MARGIN + 130, w: 30, align: 'right' },
]
y = drawTableHeader(doc, y, catCols)
doc.setFontSize(7)
for (let i = 0; i < data.byCategory.length; i++) {
y = checkPageBreak(doc, y, 7)
const c = data.byCategory[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setText(doc, '#E5E7EB')
doc.text(c.category, MARGIN + 2, y + 5)
setText(doc, '#D1D5DB')
doc.text(formatCurrency(c.amount), MARGIN + 80 + 50 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(formatNumber(c.percentage, 0) + '%', MARGIN + 130 + 30 - 2, y + 5, { align: 'right' })
y += 7
}
y += 8
// By project
y = checkPageBreak(doc, y, 20)
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('By Project', MARGIN, y + 4)
y += 8
const projCols = [
{ label: 'Project', x: MARGIN, w: 80 },
{ label: 'Amount', x: MARGIN + 80, w: 50, align: 'right' },
{ label: '%', x: MARGIN + 130, w: 30, align: 'right' },
]
y = drawTableHeader(doc, y, projCols)
doc.setFontSize(7)
for (let i = 0; i < data.byProject.length; i++) {
y = checkPageBreak(doc, y, 7)
const p = data.byProject[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setFill(doc, p.color || '#F59E0B')
doc.circle(MARGIN + 4, y + 3.5, 1.5, 'F')
setText(doc, '#E5E7EB')
doc.text(p.name.substring(0, 30), MARGIN + 8, y + 5)
setText(doc, '#D1D5DB')
doc.text(formatCurrency(p.amount), MARGIN + 80 + 50 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(formatNumber(p.percentage, 0) + '%', MARGIN + 130 + 30 - 2, y + 5, { align: 'right' })
y += 7
}
}
// ---------------------------------------------------------------------------
// Patterns Report
// ---------------------------------------------------------------------------
function renderPatternsReport(doc: jsPDF, data: PatternsReportData) {
let y = drawHeader(doc, 'Work Patterns Report', data.dateRange)
const boxW = (CONTENT_W - 2) / 2
drawStatBox(doc, MARGIN, y, boxW, 'Peak Day', data.peakDay)
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Peak Hour', data.peakHour)
y += 24
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('Activity Heatmap', MARGIN, y + 4)
y += 8
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const labelW = 12
const cellW = (CONTENT_W - labelW) / 24
const cellH = 8
// Hour labels
doc.setFontSize(5)
setText(doc, '#6B7280')
for (let h = 0; h < 24; h++) {
if (h % 3 === 0) {
doc.text(String(h).padStart(2, '0'), MARGIN + labelW + h * cellW + cellW / 2, y, { align: 'center' })
}
}
y += 4
const maxVal = Math.max(...data.heatmap.flat(), 0.01)
for (let d = 0; d < 7; d++) {
setText(doc, '#9CA3AF')
doc.setFontSize(6)
doc.text(days[d], MARGIN, y + cellH / 2 + 1.5)
for (let h = 0; h < 24; h++) {
const val = data.heatmap[d]?.[h] || 0
const intensity = Math.min(val / maxVal, 1)
if (intensity > 0) {
const r = Math.round(245 * intensity + 31 * (1 - intensity))
const g = Math.round(158 * intensity + 41 * (1 - intensity))
const b = Math.round(11 * intensity + 55 * (1 - intensity))
doc.setFillColor(r, g, b)
} else {
setFill(doc, '#1F2937')
}
doc.roundedRect(MARGIN + labelW + h * cellW + 0.5, y + 0.5, cellW - 1, cellH - 1, 0.5, 0.5, 'F')
}
y += cellH
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function generateHoursReportPdf(data: HoursReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderHoursReport(doc, data)
return doc
}
export function generateProfitabilityReportPdf(data: ProfitabilityReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderProfitabilityReport(doc, data)
return doc
}
export function generateExpensesReportPdf(data: ExpenseReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderExpensesReport(doc, data)
return doc
}
export function generatePatternsReportPdf(data: PatternsReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderPatternsReport(doc, data)
return doc
}

100
src/utils/tours.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { TourDefinition } from '../stores/tour'
export const TOURS: Record<string, TourDefinition> = {
clients: {
id: 'clients',
route: '/clients',
steps: [
{
target: '[data-tour-id="new-client"]',
title: 'Add a client',
content: 'Click here to create your first client. Add their name, email, company, and payment terms.',
placement: 'bottom',
},
],
},
projects: {
id: 'projects',
route: '/projects',
steps: [
{
target: '[data-tour-id="new-project"]',
title: 'Create a project',
content: 'Projects organize your time entries. Set an hourly rate, pick a color, and assign a client.',
placement: 'bottom',
},
],
},
timer: {
id: 'timer',
route: '/timer',
steps: [
{
target: '[data-tour-id="timer-project"]',
title: 'Pick a project',
content: 'Select which project you are working on. This links your time to the right place.',
placement: 'bottom',
},
{
target: '[data-tour-id="timer-start"]',
title: 'Start tracking',
content: 'Hit this button to start the clock. Press it again to stop and save your entry.',
placement: 'top',
},
{
target: '[data-tour-id="timer-description"]',
title: 'Describe your work',
content: 'Add a note about what you are working on. This shows up in entries and reports.',
placement: 'top',
},
],
},
entries: {
id: 'entries',
route: '/entries',
steps: [
{
target: '[data-tour-id="entries-tabs"]',
title: 'Browse your entries',
content: 'Switch between list and timeline views to see your tracked time different ways.',
placement: 'bottom',
},
],
},
invoices: {
id: 'invoices',
route: '/invoices',
steps: [
{
target: '[data-tour-id="new-invoice"]',
title: 'Create an invoice',
content: 'Generate invoices from your tracked time. Pick a client, date range, and template.',
placement: 'bottom',
},
],
},
reports: {
id: 'reports',
route: '/reports',
steps: [
{
target: '[data-tour-id="reports-daterange"]',
title: 'Set your date range',
content: 'Pick a start and end date for your report. The default is the current month.',
placement: 'bottom',
},
{
target: '[data-tour-id="reports-generate"]',
title: 'Generate the report',
content: 'Click Generate to see hours, earnings, and project breakdowns for the selected period.',
placement: 'bottom',
},
{
target: '[data-tour-id="reports-tabs"]',
title: 'Explore report types',
content: 'Switch between Hours, Profitability, and Expenses views for different insights.',
placement: 'bottom',
},
],
},
}

44
src/utils/uiFonts.ts Normal file
View File

@@ -0,0 +1,44 @@
export interface UIFont {
label: string
value: string
category: 'default' | 'accessibility' | 'general'
}
export const UI_FONTS: UIFont[] = [
{ label: 'Inter (Default)', value: 'Inter', category: 'default' },
{ label: 'OpenDyslexic', value: 'OpenDyslexic', category: 'accessibility' },
{ label: 'Atkinson Hyperlegible', value: 'Atkinson Hyperlegible', category: 'accessibility' },
{ label: 'Lexie Readable', value: 'Lexie Readable', category: 'accessibility' },
{ label: 'Comic Neue', value: 'Comic Neue', category: 'general' },
{ label: 'Nunito', value: 'Nunito', category: 'general' },
{ label: 'Lato', value: 'Lato', category: 'general' },
{ label: 'Source Sans 3', value: 'Source Sans 3', category: 'general' },
]
const loadedUIFonts = new Set<string>()
export function loadUIFont(fontName: string): void {
if (fontName === 'Inter' || loadedUIFonts.has(fontName)) return
loadedUIFonts.add(fontName)
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600;700&display=swap`
document.head.appendChild(link)
}
export function applyUIFont(fontName: string): void {
const el = document.documentElement
if (fontName === 'Inter') {
el.style.removeProperty('--font-ui')
document.body.style.fontFamily = ''
} else {
const fontValue = `'${fontName}', system-ui, sans-serif`
el.style.setProperty('--font-ui', fontValue)
document.body.style.fontFamily = `var(--font-ui)`
}
}
export function loadAndApplyUIFont(fontName: string): void {
loadUIFont(fontName)
applyUIFont(fontName)
}