Skip to content

Commit 6ecfe66

Browse files
authored
feat: add life view (#1856)
* feat: add life view * style: cleanup tailwind * chore: add perf improvements
1 parent 1a39dc9 commit 6ecfe66

13 files changed

Lines changed: 1025 additions & 96 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ Use a single-context domain-doc layout. See `.agents/config/domain.md`.
8787
and `user-event`; avoid CSS selectors and `data-*` locators.
8888
- New web styles should use Tailwind semantic colors from
8989
`packages/web/src/index.css`, not raw colors like `bg-blue-300`.
90+
- Prefer canonical Tailwind scale utilities over arbitrary values when an
91+
equivalent exists. Treat VS Code Tailwind IntelliSense
92+
`suggestCanonicalClasses` warnings as actionable cleanup before finishing
93+
changes.
9094
- Do not test login flows without the required backend setup.
9195
- Keep React components in their own files.
9296
- Do not add or use barrel files such as `index.ts` / `index.tsx`. Import from

packages/web/src/common/constants/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const ROOT_ROUTES = {
22
API: "/api",
33
CLEANUP: "/cleanup",
44
GOOGLE_AUTH_CALLBACK: "/auth/google/callback",
5+
LIFE: "/life",
56
ROOT: "/",
67
WEEK: "/week",
78
DAY: "/day",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ROOT_ROUTES } from "@web/common/constants/routes";
2+
import { routeObjects } from "@web/routers/router.routes";
3+
import { describe, expect, it } from "bun:test";
4+
5+
describe("routeObjects", () => {
6+
it("registers /life as a public route before authenticated app routes", () => {
7+
const lifeRoute = routeObjects.find((r) => r.path === ROOT_ROUTES.LIFE);
8+
const lifeRouteIndex = routeObjects.findIndex(
9+
(r) => r.path === ROOT_ROUTES.LIFE,
10+
);
11+
const authenticatedRoute = routeObjects.find((r) => r.loader !== undefined);
12+
const authenticatedRouteIndex = routeObjects.findIndex(
13+
(r) => r.loader !== undefined,
14+
);
15+
16+
expect(lifeRoute).toBeDefined();
17+
expect(lifeRoute?.loader).toBeUndefined();
18+
expect(authenticatedRoute).toBeDefined();
19+
expect(authenticatedRoute?.loader).toBeDefined();
20+
expect(lifeRouteIndex).toBeLessThan(authenticatedRouteIndex);
21+
});
22+
});

packages/web/src/routers/index.tsx

Lines changed: 5 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,16 @@
11
import {
22
createBrowserRouter,
3-
type RouteObject,
43
RouterProvider,
54
type RouterProviderProps,
65
} from "react-router-dom";
7-
import { IS_DEV } from "@web/common/constants/env.constants";
8-
import { ROOT_ROUTES } from "@web/common/constants/routes";
96
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
10-
import {
11-
loadAuthenticated,
12-
loadDayData,
13-
loadRootData,
14-
loadSpecificDayData,
15-
} from "@web/routers/loaders";
16-
17-
const devOnlyRoutes: RouteObject[] = IS_DEV
18-
? [
19-
{
20-
path: ROOT_ROUTES.CLEANUP,
21-
lazy: async () =>
22-
import(
23-
/* webpackChunkName: "cleanup" */ "@web/views/Cleanup/Cleanup"
24-
).then((module) => ({
25-
Component: module.CleanupView,
26-
})),
27-
},
28-
]
29-
: [];
7+
import { routeObjects } from "@web/routers/router.routes";
308

31-
export const router = createBrowserRouter(
32-
[
33-
{
34-
lazy: async () =>
35-
import(/* webpackChunkName: "calendar" */ "@web/views/Root").then(
36-
(module) => ({
37-
Component: module.RootView,
38-
}),
39-
),
40-
loader: loadAuthenticated,
41-
children: [
42-
{
43-
path: ROOT_ROUTES.DAY,
44-
lazy: async () =>
45-
import(
46-
/* webpackChunkName: "day" */ "@web/views/Day/view/DayView"
47-
).then((module) => ({ Component: module.DayView })),
48-
children: [
49-
{
50-
path: ROOT_ROUTES.DAY_DATE,
51-
id: ROOT_ROUTES.DAY_DATE,
52-
loader: loadSpecificDayData,
53-
lazy: async () =>
54-
import(
55-
/* webpackChunkName: "date" */ "@web/views/Day/view/DayViewContent"
56-
).then((module) => ({ Component: module.DayViewContent })),
57-
},
58-
{
59-
index: true,
60-
loader: loadDayData,
61-
},
62-
],
63-
},
64-
{
65-
path: ROOT_ROUTES.WEEK,
66-
lazy: async () =>
67-
import(
68-
/* webpackChunkName: "week" */ "@web/views/Week/WeekView"
69-
).then((module) => ({
70-
Component: module.WeekView,
71-
})),
72-
},
73-
{
74-
path: ROOT_ROUTES.ROOT,
75-
loader: loadRootData,
76-
},
77-
],
78-
},
79-
...devOnlyRoutes,
80-
{
81-
path: ROOT_ROUTES.GOOGLE_AUTH_CALLBACK,
82-
lazy: async () =>
83-
import(
84-
/* webpackChunkName: "google-auth-callback" */ "@web/views/GoogleAuthCallback"
85-
).then((module) => ({
86-
Component: module.GoogleAuthCallbackView,
87-
})),
88-
},
89-
{
90-
path: "*",
91-
lazy: async () =>
92-
import(/* webpackChunkName: "not-found" */ "@web/views/NotFound").then(
93-
(module) => ({
94-
Component: module.NotFoundView,
95-
}),
96-
),
97-
},
98-
],
99-
{
100-
future: {
101-
v7_relativeSplatPath: true,
102-
},
9+
export const router = createBrowserRouter(routeObjects, {
10+
future: {
11+
v7_relativeSplatPath: true,
10312
},
104-
);
13+
});
10514

10615
export const CompassRouterProvider = (
10716
props?: Partial<Pick<RouterProviderProps, "router">>,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { type RouteObject } from "react-router-dom";
2+
import { IS_DEV } from "@web/common/constants/env.constants";
3+
import { ROOT_ROUTES } from "@web/common/constants/routes";
4+
import {
5+
loadAuthenticated,
6+
loadDayData,
7+
loadRootData,
8+
loadSpecificDayData,
9+
} from "@web/routers/loaders";
10+
11+
const devOnlyRoutes: RouteObject[] = IS_DEV
12+
? [
13+
{
14+
path: ROOT_ROUTES.CLEANUP,
15+
lazy: async () =>
16+
import(
17+
/* webpackChunkName: "cleanup" */ "@web/views/Cleanup/Cleanup"
18+
).then((module) => ({
19+
Component: module.CleanupView,
20+
})),
21+
},
22+
]
23+
: [];
24+
25+
export const routeObjects: RouteObject[] = [
26+
{
27+
path: ROOT_ROUTES.LIFE,
28+
lazy: async () =>
29+
import(/* webpackChunkName: "life" */ "@web/views/Life/LifeView").then(
30+
(module) => ({
31+
Component: module.LifeView,
32+
}),
33+
),
34+
},
35+
{
36+
lazy: async () =>
37+
import(/* webpackChunkName: "calendar" */ "@web/views/Root").then(
38+
(module) => ({
39+
Component: module.RootView,
40+
}),
41+
),
42+
loader: loadAuthenticated,
43+
children: [
44+
{
45+
path: ROOT_ROUTES.DAY,
46+
lazy: async () =>
47+
import(
48+
/* webpackChunkName: "day" */ "@web/views/Day/view/DayView"
49+
).then((module) => ({ Component: module.DayView })),
50+
children: [
51+
{
52+
path: ROOT_ROUTES.DAY_DATE,
53+
id: ROOT_ROUTES.DAY_DATE,
54+
loader: loadSpecificDayData,
55+
lazy: async () =>
56+
import(
57+
/* webpackChunkName: "date" */ "@web/views/Day/view/DayViewContent"
58+
).then((module) => ({ Component: module.DayViewContent })),
59+
},
60+
{
61+
index: true,
62+
loader: loadDayData,
63+
},
64+
],
65+
},
66+
{
67+
path: ROOT_ROUTES.WEEK,
68+
lazy: async () =>
69+
import(
70+
/* webpackChunkName: "week" */ "@web/views/Week/WeekView"
71+
).then((module) => ({
72+
Component: module.WeekView,
73+
})),
74+
},
75+
{
76+
path: ROOT_ROUTES.ROOT,
77+
loader: loadRootData,
78+
},
79+
],
80+
},
81+
...devOnlyRoutes,
82+
{
83+
path: ROOT_ROUTES.GOOGLE_AUTH_CALLBACK,
84+
lazy: async () =>
85+
import(
86+
/* webpackChunkName: "google-auth-callback" */ "@web/views/GoogleAuthCallback"
87+
).then((module) => ({
88+
Component: module.GoogleAuthCallbackView,
89+
})),
90+
},
91+
{
92+
path: "*",
93+
lazy: async () =>
94+
import(/* webpackChunkName: "not-found" */ "@web/views/NotFound").then(
95+
(module) => ({
96+
Component: module.NotFoundView,
97+
}),
98+
),
99+
},
100+
];
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { InfoIcon } from "@phosphor-icons/react";
2+
import { useState } from "react";
3+
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
4+
5+
const BLOG_LINK =
6+
"/blog/visualize-your-life-in-weeks?utm_source=website&utm_medium=life_in_weeks_dialog&utm_campaign=blog_link";
7+
8+
export function LifeAboutDialog() {
9+
const [isOpen, setIsOpen] = useState(false);
10+
11+
return (
12+
<>
13+
<button
14+
aria-label="Information"
15+
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"
16+
onClick={() => setIsOpen(true)}
17+
type="button"
18+
>
19+
<InfoIcon aria-hidden="true" size={20} weight="bold" />
20+
</button>
21+
{isOpen ? (
22+
<OverlayPanel
23+
title="About Life in Weeks"
24+
onDismiss={() => setIsOpen(false)}
25+
variant="modal"
26+
>
27+
<div className="flex w-full flex-col gap-4 text-sm text-text-light">
28+
<p>
29+
This page shows your life as a grid of weeks. Each dot represents
30+
one week of your life, and each row represents one year.
31+
</p>
32+
<p>
33+
The default death age is set to 79. However, life expectancy
34+
varies significantly by country and other factors.
35+
</p>
36+
<p>
37+
For more information, see{" "}
38+
<a
39+
className="text-accent-primary underline hover:no-underline"
40+
href={BLOG_LINK}
41+
rel="noopener noreferrer"
42+
target="_blank"
43+
>
44+
Visualize Your Life in Weeks
45+
</a>
46+
.
47+
</p>
48+
</div>
49+
</OverlayPanel>
50+
) : null}
51+
</>
52+
);
53+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { LifeDotTooltip } from "./LifeDotTooltip";
4+
import { describe, expect, it } from "bun:test";
5+
6+
describe("LifeDotTooltip", () => {
7+
it("shows the year and week label when clicked", async () => {
8+
const user = userEvent.setup();
9+
render(
10+
<LifeDotTooltip weekNumber={105}>
11+
<span>Dot 105</span>
12+
</LifeDotTooltip>,
13+
);
14+
15+
await user.click(screen.getByRole("button", { name: "Dot 105" }));
16+
17+
await waitFor(() => {
18+
expect(screen.getByText("Year 3, Week 1")).toBeInTheDocument();
19+
});
20+
});
21+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { type ReactNode, useState } from "react";
2+
import { getLifeDotLabel } from "./life.utils";
3+
4+
interface LifeDotTooltipProps {
5+
weekNumber: number;
6+
children: ReactNode;
7+
}
8+
9+
export function LifeDotTooltip({ weekNumber, children }: LifeDotTooltipProps) {
10+
const [open, setOpen] = useState(false);
11+
const [pinned, setPinned] = useState(false);
12+
const label = getLifeDotLabel(weekNumber);
13+
14+
return (
15+
// 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.
16+
<span
17+
className="relative inline-flex cursor-pointer border-0 bg-transparent p-0"
18+
onBlur={() => {
19+
setOpen(false);
20+
setPinned(false);
21+
}}
22+
onClick={() => {
23+
setPinned((current) => {
24+
setOpen(!current);
25+
return !current;
26+
});
27+
}}
28+
onFocus={() => setOpen(true)}
29+
onPointerEnter={() => setOpen(true)}
30+
onPointerLeave={() => {
31+
if (!pinned) {
32+
setOpen(false);
33+
}
34+
}}
35+
onKeyDown={(event) => {
36+
if (event.key !== "Enter" && event.key !== " ") {
37+
return;
38+
}
39+
40+
event.preventDefault();
41+
setPinned((current) => {
42+
setOpen(!current);
43+
return !current;
44+
});
45+
}}
46+
role="button"
47+
tabIndex={0}
48+
>
49+
{children}
50+
{open ? (
51+
<span
52+
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"
53+
role="tooltip"
54+
>
55+
{label}
56+
</span>
57+
) : null}
58+
</span>
59+
);
60+
}

0 commit comments

Comments
 (0)