diff --git a/AGENTS.md b/AGENTS.md index 170b989b4..64cea174f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/web/src/common/constants/routes.ts b/packages/web/src/common/constants/routes.ts index 6c81f96f7..c830aba8b 100644 --- a/packages/web/src/common/constants/routes.ts +++ b/packages/web/src/common/constants/routes.ts @@ -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", diff --git a/packages/web/src/routers/index.test.tsx b/packages/web/src/routers/index.test.tsx new file mode 100644 index 000000000..befe693ca --- /dev/null +++ b/packages/web/src/routers/index.test.tsx @@ -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); + }); +}); diff --git a/packages/web/src/routers/index.tsx b/packages/web/src/routers/index.tsx index a27baaa3c..4397f88c8 100644 --- a/packages/web/src/routers/index.tsx +++ b/packages/web/src/routers/index.tsx @@ -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>, diff --git a/packages/web/src/routers/router.routes.tsx b/packages/web/src/routers/router.routes.tsx new file mode 100644 index 000000000..4bde103e2 --- /dev/null +++ b/packages/web/src/routers/router.routes.tsx @@ -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, + }), + ), + }, +]; diff --git a/packages/web/src/views/Life/LifeAboutDialog.tsx b/packages/web/src/views/Life/LifeAboutDialog.tsx new file mode 100644 index 000000000..97c5adbcf --- /dev/null +++ b/packages/web/src/views/Life/LifeAboutDialog.tsx @@ -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 ( + <> + + {isOpen ? ( + setIsOpen(false)} + variant="modal" + > +
+

+ This page shows your life as a grid of weeks. Each dot represents + one week of your life, and each row represents one year. +

+

+ The default death age is set to 79. However, life expectancy + varies significantly by country and other factors. +

+

+ For more information, see{" "} + + Visualize Your Life in Weeks + + . +

