Skip to content

Commit 4abea67

Browse files
anscgclaude
andcommitted
Merge branch 'main' into lookout-desktop-handoff
The Hackatime relink prompt and the panel's own project loader both landed in the picker's opening lines. Keep both: `router`/`needsRelink` for the relink alert, `usesOwnLoader` for the panel that reads its list through a panel token instead of the signed-in user's cookies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents ef2d261 + a8d4a6a commit 4abea67

16 files changed

Lines changed: 594 additions & 40 deletions

File tree

apps/client/src/components/entity/HackatimeProjectPicker.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import clsx from "clsx";
44
import { formatDuration } from "@hackclub/lapse-shared";
55
import type { HackatimeProject } from "@hackclub/lapse-api";
66

7+
import { useRouter } from "next/router";
8+
79
import { api } from "@/api";
810
import type { IconGlyph } from "@/common";
911
import { Skeleton } from "@/components/ui/Skeleton";
1012
import { Alert } from "@/components/ui/Alert";
13+
import { Button } from "@/components/ui/Button";
14+
import { useHackatimeRelink } from "@/hooks/useHackatimeRelink";
1115

1216
/** The maximum length of a Hackatime project name, as enforced by the API contract. */
1317
const MAX_PROJECT_NAME_LENGTH = 128;
@@ -274,10 +278,17 @@ export function HackatimeProjectPicker({ isActive, initialProject, onChange, onL
274278
*/
275279
compact?: boolean;
276280
}) {
281+
const router = useRouter();
282+
const needsRelink = useHackatimeRelink();
283+
277284
const usesOwnLoader = Boolean(loadProjects);
278285
const [projects, setProjects] = useState<HackatimeProject[]>(() => usesOwnLoader ? [] : cachedProjects ?? []);
279286
const [isLoadingProjects, setIsLoadingProjects] = useState(usesOwnLoader || cachedProjects === null);
280287

288+
function reconnect() {
289+
router.push(`/auth?force=1&redirect=${encodeURIComponent(router.asPath)}`);
290+
}
291+
281292
const [mode, setMode] = useState<SyncMode>("existing");
282293
const [selectedProject, setSelectedProject] = useState<string | null>(null);
283294
const [newProjectName, setNewProjectName] = useState("");
@@ -545,6 +556,18 @@ export function HackatimeProjectPicker({ isActive, initialProject, onChange, onL
545556
</div>
546557
</div>
547558
</>
559+
) : needsRelink ? (
560+
<Alert variant="warning" icon="private">
561+
<div className="flex flex-col gap-3">
562+
<p className="font-bold">Relink Hackatime to sync</p>
563+
<p>
564+
Hackatime won&apos;t let us read your projects until you authorize Lapse again, so this list is
565+
empty even if you have projects. Naming a new one here would sync your time somewhere unexpected.
566+
</p>
567+
568+
<Button kind="primary" onClick={reconnect}>Relink Hackatime</Button>
569+
</div>
570+
</Alert>
548571
) : (
549572
<Alert variant="info" icon="idea">
550573
<p>We couldn&apos;t find any projects on your Hackatime account. Give this one a name below - we&apos;ll create it for you.</p>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import NextLink from "next/link";
2+
import { useRouter } from "next/router";
3+
4+
import { useHackatimeRelink } from "@/hooks/useHackatimeRelink";
5+
6+
const AUTH_ROUTE = "/auth";
7+
8+
/**
9+
* A site-wide strip for users whose Hackatime authorization predates the `read` scope. Their timelapses still sync,
10+
* but Hackatime won't hand over their project list, so the picker looks empty. Signing in again reissues the token.
11+
*/
12+
export function HackatimeRelinkBanner() {
13+
const router = useRouter();
14+
const needsRelink = useHackatimeRelink();
15+
16+
if (!needsRelink || router.pathname === AUTH_ROUTE)
17+
return null;
18+
19+
return (
20+
<NextLink
21+
href={`${AUTH_ROUTE}?force=1&redirect=${encodeURIComponent(router.asPath)}`}
22+
className="block w-full bg-red text-white font-bold text-center px-6 py-2 transition-[filter] hover:brightness-95"
23+
>
24+
Hackatime can&apos;t show your projects until you reconnect. Click here to sign in again.
25+
</NextLink>
26+
);
27+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useEffect, useState } from "react";
2+
import { useRouter } from "next/router";
3+
4+
import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal";
5+
import { Button } from "@/components/ui/Button";
6+
import { useHackatimeRelink } from "@/hooks/useHackatimeRelink";
7+
8+
const AUTH_ROUTE = "/auth";
9+
const DISMISSED_SESSION_KEY = "lapse:hackatimeRelinkDismissed";
10+
11+
/**
12+
* Raised once per session for users whose Hackatime authorization predates the `read` scope. Dismissing it leaves
13+
* `HackatimeRelinkBanner` in place, so the way to fix it stays reachable without asking twice.
14+
*/
15+
export function HackatimeRelinkModal() {
16+
const router = useRouter();
17+
const needsRelink = useHackatimeRelink();
18+
const [dismissed, setDismissed] = useState(true);
19+
20+
useEffect(() => {
21+
setDismissed(sessionStorage.getItem(DISMISSED_SESSION_KEY) === "true");
22+
}, []);
23+
24+
function dismiss() {
25+
sessionStorage.setItem(DISMISSED_SESSION_KEY, "true");
26+
setDismissed(true);
27+
}
28+
29+
function reconnect() {
30+
router.push(`${AUTH_ROUTE}?force=1&redirect=${encodeURIComponent(router.asPath)}`);
31+
}
32+
33+
const isOpen = needsRelink && !dismissed && router.pathname !== AUTH_ROUTE;
34+
35+
return (
36+
<Modal isOpen={isOpen} size="SMALL">
37+
<ModalHeader
38+
icon="clock"
39+
title="Reconnect Hackatime"
40+
description="Hackatime needs your permission again"
41+
showCloseButton
42+
onClose={dismiss}
43+
/>
44+
45+
<ModalContent className="gap-4 text-base">
46+
<p>
47+
Your timelapses still sync! The picker shows up empty, as if you had no projects.
48+
</p>
49+
50+
<p className="text-muted">
51+
Reconnecting Hackatime fixes this issue. It only takes a few seconds.
52+
</p>
53+
54+
<Button kind="primary" onClick={reconnect} className="w-full">
55+
Reconnect Hackatime
56+
</Button>
57+
</ModalContent>
58+
</Modal>
59+
);
60+
}

apps/client/src/components/layout/RootLayout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { PropsWithChildren } from "react";
33
import clsx from "clsx";
44

55
import { Header } from "@/components/layout/Header";
6+
import { HackatimeRelinkBanner } from "@/components/layout/HackatimeRelinkBanner";
7+
import { HackatimeRelinkModal } from "@/components/layout/HackatimeRelinkModal";
68
import { LegacyRecoveryBanner } from "@/components/legacy/LegacyRecoveryBanner";
79
import { jetBrainsMono, phantomSans } from "@/fonts";
810

@@ -25,6 +27,8 @@ export default function RootLayout({ children, title = "Lapse", description = "T
2527
jetBrainsMono.variable,
2628
phantomSans.className
2729
)}>
30+
<HackatimeRelinkModal />
31+
<HackatimeRelinkBanner />
2832
<LegacyRecoveryBanner />
2933

3034
{ showHeader && <Header /> }
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useEffect, useState } from "react";
2+
import { useRouter } from "next/router";
3+
4+
import { api } from "@/api";
5+
import { useAuth } from "@/hooks/useAuth";
6+
7+
export const RELINK_SESSION_KEY = "lapse:hackatimeNeedsRelink";
8+
9+
/**
10+
* Whether the signed-in user has to authorize Lapse with Hackatime again. A "no" is cached for the browser
11+
* session, since checking costs a request to Hackatime and almost nobody needs to be asked twice.
12+
*/
13+
export function useHackatimeRelink(): boolean {
14+
const router = useRouter();
15+
const auth = useAuth(false);
16+
const [needsRelink, setNeedsRelink] = useState(false);
17+
18+
// Nothing renders this on /auth anyway, and checking there would ask about the token being replaced.
19+
const isReauthenticating = router.pathname === "/auth";
20+
21+
useEffect(() => {
22+
if (!auth.currentUser || isReauthenticating) {
23+
setNeedsRelink(false);
24+
return;
25+
}
26+
27+
// Only "no" is worth remembering. Someone told to reconnect is expected to go and do it, so a cached "yes"
28+
// would outlive the fix and keep nagging them - which is exactly what it did.
29+
if (sessionStorage.getItem(RELINK_SESSION_KEY) === "false") {
30+
setNeedsRelink(false);
31+
return;
32+
}
33+
34+
let cancelled = false;
35+
(async () => {
36+
const res = await api.hackatime.linkStatus({});
37+
if (cancelled || !res.ok)
38+
return;
39+
40+
if (res.data.needsRelink)
41+
sessionStorage.removeItem(RELINK_SESSION_KEY);
42+
else
43+
sessionStorage.setItem(RELINK_SESSION_KEY, "false");
44+
45+
setNeedsRelink(res.data.needsRelink);
46+
})();
47+
48+
return () => { cancelled = true; };
49+
}, [auth.currentUser, isReauthenticating]);
50+
51+
return needsRelink;
52+
}
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import { useEffect } from "react";
1+
import { useEffect, useRef } from "react";
22

33
export function useInterval(callback: () => void, delay: number) {
4+
// Held in a ref so an inline callback - a new function on every render - doesn't tear down and
5+
// immediately re-fire the interval each time the component renders.
6+
const latest = useRef(callback);
7+
latest.current = callback;
8+
49
useEffect(() => {
5-
const timer = setInterval(callback, delay);
6-
callback();
10+
const tick = () => latest.current();
11+
const timer = setInterval(tick, delay);
12+
tick();
713

814
return () => clearInterval(timer);
9-
}, [callback, delay]);
10-
}
15+
}, [delay]);
16+
}

0 commit comments

Comments
 (0)