Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ automatically and used as the GitHub Release notes.

## [Unreleased]

### Fixed

- Fetch every page of yearly Upwork time-report data so the Year dashboard
includes work after the first API page instead of stopping around March.

## [1.0.11] - 2026-06-26

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions Sources/UpworkBuddy/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.11</string>
<string>1.0.12</string>
<key>CFBundleVersion</key>
<string>12</string>
<string>13</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
19 changes: 18 additions & 1 deletion Sources/UpworkBuddy/Network/GraphQLClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,26 @@ private struct GraphQLPayload: Encodable {
let variables: [String: JSONValue]
}

protocol GraphQLExecuting: Sendable {
func execute<T: Decodable & Sendable>(
query: String,
variables: [String: JSONValue],
as type: T.Type
) async throws -> T
}

extension GraphQLExecuting {
func execute<T: Decodable & Sendable>(
query: String,
as type: T.Type
) async throws -> T {
try await execute(query: query, variables: [:], as: type)
}
}

/// Thin GraphQL client over URLSession with one-shot token refresh on 401 and
/// per-tenant header injection.
actor GraphQLClient {
actor GraphQLClient: GraphQLExecuting {
static let shared = GraphQLClient()

private var tenantId: String?
Expand Down
20 changes: 18 additions & 2 deletions Sources/UpworkBuddy/Upwork/Queries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,19 @@ enum Queries {
/// type name varies by tenant; literals are inlined to avoid that lookup.
/// Returns gross `totalCharges` (hours × rate, before fees), real `totalHoursWorked`,
/// and contract metadata including `hourlyTerms[].hourlyRate.rawValue`.
static func contractTimeReport(rangeStart: String, rangeEnd: String) -> String {
"""
static func contractTimeReport(
rangeStart: String,
rangeEnd: String,
first: Int = 100,
after: String? = nil
) -> String {
let afterArgument = after.map { ", after: \(graphQLStringLiteral($0))" } ?? ""
return """
query ContractTimeReport {
user {
contractTimeReport(
timeReportDate_bt: {rangeStart: "\(rangeStart)", rangeEnd: "\(rangeEnd)"}
pagination: { first: \(first)\(afterArgument) }
) {
edges {
node {
Expand Down Expand Up @@ -76,6 +83,15 @@ enum Queries {
"""
}

private static func graphQLStringLiteral(_ value: String) -> String {
guard let data = try? JSONEncoder().encode(value),
let literal = String(data: data, encoding: .utf8)
else {
return "\"\""
}
return literal
}

/// Freelancer Work Diary for one contract on one day. `date` is compact
/// `YYYYMMDD`. Each `workDiaryTimeCell` is a fixed 10-minute billing interval,
/// so logged hours == `cellCount / 6`. Used to surface the in-progress current
Expand Down
72 changes: 50 additions & 22 deletions Sources/UpworkBuddy/Upwork/UpworkAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ enum ProjectNameStyle: String, CaseIterable, Sendable, Identifiable {
/// High-level facade over `GraphQLClient`. Returns plain models the UI can render
/// without knowing GraphQL.
struct UpworkAPI: Sendable {
let client: GraphQLClient
let client: any GraphQLExecuting

init(client: GraphQLClient = .shared) {
init(client: any GraphQLExecuting = GraphQLClient.shared) {
self.client = client
}

Expand Down Expand Up @@ -243,12 +243,19 @@ struct UpworkAPI: Sendable {
// MARK: - contractTimeReport

/// Freelancer-side per-day hours + gross charges + hourly rate per contract.
/// Single-page fetch — `pageInfo.hasNextPage` ignored for now (1-month window
/// is well below typical page size for personal accounts).
/// Follows the report connection cursor until every page in the requested
/// range has been loaded.
func fetchTimeReport(range: DateRange) async throws -> [TimeReportRow] {
struct Resp: Decodable {
struct User: Decodable { let contractTimeReport: Conn? }
struct Conn: Decodable { let edges: [Edge]? }
struct Conn: Decodable {
let edges: [Edge]?
let pageInfo: PageInfo?
}
struct PageInfo: Decodable {
let endCursor: String?
let hasNextPage: Bool?
}
struct Edge: Decodable { let node: Node }
struct Node: Decodable {
let dateWorkedOn: String?
Expand All @@ -271,25 +278,46 @@ struct UpworkAPI: Sendable {
let user: User?
}

let query = Queries.contractTimeReport(
rangeStart: Self.compactDateString(range.start),
rangeEnd: Self.compactDateString(range.end)
)
let resp = try await client.execute(query: query, as: Resp.self)
let edges = resp.user?.contractTimeReport?.edges ?? []
return edges.map { e in
let topRate = e.node.contract?.terms?.hourlyTerms?.first?.hourlyRate?.rawValue
.flatMap(Double.init)
return TimeReportRow(
dateWorkedOn: e.node.dateWorkedOn,
hours: e.node.totalHoursWorked,
charges: e.node.totalCharges,
contractId: e.node.contract?.id,
contractTitle: e.node.contract?.title,
clientName: e.node.contract?.clientTeam?.name,
hourlyRate: topRate
let rangeStart = Self.compactDateString(range.start)
let rangeEnd = Self.compactDateString(range.end)
var after: String?
var seenCursors = Set<String>()
var rows: [TimeReportRow] = []

while true {
let query = Queries.contractTimeReport(
rangeStart: rangeStart,
rangeEnd: rangeEnd,
after: after
)
let resp = try await client.execute(query: query, as: Resp.self)
let connection = resp.user?.contractTimeReport

for edge in connection?.edges ?? [] {
let topRate = edge.node.contract?.terms?.hourlyTerms?.first?.hourlyRate?.rawValue
.flatMap(Double.init)
rows.append(TimeReportRow(
dateWorkedOn: edge.node.dateWorkedOn,
hours: edge.node.totalHoursWorked,
charges: edge.node.totalCharges,
contractId: edge.node.contract?.id,
contractTitle: edge.node.contract?.title,
clientName: edge.node.contract?.clientTeam?.name,
hourlyRate: topRate
))
}

guard connection?.pageInfo?.hasNextPage == true else { break }
guard let nextCursor = connection?.pageInfo?.endCursor,
!nextCursor.isEmpty,
seenCursors.insert(nextCursor).inserted
else {
throw UpworkError.decoding("Time report pagination did not advance")
}
after = nextCursor
}

return rows
}

// MARK: - Merge
Expand Down
91 changes: 91 additions & 0 deletions Tests/UpworkBuddyTests/TimeReportPaginationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
import Testing
@testable import UpworkBuddy

@Suite("UpworkAPI time report pagination")
struct TimeReportPaginationTests {
@Test func fetchTimeReportLoadsEveryPage() async throws {
let executor = StubGraphQLExecutor(payloads: [
"""
{
"user": {
"contractTimeReport": {
"edges": [
{
"node": {
"dateWorkedOn": "2026-03-31",
"totalHoursWorked": 2
}
}
],
"pageInfo": {
"endCursor": "cursor-1",
"hasNextPage": true
}
}
}
}
""",
"""
{
"user": {
"contractTimeReport": {
"edges": [
{
"node": {
"dateWorkedOn": "2026-04-01",
"totalHoursWorked": 3
}
}
],
"pageInfo": {
"endCursor": "cursor-2",
"hasNextPage": false
}
}
}
}
"""
])
let api = UpworkAPI(client: executor)
let range = DateRange(
start: try #require(DateRange.iso.date(from: "2026-01-01")),
end: try #require(DateRange.iso.date(from: "2026-12-31"))
)

let rows = try await api.fetchTimeReport(range: range)
let queries = await executor.capturedQueries()

#expect(rows.map(\.dateWorkedOn) == ["2026-03-31", "2026-04-01"])
#expect(queries.count == 2)
let firstQuery = try #require(queries.first)
let secondQuery = try #require(queries.dropFirst().first)
#expect(firstQuery.contains("pagination: { first: 100 }"))
#expect(secondQuery.contains(#"pagination: { first: 100, after: "cursor-1" }"#))
}
}

private actor StubGraphQLExecutor: GraphQLExecuting {
private var payloads: [Data]
private var queries: [String] = []

init(payloads: [String]) {
self.payloads = payloads.map { Data($0.utf8) }
}

func execute<T: Decodable & Sendable>(
query: String,
variables: [String: JSONValue],
as type: T.Type
) async throws -> T {
queries.append(query)
guard !payloads.isEmpty else {
throw UpworkError.transport("Stub received more requests than expected")
}
return try JSONDecoder().decode(T.self, from: payloads.removeFirst())
}

func capturedQueries() -> [String] {
queries
}
}
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.11
1.0.12