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
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
- Keep docs and code aligned when behavior changes.

View File

@ -1,4 +1,5 @@
{
"lock": false,
"tasks": {
"check": "deno fmt && deno fmt --check . && deno lint . && deno check",
"dev": "vite",
@ -8,13 +9,13 @@
"e2e": "deno run -A npm:playwright test",
"e2e:ui": "deno run -A npm:playwright test --ui",
"e2e:report": "deno run -A npm:playwright show-report",
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate",
"db:migrate": "node ./node_modules/drizzle-kit/bin.cjs migrate",
"db:push": "node ./node_modules/drizzle-kit/bin.cjs push",
"db:studio": "node ./node_modules/drizzle-kit/bin.cjs studio",
"db:generate": "deno run -A npm:drizzle-kit generate",
"db:migrate": "deno run -A npm:drizzle-kit migrate",
"db:push": "deno run -A npm:drizzle-kit push",
"db:studio": "deno run -A npm:drizzle-kit studio",
"test:auth-callback-db": "./tests/run-auth-callback-db-test.sh"
},
"lint": { "exclude": ["node_modules/", "_fresh/"] },
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"],
"imports": {
"fresh": "jsr:@fresh/core@^2.2.0",
@ -34,7 +35,6 @@
"denoPath": "https://deno.land/std@0.224.0/path/mod.ts",
"denoFs": "https://deno.land/std@0.224.0/fs/mod.ts",
"encoder": "jsr:@std/encoding/base64",
"@vite-plugin-static-copy": "npm:vite-plugin-static-copy",
"@GraphClient": "npm:@microsoft/microsoft-graph-client@3.0.0",
"@IsomorphicFetch": "npm:isomorphic-fetch",
"@openai": "https://deno.land/x/openai@v4.69.0/mod.ts",
@ -75,8 +75,12 @@
],
"types": ["vite/client"]
},
"test": { "exclude": ["node_modules/", "_fresh/"] },
"fmt": { "exclude": ["node_modules/", "_fresh/"] },
"test": {
"exclude": ["node_modules/", "_fresh/"]
},
"fmt": {
"exclude": ["node_modules/", "_fresh/"]
},
"nodeModulesDir": "auto",
"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 {
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(() => {
const lockRoute = localStorage.getItem("lock-route");
if (lockRoute) {
@ -55,9 +61,10 @@ export default function MemberHome({ onboardingStatus }: MemberHomeProps) {
? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" }
: { label: "Continue Tenant Setup", href: "/tenant/dashboard" };
const now = new Date();
const calendarHref = `/calendar/financial-calendar/${now.getFullYear()}/${
(now.getMonth() + 1).toString().padStart(2, "0")
const year = serverCalendarYear ?? new Date().getFullYear();
const month = serverCalendarMonth ?? new Date().getMonth() + 1;
const calendarHref = `/calendar/financial-calendar/${year}/${
month.toString().padStart(2, "0")
}?`;
return (

View File

@ -13,7 +13,7 @@ import {
* Native-rendered dropdown content for <details> menu.
*/
export default function NavigationMenu(
{ isLoggedIn, currentRoute, navigationGroups }: NavigationMenuProps,
{ isLoggedIn, currentRoute, navigationGroups, serverCalendarPath }: NavigationMenuProps,
) {
function getItemStateClass(item: NavigationItem): string {
if (item.disabled) return "navigation-menu-item--disabled";
@ -73,6 +73,7 @@ export default function NavigationMenu(
isLoggedIn,
currentRoute,
navigationGroups,
serverCalendarPath,
);
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.
*/
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 [state] = useState<WelcomeBarState>({
@ -182,6 +182,7 @@ export default function WelcomeBar(
<NavigationMenu
isLoggedIn={hasValidUserId}
currentRoute={activeRoute}
serverCalendarPath={serverCalendarPath}
/>
</details>

View File

@ -11,6 +11,9 @@ function normalizePath(path: string): string {
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 {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@ -88,7 +91,7 @@ export const defaultNavigationGroups: NavigationGroup[] = [
label: "App Switch",
items: [
{
path: getCurrentFinancialCalendarPath(),
path: CALENDAR_PATH_PLACEHOLDER,
label: "Calendar",
requiresAuth: true,
matchPrefixes: ["/calendar/financial-calendar"],
@ -110,7 +113,7 @@ export const defaultNavigationGroups: NavigationGroup[] = [
label: "Calendar",
items: [
{
path: getCurrentFinancialCalendarPath(),
path: CALENDAR_PATH_PLACEHOLDER,
label: "Financial Calendar",
requiresAuth: true,
matchPrefixes: ["/calendar/financial-calendar"],
@ -201,17 +204,27 @@ const contextGroupOrder: Record<NavigationContext, string[]> = {
/**
* 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(
isLoggedIn: boolean,
currentRoute: string,
customGroups?: NavigationGroup[],
serverCalendarPath?: string,
): NavigationGroup[] {
const calendarPath = serverCalendarPath ?? getCurrentFinancialCalendarPath();
const groups = customGroups || defaultNavigationGroups;
const route = normalizePath(currentRoute);
const context = resolveNavigationContext(route);
const orderedGroupIds = contextGroupOrder[context];
const substituteCalendarPath = (items: NavigationItem[]): NavigationItem[] =>
items.map((item) =>
item.path === CALENDAR_PATH_PLACEHOLDER
? { ...item, path: calendarPath }
: item
);
return groups
.filter((group) => {
if (customGroups) return true;
@ -223,7 +236,7 @@ export function getNavigationGroups(
})
.map((group) => ({
...group,
items: group.items.filter((item) => {
items: substituteCalendarPath(group.items).filter((item) => {
if (item.requiresAuth && !isLoggedIn) {
return false;
}

View File

@ -9,6 +9,8 @@ export interface WelcomeBarProps {
currentRoute: string;
isEncrypted: boolean;
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;
currentRoute: string;
navigationGroups?: NavigationGroup[];
serverCalendarPath?: string;
}
/**

View File

@ -22,6 +22,9 @@ export default define.page(async function App(props: PageProps) {
const publicId = 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 (
<html>
@ -37,6 +40,7 @@ export default define.page(async function App(props: PageProps) {
currentRoute={url.pathname}
isEncrypted={stateData.user_encrypted}
stateId={state.stateId}
serverCalendarPath={serverCalendarPath}
/>
<div class="page-offset">
<Component />

View File

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

View File

@ -278,7 +278,7 @@ export default define.page(
stroke-linecap="round"
stroke-linejoin="round"
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>
</div>