feat: complete release readiness work #28

Open
cesnimda wants to merge 110 commits from release-readiness into main
8 changed files with 240 additions and 23 deletions
Showing only changes of commit 11734ee058 - Show all commits
+4 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import "../src/index.css";
import { THEME_BOOTSTRAP_SCRIPT } from "../src/themeBootstrap";
export const metadata: Metadata = {
title: "Jobbjakt",
@@ -23,8 +25,9 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<Script id="jobtracker-theme-bootstrap" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
+22 -16
View File
@@ -1,8 +1,7 @@
import React, { Suspense, lazy, useEffect, useMemo, useState } from "react";
import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Box, Button, CssBaseline, IconButton, Typography } from "@mui/material";
import { Box, Button, IconButton, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { CssVarsProvider } from "@mui/material/styles";
import DashboardIcon from "@mui/icons-material/Dashboard";
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
@@ -20,7 +19,6 @@ import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
import { getTheme } from "./theme";
import { ToastProvider } from "./toast";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
@@ -40,7 +38,8 @@ import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import { AccountPlanProvider } from "./accountPlan";
import AppShell, { NavItem } from "./layout/AppShell";
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs";
import JobTrackerThemeProvider from "./ThemeProvider";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -66,6 +65,8 @@ const OperationsPage = lazy(() => import("./views/OperationsPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
const ThemeControlsContext = createContext<ThemeControls | null>(null);
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -383,19 +384,23 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
);
}
function ThemeAwareShell(props: Omit<React.ComponentProps<typeof Shell>, "themeMode" | "onThemeModeChange">) {
const controls = useContext(ThemeControlsContext);
if (!controls) throw new Error("Theme controls are unavailable.");
return <Shell {...props} {...controls} />;
}
export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); };
window.addEventListener("auth-changed", sync);
return () => window.removeEventListener("auth-changed", sync);
return subscribeToThemePreferenceChanges(sync);
}, []);
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
const onThemeModeChange = useCallback((v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }, []);
const themeControls = useMemo(() => ({ themeMode, onThemeModeChange }), [onThemeModeChange, themeMode]);
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
@@ -423,19 +428,20 @@ export default function App() {
{ path: "/confirm-email-change", element: <VerifyEmailPage emailChange />, errorElement: <RouteErrorPage /> },
{ path: "/microsoft-legacy-relink", element: <MicrosoftLegacyRelinkPage />, errorElement: <RouteErrorPage /> },
{ path: "/cv/:slug", element: <PublicCvPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
{ path: "/*", element: <ThemeAwareShell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize]);
return (
<ToastProvider>
<ConfirmProvider>
<PromptProvider>
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssBaseline enableColorScheme />
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
<I18nProvider>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
<ThemeControlsContext.Provider value={themeControls}>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
</ThemeControlsContext.Provider>
</I18nProvider>
</CssVarsProvider>
</JobTrackerThemeProvider>
</PromptProvider>
</ConfirmProvider>
</ToastProvider>
+25
View File
@@ -0,0 +1,25 @@
import React, { useLayoutEffect, useMemo } from "react";
import { CssBaseline } from "@mui/material";
import { CssVarsProvider, useColorScheme } from "@mui/material/styles";
import { getTheme } from "./theme";
import { resolveEffectiveThemeMode, ThemeModePref } from "./themePrefs";
function ThemeModeSynchronizer({ mode }: { mode: "light" | "dark" }) {
const { setMode } = useColorScheme();
useLayoutEffect(() => { setMode(mode); }, [mode, setMode]);
return null;
}
export default function JobTrackerThemeProvider({ preference, systemPrefersDark, children }: { preference: ThemeModePref; systemPrefersDark: boolean; children: React.ReactNode }) {
const mode = resolveEffectiveThemeMode(preference, systemPrefersDark);
const theme = useMemo(() => getTheme("light"), []);
return (
<CssVarsProvider theme={theme as any} defaultMode={mode} storageManager={null} disableTransitionOnChange>
<ThemeModeSynchronizer mode={mode} />
<CssBaseline enableColorScheme />
{children}
</CssVarsProvider>
);
}
+4
View File
@@ -2,6 +2,7 @@ export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
const AUTH_USER_KEY = "authUserKey";
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
export const AUTH_USER_CHANGED_EVENT = "auth-user-changed";
export type AuthPersistence = "local" | "session";
@@ -72,12 +73,14 @@ export function getAuthUserKey(): string {
}
export function setAuthUserKey(value: string | null | undefined, emit = true) {
const previous = getAuthUserKey();
const next = typeof value === "string" ? value.trim() : "";
if (!next) {
safeRemove(window.localStorage, AUTH_USER_KEY);
} else {
safeSet(window.localStorage, AUTH_USER_KEY, next);
}
if (previous !== (next || "anon")) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
if (emit) emitAuthChanged();
}
@@ -89,6 +92,7 @@ export function clearAuthClientState(emit = true) {
// the user is logged out (login page, expired session).
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
safeRemove(window.localStorage, AUTH_USER_KEY);
if (had) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
if (emit && had) emitAuthChanged();
}
@@ -110,7 +110,7 @@ export default function SettingsView({
{t("settingsSubtitle")}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 1 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}>
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
+123
View File
@@ -0,0 +1,123 @@
import React, { useEffect, useState } from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import JobTrackerThemeProvider from "./ThemeProvider";
import { THEME_BOOTSTRAP_SCRIPT } from "./themeBootstrap";
import {
getThemeModePref,
resolveEffectiveThemeMode,
setThemeModePref,
subscribeToThemePreferenceChanges,
} from "./themePrefs";
function setSystemDark(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: jest.fn().mockImplementation(() => ({
matches,
media: "(prefers-color-scheme: dark)",
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
describe("deterministic theme state", () => {
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute("data-color-scheme");
document.documentElement.style.colorScheme = "";
setSystemDark(false);
});
it("uses user preference, then anonymous preference, then the documented System default", () => {
expect(getThemeModePref()).toBe("system");
setThemeModePref("light");
expect(getThemeModePref()).toBe("light");
setAuthUserKey("user-a", false);
expect(getThemeModePref()).toBe("light");
setThemeModePref("dark");
expect(getThemeModePref()).toBe("dark");
setAuthUserKey("user-b", false);
expect(getThemeModePref()).toBe("light");
window.localStorage.removeItem("themeMode:anon");
expect(getThemeModePref()).toBe("system");
});
it("switches to the correct scoped preference on login and logout without a refresh", () => {
window.localStorage.setItem("themeMode:anon", "light");
window.localStorage.setItem("themeMode:user-a", "dark");
const observed: string[] = [];
const unsubscribe = subscribeToThemePreferenceChanges(() => observed.push(getThemeModePref()));
setAuthUserKey("user-a", false);
clearAuthClientState(false);
expect(observed).toEqual(["dark", "light"]);
unsubscribe();
});
it("synchronizes relevant cross-tab storage changes without writing them back", () => {
setAuthUserKey("user-a", false);
const sync = jest.fn();
const unsubscribe = subscribeToThemePreferenceChanges(sync);
window.dispatchEvent(new StorageEvent("storage", { key: "themeMode:user-a", newValue: "dark" }));
window.dispatchEvent(new StorageEvent("storage", { key: "unrelated", newValue: "value" }));
window.dispatchEvent(new StorageEvent("storage", { key: "authUserKey", newValue: "user-b" }));
expect(sync).toHaveBeenCalledTimes(2);
unsubscribe();
});
it("uses system preference only for System mode", () => {
expect(resolveEffectiveThemeMode("light", true)).toBe("light");
expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
expect(resolveEffectiveThemeMode("system", true)).toBe("dark");
expect(resolveEffectiveThemeMode("system", false)).toBe("light");
});
it("applies the saved preference before the client application renders", () => {
window.localStorage.setItem("themeMode:anon", "light");
setSystemDark(true);
new Function(THEME_BOOTSTRAP_SCRIPT)();
expect(document.documentElement).toHaveAttribute("data-color-scheme", "light");
expect(document.documentElement.style.colorScheme).toBe("light");
});
it("changes color scheme without remounting application state", async () => {
let mounts = 0;
function StatefulProbe() {
const [count, setCount] = useState(0);
useEffect(() => { mounts += 1; }, []);
return <button onClick={() => setCount((value) => value + 1)}>Count {count}</button>;
}
const view = render(
<JobTrackerThemeProvider preference="light" systemPrefersDark>
<StatefulProbe />
</JobTrackerThemeProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Count 0" }));
view.rerender(
<JobTrackerThemeProvider preference="dark" systemPrefersDark={false}>
<StatefulProbe />
</JobTrackerThemeProvider>,
);
await waitFor(() => expect(document.documentElement).toHaveAttribute("data-color-scheme", "dark"));
expect(screen.getByRole("button", { name: "Count 1" })).toBeInTheDocument();
expect(mounts).toBe(1);
});
});
+17
View File
@@ -0,0 +1,17 @@
export const THEME_BOOTSTRAP_SCRIPT = `(() => {
try {
const read = (key) => {
const value = window.localStorage.getItem(key);
return value === "light" || value === "dark" || value === "system" ? value : null;
};
const userKey = window.localStorage.getItem("authUserKey") || "anon";
const preference = read("themeMode:" + userKey) || (userKey !== "anon" ? read("themeMode:anon") : null) || "system";
const mode = preference === "dark" || (preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
document.documentElement.setAttribute("data-color-scheme", mode);
document.documentElement.style.colorScheme = mode;
} catch {
const mode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.setAttribute("data-color-scheme", mode);
document.documentElement.style.colorScheme = mode;
}
})();`;
+44 -5
View File
@@ -1,6 +1,10 @@
import { getAuthUserKey } from "./auth";
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "./auth";
export type ThemeModePref = "system" | "light" | "dark";
export type EffectiveThemeMode = "light" | "dark";
export const DEFAULT_THEME_MODE: ThemeModePref = "system";
const THEME_KEY_PREFIX = "themeMode:";
export function getUserKeyFromToken(): string {
return getAuthUserKey();
@@ -10,12 +14,47 @@ function k(base: string) {
return `${base}:${getUserKeyFromToken()}`;
}
function readThemeMode(key: string): ThemeModePref | null {
try {
const raw = window.localStorage.getItem(key);
return raw === "light" || raw === "dark" || raw === "system" ? raw : null;
} catch {
return null;
}
}
export function getThemeModePref(): ThemeModePref {
const raw = window.localStorage.getItem(k("themeMode"));
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
const userKey = getUserKeyFromToken();
const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
if (savedUserMode) return savedUserMode;
if (userKey !== "anon") {
const anonymousMode = readThemeMode(`${THEME_KEY_PREFIX}anon`);
if (anonymousMode) return anonymousMode;
}
return DEFAULT_THEME_MODE;
}
export function setThemeModePref(v: ThemeModePref) {
window.localStorage.setItem(k("themeMode"), v);
try {
window.localStorage.setItem(k("themeMode"), v);
} catch {
// Keep the in-memory selection even when storage is unavailable.
}
}
export function resolveEffectiveThemeMode(preference: ThemeModePref, systemPrefersDark: boolean): EffectiveThemeMode {
if (preference === "light" || preference === "dark") return preference;
return systemPrefersDark ? "dark" : "light";
}
export function subscribeToThemePreferenceChanges(sync: () => void) {
const onStorage = (event: StorageEvent) => {
if (event.key === "authUserKey" || event.key?.startsWith(THEME_KEY_PREFIX)) sync();
};
window.addEventListener(AUTH_USER_CHANGED_EVENT, sync);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(AUTH_USER_CHANGED_EVENT, sync);
window.removeEventListener("storage", onStorage);
};
}