Fixed
This commit is contained in:
parent
ebbf987e95
commit
d487282fa5
35
CLAUDE.md
35
CLAUDE.md
@ -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.
|
||||||
|
|||||||
20
deno.json
20
deno.json
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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">
|
||||||
© {getYear(new Date())} Cyber Hybrid Hub. All rights reserved.
|
© {data.serverYear} Cyber Hybrid Hub. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user