Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Human-facing architecture notes, provider research, and operational runbooks for
- [adding-a-provider.md](adding-a-provider.md): how to build or extend a provider.
- [exercise-metadata.md](exercise-metadata.md): Free Exercise DB source file, Dofek exercise metadata overrides, and update workflow.
- [testing.md](testing.md): testing patterns that come up repeatedly in this codebase.
- [roadmap.md](roadmap.md): product-level roadmap notes and planned user-facing improvements.

## Architecture And Product Flows

Expand All @@ -26,6 +27,7 @@ Human-facing architecture notes, provider research, and operational runbooks for
| [exercise-metadata.md](exercise-metadata.md) | Strength exercise metadata source, override format, and upstream refresh workflow. |
| [mcp.md](mcp.md) | Remote MCP endpoint setup, scopes, and tools. |
| [nutrition-ai-input.md](nutrition-ai-input.md) | Web + iOS meal logging flow for natural-language AI input. |
| [roadmap.md](roadmap.md) | Product-level roadmap notes, including first-run and onboarding opportunities. |
| [apple-health.md](apple-health.md) | Apple Health import model and type mapping. |
| [apple-watch-accelerometer.md](apple-watch-accelerometer.md) | Notes on Apple Watch accelerometer capture and interpretation. |

Expand Down
15 changes: 15 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Roadmap

Product-level roadmap notes for planned Dofek improvements. This file should stay focused on user-facing outcomes, not implementation details.

## Near-Term Product Opportunities

### Getting Started Flow

Create a polished first-run flow that helps a new user reach a useful dashboard quickly.

- Explain what Dofek can help them see: correlations, trends, and comparisons across their health data.
- Guide them through connecting supported sources without exposing implementation mechanics.
- Include iOS app setup as a first-class path for Apple Health and mobile tracking.
- Call out Slack food logging as an easy way to start capturing nutrition context.
- Show a short progress path from "connect sources" to "first useful insight" so the app feels directed before enough data exists for deeper analysis.
62 changes: 62 additions & 0 deletions packages/web/src/components/AppHeader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from "@tanstack/react-router";
import type { ComponentType } from "react";
import { AppHeader } from "./AppHeader";

function withRouter(Story: ComponentType) {
const rootRoute = createRootRoute({
component: Outlet,
});
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/dashboard",
component: () => <Story />,
});
const routeTree = rootRoute.addChildren([dashboardRoute]);
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ["/dashboard"] }),
});

return (
<div className="min-h-screen bg-page">
<RouterProvider router={router} />
</div>
);
}

