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:
228
src/utils/audio.ts
Normal file
228
src/utils/audio.ts
Normal 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
43
src/utils/chartTheme.ts
Normal 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
44
src/utils/csvExport.ts
Normal 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
86
src/utils/dropdown.ts
Normal 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
71
src/utils/focusTrap.ts
Normal 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
47
src/utils/fonts.ts
Normal 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
33
src/utils/formGuard.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
481
src/utils/reportPdf.ts
Normal 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
100
src/utils/tours.ts
Normal 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
44
src/utils/uiFonts.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user