This commit is contained in:
Nathan Anderson 2026-02-15 02:49:28 +00:00
parent ebbf987e95
commit d487282fa5
11 changed files with 101 additions and 2568 deletions

View File

@ -93,6 +93,41 @@ Checks:
- If `SANDBOX_MODE=true` or `DISABLE_EXTERNAL_APIS=true`, payment intent flow - If `SANDBOX_MODE=true` or `DISABLE_EXTERNAL_APIS=true`, payment intent flow
stays local and does not call Stripe. stays local and does not call Stripe.
### Islands disappear after load (production / Ubuntu server)
Symptom:
- Island components (e.g. WelcomeBar, GuestHome) render briefly then vanish when
the app is run from a production build on another machine (e.g. Ubuntu).
Cause:
- Multiple copies of Preact end up in the bundle/cache. Hydration then fails and
Preact can tear down the island DOM. Common when the server has a different or
stale Deno cache than the machine that built the app.
Checks and fix:
1. On the **deployment server** (Ubuntu), force a clean dependency install
before building:
```bash
deno install -r --allow-scripts
deno task build
deno task start
```
2. Ensure the process **working directory is the project root** so that
`_fresh/server.js` and `_fresh/static/` are found (e.g. in systemd set
`WorkingDirectory=` to the app directory).
3. For reproducible builds across machines, consider re-enabling the lockfile
in `deno.json` (`"lock": true`) and committing `deno.lock`, then using the
same lock on the server.
4. Do not use `esm.sh` for Preact (this project already uses `npm:preact`);
duplicate Preact from mixed sources is a common cause.
## 7. Change Discipline ## 7. Change Discipline
- Keep docs and code aligned when behavior changes. - Keep docs and code aligned when behavior changes.

View File

@ -1,4 +1,5 @@
{ {
"lock": false,
"tasks": { "tasks": {
"check": "deno fmt && deno fmt --check . && deno lint . && deno check", "check": "deno fmt && deno fmt --check . && deno lint . && deno check",
"dev": "vite", "dev": "vite",
@ -8,13 +9,13 @@
"e2e": "deno run -A npm:playwright test", "e2e": "deno run -A npm:playwright test",
"e2e:ui": "deno run -A npm:playwright test --ui", "e2e:ui": "deno run -A npm:playwright test --ui",
"e2e:report": "deno run -A npm:playwright show-report", "e2e:report": "deno run -A npm:playwright show-report",
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate", "db:generate": "deno run -A npm:drizzle-kit generate",
"db:migrate": "node ./node_modules/drizzle-kit/bin.cjs migrate", "db:migrate": "deno run -A npm:drizzle-kit migrate",
"db:push": "node ./node_modules/drizzle-kit/bin.cjs push", "db:push": "deno run -A npm:drizzle-kit push",
"db:studio": "node ./node_modules/drizzle-kit/bin.cjs studio", "db:studio": "deno run -A npm:drizzle-kit studio",
"test:auth-callback-db": "./tests/run-auth-callback-db-test.sh" "test:auth-callback-db": "./tests/run-auth-callback-db-test.sh"
}, },
"lint": { "exclude": ["node_modules/", "_fresh/"] }, "lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"], "exclude": ["**/_fresh/*"],
"imports": { "imports": {
"fresh": "jsr:@fresh/core@^2.2.0", "fresh": "jsr:@fresh/core@^2.2.0",
@ -34,7 +35,6 @@
"denoPath": "https://deno.land/std@0.224.0/path/mod.ts", "denoPath": "https://deno.land/std@0.224.0/path/mod.ts",
"denoFs": "https://deno.land/std@0.224.0/fs/mod.ts", "denoFs": "https://deno.land/std@0.224.0/fs/mod.ts",
"encoder": "jsr:@std/encoding/base64", "encoder": "jsr:@std/encoding/base64",
"@vite-plugin-static-copy": "npm:vite-plugin-static-copy",
"@GraphClient": "npm:@microsoft/microsoft-graph-client@3.0.0", "@GraphClient": "npm:@microsoft/microsoft-graph-client@3.0.0",
"@IsomorphicFetch": "npm:isomorphic-fetch", "@IsomorphicFetch": "npm:isomorphic-fetch",
"@openai": "https://deno.land/x/openai@v4.69.0/mod.ts", "@openai": "https://deno.land/x/openai@v4.69.0/mod.ts",
@ -75,8 +75,12 @@
], ],
"types": ["vite/client"] "types": ["vite/client"]
}, },
"test": { "exclude": ["node_modules/", "_fresh/"] }, "test": {
"fmt": { "exclude": ["node_modules/", "_fresh/"] }, "exclude": ["node_modules/", "_fresh/"]
},
"fmt": {
"exclude": ["node_modules/", "_fresh/"]
},
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"unstable": ["kv"] "unstable": ["kv"]
} }

