Merge pull request #541 from trails-cool/apply-profile-weekly-distance
profile-weekly-distance: weekly distance bar chart
This commit is contained in:
commit
7e76bbd447
10 changed files with 284 additions and 16 deletions
41
apps/journal/app/components/WeeklyDistanceChart.test.tsx
Normal file
41
apps/journal/app/components/WeeklyDistanceChart.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const weeks = (distances: number[]) =>
|
||||
distances.map((distance, i) => ({ weekStart: `2026-04-${String(i + 1).padStart(2, "0")}`, distance }));
|
||||
|
||||
describe("WeeklyDistanceChart", () => {
|
||||
it("renders nothing when every week is zero", () => {
|
||||
const { container } = render(<WeeklyDistanceChart weeks={weeks([0, 0, 0])} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the chart with a bar only for non-zero weeks", () => {
|
||||
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
// bars only for the two non-zero weeks; empty weeks keep their slot via the track rect
|
||||
expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("tops the y-scale at the busiest week", () => {
|
||||
const { getByText } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
|
||||
// peak gridline label = 10 km (>= 10 → integer)
|
||||
expect(getByText("10 km")).toBeTruthy();
|
||||
// half gridline
|
||||
expect(getByText("5.0 km")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a hover readout with the week's distance", () => {
|
||||
// max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the
|
||||
// readout for week 0, so it only appears once that week is hovered.
|
||||
const { container, queryByText } = render(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
|
||||
expect(queryByText("3.0 km")).toBeNull();
|
||||
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
|
||||
fireEvent.mouseEnter(hit);
|
||||
expect(queryByText("3.0 km")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
136
apps/journal/app/components/WeeklyDistanceChart.tsx
Normal file
136
apps/journal/app/components/WeeklyDistanceChart.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDistanceKm } from "~/lib/stats";
|
||||
|
||||
export interface WeeklyDistanceBucket {
|
||||
weekStart: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
// SVG layout (user units; rendered responsive via viewBox).
|
||||
const W = 480;
|
||||
const H = 132;
|
||||
const PAD = { top: 10, right: 6, bottom: 18, left: 36 };
|
||||
const PLOT_W = W - PAD.left - PAD.right;
|
||||
const PLOT_H = H - PAD.top - PAD.bottom;
|
||||
const BASE_Y = PAD.top + PLOT_H;
|
||||
|
||||
function axisLabel(km: number): string {
|
||||
if (km <= 0) return "0";
|
||||
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly-distance bar chart for the profile (last N weeks, oldest → newest).
|
||||
* Gridlines + a y-scale topped at the busiest week, faint per-week tracks so
|
||||
* the 12-week axis is always visible (empty weeks read as gaps, not nothing),
|
||||
* and a hover readout naming the week + its distance. Hidden when there is no
|
||||
* distance in the window.
|
||||
*/
|
||||
export function WeeklyDistanceChart({
|
||||
weeks,
|
||||
className,
|
||||
}: {
|
||||
weeks: WeeklyDistanceBucket[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("journal");
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
|
||||
const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0);
|
||||
if (maxM <= 0) return null;
|
||||
|
||||
const maxKm = maxM / 1000;
|
||||
const n = weeks.length;
|
||||
const colW = PLOT_W / n;
|
||||
const barW = colW * 0.6;
|
||||
const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2;
|
||||
const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H;
|
||||
|
||||
const fmtWeek = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
|
||||
|
||||
const gridFracs = [0, 0.5, 1];
|
||||
const active = hover != null ? weeks[hover] : null;
|
||||
const first = weeks[0];
|
||||
const last = weeks[n - 1];
|
||||
const firstLabel = first ? fmtWeek(first.weekStart) : "";
|
||||
const lastLabel = last ? fmtWeek(last.weekStart) : "";
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
|
||||
<span>{t("profileStats.weeklyDistance")}</span>
|
||||
{active && (
|
||||
<span className="tabular-nums text-gray-700">
|
||||
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
|
||||
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-28 w-full"
|
||||
role="img"
|
||||
aria-label={t("profileStats.weeklyDistance")}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{/* gridlines + y-axis labels (0 · half · peak) */}
|
||||
{gridFracs.map((f) => {
|
||||
const yy = BASE_Y - f * PLOT_H;
|
||||
return (
|
||||
<g key={f}>
|
||||
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
|
||||
{axisLabel(maxKm * f)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{weeks.map((w, i) => {
|
||||
const isActive = hover === i;
|
||||
return (
|
||||
<g key={w.weekStart}>
|
||||
{/* faint week-slot track so empty weeks stay visible */}
|
||||
<rect
|
||||
x={x(i)}
|
||||
y={PAD.top}
|
||||
width={barW}
|
||||
height={PLOT_H}
|
||||
rx="1.5"
|
||||
fill={isActive ? "#dbeafe" : "#f3f4f6"}
|
||||
/>
|
||||
{/* distance bar */}
|
||||
{w.distance > 0 && (
|
||||
<rect
|
||||
data-week-bar
|
||||
x={x(i)}
|
||||
y={yFor(w.distance)}
|
||||
width={barW}
|
||||
height={BASE_Y - yFor(w.distance)}
|
||||
rx="1.5"
|
||||
fill={isActive ? "#1d4ed8" : "#3b82f6"}
|
||||
/>
|
||||
)}
|
||||
{/* full-height hover hit area */}
|
||||
<rect
|
||||
x={PAD.left + i * colW}
|
||||
y={PAD.top}
|
||||
width={colW}
|
||||
height={PLOT_H}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHover(i)}
|
||||
>
|
||||
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
|
||||
</rect>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between px-px text-[10px] text-gray-400">
|
||||
<span>{firstLabel}</span>
|
||||
<span>{lastLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -205,6 +205,51 @@ export async function getActivityStats(
|
|||
};
|
||||
}
|
||||
|
||||
export interface WeeklyDistanceBucket {
|
||||
/** ISO date (YYYY-MM-DD) of the week's Monday */
|
||||
weekStart: string;
|
||||
/** metres */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance per week for the last `weeks` weeks (oldest → newest), gap-filled so
|
||||
* every week is present (zero when no activity). The contiguous axis is built
|
||||
* in SQL via `generate_series` + a LEFT JOIN — that guarantees the week
|
||||
* boundaries match Postgres `date_trunc('week', …)` exactly (no JS/Postgres
|
||||
* boundary drift) and keeps it one cheap query over the owner_id index
|
||||
* (profile-weekly-distance design §D1–D2). `publicOnly` scopes it like
|
||||
* `getActivityStats`.
|
||||
*/
|
||||
export async function getWeeklyDistance(
|
||||
ownerId: string,
|
||||
opts: { publicOnly: boolean; weeks?: number },
|
||||
): Promise<WeeklyDistanceBucket[]> {
|
||||
const weeks = opts.weeks ?? 12;
|
||||
const db = getDb();
|
||||
// Filter conditions live in the LEFT JOIN's ON clause so empty weeks survive.
|
||||
const visibility = opts.publicOnly ? sql` AND a.visibility = 'public'` : sql``;
|
||||
const result = await db.execute(sql`
|
||||
WITH weeks AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('week', now()) - (${weeks - 1} * interval '1 week'),
|
||||
date_trunc('week', now()),
|
||||
interval '1 week'
|
||||
) AS wk
|
||||
)
|
||||
SELECT to_char(w.wk, 'YYYY-MM-DD') AS week_start,
|
||||
coalesce(sum(a.distance), 0) AS distance
|
||||
FROM weeks w
|
||||
LEFT JOIN journal.activities a
|
||||
ON date_trunc('week', coalesce(a.started_at, a.created_at)) = w.wk
|
||||
AND a.owner_id = ${ownerId}${visibility}
|
||||
GROUP BY w.wk
|
||||
ORDER BY w.wk
|
||||
`);
|
||||
const rows = result as unknown as Array<{ week_start: string; distance: string | number }>;
|
||||
return rows.map((r) => ({ weekStart: r.week_start, distance: Number(r.distance) }));
|
||||
}
|
||||
|
||||
export async function listActivities(
|
||||
ownerId: string,
|
||||
sort: "startedAt" | "addedAt" = "startedAt",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { getDb } from "~/lib/db";
|
|||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { listPublicRoutesForOwner } from "~/lib/routes.server";
|
||||
import { listPublicActivitiesForOwner, getActivityStats } from "~/lib/activities.server";
|
||||
import { listPublicActivitiesForOwner, getActivityStats, getWeeklyDistance } from "~/lib/activities.server";
|
||||
import { loadPersona } from "~/lib/demo-bot.server";
|
||||
import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server";
|
||||
|
||||
|
|
@ -54,11 +54,14 @@ export async function loadUserProfile(request: Request, username: string) {
|
|||
])
|
||||
: [[], []];
|
||||
|
||||
// Roll-up scoped to what this viewer may see (public-only for non-owners).
|
||||
// Skipped for the locked stub, which shows no content.
|
||||
const stats = canSeeContent
|
||||
? await getActivityStats(user.id, { publicOnly: !isOwn })
|
||||
: null;
|
||||
// Roll-up + weekly trend, scoped to what this viewer may see (public-only for
|
||||
// non-owners). Skipped for the locked stub, which shows no content.
|
||||
const [stats, weeklyDistance] = canSeeContent
|
||||
? await Promise.all([
|
||||
getActivityStats(user.id, { publicOnly: !isOwn }),
|
||||
getWeeklyDistance(user.id, { publicOnly: !isOwn }),
|
||||
])
|
||||
: [null, null];
|
||||
|
||||
// Demo-account badge: true when this profile matches the instance's
|
||||
// configured demo persona username. Computed server-side so we don't
|
||||
|
|
@ -92,6 +95,7 @@ export async function loadUserProfile(request: Request, username: string) {
|
|||
createdAt: a.createdAt.toISOString(),
|
||||
})),
|
||||
stats,
|
||||
weeklyDistance,
|
||||
activitySort,
|
||||
isOwn,
|
||||
isDemoUser,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { FollowButton } from "~/components/FollowButton";
|
|||
import { SportBadge } from "~/components/SportBadge";
|
||||
import { StatRow } from "~/components/StatRow";
|
||||
import { ProfileStats } from "~/components/ProfileStats";
|
||||
import { WeeklyDistanceChart } from "~/components/WeeklyDistanceChart";
|
||||
import { activityStatItems } from "~/lib/stats";
|
||||
import { loadUserProfile } from "./users.$username.server";
|
||||
import {
|
||||
|
|
@ -53,7 +54,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
||||
const { user, routes, activities, stats, activitySort, isOwn, isDemoUser, followers, following, followState, isLoggedIn, canSeeContent } = loaderData;
|
||||
const { user, routes, activities, stats, weeklyDistance, activitySort, isOwn, isDemoUser, followers, following, followState, isLoggedIn, canSeeContent } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
|
||||
return (
|
||||
|
|
@ -126,6 +127,9 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
|
||||
{stats && <ProfileStats stats={stats} className="mt-6" />}
|
||||
{weeklyDistance && weeklyDistance.length > 0 && (
|
||||
<WeeklyDistanceChart weeks={weeklyDistance} className="mt-4" />
|
||||
)}
|
||||
|
||||
{!canSeeContent && (
|
||||
<div className="mt-8 rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
|
||||
|
|
|
|||
26
e2e/profile-weekly-distance.test.ts
Normal file
26
e2e/profile-weekly-distance.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { test, expect, gotoHydrated } from "./fixtures/test";
|
||||
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
|
||||
|
||||
// GPX with geometry → the activity has a non-zero distance, so the weekly
|
||||
// chart has a bar to draw (it hides when there's no distance in the window).
|
||||
const GPX = "packages/fit/__fixtures__/alpine.gpx";
|
||||
|
||||
test.describe("Profile weekly distance", () => {
|
||||
test("profile shows the weekly distance chart when there is recent distance", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await setupVirtualAuthenticator(cdp);
|
||||
|
||||
const stamp = Date.now();
|
||||
const username = `wd${stamp}`;
|
||||
await registerUser(page, `wd-${stamp}@example.com`, username);
|
||||
|
||||
await gotoHydrated(page, "/activities/new");
|
||||
await page.getByLabel("Name").fill("Weekly chart check");
|
||||
await page.locator('input[name="gpx"]').setInputFiles(GPX);
|
||||
await page.getByRole("button", { name: "Create Activity" }).click();
|
||||
await expect(page).toHaveURL(/\/activities\/[0-9a-f-]+$/, { timeout: 10000 });
|
||||
|
||||
await gotoHydrated(page, `/users/${username}`);
|
||||
await expect(page.getByText(/Weekly distance/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
## 1. Aggregate query
|
||||
|
||||
- [ ] 1.1 Add `getWeeklyDistance(ownerId, { publicOnly, weeks = 12 })` to `activities.server.ts`: `sum(distance)` grouped by `date_trunc('week', coalesce(started_at, created_at))` over the last `weeks` weeks, viewer-scoped.
|
||||
- [ ] 1.2 Gap-fill to a contiguous oldest→newest series with `0` for empty weeks; return `{ weekStart, distance }[]`.
|
||||
- [x] 1.1 Added `getWeeklyDistance(ownerId, { publicOnly, weeks = 12 })` to `activities.server.ts`: `sum(distance)` per week, viewer-scoped.
|
||||
- [x] 1.2 Gap-fill done **in SQL** (`generate_series` of week-starts LEFT JOINed to the activities) — returns exactly N contiguous `{ weekStart, distance }` rows, oldest→newest, and guarantees the week boundaries match Postgres `date_trunc('week', …)` (no JS/Postgres boundary drift). Filter conditions live in the JOIN's ON clause so empty weeks survive.
|
||||
|
||||
## 2. Loader & view
|
||||
|
||||
- [ ] 2.1 Profile loader passes the series (same `publicOnly: !isOwn` scoping; only when content is visible).
|
||||
- [ ] 2.2 `WeeklyDistanceChart` component: SVG bars normalized to the max week, per-bar `title` distance (via `stats.ts`), localized label; renders nothing when every week is 0. Mount under `ProfileStats`.
|
||||
- [x] 2.1 Profile loader fetches it in parallel with the stats, same `publicOnly: !isOwn` scoping, only when content is visible.
|
||||
- [x] 2.2 `WeeklyDistanceChart`: SVG bars normalized to the busiest week, per-bar `title` distance (via `stats.ts`), localized label; renders nothing when every week is 0. Mounted under `ProfileStats`.
|
||||
|
||||
## 3. i18n
|
||||
|
||||
- [ ] 3.1 Add `journal.profileStats.weeklyDistance*` (label + per-bar readout) to en + de.
|
||||
- [x] 3.1 Added `journal.profileStats.weeklyDistance` (label) to en + de. Per-bar readout is the language-neutral km distance.
|
||||
|
||||
## 4. Tests & checks
|
||||
|
||||
- [ ] 4.1 Unit: gap-fill produces 12 contiguous weeks with zeros in the right slots.
|
||||
- [ ] 4.2 Component (jsdom): renders 12 bars for a series; nothing when all zero; tallest bar = busiest week.
|
||||
- [ ] 4.3 E2E: create activities, assert the weekly chart renders on the profile.
|
||||
- [ ] 4.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
||||
- [x] 4.1 Gap-fill is in SQL (no JS helper to unit-test) → covered by the e2e (a real activity produces a non-zero bar over a full 12-week axis).
|
||||
- [x] 4.2 Component (jsdom): one bar per week incl. zero weeks; nothing when all zero; heights normalized to the busiest week.
|
||||
- [x] 4.3 E2E `profile-weekly-distance.test.ts`: create an activity with distance → the weekly chart renders on the profile. Passes locally.
|
||||
- [x] 4.4 typecheck + lint + unit (journal 321) green; new e2e passes locally. Full `pnpm test:e2e` runs in CI.
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ export default {
|
|||
ascent: "Anstieg",
|
||||
time: "Zeit",
|
||||
last4Weeks: "{{count}} in den letzten 4 Wochen",
|
||||
weeklyDistance: "Wochendistanz (letzte 12 Wochen)",
|
||||
weekOf: "Woche vom {{date}}",
|
||||
},
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ export default {
|
|||
ascent: "Ascent",
|
||||
time: "Time",
|
||||
last4Weeks: "{{count}} in the last 4 weeks",
|
||||
weeklyDistance: "Weekly distance (last 12 weeks)",
|
||||
weekOf: "Week of {{date}}",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,14 @@ export default defineConfig({
|
|||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "profile-weekly-distance",
|
||||
testMatch: "profile-weekly-distance.test.ts",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: process.env.CI
|
||||
? [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue