Skip to content

Commit dda79b0

Browse files
committed
Add floating migration notice with host targeting and 24h dismiss
1 parent c32123c commit dda79b0

5 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { MemoryRouter } from "react-router-dom";
3+
4+
vi.mock("../lib/migrationNotice", async () => {
5+
const actual = await vi.importActual<typeof import("../lib/migrationNotice")>(
6+
"../lib/migrationNotice"
7+
);
8+
9+
return {
10+
...actual,
11+
getRuntimeHostname: () => "c4lab.bime.ntu.edu.tw"
12+
};
13+
});
14+
15+
import { App } from "./App";
16+
17+
const renderApp = (initialEntries: string[]) =>
18+
render(
19+
<MemoryRouter initialEntries={initialEntries}>
20+
<App />
21+
</MemoryRouter>
22+
);
23+
24+
describe("Home migration notice routing", () => {
25+
beforeEach(() => {
26+
window.localStorage.clear();
27+
});
28+
29+
test("shows migration notice on the home route for non-primary hosts", async () => {
30+
renderApp(["/"]);
31+
expect(await screen.findByRole("region", { name: /site migration notice/i })).toBeVisible();
32+
});
33+
34+
test("does not show migration notice on non-home routes", async () => {
35+
renderApp(["/research"]);
36+
await screen.findByRole("heading", { name: /deep learning for immunogenomics/i });
37+
expect(screen.queryByRole("region", { name: /site migration notice/i })).not.toBeInTheDocument();
38+
});
39+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { MigrationNotice } from "./MigrationNotice";
4+
import {
5+
MIGRATION_NOTICE_DISMISSED_KEY,
6+
MIGRATION_NOTICE_TTL_MS,
7+
MIGRATION_TARGET_URL
8+
} from "../../lib/migrationNotice";
9+
10+
function createMockStorage(seed: Record<string, string> = {}) {
11+
const values = new Map<string, string>(Object.entries(seed));
12+
13+
return {
14+
getItem: vi.fn((key: string) => values.get(key) ?? null),
15+
setItem: vi.fn((key: string, value: string) => {
16+
values.set(key, value);
17+
})
18+
};
19+
}
20+
21+
describe("MigrationNotice", () => {
22+
const fixedNow = 1_750_000_000_000;
23+
const nowProvider = () => fixedNow;
24+
25+
test("renders for non-primary hosts and points to the new site", () => {
26+
const storage = createMockStorage();
27+
render(
28+
<MigrationNotice hostname="c4lab.bime.ntu.edu.tw" storage={storage} nowProvider={nowProvider} />
29+
);
30+
31+
expect(screen.getByRole("region", { name: /site migration notice/i })).toBeVisible();
32+
expect(screen.getByRole("link", { name: /c4lab.github.io/i })).toHaveAttribute(
33+
"href",
34+
MIGRATION_TARGET_URL
35+
);
36+
});
37+
38+
test("does not render for the primary host", () => {
39+
const storage = createMockStorage();
40+
render(<MigrationNotice hostname="c4lab.github.io" storage={storage} nowProvider={nowProvider} />);
41+
42+
expect(screen.queryByRole("region", { name: /site migration notice/i })).not.toBeInTheDocument();
43+
});
44+
45+
test("renders for localhost", () => {
46+
const storage = createMockStorage();
47+
render(<MigrationNotice hostname="localhost" storage={storage} nowProvider={nowProvider} />);
48+
49+
expect(screen.getByRole("region", { name: /site migration notice/i })).toBeVisible();
50+
});
51+
52+
test("dismisses and persists to storage", async () => {
53+
const user = userEvent.setup();
54+
const storage = createMockStorage();
55+
render(
56+
<MigrationNotice hostname="c4lab.bime.ntu.edu.tw" storage={storage} nowProvider={nowProvider} />
57+
);
58+
59+
await user.click(screen.getByRole("button", { name: /dismiss migration notice/i }));
60+
61+
expect(screen.queryByRole("region", { name: /site migration notice/i })).not.toBeInTheDocument();
62+
expect(storage.setItem).toHaveBeenCalledWith(MIGRATION_NOTICE_DISMISSED_KEY, String(fixedNow));
63+
});
64+
65+
test("stays hidden when dismissed within 24 hours", () => {
66+
const storage = createMockStorage({
67+
[MIGRATION_NOTICE_DISMISSED_KEY]: String(fixedNow - MIGRATION_NOTICE_TTL_MS + 1)
68+
});
69+
render(
70+
<MigrationNotice hostname="c4lab.bime.ntu.edu.tw" storage={storage} nowProvider={nowProvider} />
71+
);
72+
73+
expect(screen.queryByRole("region", { name: /site migration notice/i })).not.toBeInTheDocument();
74+
});
75+
76+
test("shows again when dismissed more than 24 hours ago", () => {
77+
const storage = createMockStorage({
78+
[MIGRATION_NOTICE_DISMISSED_KEY]: String(fixedNow - MIGRATION_NOTICE_TTL_MS - 1)
79+
});
80+
render(
81+
<MigrationNotice hostname="c4lab.bime.ntu.edu.tw" storage={storage} nowProvider={nowProvider} />
82+
);
83+
84+
expect(screen.getByRole("region", { name: /site migration notice/i })).toBeVisible();
85+
});
86+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useState } from "react";
2+
import {
3+
getRuntimeHostname,
4+
MIGRATION_NOTICE_DISMISSED_KEY,
5+
MIGRATION_NOTICE_TTL_MS,
6+
MIGRATION_TARGET_URL,
7+
shouldShowMigrationNotice
8+
} from "../../lib/migrationNotice";
9+
10+
type NoticeStorage = Pick<Storage, "getItem" | "setItem">;
11+
12+
type MigrationNoticeProps = {
13+
hostname?: string;
14+
storage?: NoticeStorage;
15+
nowProvider?: () => number;
16+
};
17+
18+
function readDismissed(storage: NoticeStorage, now: number): boolean {
19+
try {
20+
const rawDismissedAt = storage.getItem(MIGRATION_NOTICE_DISMISSED_KEY);
21+
if (!rawDismissedAt) {
22+
return false;
23+
}
24+
25+
const dismissedAt = Number(rawDismissedAt);
26+
if (!Number.isFinite(dismissedAt)) {
27+
return false;
28+
}
29+
30+
return now - dismissedAt < MIGRATION_NOTICE_TTL_MS;
31+
} catch {
32+
return false;
33+
}
34+
}
35+
36+
export function MigrationNotice({
37+
hostname = getRuntimeHostname(),
38+
storage = window.localStorage,
39+
nowProvider = () => Date.now()
40+
}: MigrationNoticeProps) {
41+
const [dismissed, setDismissed] = useState(() => readDismissed(storage, nowProvider()));
42+
43+
if (!shouldShowMigrationNotice(hostname) || dismissed) {
44+
return null;
45+
}
46+
47+
const handleDismiss = () => {
48+
setDismissed(true);
49+
try {
50+
storage.setItem(MIGRATION_NOTICE_DISMISSED_KEY, String(nowProvider()));
51+
} catch {
52+
// Ignore storage failures and still hide the notice for this render cycle.
53+
}
54+
};
55+
56+
return (
57+
<div className="pointer-events-none fixed inset-x-0 top-20 z-50 px-4 sm:top-24">
58+
<section
59+
aria-label="Site migration notice"
60+
className="glass-panel pointer-events-auto mx-auto w-full max-w-3xl rounded-2xl border-l-4 border-l-primary px-4 py-2.5 text-sm text-slate-700 shadow-lg"
61+
>
62+
<div className="flex items-center justify-between gap-3">
63+
<p className="min-w-0 leading-6">
64+
本站即將遷移至 / This site is migrating to{" "}
65+
<a
66+
href={MIGRATION_TARGET_URL}
67+
className="font-semibold text-primary-strong underline underline-offset-2"
68+
>
69+
c4lab.github.io
70+
</a>
71+
.
72+
</p>
73+
<button
74+
type="button"
75+
onClick={handleDismiss}
76+
className="shrink-0 cursor-pointer rounded-full border border-slate-300 bg-white px-2.5 py-1 text-xs font-semibold text-navy transition-colors duration-200 hover:bg-slate-50"
77+
aria-label="Dismiss migration notice"
78+
>
79+
Hide
80+
</button>
81+
</div>
82+
</section>
83+
</div>
84+
);
85+
}

src/lib/migrationNotice.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const PRIMARY_HOSTNAME = "c4lab.github.io";
2+
export const MIGRATION_TARGET_URL = "https://c4lab.github.io/";
3+
export const MIGRATION_NOTICE_DISMISSED_KEY = "migration_notice_dismissed_v1";
4+
export const MIGRATION_NOTICE_TTL_MS = 24 * 60 * 60 * 1000;
5+
6+
export function shouldShowMigrationNotice(hostname: string): boolean {
7+
const normalizedHostname = hostname.toLowerCase();
8+
return normalizedHostname.length > 0 && normalizedHostname !== PRIMARY_HOSTNAME;
9+
}
10+
11+
export function getRuntimeHostname(): string {
12+
if (typeof window === "undefined") {
13+
return "";
14+
}
15+
return window.location.hostname;
16+
}

src/pages/HomePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ProjectHighlightGrid } from "../components/sections/ProjectHighlightGrid";
22
import { HomeHeroSection } from "../components/sections/HomeHeroSection";
3+
import { MigrationNotice } from "../components/sections/MigrationNotice";
34
import { TimelinePreviewSection } from "../components/sections/TimelinePreviewSection";
45
import { SeoHead } from "../components/seo/SeoHead";
56
import { pageSeo } from "../lib/seo";
@@ -8,6 +9,7 @@ export function HomePage() {
89
return (
910
<>
1011
<SeoHead {...pageSeo.home} />
12+
<MigrationNotice />
1113
<HomeHeroSection />
1214
<ProjectHighlightGrid />
1315
<TimelinePreviewSection />

0 commit comments

Comments
 (0)