2547
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,15 @@ import type { OnboardingStatus } from "../services/onboardingService.ts";
interface MemberHomeProps { interface MemberHomeProps {
onboardingStatus?: OnboardingStatus | null; onboardingStatus?: OnboardingStatus | null;
/** Server-provided calendar year for hydration-safe initial link (avoids server/client date mismatch). */
serverCalendarYear?: number;
/** Server-provided calendar month (1–12) for hydration-safe initial link. */
serverCalendarMonth?: number;
} }
export default function MemberHome({ onboardingStatus }: MemberHomeProps) { export default function MemberHome(
{ onboardingStatus, serverCalendarYear, serverCalendarMonth }: MemberHomeProps,
) {
useEffect(() => { useEffect(() => {
const lockRoute = localStorage.getItem("lock-route"); const lockRoute = localStorage.getItem("lock-route");
if (lockRoute) { if (lockRoute) {
@ -55,9 +61,10 @@ export default function MemberHome({ onboardingStatus }: MemberHomeProps) {
? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" } ? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" }
: { label: "Continue Tenant Setup", href: "/tenant/dashboard" }; : { label: "Continue Tenant Setup", href: "/tenant/dashboard" };
const now = new Date(); const year = serverCalendarYear ?? new Date().getFullYear();
const calendarHref = `/calendar/financial-calendar/${now.getFullYear()}/${ const month = serverCalendarMonth ?? new Date().getMonth() + 1;
(now.getMonth() + 1).toString().padStart(2, "0") const calendarHref = `/calendar/financial-calendar/${year}/${
month.toString().padStart(2, "0")
}?`; }?`;
return ( return (

View File

@ -13,7 +13,7 @@ import {
* Native-rendered dropdown content for <details> menu. * Native-rendered dropdown content for <details> menu.
*/ */
export default function NavigationMenu( export default function NavigationMenu(
{ isLoggedIn, currentRoute, navigationGroups }: NavigationMenuProps, { isLoggedIn, currentRoute, navigationGroups, serverCalendarPath }: NavigationMenuProps,
) { ) {
function getItemStateClass(item: NavigationItem): string { function getItemStateClass(item: NavigationItem): string {
if (item.disabled) return "navigation-menu-item--disabled"; if (item.disabled) return "navigation-menu-item--disabled";
@ -73,6 +73,7 @@ export default function NavigationMenu(
isLoggedIn, isLoggedIn,
currentRoute, currentRoute,
navigationGroups, navigationGroups,
serverCalendarPath,
); );
if (groups.length === 0) { if (groups.length === 0) {

View File

@ -16,7 +16,7 @@ function normalizeRoute(path: string): string {
* Main navigation bar with user info, encryption status, and menu. * Main navigation bar with user info, encryption status, and menu.
*/ */
export default function WelcomeBar( export default function WelcomeBar(
{ publicId, user_name, currentRoute, isEncrypted, stateId }: WelcomeBarProps, { publicId, user_name, currentRoute, isEncrypted, stateId, serverCalendarPath }: WelcomeBarProps,
) { ) {
const [activeRoute, setActiveRoute] = useState(normalizeRoute(currentRoute)); const [activeRoute, setActiveRoute] = useState(normalizeRoute(currentRoute));
const [state] = useState<WelcomeBarState>({ const [state] = useState<WelcomeBarState>({
@ -182,6 +182,7 @@ export default function WelcomeBar(
<NavigationMenu <NavigationMenu
isLoggedIn={hasValidUserId} isLoggedIn={hasValidUserId}
currentRoute={activeRoute} currentRoute={activeRoute}
serverCalendarPath={serverCalendarPath}
/> />
</details> </details>

View File

@ -11,6 +11,9 @@ function normalizePath(path: string): string {
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
} }
/** Placeholder replaced at runtime with server or client calendar path for hydration-safe nav. */
export const CALENDAR_PATH_PLACEHOLDER = "__SERVER_CALENDAR_PATH__";
function getCurrentFinancialCalendarPath(date = new Date()): string { function getCurrentFinancialCalendarPath(date = new Date()): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0");
@ -88,7 +91,7 @@ export const defaultNavigationGroups: NavigationGroup[] = [
label: "App Switch", label: "App Switch",
items: [ items: [
{ {
path: getCurrentFinancialCalendarPath(), path: CALENDAR_PATH_PLACEHOLDER,
label: "Calendar", label: "Calendar",
requiresAuth: true, requiresAuth: true,
matchPrefixes: ["/calendar/financial-calendar"], matchPrefixes: ["/calendar/financial-calendar"],
@ -110,7 +113,7 @@ export const defaultNavigationGroups: NavigationGroup[] = [
label: "Calendar", label: "Calendar",
items: [ items: [
{ {
path: getCurrentFinancialCalendarPath(), path: CALENDAR_PATH_PLACEHOLDER,
label: "Financial Calendar", label: "Financial Calendar",
requiresAuth: true, requiresAuth: true,
matchPrefixes: ["/calendar/financial-calendar"], matchPrefixes: ["/calendar/financial-calendar"],
@ -201,17 +204,27 @@ const contextGroupOrder: Record<NavigationContext, string[]> = {
/** /**
* Get navigation groups filtered by authentication status and current route context. * Get navigation groups filtered by authentication status and current route context.
* serverCalendarPath: use when provided so server and client render the same calendar links (avoids hydration mismatch).
*/ */
export function getNavigationGroups( export function getNavigationGroups(
isLoggedIn: boolean, isLoggedIn: boolean,
currentRoute: string, currentRoute: string,
customGroups?: NavigationGroup[], customGroups?: NavigationGroup[],
serverCalendarPath?: string,
): NavigationGroup[] { ): NavigationGroup[] {
const calendarPath = serverCalendarPath ?? getCurrentFinancialCalendarPath();
const groups = customGroups || defaultNavigationGroups; const groups = customGroups || defaultNavigationGroups;
const route = normalizePath(currentRoute); const route = normalizePath(currentRoute);
const context = resolveNavigationContext(route); const context = resolveNavigationContext(route);
const orderedGroupIds = contextGroupOrder[context]; const orderedGroupIds = contextGroupOrder[context];
const substituteCalendarPath = (items: NavigationItem[]): NavigationItem[] =>
items.map((item) =>
item.path === CALENDAR_PATH_PLACEHOLDER
? { ...item, path: calendarPath }
: item
);
return groups return groups
.filter((group) => { .filter((group) => {
if (customGroups) return true; if (customGroups) return true;
@ -223,7 +236,7 @@ export function getNavigationGroups(
}) })
.map((group) => ({ .map((group) => ({
...group, ...group,
items: group.items.filter((item) => { items: substituteCalendarPath(group.items).filter((item) => {
if (item.requiresAuth && !isLoggedIn) { if (item.requiresAuth && !isLoggedIn) {
return false; return false;
} }

View File

@ -9,6 +9,8 @@ export interface WelcomeBarProps {
currentRoute: string; currentRoute: string;
isEncrypted: boolean; isEncrypted: boolean;
stateId: string; stateId: string;
/** Server calendar path for hydration-safe nav (layout should pass from server Date). */
serverCalendarPath?: string;
} }
/** /**
@ -68,6 +70,7 @@ export interface NavigationMenuProps {
isLoggedIn: boolean; isLoggedIn: boolean;
currentRoute: string; currentRoute: string;
navigationGroups?: NavigationGroup[]; navigationGroups?: NavigationGroup[];
serverCalendarPath?: string;
} }
/** /**

View File

@ -22,6 +22,9 @@ export default define.page(async function App(props: PageProps) {
const publicId = stateData.user_id const publicId = stateData.user_id
? await createPublicId(stateData.user_id) ? await createPublicId(stateData.user_id)
: ""; : "";
const now = new Date();
const serverCalendarPath =
`/calendar/financial-calendar/${now.getFullYear()}/${(now.getMonth() + 1).toString().padStart(2, "0")}`;
return ( return (
<html> <html>
@ -37,6 +40,7 @@ export default define.page(async function App(props: PageProps) {
currentRoute={url.pathname} currentRoute={url.pathname}
isEncrypted={stateData.user_encrypted} isEncrypted={stateData.user_encrypted}
stateId={state.stateId} stateId={state.stateId}
serverCalendarPath={serverCalendarPath}
/> />
<div class="page-offset"> <div class="page-offset">
<Component /> <Component />

View File

@ -3,7 +3,6 @@ import { define, State } from "../utils.ts";
import GuestHome from "../islands/GuestHome.tsx"; import GuestHome from "../islands/GuestHome.tsx";
import MemberHome from "../islands/MemberHome.tsx"; import MemberHome from "../islands/MemberHome.tsx";
import { page } from "fresh"; import { page } from "fresh";
import { getYear } from "../helpers.ts";
import { SessionCookie } from "./auth/callback.ts"; import { SessionCookie } from "./auth/callback.ts";
import { createPublicId } from "../helpers.ts"; import { createPublicId } from "../helpers.ts";
import { getOnboardingStatus } from "../services/onboardingService.ts"; import { getOnboardingStatus } from "../services/onboardingService.ts";
@ -29,12 +28,16 @@ export const handler = define.handlers(async (ctx) => {
const onboardingStatus = stateData.user_id const onboardingStatus = stateData.user_id
? await getOnboardingStatus(stateData.user_id) ? await getOnboardingStatus(stateData.user_id)
: null; : null;
const now = new Date();
return page({ return page({
stateData, stateData,
urlRoute: ctx.state.urlRoute, urlRoute: ctx.state.urlRoute,
publicId, publicId,
stateId: state.stateId, stateId: state.stateId,
onboardingStatus, onboardingStatus,
serverYear: now.getFullYear(),
serverCalendarYear: now.getFullYear(),
serverCalendarMonth: now.getMonth() + 1,
}); });
}); });
@ -47,6 +50,9 @@ export default define.page(
publicId: string; publicId: string;
stateId: string; stateId: string;
onboardingStatus: Awaited<ReturnType<typeof getOnboardingStatus>>; onboardingStatus: Awaited<ReturnType<typeof getOnboardingStatus>>;
serverYear: number;
serverCalendarYear: number;
serverCalendarMonth: number;
}; };
}, },
) => { ) => {
@ -54,11 +60,17 @@ export default define.page(
<main class="home-route"> <main class="home-route">
<div class="w-full flex-1"> <div class="w-full flex-1">
{data.stateData.user_id {data.stateData.user_id
? <MemberHome onboardingStatus={data.onboardingStatus} /> ? (
<MemberHome
onboardingStatus={data.onboardingStatus}
serverCalendarYear={data.serverCalendarYear}
serverCalendarMonth={data.serverCalendarMonth}
/>
)
: <GuestHome />} : <GuestHome />}
</div> </div>
<footer class="home-footer"> <footer class="home-footer">
&copy; {getYear(new Date())} Cyber Hybrid Hub. All rights reserved. &copy; {data.serverYear} Cyber Hybrid Hub. All rights reserved.
</footer> </footer>
</main> </main>
); );

View File

@ -278,7 +278,7 @@ export default define.page(
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
</div> </div>