Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Use a single-context domain-doc layout. See `.agents/config/domain.md`.
and `user-event`; avoid CSS selectors and `data-*` locators.
- New web styles should use Tailwind semantic colors from
`packages/web/src/index.css`, not raw colors like `bg-blue-300`.
- Prefer canonical Tailwind scale utilities over arbitrary values when an
equivalent exists. Treat VS Code Tailwind IntelliSense
`suggestCanonicalClasses` warnings as actionable cleanup before finishing
changes.
- Do not test login flows without the required backend setup.
- Keep React components in their own files.
- Do not add or use barrel files such as `index.ts` / `index.tsx`. Import from
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/common/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const ROOT_ROUTES = {
API: "/api",
CLEANUP: "/cleanup",
GOOGLE_AUTH_CALLBACK: "/auth/google/callback",
LIFE: "/life",
ROOT: "/",
WEEK: "/week",
DAY: "/day",
Expand Down
22 changes: 22 additions & 0 deletions packages/web/src/routers/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ROOT_ROUTES } from "@web/common/constants/routes";
import { routeObjects } from "@web/routers/router.routes";
import { describe, expect, it } from "bun:test";

describe("routeObjects", () => {
it("registers /life as a public route before authenticated app routes", () => {
const lifeRoute = routeObjects.find((r) => r.path === ROOT_ROUTES.LIFE);
const lifeRouteIndex = routeObjects.findIndex(
(r) => r.path === ROOT_ROUTES.LIFE,
);
const authenticatedRoute = routeObjects.find((r) => r.loader !== undefined);
const authenticatedRouteIndex = routeObjects.findIndex(
(r) => r.loader !== undefined,
);

expect(lifeRoute).toBeDefined();
expect(lifeRoute?.loader).toBeUndefined();
expect(authenticatedRoute).toBeDefined();
expect(authenticatedRoute?.loader).toBeDefined();
expect(lifeRouteIndex).toBeLessThan(authenticatedRouteIndex);
});
});
101 changes: 5 additions & 96 deletions packages/web/src/routers/index.tsx
Original file line number Diff line number Diff line change
@@ -1,107 +1,16 @@
import {
createBrowserRouter,
type RouteObject,
RouterProvider,
type RouterProviderProps,
} from "react-router-dom";
import { IS_DEV } from "@web/common/constants/env.constants";
import { ROOT_ROUTES } from "@web/common/constants/routes";
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
import {
loadAuthenticated,
loadDayData,
loadRootData,
loadSpecificDayData,
} from "@web/routers/loaders";

const devOnlyRoutes: RouteObject[] = IS_DEV
? [
{
path: ROOT_ROUTES.CLEANUP,
lazy: async () =>
import(
/* webpackChunkName: "cleanup" */ "@web/views/Cleanup/Cleanup"
).then((module) => ({
Component: module.CleanupView,
})),
},
]
: [];
import { routeObjects } from "@web/routers/router.routes";

