@@ -82,6 +82,39 @@ export async function exchangeHackatimeAuthorizationCode(code, redirectUri) {
8282 return data ;
8383}
8484
85+ export async function refreshHackatimeAccessToken ( refreshToken ) {
86+ const clientId = requiredEnv ( "HACKATIME_UID" ) ;
87+ const clientSecret = requiredEnv ( "HACKATIME_SECRET" ) ;
88+
89+ const body = new URLSearchParams ( {
90+ client_id : clientId ,
91+ client_secret : clientSecret ,
92+ refresh_token : refreshToken ,
93+ grant_type : "refresh_token" ,
94+ } ) ;
95+
96+ const response = await fetch ( `${ HACKATIME_BASE } /oauth/token` , {
97+ method : "POST" ,
98+ headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
99+ body : body . toString ( ) ,
100+ } ) ;
101+
102+ const text = await response . text ( ) ;
103+ let data ;
104+ try {
105+ data = text ? JSON . parse ( text ) : { } ;
106+ } catch {
107+ throw new Error ( "Hackatime token response was not JSON." ) ;
108+ }
109+
110+ if ( ! response . ok ) {
111+ const msg = data . error_description || data . error || response . statusText ;
112+ throw new Error ( `Hackatime token refresh failed: ${ msg } ` ) ;
113+ }
114+
115+ return data ;
116+ }
117+
85118async function hackatimeApiGet ( accessToken , path , query = { } ) {
86119 const params = new URLSearchParams ( query ) ;
87120 const qs = params . toString ( ) ;
@@ -138,15 +171,33 @@ export function stackHackatimeHoursQuery() {
138171 } ;
139172}
140173
174+ function stackHackatimeWindowQuery ( ) {
175+ const statsRange = stackHackatimeStatsRange ( ) ;
176+ return {
177+ since : statsRange . start ,
178+ until : statsRange . end ,
179+ start : statsRange . start ,
180+ end : statsRange . end ,
181+ } ;
182+ }
183+
141184export async function fetchHackatimeProjects (
142185 accessToken ,
143- { includeArchived = false , start = null , end = null , since = null , until = null } = { }
186+ {
187+ includeArchived = false ,
188+ start = null ,
189+ end = null ,
190+ since = null ,
191+ until = null ,
192+ projectNames = null ,
193+ } = { }
144194) {
145195 const query = { include_archived : includeArchived ? "true" : "false" } ;
146196 if ( since ) query . since = since ;
147197 if ( until ) query . until = until ;
148198 if ( start ) query . start = start ;
149199 if ( end ) query . end = end ;
200+ if ( projectNames ) query . projects = projectNames ;
150201 const data = await hackatimeApiGet ( accessToken , "/api/v1/authenticated/projects" , query ) ;
151202
152203 const projects = Array . isArray ( data . projects ) ? data . projects : [ ] ;
@@ -160,31 +211,10 @@ export async function fetchHackatimeProjects(
160211 } ) ) ;
161212}
162213
163- export async function fetchHackatimeProjectsForStack ( accessToken ) {
164- const statsRange = stackHackatimeStatsRange ( ) ;
165- // Always pass start/end so Hackatime queries live heartbeats instead of stale rollups.
166- const allTimeDiscovery = {
167- since : "1970-01-01T00:00:00Z" ,
168- until : statsRange . end ,
169- start : "1970-01-01T00:00:00Z" ,
170- end : statsRange . end ,
171- } ;
172- const stackWindow = {
173- since : statsRange . start ,
174- until : statsRange . end ,
175- start : statsRange . start ,
176- end : statsRange . end ,
177- } ;
178-
179- const [ allProjects , postLaunchProjects ] = await Promise . all ( [
180- fetchHackatimeProjects ( accessToken , allTimeDiscovery ) ,
181- fetchHackatimeProjects ( accessToken , stackWindow ) ,
182- ] ) ;
183-
214+ function mergeStackHoursOntoCatalog ( allProjects , postLaunchProjects ) {
184215 const postLaunchByName = new Map (
185- postLaunchProjects . map ( ( p ) => [ p . name . trim ( ) . toLowerCase ( ) , p ] )
216+ postLaunchProjects . map ( ( project ) => [ project . name . trim ( ) . toLowerCase ( ) , project ] )
186217 ) ;
187-
188218 const mergedByName = new Map ( ) ;
189219
190220 for ( const project of allProjects ) {
@@ -198,7 +228,6 @@ export async function fetchHackatimeProjectsForStack(accessToken) {
198228 } ) ;
199229 }
200230
201- // Projects with Stack-era activity may not appear in the all-time discovery response yet.
202231 for ( const project of postLaunchProjects ) {
203232 const key = project . name . trim ( ) . toLowerCase ( ) ;
204233 if ( mergedByName . has ( key ) ) continue ;
@@ -212,6 +241,52 @@ export async function fetchHackatimeProjectsForStack(accessToken) {
212241 return Array . from ( mergedByName . values ( ) ) ;
213242}
214243
244+ /** All Hackatime project names for linking, with Stack-era hours attached. */
245+ export async function fetchHackatimeProjectCatalog ( accessToken ) {
246+ const statsRange = stackHackatimeStatsRange ( ) ;
247+ const allTimeDiscovery = {
248+ since : "1970-01-01T00:00:00Z" ,
249+ until : statsRange . end ,
250+ start : "1970-01-01T00:00:00Z" ,
251+ end : statsRange . end ,
252+ includeArchived : true ,
253+ } ;
254+ const stackWindow = {
255+ ...stackHackatimeWindowQuery ( ) ,
256+ includeArchived : true ,
257+ } ;
258+
259+ const [ allProjects , postLaunchProjects ] = await Promise . all ( [
260+ fetchHackatimeProjects ( accessToken , allTimeDiscovery ) ,
261+ fetchHackatimeProjects ( accessToken , stackWindow ) ,
262+ ] ) ;
263+
264+ return mergeStackHoursOntoCatalog ( allProjects , postLaunchProjects ) ;
265+ }
266+
267+ /** Fetch Stack-era hours for specific linked project names (bypasses discovery gaps). */
268+ export async function fetchHackatimeProjectsForLinkedNames ( accessToken , linkedNames ) {
269+ const names = [
270+ ...new Set (
271+ ( Array . isArray ( linkedNames ) ? linkedNames : [ ] )
272+ . map ( ( name ) => String ( name ) . trim ( ) )
273+ . filter ( Boolean )
274+ ) ,
275+ ] ;
276+ if ( names . length === 0 ) return [ ] ;
277+
278+ return fetchHackatimeProjects ( accessToken , {
279+ ...stackHackatimeWindowQuery ( ) ,
280+ includeArchived : true ,
281+ projectNames : names . join ( "," ) ,
282+ } ) ;
283+ }
284+
285+ /** @deprecated Use fetchHackatimeProjectCatalog or fetchHackatimeProjectsForLinkedNames. */
286+ export async function fetchHackatimeProjectsForStack ( accessToken ) {
287+ return fetchHackatimeProjectCatalog ( accessToken ) ;
288+ }
289+
215290export async function fetchHackatimeTotalHours ( accessToken ) {
216291 const data = await hackatimeApiGet (
217292 accessToken ,
0 commit comments