Skip to content

Commit eb1dc04

Browse files
committed
FIX - Hackatime not logging all
1 parent 1f7b4f6 commit eb1dc04

4 files changed

Lines changed: 264 additions & 68 deletions

File tree

client/src/components/ProjectsPage.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,16 @@ export function ProjectsPage() {
299299
loadHackatimeStatus();
300300
}, []);
301301

302+
useEffect(() => {
303+
function handleVisibilityChange() {
304+
if (document.visibilityState !== "visible") return;
305+
refreshHackatimeHours();
306+
}
307+
308+
document.addEventListener("visibilitychange", handleVisibilityChange);
309+
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
310+
}, []);
311+
302312
useEffect(() => {
303313
if (!editingProject) return;
304314
refreshHackatimeHours();

server/hackatimeAuth.js

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
85118
async 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+
141184
export 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+
215290
export async function fetchHackatimeTotalHours(accessToken) {
216291
const data = await hackatimeApiGet(
217292
accessToken,

0 commit comments

Comments
 (0)