export const router = createBrowserRouter(
[
{
lazy: async () =>
import(/* webpackChunkName: "calendar" */ "@web/views/Root").then(
(module) => ({
Component: module.RootView,
}),
),
loader: loadAuthenticated,
children: [
{
path: ROOT_ROUTES.DAY,
lazy: async () =>
import(
/* webpackChunkName: "day" */ "@web/views/Day/view/DayView"
).then((module) => ({ Component: module.DayView })),
children: [
{
path: ROOT_ROUTES.DAY_DATE,
id: ROOT_ROUTES.DAY_DATE,
loader: loadSpecificDayData,
lazy: async () =>
import(
/* webpackChunkName: "date" */ "@web/views/Day/view/DayViewContent"
).then((module) => ({ Component: module.DayViewContent })),
},
{
index: true,
loader: loadDayData,
},
],
},
{
path: ROOT_ROUTES.WEEK,
lazy: async () =>
import(
/* webpackChunkName: "week" */ "@web/views/Week/WeekView"
).then((module) => ({
Component: module.WeekView,
})),
},
{
path: ROOT_ROUTES.ROOT,
loader: loadRootData,
},
],
},
...devOnlyRoutes,
{
path: ROOT_ROUTES.GOOGLE_AUTH_CALLBACK,
lazy: async () =>
import(
/* webpackChunkName: "google-auth-callback" */ "@web/views/GoogleAuthCallback"
).then((module) => ({
Component: module.GoogleAuthCallbackView,
})),
},
{
path: "*",
lazy: async () =>
import(/* webpackChunkName: "not-found" */ "@web/views/NotFound").then(
(module) => ({
Component: module.NotFoundView,
}),
),
},
],
{
future: {
v7_relativeSplatPath: true,
},
export const router = createBrowserRouter(routeObjects, {
future: {
v7_relativeSplatPath: true,
},
);
});

export const CompassRouterProvider = (
props?: Partial<Pick<RouterProviderProps, "router">>,
Expand Down
100 changes: 100 additions & 0 deletions packages/web/src/routers/router.routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { type RouteObject } from "react-router-dom";
import { IS_DEV } from "@web/common/constants/env.constants";
import { ROOT_ROUTES } from "@web/common/constants/routes";
import {
loadAuthenticated,
loadDayData,
loadRootData,
loadSpecificDayData,
} from "@web/routers/loaders";

const devOnlyRoutes: RouteObject[] = IS_DEV
? [
{
path: ROOT_ROUTES.CLEANUP,
lazy: async () =>
import(
/* webpackChunkName: "cleanup" */ "@web/views/Cleanup/Cleanup"
).then((module) => ({
Component: module.CleanupView,
})),
},
]
: [];

export const routeObjects: RouteObject[] = [
{
path: ROOT_ROUTES.LIFE,
lazy: async () =>
import(/* webpackChunkName: "life" */ "@web/views/Life/LifeView").then(
(module) => ({
Component: module.LifeView,
}),
),
},
{
lazy: async () =>
import(/* webpackChunkName: "calendar" */ "@web/views/Root").then(
(module) => ({
Component: module.RootView,
}),
),
loader: loadAuthenticated,
children: [
{
path: ROOT_ROUTES.DAY,
lazy: async () =>
import(
/* webpackChunkName: "day" */ "@web/views/Day/view/DayView"
).then((module) => ({ Component: module.DayView })),
children: [
{
path: ROOT_ROUTES.DAY_DATE,
id: ROOT_ROUTES.DAY_DATE,
loader: loadSpecificDayData,
lazy: async () =>
import(
/* webpackChunkName: "date" */ "@web/views/Day/view/DayViewContent"
).then((module) => ({ Component: module.DayViewContent })),
},
{
index: true,
loader: loadDayData,
},
],
},
{
path: ROOT_ROUTES.WEEK,
lazy: async () =>
import(
/* webpackChunkName: "week" */ "@web/views/Week/WeekView"
).then((module) => ({
Component: module.WeekView,
})),
},
{
path: ROOT_ROUTES.ROOT,
loader: loadRootData,
},
],
},
...devOnlyRoutes,
{
path: ROOT_ROUTES.GOOGLE_AUTH_CALLBACK,
lazy: async () =>
import(
/* webpackChunkName: "google-auth-callback" */ "@web/views/GoogleAuthCallback"
).then((module) => ({
Component: module.GoogleAuthCallbackView,
})),
},
{
path: "*",
lazy: async () =>
import(/* webpackChunkName: "not-found" */ "@web/views/NotFound").then(
(module) => ({
Component: module.NotFoundView,
}),
),
},
];
53 changes: 53 additions & 0 deletions packages/web/src/views/Life/LifeAboutDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { InfoIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";

const BLOG_LINK =
"/blog/visualize-your-life-in-weeks?utm_source=website&utm_medium=life_in_weeks_dialog&utm_campaign=blog_link";

export function LifeAboutDialog() {
const [isOpen, setIsOpen] = useState(false);

return (
<>
<button
aria-label="Information"
className="inline-flex h-9 w-9 items-center justify-center rounded text-text-light transition-colors hover:bg-panel-bg hover:text-text-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary"
onClick={() => setIsOpen(true)}
type="button"
>
<InfoIcon aria-hidden="true" size={20} weight="bold" />
</button>
{isOpen ? (
<OverlayPanel
title="About Life in Weeks"
onDismiss={() => setIsOpen(false)}
variant="modal"
>
<div className="flex w-full flex-col gap-4 text-sm text-text-light">
<p>
This page shows your life as a grid of weeks. Each dot represents
one week of your life, and each row represents one year.
</p>
<p>
The default death age is set to 79. However, life expectancy
varies significantly by country and other factors.
</p>
<p>
For more information, see{" "}
<a
className="text-accent-primary underline hover:no-underline"
href={BLOG_LINK}
rel="noopener noreferrer"
target="_blank"
>
Visualize Your Life in Weeks
</a>
.
</p>
</div>
</OverlayPanel>
) : null}
</>
);
}
21 changes: 21 additions & 0 deletions packages/web/src/views/Life/LifeDotTooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LifeDotTooltip } from "./LifeDotTooltip";
import { describe, expect, it } from "bun:test";

describe("LifeDotTooltip", () => {
it("shows the year and week label when clicked", async () => {
const user = userEvent.setup();
render(
<LifeDotTooltip weekNumber={105}>
<span>Dot 105</span>
</LifeDotTooltip>,
);

await user.click(screen.getByRole("button", { name: "Dot 105" }));

await waitFor(() => {
expect(screen.getByText("Year 3, Week 1")).toBeInTheDocument();
});
});
});
60 changes: 60 additions & 0 deletions packages/web/src/views/Life/LifeDotTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { type ReactNode, useState } from "react";
import { getLifeDotLabel } from "./life.utils";

interface LifeDotTooltipProps {
weekNumber: number;
children: ReactNode;
}

export function LifeDotTooltip({ weekNumber, children }: LifeDotTooltipProps) {
const [open, setOpen] = useState(false);
const [pinned, setPinned] = useState(false);
const label = getLifeDotLabel(weekNumber);

return (
// biome-ignore lint/a11y/useSemanticElements: This trigger wraps thousands of grid cells; using real buttons makes the Bun web suite materially slower. Keyboard semantics are provided explicitly.
<span
className="relative inline-flex cursor-pointer border-0 bg-transparent p-0"
onBlur={() => {
setOpen(false);
setPinned(false);
}}
onClick={() => {
setPinned((current) => {
setOpen(!current);
return !current;
});
}}
onFocus={() => setOpen(true)}
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => {
if (!pinned) {
setOpen(false);
}
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}

event.preventDefault();
setPinned((current) => {
setOpen(!current);
return !current;
});
}}
role="button"
tabIndex={0}
>
{children}
{open ? (
<span
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1 -translate-x-1/2 whitespace-nowrap rounded border border-border-primary bg-bg-secondary px-2 py-1 text-text-lighter text-xs shadow-lg"
role="tooltip"
>
{label}
</span>
) : null}
</span>
);
}
Loading