diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b733ef..30f6a00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Sources/UpworkBuddy/Info.plist b/Sources/UpworkBuddy/Info.plist
index 9e2fef9..4dced4c 100644
--- a/Sources/UpworkBuddy/Info.plist
+++ b/Sources/UpworkBuddy/Info.plist
@@ -33,9 +33,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.0.11
+ 1.0.12
CFBundleVersion
- 12
+ 13
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/Sources/UpworkBuddy/Network/GraphQLClient.swift b/Sources/UpworkBuddy/Network/GraphQLClient.swift
index 9d91103..acbfa93 100644
--- a/Sources/UpworkBuddy/Network/GraphQLClient.swift
+++ b/Sources/UpworkBuddy/Network/GraphQLClient.swift
@@ -29,9 +29,26 @@ private struct GraphQLPayload: Encodable {
let variables: [String: JSONValue]
}
+protocol GraphQLExecuting: Sendable {
+ func execute(
+ query: String,
+ variables: [String: JSONValue],
+ as type: T.Type
+ ) async throws -> T
+}
+
+extension GraphQLExecuting {
+ func execute(
+ 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?
diff --git a/Sources/UpworkBuddy/Upwork/Queries.swift b/Sources/UpworkBuddy/Upwork/Queries.swift
index ededb6d..313bed1 100644
--- a/Sources/UpworkBuddy/Upwork/Queries.swift
+++ b/Sources/UpworkBuddy/Upwork/Queries.swift
@@ -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 {
@@ -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
diff --git a/Sources/UpworkBuddy/Upwork/UpworkAPI.swift b/Sources/UpworkBuddy/Upwork/UpworkAPI.swift
index f484913..e9c250d 100644
--- a/Sources/UpworkBuddy/Upwork/UpworkAPI.swift
+++ b/Sources/UpworkBuddy/Upwork/UpworkAPI.swift
@@ -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
}
@@ -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?
@@ -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()
+ 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
diff --git a/Tests/UpworkBuddyTests/TimeReportPaginationTests.swift b/Tests/UpworkBuddyTests/TimeReportPaginationTests.swift
new file mode 100644
index 0000000..30b597c
--- /dev/null
+++ b/Tests/UpworkBuddyTests/TimeReportPaginationTests.swift
@@ -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(
+ 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
+ }
+}
diff --git a/VERSION b/VERSION
index 59e9e60..bb83058 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.11
+1.0.12