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