const meta = {
title: "Layout/AppHeader",
component: AppHeader,
tags: ["autodocs"],
decorators: [withRouter],
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof AppHeader>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const WithHeaderAction: Story = {
args: {
children: (
<button
type="button"
className="rounded-md border border-border bg-surface-solid px-3 py-1.5 text-xs font-semibold text-foreground"
>
30 days
</button>
),
},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
49 changes: 49 additions & 0 deletions packages/web/src/components/AppHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { AppHeader } from "./AppHeader.tsx";

vi.mock("../lib/auth-context.tsx", () => ({
useAuth: () => ({
user: { name: "Ada Lovelace", email: "ada@example.com", isAdmin: false },
isLoading: false,
logout: vi.fn(),
}),
}));

vi.mock("@tanstack/react-router", () => ({
Link: ({ children, className, to }: { children: ReactNode; className?: string; to: string }) => (
<a href={to} className={className}>
{children}
</a>
),
}));

describe("AppHeader", () => {
it("renders desktop navigation as a sidebar evidence desk", () => {
render(<AppHeader />);

const sidebar = screen.getByLabelText("Primary navigation");
expect(sidebar.tagName).toBe("ASIDE");
expect(sidebar.className).toContain("lg:sticky");
expect(sidebar.className).toContain("lg:w-[13.5rem]");
expect(sidebar.className).toContain("border-border-strong");
});

it("keeps a compact mobile header for small screens", () => {
render(<AppHeader />);

const mobileHeader = screen.getByRole("banner");
expect(mobileHeader.className).toContain("lg:hidden");
expect(screen.getByLabelText("Toggle navigation menu")).toBeTruthy();
});

it("renders primary app destinations and the signed-in user", () => {
render(<AppHeader />);

expect(screen.getAllByText("Overview").length).toBeGreaterThan(0);
expect(screen.getAllByText("Nutrition").length).toBeGreaterThan(0);
expect(screen.getByText("Ada Lovelace")).toBeTruthy();
});
});
151 changes: 93 additions & 58 deletions packages/web/src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { useState } from "react";
import { useAuth } from "../lib/auth-context.tsx";

const navItems = [
{ to: "/dashboard", label: "Dashboard" },
{ to: "/dashboard", label: "Overview" },
{ to: "/training", label: "Training" },
{ to: "/activities", label: "Activities" },
{ to: "/sleep", label: "Sleep" },
Expand All @@ -16,92 +17,126 @@ const navItems = [

const adminNavItems = [...navItems, { to: "/admin", label: "Admin" }] as const;

export function AppHeader({ children }: { children?: React.ReactNode }) {
const desktopLinkClass =
"block rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-accent/10 hover:text-foreground";

const desktopActiveLinkClass =
"block rounded-md px-3 py-2 text-sm font-semibold bg-accent/10 text-foreground";

const mobileLinkClass =
"rounded-md px-3 py-2 text-xs font-medium text-muted transition-colors hover:bg-accent/10 hover:text-foreground whitespace-nowrap";

const mobileActiveLinkClass =
"rounded-md px-3 py-2 text-xs font-semibold bg-accent/10 text-foreground whitespace-nowrap";

export function AppHeader({ children }: { children?: ReactNode }) {
const { user, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);

const items = user?.isAdmin ? adminNavItems : navItems;

return (
<header className="border-b border-border">
<div className="px-3 sm:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="lg:hidden p-1 text-muted hover:text-foreground transition-colors"
aria-label="Toggle navigation menu"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
role="img"
aria-hidden="true"
<>
<header className="lg:hidden border-b border-border bg-surface/85 backdrop-blur-xl">
<div className="px-3 py-3 flex items-center justify-between gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={() => setMenuOpen((value) => !value)}
className="p-1.5 text-muted hover:text-foreground transition-colors press"
aria-label="Toggle navigation menu"
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<path
fillRule="evenodd"
d="M3 5h14a1 1 0 010 2H3a1 1 0 010-2zm0 4h14a1 1 0 010 2H3a1 1 0 010-2zm0 4h14a1 1 0 010 2H3a1 1 0 010-2z"
clipRule="evenodd"
/>
</svg>
</button>
<DofekLogo />
<h1 className="text-lg sm:text-xl font-semibold tracking-tight shrink-0">Dofek</h1>
<nav className="hidden lg:flex gap-1 ml-4 overflow-x-auto scrollbar-hide">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
role="img"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M3 5h14a1 1 0 010 2H3a1 1 0 010-2zm0 4h14a1 1 0 010 2H3a1 1 0 010-2zm0 4h14a1 1 0 010 2H3a1 1 0 010-2z"
clipRule="evenodd"
/>
</svg>
</button>
<DofekLogo />
<h1 className="text-lg font-semibold tracking-tight shrink-0">Dofek</h1>
</div>
<div className="flex items-center gap-2 shrink-0">
{children}
{user && (
<button
type="button"
onClick={logout}
className="text-xs font-medium text-subtle hover:text-foreground transition-colors cursor-pointer"
>
Sign out
</button>
)}
</div>
</div>
{menuOpen && (
<nav className="px-3 pb-3 flex flex-wrap gap-1 nav-slide-enter" aria-label="Mobile">
{items.map(({ to, label }) => (
<Link
key={to}
to={to}
className="px-3 py-2 text-xs rounded-md transition-colors text-subtle hover:text-foreground whitespace-nowrap"
activeProps={{
className:
"px-3 py-2 text-xs rounded-md transition-colors bg-accent/15 text-foreground whitespace-nowrap",
}}
onClick={() => setMenuOpen(false)}
className={mobileLinkClass}
activeProps={{ className: mobileActiveLinkClass }}
activeOptions={{ exact: to === "/dashboard" }}
>
{label}
</Link>
))}
</nav>
)}
</header>

<aside
aria-label="Primary navigation"
className="hidden lg:flex lg:sticky lg:top-0 lg:h-screen lg:w-[13.5rem] lg:shrink-0 flex-col border-r border-border-strong bg-surface/70 px-4 py-5 backdrop-blur-xl"
>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<DofekLogo />
<h1 className="text-lg font-semibold tracking-tight shrink-0">Dofek</h1>
</div>
<div className="flex items-center gap-3 shrink-0">

<nav className="mt-8 flex flex-col gap-1" aria-label="Sections">
{items.map(({ to, label }) => (
<Link
key={to}
to={to}
className={desktopLinkClass}
activeProps={{ className: desktopActiveLinkClass }}
activeOptions={{ exact: to === "/dashboard" }}
>
{label}
</Link>
))}
</nav>

<div className="mt-auto space-y-3">
{children}
{user && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted hidden sm:inline">{user.name}</span>
<div className="rounded-md border border-border bg-surface-solid/70 p-3">
<span className="block truncate text-xs font-semibold text-foreground">
{user.name}
</span>
<button
type="button"
onClick={logout}
className="text-xs text-subtle hover:text-foreground transition-colors cursor-pointer"
className="mt-1 text-xs text-subtle hover:text-foreground transition-colors cursor-pointer"
>
Sign out
</button>
</div>
)}
</div>
</div>
{menuOpen && (
<nav className="lg:hidden px-3 pb-3 flex flex-wrap gap-1 nav-slide-enter">
{items.map(({ to, label }) => (
<Link
key={to}
to={to}
onClick={() => setMenuOpen(false)}
className="px-3 py-2 text-xs rounded-md transition-colors text-subtle hover:text-foreground whitespace-nowrap"
activeProps={{
className:
"px-3 py-2 text-xs rounded-md transition-colors bg-accent/15 text-foreground whitespace-nowrap",
}}
activeOptions={{ exact: to === "/dashboard" }}
>
{label}
</Link>
))}
</nav>
)}
</header>
</aside>
</>
);
}

Expand Down
43 changes: 43 additions & 0 deletions packages/web/src/components/DailyOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,49 @@ describe("DailyOverview", () => {
expect(screen.getByText("Recovered")).toBeTruthy();
});

it("uses the evidence desk summary panel styling", () => {
const { container } = render(
<DailyOverview
readiness={mockReadiness}
workloadRatio={mockWorkloadRatio}
sleepPerformance={mockSleepPerformance}
readinessLoading={false}
workloadLoading={false}
sleepLoading={false}
/>,
);
const panel = container.firstElementChild;
expect(panel).toBeTruthy();
expect(panel instanceof HTMLElement).toBe(true);
if (panel instanceof HTMLElement) {
expect(panel.getAttribute("aria-label")).toBe("Daily health summary");
expect(panel.className).toContain("dashboard-hero");
expect(panel.className).toContain("card");
}
});

it("can render as an embedded panel without a nested card", () => {
const { container } = render(
<DailyOverview
embedded
readiness={mockReadiness}
workloadRatio={mockWorkloadRatio}
sleepPerformance={mockSleepPerformance}
readinessLoading={false}
workloadLoading={false}
sleepLoading={false}
/>,
);
const panel = container.firstElementChild;
expect(panel).toBeTruthy();
expect(panel instanceof HTMLElement).toBe(true);
if (panel instanceof HTMLElement) {
expect(panel.className).not.toContain("dashboard-hero");
expect(panel.className).not.toContain(" card ");
expect(panel.className).toContain("bg-white/62");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
});

it("renders strain ring", () => {
render(
<DailyOverview
Expand Down
Loading
Loading