#set page(margin: (top: 0.75in, bottom: 1in, left: 1in, right: 1in)) #set text(font: ("EB Garamond", "Georgia"), size: 10pt) #set par(leading: 0.65em) // Load timesheet data from JSON file #let data = json("data.json") // Helper function to format hours as HH:MM with proper rounding #let format-hours(hours) = { let total-minutes = calc.round(hours * 60) let h = calc.floor(total-minutes / 60) let m = calc.rem(total-minutes, 60) str(h) + ":" + if m < 10 { "0" + str(m) } else { str(m) } } // Helper function to group entries by date #let group-by-date(entries) = { let groups = (:) for entry in entries { let date = entry.date if date not in groups { groups.insert(date, ()) } groups.at(date).push(entry) } groups } // Professional header with company info #let professional-header() = { // Company header align(left)[ #text(size: 9pt, fill: gray)[ #text(weight: "bold")[#data.contractor_name] • #data.contractor_label • #data.contractor_email ] ] v(3em) // Timesheet title grid( columns: (1fr, auto), align(left)[ #text(size: 28pt, weight: "bold")[Timesheet] ], align(right)[ #text(size: 11pt)[ #data.generated_date ] ] ) v(2.5em) } #let client-info-section() = { grid( columns: (1fr, 1fr), gutter: 3em, // Client section [ #text(size: 9pt, fill: gray)[CLIENT] #v(0.5em) #text(size: 12pt, weight: "bold")[#data.client_name] #if data.project_name != "" [ #v(0.3em) #text(size: 10pt)[Project: #data.project_name] ] ], // Period details align(right)[ #text(size: 9pt, fill: gray)[PERIOD] #v(0.5em) #text(size: 10pt)[#data.date_range_start to #data.date_range_end] #v(0.3em) #text(size: 9pt, fill: gray)[Times shown in: #data.timezone] ] ) v(2.5em) } #let timesheet-table() = { let grouped = group-by-date(data.entries) let sorted-dates = grouped.keys().sorted() // Table header table( columns: (auto, auto, auto, auto, 1fr, 1fr), stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { none }, inset: (x: 8pt, y: 4pt), align: (center, center, center, center, left, left), column-gutter: 8pt, // Header row with extra vertical padding table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[DATE]], table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[START]], table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[END]], table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[DURATION]], table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[PROJECT]], table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[DESCRIPTION]], // Data rows grouped by date ..for date in sorted-dates { let entries = grouped.at(date) let daily-total = entries.map(entry => entry.hours).sum() // Create rows for this date let date-rows = () // Add all entries for this date for (i, entry) in entries.enumerate() { let date-text = if i == 0 { date } else { "" } date-rows.push(( text(size: 9pt, weight: "medium")[#date-text], text(size: 9pt)[#entry.start_time], text(size: 9pt)[#entry.end_time], text(size: 9pt)[#entry.duration], text(size: 9pt)[#entry.project_name], text(size: 9pt)[#entry.description] )) } // Add daily subtotal row date-rows.push(( table.cell(colspan: 3, align: right)[#text(size: 9pt, weight: "medium", fill: gray)[Daily Total:]], text(size: 9pt, weight: "medium")[#format-hours(daily-total)], table.cell(colspan: 2)[] )) // Add separator line after each day date-rows.push(( table.cell(colspan: 6, stroke: (top: 0.5pt + gray))[#v(0.1em)], )) date-rows }.flatten() ) } #let timesheet-summary() = { v(1.5em) // Total hours section align(right, table( columns: (auto, auto), stroke: none, inset: (x: 12pt, y: 6pt), align: (right, right), table.hline(stroke: 0.5pt), [#text(size: 12pt, weight: "bold")[Total Hours:]], [#text(size: 12pt, weight: "bold")[#format-hours(data.total_hours)]] ) ) } // Main timesheet layout #professional-header() #client-info-section() #timesheet-table() #timesheet-summary()