+
+
+ ) : null} + + ); +} diff --git a/packages/web/src/views/Life/LifeDotTooltip.test.tsx b/packages/web/src/views/Life/LifeDotTooltip.test.tsx new file mode 100644 index 000000000..b0dd05e37 --- /dev/null +++ b/packages/web/src/views/Life/LifeDotTooltip.test.tsx @@ -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( + + Dot 105 + , + ); + + await user.click(screen.getByRole("button", { name: "Dot 105" })); + + await waitFor(() => { + expect(screen.getByText("Year 3, Week 1")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/src/views/Life/LifeDotTooltip.tsx b/packages/web/src/views/Life/LifeDotTooltip.tsx new file mode 100644 index 000000000..9cbc8f602 --- /dev/null +++ b/packages/web/src/views/Life/LifeDotTooltip.tsx @@ -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. + { + 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 ? ( + + {label} + + ) : null} + + ); +} diff --git a/packages/web/src/views/Life/LifeSelect.tsx b/packages/web/src/views/Life/LifeSelect.tsx new file mode 100644 index 000000000..98d5e3091 --- /dev/null +++ b/packages/web/src/views/Life/LifeSelect.tsx @@ -0,0 +1,37 @@ +import { type ChangeEventHandler, type ReactNode } from "react"; + +interface LifeSelectProps { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + children: ReactNode; + className?: string; +} + +export function LifeSelect({ + id, + label, + value, + onChange, + children, + className = "max-w-36", +}: LifeSelectProps) { + const handleChange: ChangeEventHandler = (event) => { + onChange(event.target.value); + }; + + return ( + + ); +} diff --git a/packages/web/src/views/Life/LifeView.test.tsx b/packages/web/src/views/Life/LifeView.test.tsx new file mode 100644 index 000000000..61ebf4db3 --- /dev/null +++ b/packages/web/src/views/Life/LifeView.test.tsx @@ -0,0 +1,163 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LifeView } from "./LifeView"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +const fixedToday = new Date(2026, 0, 1); +const originalInnerWidth = window.innerWidth; +const originalMatchMedia = window.matchMedia; + +function renderLifeView() { + return render(); +} + +function mockViewport(isMobile: boolean) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: isMobile ? 375 : 1024, + writable: true, + }); + window.matchMedia = ((query: string) => + ({ + matches: isMobile && query.includes("max-width"), + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => true, + }) as MediaQueryList) as typeof window.matchMedia; +} + +function getGrid(region: HTMLElement) { + return region.querySelector("[data-total-dots]") as HTMLElement; +} + +beforeEach(() => { + mockViewport(false); +}); + +afterEach(() => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: originalInnerWidth, + writable: true, + }); + window.matchMedia = originalMatchMedia; +}); + +describe("LifeView", () => { + it("renders default controls, status, grid, and zoom instructions", () => { + renderLifeView(); + + expect( + screen.getByRole("heading", { name: /my life in weeks/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/birth year/i)).toHaveValue("2000"); + expect(screen.getByLabelText(/birth month/i)).toHaveValue("1"); + expect(screen.getByLabelText(/birth day/i)).toHaveValue("1"); + expect(screen.getByLabelText(/death age/i)).toHaveValue("79"); + expect(screen.getByRole("status")).toHaveTextContent( + "You've lived 1356 weeks (26 years)", + ); + expect( + screen.getByRole("region", { name: /life in weeks visualization/i }), + ).toBeInTheDocument(); + expect(screen.getByText(/or use buttons to zoom/i)).toBeInTheDocument(); + }); + + it("updates weeks lived when the birth date changes", async () => { + const user = userEvent.setup(); + renderLifeView(); + + await user.selectOptions(screen.getByLabelText(/birth year/i), "1990"); + await user.selectOptions(screen.getByLabelText(/birth month/i), "6"); + await user.selectOptions(screen.getByLabelText(/birth day/i), "15"); + + expect(screen.getByRole("status")).toHaveTextContent( + "You've lived 1854 weeks (35 years)", + ); + }); + + it("updates the grid size when the death age changes", async () => { + const user = userEvent.setup(); + renderLifeView(); + + const region = screen.getByRole("region", { + name: /life in weeks visualization/i, + }); + expect(getGrid(region).dataset.totalDots).toBe(String(79 * 52)); + + await user.selectOptions(screen.getByLabelText(/death age/i), "85"); + + expect(getGrid(region).dataset.totalDots).toBe(String(85 * 52)); + }); + + it("zooms with buttons and disables zoom out at the minimum", async () => { + const user = userEvent.setup(); + renderLifeView(); + + expect(screen.getByText("100%")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /zoom in/i })); + expect(screen.getByText("120%")).toBeInTheDocument(); + + const zoomOut = screen.getByRole("button", { name: /zoom out/i }); + await user.click(zoomOut); + await user.click(zoomOut); + await user.click(zoomOut); + await user.click(zoomOut); + + expect(screen.getByText("50%")).toBeInTheDocument(); + expect(zoomOut).toBeDisabled(); + }); + + it("allows desktop scrolling when zoomed beyond the fit scale", async () => { + const user = userEvent.setup(); + renderLifeView(); + + const region = screen.getByRole("region", { + name: /life in weeks visualization/i, + }); + expect(region).toHaveClass("overflow-hidden"); + + await user.click(screen.getByRole("button", { name: /zoom in/i })); + + await waitFor(() => expect(region).toHaveClass("overflow-auto")); + }); + + it("reflows columns on mobile when zoomed while keeping overflow hidden", async () => { + const user = userEvent.setup(); + mockViewport(true); + renderLifeView(); + + const region = screen.getByRole("region", { + name: /life in weeks visualization/i, + }); + const grid = getGrid(region); + expect(grid.style.gridTemplateColumns).toContain("repeat(52,"); + + await user.click(screen.getByRole("button", { name: /zoom in/i })); + + expect(grid.style.gridTemplateColumns).toContain("repeat(43,"); + expect(region).toHaveClass("overflow-hidden"); + }); + + it("opens the about dialog with the blog link", async () => { + const user = userEvent.setup(); + renderLifeView(); + + await user.click(screen.getByRole("button", { name: /information/i })); + + const dialog = await screen.findByRole("dialog", { + name: /about life in weeks/i, + }); + const link = within(dialog).getByRole("link", { + name: /visualize your life in weeks/i, + }); + expect(link).toHaveAttribute( + "href", + "/blog/visualize-your-life-in-weeks?utm_source=website&utm_medium=life_in_weeks_dialog&utm_campaign=blog_link", + ); + }); +}); diff --git a/packages/web/src/views/Life/LifeView.tsx b/packages/web/src/views/Life/LifeView.tsx new file mode 100644 index 000000000..d0d6e6fb2 --- /dev/null +++ b/packages/web/src/views/Life/LifeView.tsx @@ -0,0 +1,385 @@ +import { + type Dispatch, + type SetStateAction, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useIsMobile } from "@web/common/hooks/useIsMobile"; +import { LifeAboutDialog } from "./LifeAboutDialog"; +import { LifeDotTooltip } from "./LifeDotTooltip"; +import { LifeSelect } from "./LifeSelect"; +import { + CONTAINER_PADDING, + DOT_GAP, + DOT_SIZE, + getAgeOptions, + getLifeGridColumns, + getTotalLifeDots, + getValidBirthDays, + getWeekLivedCount, + getYearOptions, + MONTHS, +} from "./life.utils"; + +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 4; + +interface LifeViewProps { + enableDotTooltips?: boolean; + today?: Date; +} + +function clampZoom(value: number) { + return Number(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)).toFixed(2)); +} + +function ZoomButton({ + "aria-label": ariaLabel, + children, + disabled, + onClick, +}: { + "aria-label": string; + children: React.ReactNode; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ZoomControls({ + setZoom, + zoom, +}: { + setZoom: Dispatch>; + zoom: number; +}) { + return ( +
+ setZoom((current) => clampZoom(current - 0.2))} + > + + + + {Math.round(zoom * 100)}% + + = MAX_ZOOM} + onClick={() => setZoom((current) => clampZoom(current + 0.2))} + > + + +
+ ); +} + +export function LifeView({ enableDotTooltips = true, today }: LifeViewProps) { + const [birthYear, setBirthYear] = useState("2000"); + const [birthMonth, setBirthMonth] = useState("1"); + const [birthDay, setBirthDay] = useState("1"); + const [deathAge, setDeathAge] = useState("79"); + const [zoom, setZoom] = useState(1); + const [baseScale, setBaseScale] = useState(1); + const isMobile = useIsMobile(); + const containerRef = useRef(null); + const lastTouchDistance = useRef(null); + + const years = useMemo( + () => getYearOptions((today ?? new Date()).getFullYear()), + [today], + ); + const ages = useMemo(() => getAgeOptions(), []); + const validDays = useMemo( + () => getValidBirthDays(birthYear, birthMonth), + [birthYear, birthMonth], + ); + const totalDots = useMemo(() => getTotalLifeDots(deathAge), [deathAge]); + const weeksLived = useMemo( + () => + getWeekLivedCount( + birthYear, + birthMonth, + birthDay, + totalDots, + today ?? new Date(), + ), + [birthYear, birthMonth, birthDay, totalDots, today], + ); + const columns = getLifeGridColumns({ isMobile, zoom }); + const dots = useMemo(() => { + return Array.from({ length: totalDots }).map((_, index) => { + const weekNumber = index + 1; + const isFilled = index < weeksLived; + const dot = ( + + ); + + if (enableDotTooltips) { + return ( + + {dot} + + ); + } + + return dot; + }); + }, [totalDots, weeksLived, enableDotTooltips]); + + useEffect(() => { + const maxDay = Number.parseInt(validDays.at(-1) ?? "31", 10); + const currentDay = Number.parseInt(birthDay, 10); + + if (currentDay > maxDay) { + setBirthDay(String(maxDay)); + } + }, [birthDay, validDays]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateBaseScale = () => { + const rect = container.getBoundingClientRect(); + const containerWidth = rect.width - CONTAINER_PADDING; + const containerHeight = rect.height - CONTAINER_PADDING; + + if (containerWidth <= 0 || containerHeight <= 0) { + setBaseScale(1); + return; + } + + const cellSize = DOT_SIZE + DOT_GAP; + const rows = Math.ceil(totalDots / columns); + const gridWidth = columns * cellSize; + const gridHeight = rows * cellSize; + setBaseScale( + Math.min(containerWidth / gridWidth, containerHeight / gridHeight, 1), + ); + }; + + updateBaseScale(); + const resizeObserver = new ResizeObserver(updateBaseScale); + resizeObserver.observe(container); + + return () => resizeObserver.disconnect(); + }, [columns, totalDots]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleWheel = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) return; + + event.preventDefault(); + const delta = event.deltaY > 0 ? -0.1 : 0.1; + setZoom((current) => clampZoom(current + delta)); + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + + return () => container.removeEventListener("wheel", handleWheel); + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 2) return; + + lastTouchDistance.current = Math.hypot( + event.touches[0].clientX - event.touches[1].clientX, + event.touches[0].clientY - event.touches[1].clientY, + ); + }; + + const handleTouchMove = (event: TouchEvent) => { + if (event.touches.length !== 2 || lastTouchDistance.current === null) { + return; + } + + event.preventDefault(); + const distance = Math.hypot( + event.touches[0].clientX - event.touches[1].clientX, + event.touches[0].clientY - event.touches[1].clientY, + ); + const delta = (distance - lastTouchDistance.current) * 0.01; + setZoom((current) => clampZoom(current + delta)); + lastTouchDistance.current = distance; + }; + + const clearTouchDistance = () => { + lastTouchDistance.current = null; + }; + + container.addEventListener("touchstart", handleTouchStart); + container.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + container.addEventListener("touchend", clearTouchDistance); + container.addEventListener("touchcancel", clearTouchDistance); + + return () => { + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", clearTouchDistance); + container.removeEventListener("touchcancel", clearTouchDistance); + }; + }, []); + + const effectiveScale = isMobile ? baseScale : baseScale * zoom; + const allowScroll = !isMobile && effectiveScale > 1.001; + + return ( +
+
+
+
+

+ MY LIFE IN WEEKS +

+ +
+

+ Each dot represents one week of your life. +

+
+ +
+ + {years.map((year) => ( + + ))} + + + {MONTHS.map((month) => ( + + ))} + + + {validDays.map((day) => ( + + ))} + + + {ages.map((age) => ( + + ))} + +
+ + {weeksLived > 0 ? ( +
+

+ You've lived{" "} + + {weeksLived} + {" "} + weeks ({Math.floor(weeksLived / 52)} years) +

+
+ ) : null} + + + +
+
+
+
+ {dots} +
+
+
+
+ +

+ {isMobile ? "Pinch " : "Use Ctrl+Scroll "} or use buttons to zoom +

+
+
+ ); +} + +export default LifeView; diff --git a/packages/web/src/views/Life/life.utils.test.ts b/packages/web/src/views/Life/life.utils.test.ts new file mode 100644 index 000000000..6254da3d9 --- /dev/null +++ b/packages/web/src/views/Life/life.utils.test.ts @@ -0,0 +1,64 @@ +import { + clampWeeksLived, + getAgeOptions, + getLifeDotLabel, + getLifeGridColumns, + getTotalLifeDots, + getValidBirthDays, + getWeekLivedCount, + getYearOptions, +} from "./life.utils"; +import { describe, expect, it } from "bun:test"; + +describe("life utils", () => { + it("builds year options from 1900 through the current year", () => { + expect(getYearOptions(2026).slice(0, 3)).toEqual(["1900", "1901", "1902"]); + expect(getYearOptions(2026).at(-1)).toBe("2026"); + }); + + it("builds age options from 1 through 150", () => { + const ages = getAgeOptions(); + + expect(ages[0]).toBe("1"); + expect(ages.at(-1)).toBe("150"); + expect(ages).toHaveLength(150); + }); + + it("returns valid birth days for normal and leap-year months", () => { + expect(getValidBirthDays("2024", "2").at(-1)).toBe("29"); + expect(getValidBirthDays("2025", "2").at(-1)).toBe("28"); + expect(getValidBirthDays("2025", "4").at(-1)).toBe("30"); + }); + + it("calculates and caps lived weeks", () => { + const today = new Date(2026, 0, 1); + + expect(getWeekLivedCount("2000", "1", "1", 79 * 52, today)).toBe(1356); + expect(getWeekLivedCount("2026", "12", "31", 79 * 52, today)).toBe(0); + expect(getWeekLivedCount("1900", "1", "1", 79 * 52, today)).toBe(79 * 52); + }); + + it("calculates total dots from death age and falls back to 79 years", () => { + expect(getTotalLifeDots("85")).toBe(85 * 52); + expect(getTotalLifeDots("")).toBe(79 * 52); + expect(getTotalLifeDots("-1")).toBe(79 * 52); + }); + + it("chooses desktop and mobile grid columns from zoom", () => { + expect(getLifeGridColumns({ isMobile: false, zoom: 4 })).toBe(52); + expect(getLifeGridColumns({ isMobile: true, zoom: 1 })).toBe(52); + expect(getLifeGridColumns({ isMobile: true, zoom: 2 })).toBe(26); + expect(getLifeGridColumns({ isMobile: true, zoom: 8 })).toBe(10); + }); + + it("formats dot tooltip labels from one-indexed week numbers", () => { + expect(getLifeDotLabel(1)).toBe("Year 1, Week 1"); + expect(getLifeDotLabel(105)).toBe("Year 3, Week 1"); + }); + + it("clamps arbitrary week counts to the available dot count", () => { + expect(clampWeeksLived(-5, 100)).toBe(0); + expect(clampWeeksLived(50, 100)).toBe(50); + expect(clampWeeksLived(150, 100)).toBe(100); + }); +}); diff --git a/packages/web/src/views/Life/life.utils.ts b/packages/web/src/views/Life/life.utils.ts new file mode 100644 index 000000000..dce7b6da6 --- /dev/null +++ b/packages/web/src/views/Life/life.utils.ts @@ -0,0 +1,110 @@ +export const WEEKS_PER_ROW = 52; +export const DEFAULT_DEATH_AGE = 79; +export const DOT_SIZE = 8; +export const DOT_GAP = 2; +export const CONTAINER_PADDING = 48; + +export const MONTHS = [ + { value: "1", label: "January" }, + { value: "2", label: "February" }, + { value: "3", label: "March" }, + { value: "4", label: "April" }, + { value: "5", label: "May" }, + { value: "6", label: "June" }, + { value: "7", label: "July" }, + { value: "8", label: "August" }, + { value: "9", label: "September" }, + { value: "10", label: "October" }, + { value: "11", label: "November" }, + { value: "12", label: "December" }, +] as const; + +const MS_PER_WEEK = 1000 * 60 * 60 * 24 * 7; + +export function getYearOptions(currentYear = new Date().getFullYear()) { + return Array.from({ length: currentYear - 1900 + 1 }, (_, index) => + String(1900 + index), + ); +} + +export function getAgeOptions() { + return Array.from({ length: 150 }, (_, index) => String(index + 1)); +} + +export function getValidBirthDays(yearValue: string, monthValue: string) { + const year = Number.parseInt(yearValue, 10); + const month = Number.parseInt(monthValue, 10); + + if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) { + return Array.from({ length: 31 }, (_, index) => String(index + 1)); + } + + const daysInMonth = new Date(year, month, 0).getDate(); + return Array.from({ length: daysInMonth }, (_, index) => String(index + 1)); +} + +export function getTotalLifeDots(deathAgeValue: string) { + const years = Number.parseInt(deathAgeValue, 10); + + if (Number.isNaN(years) || years <= 0) { + return WEEKS_PER_ROW * DEFAULT_DEATH_AGE; + } + + return WEEKS_PER_ROW * years; +} + +export function clampWeeksLived(weeks: number, totalDots: number) { + return Math.max(0, Math.min(weeks, totalDots)); +} + +export function getWeekLivedCount( + birthYear: string, + birthMonth: string, + birthDay: string, + totalDots: number, + today = new Date(), +) { + const year = Number.parseInt(birthYear, 10); + const month = Number.parseInt(birthMonth, 10); + const day = Number.parseInt(birthDay, 10); + + if ( + Number.isNaN(year) || + Number.isNaN(month) || + Number.isNaN(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return 0; + } + + const birthDate = new Date(year, month - 1, day); + const diffWeeks = Math.floor( + (today.getTime() - birthDate.getTime()) / MS_PER_WEEK, + ); + + return clampWeeksLived(diffWeeks, totalDots); +} + +export function getLifeGridColumns({ + isMobile, + zoom, +}: { + isMobile: boolean; + zoom: number; +}) { + if (!isMobile) { + return WEEKS_PER_ROW; + } + + return Math.max(10, Math.floor(WEEKS_PER_ROW / zoom)); +} + +export function getLifeDotLabel(weekNumber: number) { + const yearOfLife = Math.floor((weekNumber - 1) / WEEKS_PER_ROW) + 1; + const weekOfYear = ((weekNumber - 1) % WEEKS_PER_ROW) + 1; + + return `Year ${yearOfLife}, Week ${weekOfYear}`; +}