Changeset a8381b1


Ignore:
Timestamp:
05/06/26 19:00:44 (2 months ago)
Author:
Andrej <asumanovski@…>
Branches:
master
Children:
42da64d
Parents:
b3d5fed
Message:

Control center page developed

Location:
frontend/src
Files:
3 added
12 edited

Legend:

Unmodified
Added
Removed
  • frontend/src/pages/Dashboard/DashboardLayout.jsx

    rb3d5fed ra8381b1  
    2929  return (
    3030    <SidebarProvider>
     31      {/* App shell background */}
     32      <div className="pointer-events-none fixed inset-0 -z-10">
     33        <div className="absolute inset-0 bg-base-300/10" />
     34        <div className="absolute inset-0 bg-radial-[circle_at_20%_20%] from-green-400/10 via-transparent to-transparent" />
     35        <div className="absolute inset-0 bg-radial-[circle_at_80%_30%] from-blue-400/10 via-transparent to-transparent" />
     36        <div className="absolute inset-0 bg-linear-to-b from-black/10 via-transparent to-black/25" />
     37      </div>
     38
    3139      <DashboardSidebar />
     40
    3241      <SidebarInset className="pt-16 lg:pt-0">
    3342        <DashboardTopbar username={username} onLogout={onLogout} />
     43
    3444        <div className="h-[calc(100vh-4rem)] overflow-auto">
    35           <div className="container mx-auto px-6 py-8 md:px-8 md:py-10 lg:py-12">
    36             <Outlet />
     45          <div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
     46            <div className="rounded-3xl border border-white/10 bg-base-100/60 shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_24px_90px_rgba(0,0,0,0.35)] backdrop-blur-xl">
     47              <div className="p-4 sm:p-6 lg:p-8">
     48                <Outlet />
     49              </div>
     50            </div>
    3751          </div>
    3852        </div>
  • frontend/src/pages/Dashboard/components/DashboardSidebar.jsx

    rb3d5fed ra8381b1  
    8989  return (
    9090    <Sidebar
    91       className="py-6"
     91      className="py-6 border-r border-white/10 bg-base-100/60 backdrop-blur-xl"
    9292      closeButtonClassName="fixed top-4 right-4"
    9393      collapsible="none"
     
    114114                    className={
    115115                      isActive
    116                         ? "!bg-green-400 !text-black hover:!bg-green-400 active:!bg-green-400"
    117                         : ""
     116                        ? "bg-green-400! text-black! hover:bg-green-400! active:bg-green-400!"
     117                        : "hover:bg-white/10!"
    118118                    }
    119119                  >
     
    156156                    className={
    157157                      isActive
    158                         ? "!bg-green-400 !text-black hover:!bg-green-400 active:!bg-green-400"
    159                         : ""
     158                        ? "bg-green-400! text-black! hover:bg-green-400! active:bg-green-400!"
     159                        : "hover:bg-white/10!"
    160160                    }
    161161                  >
     
    185185          <Button
    186186            variant="secondary"
    187             className="w-full justify-start gap-2 !bg-green-400 !text-black hover:!bg-green-500"
     187            className="w-full justify-start gap-2 bg-green-400! text-black! hover:bg-green-500!"
    188188            type="button"
    189189            onClick={() => setCustomOpen(true)}
  • frontend/src/pages/Dashboard/components/DashboardTopbar.jsx

    rb3d5fed ra8381b1  
    1010    <>
    1111      {/* Desktop */}
    12       <div className="sticky top-0 z-30 hidden min-h-16 w-full items-center border-b border-base-300 bg-base-100 px-6 lg:flex lg:px-8">
     12      <div className="sticky top-0 z-30 hidden min-h-16 w-full items-center border-b border-white/10 bg-base-100/70 px-6 backdrop-blur-xl lg:flex lg:px-8">
    1313        <div className="mx-auto grid size-full grid-cols-1 items-center justify-end justify-items-end gap-4 lg:grid-cols-[1fr_max-content] lg:justify-between lg:justify-items-stretch">
    1414          <div className="flex items-center gap-3"></div>
     
    1818            <button
    1919              type="button"
    20               className="btn btn-ghost btn-sm"
     20              className="btn btn-ghost btn-sm hover:bg-white/10 transition"
    2121              onClick={onLogout}
    2222              aria-label="Log out"
     
    3030
    3131      {/* Mobile */}
    32       <div className="fixed left-0 right-0 top-0 z-30 flex min-h-16 w-full items-center justify-between border-b border-base-300 bg-base-100 px-6 lg:hidden">
     32      <div className="fixed left-0 right-0 top-0 z-30 flex min-h-16 w-full items-center justify-between border-b border-white/10 bg-base-100/70 px-4 backdrop-blur-xl sm:px-6 lg:hidden">
    3333        <div className="flex items-center gap-4">
    3434          <Link
     
    4444          <button
    4545            type="button"
    46             className="btn btn-ghost btn-sm"
     46            className="btn btn-ghost btn-sm hover:bg-white/10 transition"
    4747            onClick={onLogout}
    4848            aria-label="Log out"
  • frontend/src/pages/Dashboard/pages/ControlCenter/ControlCenter.jsx

    rb3d5fed ra8381b1  
    1 import React from "react";
    2 
    3 const ControlCenter = () => {
     1import React, { useCallback, useEffect, useMemo, useState } from "react";
     2
     3import api from "../../../../api/axios";
     4import { getFinanceStatus, getIncomes } from "../../../../api/finance";
     5import { getDisciplineStatus } from "../../../../api/discipline";
     6import { getDailyCompletions } from "../../../../api/dailyCompletion";
     7
     8import MultiPercentChangeAreaChart from "../../../../components/graphs/MultiPercentChangeAreaChart.jsx";
     9import TimeRangeToggle from "../../../../components/graphs/TimeRangeToggle.jsx";
     10import { percentChangeSeries, sumByTimeBucket } from "../../../../utils/timeSeries.js";
     11
     12import TrainingProgressCard from "../Training/components/TrainingProgressCard.jsx";
     13import WeightProgressCard from "../Weight/components/WeightProgressCard.jsx";
     14import FinanceProgressCard from "../Finance/components/FinanceProgressCard.jsx";
     15import DisciplineProgressCard from "../Discipline/components/DisciplineProgressCard.jsx";
     16import InvestingProgressCard from "../Investing/components/InvestingProgressCard.jsx";
     17
     18function Legend({ items, enabledKeys, onToggle, onShowAll, onHideAll }) {
     19  if (!items?.length) return null;
     20
    421  return (
    5     <div className="space-y-4">
    6       <h1 className="text-2xl font-bold">Control Center</h1>
    7       <div className="card bg-base-200 border border-base-300">
    8         <div className="card-body">
    9           <p className="opacity-80">Control Center content goes here.</p>
    10         </div>
     22    <div className="flex flex-col gap-2">
     23      <div className="flex flex-wrap justify-end gap-2">
     24        <button
     25          type="button"
     26          className="btn btn-ghost btn-xs hover:bg-white/10"
     27          onClick={onShowAll}
     28        >
     29          Show all
     30        </button>
     31        <button
     32          type="button"
     33          className="btn btn-ghost btn-xs hover:bg-white/10"
     34          onClick={onHideAll}
     35        >
     36          Hide all
     37        </button>
     38      </div>
     39
     40      <div className="flex flex-wrap gap-x-4 gap-y-2 text-xs">
     41        {items.map((it) => {
     42          const enabled = enabledKeys?.has(it.key);
     43          return (
     44            <button
     45              key={it.key}
     46              type="button"
     47              onClick={() => onToggle?.(it.key)}
     48              className={
     49                "flex items-center gap-2 rounded-full border px-2.5 py-1 transition " +
     50                (enabled
     51                  ? "border-base-300 bg-base-100 hover:bg-base-100/70"
     52                  : "border-transparent bg-base-300/30 opacity-60 hover:opacity-80")
     53              }
     54              title={enabled ? "Click to hide" : "Click to show"}
     55            >
     56              <span
     57                className="inline-block h-2.5 w-2.5 rounded-full"
     58                style={{ backgroundColor: it.color }}
     59                aria-hidden
     60              />
     61              <span className="opacity-90">{it.label}</span>
     62            </button>
     63          );
     64        })}
    1165      </div>
    1266    </div>
    1367  );
    14 };
    15 
    16 export default ControlCenter;
     68}
     69
     70export default function ControlCenter() {
     71  const [range, setRange] = useState("weekly");
     72
     73  const [visibleSeries, setVisibleSeries] = useState(() => new Set());
     74
     75  const [isLoading, setIsLoading] = useState(true);
     76  const [error, setError] = useState("");
     77
     78  const [tracking, setTracking] = useState({
     79    weight: false,
     80    training: false,
     81    finance: false,
     82    discipline: false,
     83    investing: false,
     84  });
     85
     86  const [trainingSessions, setTrainingSessions] = useState([]);
     87  const [weightProfile, setWeightProfile] = useState(null);
     88  const [weightIntakes, setWeightIntakes] = useState([]);
     89  const [financeIncomes, setFinanceIncomes] = useState([]);
     90  const [disciplineCompletions, setDisciplineCompletions] = useState([]);
     91  const [investingAssets, setInvestingAssets] = useState([]);
     92
     93  const fetchAllPaged = useCallback(async (fnPage, pageSize, maxPages = 400) => {
     94    let p = 0;
     95    let hasMorePages = true;
     96    const all = [];
     97    while (hasMorePages) {
     98      const res = await fnPage(p, pageSize);
     99      const items = res.items;
     100      all.push(...items);
     101      hasMorePages = Boolean(res.hasMore) && items.length > 0;
     102      p += 1;
     103      if (p > maxPages) break;
     104    }
     105    return all;
     106  }, []);
     107
     108  useEffect(() => {
     109    let cancelled = false;
     110    (async () => {
     111      try {
     112        setIsLoading(true);
     113        setError("");
     114
     115        // Determine tracking flags (cheap endpoints)
     116        const [weightRes, trainingRes, investingRes, financeTracking, disciplineTracking] =
     117          await Promise.all([
     118            api.get("/weight/status"),
     119            api.get("/training/status"),
     120            api.get("/investing/status"),
     121            getFinanceStatus(),
     122            getDisciplineStatus(),
     123          ]);
     124
     125        const nextTracking = {
     126          weight: Boolean(weightRes?.data?.tracking),
     127          training: Boolean(trainingRes?.data?.tracking),
     128          investing: Boolean(investingRes?.data?.tracking),
     129          finance: Boolean(financeTracking),
     130          discipline: Boolean(disciplineTracking),
     131        };
     132
     133        if (cancelled) return;
     134        setTracking(nextTracking);
     135
     136        // Data fetches (only for tracked features)
     137        const tasks = [];
     138
     139        if (nextTracking.training) {
     140          tasks.push(
     141            fetchAllPaged(
     142              async (page, size) => {
     143                const resp = await api.get("/training/sessions", { params: { page, size } });
     144                const data = resp?.data ?? {};
     145                return {
     146                  items: Array.isArray(data.sessions) ? data.sessions : [],
     147                  hasMore: Boolean(data.hasMore),
     148                };
     149              },
     150              500,
     151              200,
     152            ).then((all) => setTrainingSessions(all)),
     153          );
     154        }
     155
     156        if (nextTracking.weight) {
     157          tasks.push(
     158            api.get("/weight/profile").then((res) => setWeightProfile(res?.data ?? null)),
     159          );
     160          tasks.push(
     161            fetchAllPaged(
     162              async (page, size) => {
     163                const resp = await api.get("/weight/intakes", { params: { page, size } });
     164                const data = resp?.data ?? {};
     165                return {
     166                  items: Array.isArray(data.intakes) ? data.intakes : [],
     167                  hasMore: Boolean(data.hasMore),
     168                };
     169              },
     170              500,
     171              200,
     172            ).then((all) => setWeightIntakes(all)),
     173          );
     174        }
     175
     176        if (nextTracking.finance) {
     177          tasks.push(
     178            fetchAllPaged(
     179              async (page, size) => {
     180                const data = await getIncomes({ page, size });
     181                return {
     182                  items: Array.isArray(data?.incomes) ? data.incomes : [],
     183                  hasMore: Boolean(data?.hasMore),
     184                };
     185              },
     186              500,
     187              200,
     188            ).then((all) => setFinanceIncomes(all)),
     189          );
     190        }
     191
     192        if (nextTracking.discipline) {
     193          tasks.push(
     194            fetchAllPaged(
     195              async (page, size) => {
     196                const data = await getDailyCompletions({ page, size });
     197                return {
     198                  items: Array.isArray(data?.completions) ? data.completions : [],
     199                  hasMore: Boolean(data?.hasMore),
     200                };
     201              },
     202              500,
     203              400,
     204            ).then((all) => setDisciplineCompletions(all)),
     205          );
     206        }
     207
     208        if (nextTracking.investing) {
     209          tasks.push(
     210            fetchAllPaged(
     211              async (page, size) => {
     212                const resp = await api.get("/investing/assets", { params: { page, size } });
     213                const data = resp?.data ?? {};
     214                return {
     215                  items: Array.isArray(data.assets) ? data.assets : [],
     216                  hasMore: Boolean(data.hasMore),
     217                };
     218              },
     219              200,
     220              200,
     221            ).then((all) => setInvestingAssets(all)),
     222          );
     223        }
     224
     225        await Promise.all(tasks);
     226      } catch (e) {
     227        if (cancelled) return;
     228        setError(e?.response?.data?.message || "Failed to load Control Center.");
     229      } finally {
     230        if (!cancelled) setIsLoading(false);
     231      }
     232    })();
     233
     234    return () => {
     235      cancelled = true;
     236    };
     237  }, [fetchAllPaged]);
     238
     239  const weightGoalCalories = weightProfile?.goalCalories ?? null;
     240  const isBulking = useMemo(() => {
     241    const current = Number(weightProfile?.weight);
     242    const goal = Number(weightProfile?.goalWeight);
     243    if (!Number.isFinite(current) || !Number.isFinite(goal)) return null;
     244    return goal > current;
     245  }, [weightProfile]);
     246
     247  const combinedLegend = useMemo(
     248    () =>
     249      [
     250        tracking.training ? { key: "training", label: "Training", color: "#22c55e" } : null,
     251        tracking.weight ? { key: "weight", label: "Weight", color: "#60a5fa" } : null,
     252        tracking.finance ? { key: "finance", label: "Finance", color: "#a855f7" } : null,
     253        tracking.discipline ? { key: "discipline", label: "Discipline", color: "#fbbf24" } : null,
     254        tracking.investing ? { key: "investing", label: "Investing", color: "#14b8a6" } : null,
     255      ].filter(Boolean),
     256    [tracking],
     257  );
     258
     259  // Initialize visible series to "all tracked" whenever tracking changes.
     260  useEffect(() => {
     261    setVisibleSeries(new Set((combinedLegend ?? []).map((x) => x.key)));
     262  }, [combinedLegend]);
     263
     264  const onToggleSeries = useCallback((key) => {
     265    setVisibleSeries((prev) => {
     266      const next = new Set(prev);
     267      if (next.has(key)) next.delete(key);
     268      else next.add(key);
     269      return next;
     270    });
     271  }, []);
     272
     273  const onShowAllSeries = useCallback(() => {
     274    setVisibleSeries(new Set((combinedLegend ?? []).map((x) => x.key)));
     275  }, [combinedLegend]);
     276
     277  const onHideAllSeries = useCallback(() => {
     278    setVisibleSeries(new Set());
     279  }, []);
     280
     281  const combinedSeriesList = useMemo(() => {
     282    const out = [];
     283
     284    if (tracking.training) {
     285      const buckets = sumByTimeBucket(
     286        trainingSessions,
     287        range,
     288        (s) => s.date,
     289        (s) => s.calories,
     290      );
     291      out.push({
     292        name: "Training",
     293        key: "training",
     294        color: "#22c55e",
     295        points: percentChangeSeries(buckets),
     296      });
     297    }
     298
     299    if (tracking.finance) {
     300      const buckets = sumByTimeBucket(
     301        financeIncomes,
     302        range,
     303        (i) => i.date,
     304        (i) => i.amount,
     305      );
     306      out.push({
     307        name: "Finance",
     308        key: "finance",
     309        color: "#a855f7",
     310        points: percentChangeSeries(buckets),
     311      });
     312    }
     313
     314    if (tracking.discipline) {
     315      // average completion per bucket
     316      const sums = sumByTimeBucket(
     317        disciplineCompletions,
     318        range,
     319        (c) => c.date,
     320        (c) => Number(c?.procent) || 0,
     321      );
     322      const counts = sumByTimeBucket(
     323        disciplineCompletions,
     324        range,
     325        (c) => c.date,
     326        () => 1,
     327      );
     328      const countByTs = new Map(counts.map((p) => [p.ts, p.value]));
     329      const avgPoints = sums.map((p) => {
     330        const count = Number(countByTs.get(p.ts) ?? 0);
     331        const avg = count > 0 ? Number(p.value ?? 0) / count : 0;
     332        return { ts: p.ts, value: avg };
     333      });
     334
     335      out.push({
     336        name: "Discipline",
     337        key: "discipline",
     338        color: "#fbbf24",
     339        points: percentChangeSeries(avgPoints),
     340      });
     341    }
     342
     343    if (tracking.investing) {
     344      const buckets = sumByTimeBucket(
     345        investingAssets,
     346        range,
     347        (a) => a.buyDate,
     348        (a) => (Number(a?.quantity) || 0) * (Number(a?.buyPrice) || 0),
     349      );
     350      out.push({
     351        name: "Investing",
     352        key: "investing",
     353        color: "#14b8a6",
     354        points: percentChangeSeries(buckets),
     355      });
     356    }
     357
     358    if (tracking.weight) {
     359      // replicate WeightProgressCard base series (closeness %) then % change.
     360      const totals = sumByTimeBucket(
     361        weightIntakes,
     362        range,
     363        (i) => i.date,
     364        (i) => Number(i?.calories) || 0,
     365      );
     366      const goalTotals = sumByTimeBucket(
     367        weightIntakes,
     368        range,
     369        (i) => i.date,
     370        (i) => {
     371          const goal = Number(weightGoalCalories);
     372          if (!Number.isFinite(goal) || goal <= 0) return 0;
     373          const burned = Number(i?.burnedCalories) || 0;
     374          const trained = Boolean(i?.trainedThatDay);
     375          return trained ? goal + burned : goal;
     376        },
     377      );
     378      const goalByTs = new Map(goalTotals.map((p) => [p.ts, p.value]));
     379      const closeness = totals.map((p) => {
     380        const adjustedGoal = Number(goalByTs.get(p.ts) ?? 0);
     381        const calories = Number(p.value ?? 0);
     382        if (!Number.isFinite(adjustedGoal) || adjustedGoal <= 0) return { ts: p.ts, value: 0 };
     383        const pct = 100 - (Math.abs(calories - adjustedGoal) / adjustedGoal) * 100;
     384        const clamped = Math.min(200, Math.max(0, pct));
     385        return { ts: p.ts, value: clamped };
     386      });
     387      let weightSeries = percentChangeSeries(closeness);
     388      if (isBulking === false) {
     389        weightSeries = weightSeries.map((p) => ({ ...p, value: -Number(p.value ?? 0) }));
     390      }
     391      // Weight graph is reversed (matches WeightProgressCard)
     392      weightSeries = weightSeries.map((p) => ({ ...p, value: -Number(p.value ?? 0) }));
     393
     394      out.push({
     395        name: "Weight",
     396        key: "weight",
     397        color: "#60a5fa",
     398        points: weightSeries,
     399      });
     400    }
     401
     402    return out;
     403  }, [
     404    disciplineCompletions,
     405    financeIncomes,
     406    investingAssets,
     407    isBulking,
     408    range,
     409    tracking,
     410    trainingSessions,
     411    weightGoalCalories,
     412    weightIntakes,
     413  ]);
     414
     415  const filteredCombinedSeriesList = useMemo(() => {
     416    return (combinedSeriesList ?? []).filter((s) => visibleSeries.has(s.key));
     417  }, [combinedSeriesList, visibleSeries]);
     418
     419  const trackedCount = Object.values(tracking).filter(Boolean).length;
     420
     421  if (isLoading) {
     422    return (
     423      <div className="space-y-6">
     424        <h1 className="text-2xl font-bold">Control Center</h1>
     425        <div className="card bg-base-200 border border-base-300">
     426          <div className="card-body">
     427            <p className="opacity-80">Loading…</p>
     428          </div>
     429        </div>
     430      </div>
     431    );
     432  }
     433
     434  if (error) {
     435    return (
     436      <div className="space-y-6">
     437        <h1 className="text-2xl font-bold">Control Center</h1>
     438        <div className="alert alert-error">
     439          <span>{error}</span>
     440        </div>
     441      </div>
     442    );
     443  }
     444
     445  if (trackedCount === 0) {
     446    return (
     447      <div className="space-y-6">
     448        <h1 className="text-2xl font-bold">Control Center</h1>
     449        <div className="card bg-base-200 border border-base-300">
     450          <div className="card-body">
     451            <p className="opacity-80">
     452              Start tracking at least one area (Weight / Training / Finance / Discipline / Investing) to see your dashboard.
     453            </p>
     454          </div>
     455        </div>
     456      </div>
     457    );
     458  }
     459
     460  return (
     461    <div className="space-y-8">
     462      <div className="flex items-start justify-between gap-4 flex-wrap">
     463        <div>
     464          <h1 className="text-2xl font-bold tracking-tight">Control Center</h1>
     465          <p className="mt-1 text-sm opacity-70">
     466            Correlate your progress across everything you track.
     467          </p>
     468        </div>
     469        <TimeRangeToggle value={range} onChange={setRange} />
     470      </div>
     471
     472      <div className="card bg-base-200/60 border border-white/10 shadow-[0_0_0_1px_rgba(255,255,255,0.04)] backdrop-blur">
     473        <div className="card-body">
     474          <div className="flex items-start justify-between gap-4 flex-wrap">
     475            <div>
     476              <h2 className="card-title">Correlation</h2>
     477              <p className="mt-1 text-xs opacity-70">
     478                Toggle lines to isolate signals. All metrics are % change per {range} bucket.
     479              </p>
     480            </div>
     481            <Legend
     482              items={combinedLegend}
     483              enabledKeys={visibleSeries}
     484              onToggle={onToggleSeries}
     485              onShowAll={onShowAllSeries}
     486              onHideAll={onHideAllSeries}
     487            />
     488          </div>
     489
     490          <div className="mt-4 w-full overflow-hidden rounded-xl border border-base-300 bg-base-100 p-2">
     491            {filteredCombinedSeriesList.length === 0 ? (
     492              <div className="flex h-80 items-center justify-center text-sm opacity-70">
     493                Toggle at least one line in the legend to display the chart.
     494              </div>
     495            ) : filteredCombinedSeriesList.every((s) => (s.points?.length ?? 0) < 2) ? (
     496              <div className="flex h-80 items-center justify-center text-sm opacity-70">
     497                Add more history to see correlations.
     498              </div>
     499            ) : (
     500              <MultiPercentChangeAreaChart
     501                seriesList={filteredCombinedSeriesList}
     502                granularity={range}
     503                height={340}
     504              />
     505            )}
     506          </div>
     507
     508          <div className="mt-3 text-xs opacity-70">
     509            Each line uses the same time bucket toggle, but has its own base metric (calories, income totals, completion average, etc.).
     510          </div>
     511        </div>
     512      </div>
     513
     514      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-6">
     515        {tracking.training ? (
     516          <div>
     517            <div className="mb-2 flex items-center justify-between">
     518              <h3 className="font-semibold">Training</h3>
     519              <span className="badge badge-ghost" style={{ borderColor: "#22c55e" }}>
     520                % change
     521              </span>
     522            </div>
     523            <TrainingProgressCard sessions={trainingSessions} />
     524          </div>
     525        ) : null}
     526
     527        {tracking.weight ? (
     528          <div>
     529            <div className="mb-2 flex items-center justify-between">
     530              <h3 className="font-semibold">Weight</h3>
     531              <span className="badge badge-ghost" style={{ borderColor: "#60a5fa" }}>
     532                % change
     533              </span>
     534            </div>
     535            <WeightProgressCard
     536              intakes={weightIntakes}
     537              goalCalories={weightGoalCalories}
     538              isBulking={isBulking}
     539            />
     540          </div>
     541        ) : null}
     542
     543        {tracking.finance ? (
     544          <div>
     545            <div className="mb-2 flex items-center justify-between">
     546              <h3 className="font-semibold">Finance</h3>
     547              <span className="badge badge-ghost" style={{ borderColor: "#a855f7" }}>
     548                % change
     549              </span>
     550            </div>
     551            <FinanceProgressCard incomes={financeIncomes} />
     552          </div>
     553        ) : null}
     554
     555        {tracking.discipline ? (
     556          <div>
     557            <div className="mb-2 flex items-center justify-between">
     558              <h3 className="font-semibold">Discipline</h3>
     559              <span className="badge badge-ghost" style={{ borderColor: "#fbbf24" }}>
     560                % change
     561              </span>
     562            </div>
     563            <DisciplineProgressCard completions={disciplineCompletions} />
     564          </div>
     565        ) : null}
     566
     567        {tracking.investing ? (
     568          <div>
     569            <div className="mb-2 flex items-center justify-between">
     570              <h3 className="font-semibold">Investing</h3>
     571              <span className="badge badge-ghost" style={{ borderColor: "#14b8a6" }}>
     572                % change
     573              </span>
     574            </div>
     575            <InvestingProgressCard assets={investingAssets} />
     576          </div>
     577        ) : null}
     578      </div>
     579    </div>
     580  );
     581}
  • frontend/src/pages/Dashboard/pages/Discipline/DisciplineTracking.jsx

    rb3d5fed ra8381b1  
    11import React, { useCallback, useEffect, useMemo, useState } from "react";
    2 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";
    32
    43import {
     
    1615
    1716import TaskList from "./components/TaskList.jsx";
     17import DisciplineProgressCard from "./components/DisciplineProgressCard.jsx";
    1818
    1919export default function DisciplineTracking() {
    2020  const pageSize = 50;
    2121  const completionPageSize = 14;
     22  const graphCompletionPageSize = 500;
    2223
    2324  const todayIso = useMemo(() => {
     
    6566      nextHasMore: Boolean(data?.hasMore),
    6667    };
     68  }, []);
     69
     70  const [graphCompletions, setGraphCompletions] = useState([]);
     71
     72  const fetchAllCompletionsForGraph = useCallback(async () => {
     73    let p = 0;
     74    let hasMorePages = true;
     75    const all = [];
     76
     77    while (hasMorePages) {
     78      const data = await getDailyCompletions({ page: p, size: graphCompletionPageSize });
     79      const chunk = Array.isArray(data?.completions) ? data.completions : [];
     80      all.push(...chunk);
     81      hasMorePages = Boolean(data?.hasMore) && chunk.length > 0;
     82      p += 1;
     83      if (p > 400) break;
     84    }
     85
     86    return all;
    6787  }, []);
    6888
     
    96116        setIsLoadingCompletions(true);
    97117        setCompletionError("");
    98         const { nextCompletions, nextHasMore } = await fetchCompletionsPage(0);
     118        const [{ nextCompletions, nextHasMore }, allForGraph] = await Promise.all([
     119          fetchCompletionsPage(0),
     120          fetchAllCompletionsForGraph(),
     121        ]);
    99122        if (cancelled) return;
    100123        setDailyCompletions(nextCompletions);
     124        setGraphCompletions(allForGraph);
    101125        setCompletionHasMore(nextHasMore);
    102126        setCompletionPage(0);
     
    115139      cancelled = true;
    116140    };
    117   }, [fetchCompletionsPage]);
     141  }, [fetchAllCompletionsForGraph, fetchCompletionsPage]);
    118142
    119143  const onLoadMoreCompletions = async () => {
     
    240264        setCompletionHasMore(nextHasMore);
    241265        setCompletionPage(0);
     266
     267        // also refresh graph dataset
     268        try {
     269          const allForGraph = await getDailyCompletions({ page: 0, size: 1000 });
     270          setGraphCompletions(
     271            Array.isArray(allForGraph?.completions) ? allForGraph.completions : [],
     272          );
     273        } catch {
     274          // ignore
     275        }
    242276      } catch {
    243277        // ignore
     
    316350          ) : null}
    317351
    318           <div className="card bg-base-200 border border-base-300">
    319             <div className="card-body">
    320               <h2 className="card-title">Progress</h2>
    321               <p className="text-sm opacity-80">(Placeholder graph for now)</p>
    322               <div className="mt-4">
    323                 <img
    324                   src={graphPlaceholder}
    325                   alt="Discipline graph placeholder"
    326                   className="w-full"
    327                 />
    328               </div>
    329             </div>
    330           </div>
     352          <DisciplineProgressCard completions={graphCompletions} />
    331353
    332354          <div className="card bg-base-200 border border-base-300">
  • frontend/src/pages/Dashboard/pages/Finance/FinanceTracking.jsx

    rb3d5fed ra8381b1  
    22import { useNavigate } from "react-router-dom";
    33
    4 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";
    54import {
    65  getFinanceProfile,
     
    1211import FinanceIncomesTable from "./components/FinanceIncomesTable.jsx";
    1312import FinanceStartForm from "./components/FinanceStartForm.jsx";
     13import FinanceProgressCard from "./components/FinanceProgressCard.jsx";
    1414
    1515function toNumber(value) {
     
    2121  const navigate = useNavigate();
    2222  const pageSize = 5;
     23  const graphPageSize = 500;
    2324
    2425  const [profile, setProfile] = useState(null);
     
    7677  }, []);
    7778
     79  const [graphIncomes, setGraphIncomes] = useState([]);
     80
     81  const fetchAllIncomesForGraph = useCallback(async () => {
     82    let p = 0;
     83    let hasMorePages = true;
     84    const all = [];
     85
     86    while (hasMorePages) {
     87      const data = await getIncomes({ page: p, size: graphPageSize });
     88      const chunk = Array.isArray(data?.incomes) ? data.incomes : [];
     89      all.push(...chunk);
     90      hasMorePages = Boolean(data?.hasMore) && chunk.length > 0;
     91      p += 1;
     92      if (p > 200) break;
     93    }
     94
     95    return all;
     96  }, []);
     97
    7898  useEffect(() => {
    7999    let cancelled = false;
     
    83103        setError("");
    84104
    85         const [p, inc] = await Promise.all([getFinanceProfile(), fetchPage(0)]);
     105        const [p, inc, allInc] = await Promise.all([
     106          getFinanceProfile(),
     107          fetchPage(0),
     108          fetchAllIncomesForGraph(),
     109        ]);
    86110        if (cancelled) return;
    87111        setProfile(p);
     
    94118        });
    95119        setIncomes(inc.nextIncomes);
     120        setGraphIncomes(allInc);
    96121        setHasMore(inc.nextHasMore);
    97122        setPage(0);
     
    107132      cancelled = true;
    108133    };
    109   }, [fetchPage]);
     134  }, [fetchAllIncomesForGraph, fetchPage]);
    110135
    111136  const onEdit = () => {
     
    205230      )}
    206231
    207       <div className="card bg-base-200 border border-base-300">
    208         <div className="card-body">
    209           <h2 className="card-title">Progress</h2>
    210           <p className="text-sm opacity-80">(Placeholder graph for now)</p>
    211           <div className="mt-4">
    212             <img src={graphPlaceholder} alt="Finance graph placeholder" className="w-full" />
    213           </div>
    214         </div>
    215       </div>
     232      <FinanceProgressCard incomes={graphIncomes} />
    216233
    217234      <FinanceIncomesTable
  • frontend/src/pages/Dashboard/pages/Investing/InvestingTracking.jsx

    rb3d5fed ra8381b1  
    33
    44import api from "../../../../api/axios";
    5 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";
    65import { fetchCurrentPricesBySymbol } from "../../../../api/twelveData";
    76
     
    3534  const navigate = useNavigate();
    3635  const pageSize = 5;
     36  const graphPageSize = 200;
    3737
    3838  const [assets, setAssets] = useState([]);
     
    6262  );
    6363
    64   const fetchPage = useCallback(async (nextPage) => {
     64  const fetchPage = useCallback(async (nextPage, size = pageSize) => {
    6565    const resp = await api.get("/investing/assets", {
    66       params: { page: nextPage, size: pageSize },
     66      params: { page: nextPage, size },
    6767    });
    6868    const data = resp?.data ?? {};
     
    7272  }, []);
    7373
     74  const [graphAssets, setGraphAssets] = useState([]);
     75
     76  const fetchAllAssetsForGraph = useCallback(async () => {
     77    let p = 0;
     78    let hasMorePages = true;
     79    const all = [];
     80
     81    while (hasMorePages) {
     82      const { nextAssets: chunk, nextHasMore } = await fetchPage(p, graphPageSize);
     83      all.push(...chunk);
     84      hasMorePages = nextHasMore && chunk.length > 0;
     85      p += 1;
     86      if (p > 200) break;
     87    }
     88
     89    return all;
     90  }, [fetchPage]);
     91
    7492  useEffect(() => {
    7593    let cancelled = false;
     
    7896        setIsLoading(true);
    7997        setError("");
    80         const { nextAssets, nextHasMore } = await fetchPage(0);
    81         if (cancelled) return;
    82         setAssets(nextAssets);
    83         setHasMore(nextHasMore);
     98        const [tableFirstPage, allForGraph] = await Promise.all([
     99          fetchPage(0, pageSize),
     100          fetchAllAssetsForGraph(),
     101        ]);
     102        if (cancelled) return;
     103        setAssets(tableFirstPage.nextAssets);
     104        setHasMore(tableFirstPage.nextHasMore);
     105        setGraphAssets(allForGraph);
    84106        setPage(0);
    85107      } catch (e) {
     
    93115      cancelled = true;
    94116    };
    95   }, [fetchPage]);
     117  }, [fetchAllAssetsForGraph, fetchPage]);
    96118
    97119  const tickerSymbols = useMemo(() => {
     
    102124    return Array.from(set).sort();
    103125  }, [assets]);
     126
     127  const tickerSymbolsKey = useMemo(() => tickerSymbols.join(","), [tickerSymbols]);
    104128
    105129  useEffect(() => {
     
    143167      if (intervalId) clearInterval(intervalId);
    144168    };
    145   }, [tickerSymbols.join(",")]);
     169  }, [tickerSymbolsKey, tickerSymbols]);
    146170
    147171  const onLoadMore = async () => {
     
    190214        onAddAsset={() => navigate("/dashboard/investing/assets/new")}
    191215      />
    192       <InvestingProgressCard graphSrc={graphPlaceholder} />
     216      <InvestingProgressCard assets={graphAssets} />
    193217
    194218      {quotesError ? (
  • frontend/src/pages/Dashboard/pages/Investing/components/InvestingProgressCard.jsx

    rb3d5fed ra8381b1  
    1 import React from "react";
     1import React, { useMemo, useState } from "react";
    22
    3 export default function InvestingProgressCard({ graphSrc }) {
     3import TimeRangeToggle from "../../../../../components/graphs/TimeRangeToggle.jsx";
     4import PercentChangeAreaChart from "../../../../../components/graphs/PercentChangeAreaChart.jsx";
     5import { percentChangeSeries, sumByTimeBucket } from "../../../../../utils/timeSeries.js";
     6
     7export default function InvestingProgressCard({ assets }) {
     8  const [range, setRange] = useState("weekly");
     9
     10  const points = useMemo(() => {
     11    // Option B: "net invested momentum".
     12    // Since we only have assets (with buy date/price/quantity) and no transaction history,
     13    // we treat each asset as a one-time investment at buyDate.
     14    const buckets = sumByTimeBucket(
     15      assets,
     16      range,
     17      (a) => a.buyDate,
     18      (a) => (Number(a?.quantity) || 0) * (Number(a?.buyPrice) || 0),
     19    );
     20    return percentChangeSeries(buckets);
     21  }, [assets, range]);
     22
     23  const latest = points?.length ? points[points.length - 1] : null;
     24  const latestPct = latest ? Number(latest.value ?? 0) : 0;
     25  const latestBase = latest ? Number(latest.base ?? 0) : 0;
     26
    427  return (
    528    <div className="card bg-base-200 border border-base-300">
    629      <div className="card-body">
    730        <div className="flex items-center justify-between gap-4">
    8           <div>
    9             <h2 className="text-lg font-semibold">Progress</h2>
    10             <p className="opacity-80">
    11               Graph placeholder — we’ll visualize portfolio value here.
    12             </p>
     31          <h2 className="card-title">Progress</h2>
     32          <div className="flex items-center gap-3">
     33            <span className="badge badge-ghost">
     34              {latestPct >= 0 ? "+" : ""}
     35              {latestPct.toFixed(1)}% (last)
     36            </span>
     37            <TimeRangeToggle value={range} onChange={setRange} />
    1338          </div>
    1439        </div>
    15         <div className="mt-4 overflow-hidden rounded-xl bg-base-300/30 border border-base-300">
    16           <div className="p-4">
    17             <img
    18               src={graphSrc}
    19               alt="Graph placeholder"
    20               className="w-full max-h-64 object-contain opacity-90"
     40
     41        <div className="mt-4 w-full overflow-hidden rounded-xl border border-base-300 bg-base-100 p-2">
     42          {points.length < 2 ? (
     43            <div className="flex h-65 items-center justify-center text-sm opacity-70">
     44              Add at least 2 investments to see % change.
     45            </div>
     46          ) : (
     47            <PercentChangeAreaChart
     48              points={points}
     49              granularity={range}
     50              height={260}
     51              positiveColor="#14b8a6"
    2152            />
    22           </div>
     53          )}
     54        </div>
     55
     56        <div className="mt-2 text-xs opacity-70">
     57          Showing percentage change in invested amount per {range} bucket. Latest bucket invested: {Math.round(latestBase)}.
    2358        </div>
    2459      </div>
  • frontend/src/pages/Dashboard/pages/Training/TrainingTracking.jsx

    rb3d5fed ra8381b1  
    2727  const navigate = useNavigate();
    2828  const pageSize = 5;
     29  const graphPageSize = 500;
    2930
    3031  const [sessions, setSessions] = useState([]);
     
    4546  );
    4647
    47   const fetchPage = useCallback(async (nextPage) => {
     48  const fetchPage = useCallback(async (nextPage, size = pageSize) => {
    4849    const resp = await api.get("/training/sessions", {
    49       params: { page: nextPage, size: pageSize },
     50      params: { page: nextPage, size },
    5051    });
    5152    const data = resp?.data ?? {};
     
    5556  }, []);
    5657
     58  const fetchAllForGraph = useCallback(async () => {
     59    let p = 0;
     60    let hasMorePages = true;
     61    const all = [];
     62
     63    while (hasMorePages) {
     64      const { nextSessions, nextHasMore } = await fetchPage(p, graphPageSize);
     65      all.push(...nextSessions);
     66      hasMorePages = nextHasMore && nextSessions.length > 0;
     67      p += 1;
     68      // Basic safety to avoid accidental infinite loops if backend misbehaves.
     69      if (p > 200) break;
     70    }
     71
     72    return all;
     73  }, [fetchPage]);
     74
     75  const [graphSessions, setGraphSessions] = useState([]);
     76
    5777  useEffect(() => {
    5878    let cancelled = false;
     
    6181        setIsLoading(true);
    6282        setError("");
    63         const { nextSessions, nextHasMore } = await fetchPage(0);
     83        const [tableFirstPage, allForGraph] = await Promise.all([
     84          fetchPage(0, pageSize),
     85          fetchAllForGraph(),
     86        ]);
    6487        if (cancelled) return;
    65         setSessions(nextSessions);
    66         setHasMore(nextHasMore);
     88        setSessions(tableFirstPage.nextSessions);
     89        setHasMore(tableFirstPage.nextHasMore);
     90        setGraphSessions(allForGraph);
    6791        setPage(0);
    6892      } catch (e) {
     
    78102      cancelled = true;
    79103    };
    80   }, [fetchPage]);
     104  }, [fetchAllForGraph, fetchPage]);
    81105
    82106  const onLoadMore = async () => {
     
    102126        onAddSession={() => navigate("/dashboard/training/sessions/new")}
    103127      />
    104       <TrainingProgressCard sessions={sessions} />
     128      <TrainingProgressCard sessions={graphSessions} />
    105129      <TrainingSessionsTable
    106130        columns={columns}
  • frontend/src/pages/Dashboard/pages/Weight/WeightTracking.jsx

    rb3d5fed ra8381b1  
    33
    44import api from "../../../../api/axios";
    5 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";
    65
    76import WeightProgressCard from "./components/WeightProgressCard.jsx";
     
    3736  const navigate = useNavigate();
    3837  const pageSize = 5;
     38  const graphPageSize = 500;
    3939
    4040  const [profile, setProfile] = useState(null);
     
    5353  const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
    5454
    55   const fetchPage = useCallback(async (nextPage) => {
     55  const fetchPage = useCallback(async (nextPage, size = pageSize) => {
    5656    const [profileRes, intakesRes, trainingRes] = await Promise.all([
    5757      api.get("/weight/profile"),
    5858      api.get("/weight/intakes", {
    59         params: { page: nextPage, size: pageSize },
     59        params: { page: nextPage, size },
    6060      }),
    6161      api.get("/weight/today-training"),
     
    6969  }, []);
    7070
     71  const [graphIntakes, setGraphIntakes] = useState([]);
     72
     73  const fetchAllIntakesForGraph = useCallback(async () => {
     74    let p = 0;
     75    let hasMorePages = true;
     76    const all = [];
     77
     78    while (hasMorePages) {
     79      const { intakesData } = await fetchPage(p, graphPageSize);
     80      const chunk = Array.isArray(intakesData.intakes) ? intakesData.intakes : [];
     81      all.push(...chunk);
     82      hasMorePages = Boolean(intakesData.hasMore) && chunk.length > 0;
     83      p += 1;
     84      if (p > 200) break;
     85    }
     86
     87    return all;
     88  }, [fetchPage]);
     89
    7190  useEffect(() => {
    7291    let cancelled = false;
     
    7594        setIsLoading(true);
    7695        setError("");
    77         const { profileData, intakesData, trainingData } = await fetchPage(0);
     96        const [{ profileData, intakesData, trainingData }, allIntakes] = await Promise.all([
     97          fetchPage(0, pageSize),
     98          fetchAllIntakesForGraph(),
     99        ]);
    78100        if (cancelled) return;
    79101        setProfile(profileData);
    80102        setIntakes(Array.isArray(intakesData.intakes) ? intakesData.intakes : []);
     103        setGraphIntakes(allIntakes);
    81104        setHasMore(Boolean(intakesData.hasMore));
    82105        setHasTodayIntake(Boolean(intakesData.hasTodayIntake));
     
    100123      cancelled = true;
    101124    };
    102   }, [fetchPage, navigate]);
     125  }, [fetchAllIntakesForGraph, fetchPage, navigate]);
    103126
    104127  const onLoadMore = async () => {
     
    213236      />
    214237
    215       <WeightProgressCard graphSrc={graphPlaceholder} />
     238      <WeightProgressCard
     239        intakes={graphIntakes}
     240        goalCalories={goalCalories}
     241        isBulking={
     242          Number.isFinite(Number(profile?.weight)) && Number.isFinite(Number(profile?.goalWeight))
     243            ? Number(profile.goalWeight) > Number(profile.weight)
     244            : null
     245        }
     246      />
    216247
    217248      <WeightIntakesTable
  • frontend/src/pages/Dashboard/pages/Weight/components/WeightIntakesTable.jsx

    rb3d5fed ra8381b1  
    136136
    137137        <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
    138           <button
    139             type="button"
    140             className="btn bg-green-400! text-black! hover:bg-green-500! border-0"
    141             onClick={onAddIntake}
    142             disabled={isLoading}
    143           >
    144             + Add today's intake
    145           </button>
    146138
    147139          <button
  • frontend/src/pages/Dashboard/pages/Weight/components/WeightProgressCard.jsx

    rb3d5fed ra8381b1  
    1 import React from "react";
     1import React, { useMemo, useState } from "react";
    22
    3 export default function WeightProgressCard({ graphSrc }) {
     3import TimeRangeToggle from "../../../../../components/graphs/TimeRangeToggle.jsx";
     4import PercentChangeAreaChart from "../../../../../components/graphs/PercentChangeAreaChart.jsx";
     5import { percentChangeSeries, sumByTimeBucket } from "../../../../../utils/timeSeries.js";
     6
     7function clamp(n, min, max) {
     8  const x = Number(n);
     9  if (!Number.isFinite(x)) return min;
     10  return Math.min(max, Math.max(min, x));
     11}
     12
     13export default function WeightProgressCard({ intakes, goalCalories, isBulking }) {
     14  const [range, setRange] = useState("weekly");
     15
     16
     17  const points = useMemo(() => {
     18    // For a bucket (week/month/year) we aggregate:
     19    // - total calories
     20    // - total adjusted goal
     21    // then compute bucketCloseness% from those totals.
     22    const totals = sumByTimeBucket(
     23      intakes,
     24      range,
     25      (i) => i.date,
     26      (i) => Number(i?.calories) || 0,
     27    );
     28
     29    const goalTotals = sumByTimeBucket(
     30      intakes,
     31      range,
     32      (i) => i.date,
     33      (i) => {
     34        const goal = Number(goalCalories);
     35        if (!Number.isFinite(goal) || goal <= 0) return 0;
     36        const burned = Number(i?.burnedCalories) || 0;
     37        const trained = Boolean(i?.trainedThatDay);
     38        return trained ? goal + burned : goal;
     39      },
     40    );
     41
     42    const goalByTs = new Map(goalTotals.map((p) => [p.ts, p.value]));
     43
     44    const closenessPoints = totals.map((p) => {
     45      const adjustedGoal = Number(goalByTs.get(p.ts) ?? 0);
     46      const calories = Number(p.value ?? 0);
     47
     48      if (!Number.isFinite(adjustedGoal) || adjustedGoal <= 0) {
     49        return { ts: p.ts, value: 0 };
     50      }
     51
     52      const pct = 100 - (Math.abs(calories - adjustedGoal) / adjustedGoal) * 100;
     53      return { ts: p.ts, value: clamp(pct, 0, 200) };
     54    });
     55
     56    const pctChange = percentChangeSeries(closenessPoints);
     57    const adjusted =
     58      isBulking === false
     59        ? // Cutting: improvements should read as "positive" when the user stays under goal.
     60          // Flip the sign so the line direction matches the user's intent.
     61          pctChange.map((p) => ({ ...p, value: -Number(p.value ?? 0) }))
     62        : pctChange;
     63
     64    // Reverse the graph line direction (invert Y) as requested.
     65    return adjusted.map((p) => ({ ...p, value: -Number(p.value ?? 0) }));
     66  }, [intakes, range, goalCalories, isBulking]);
     67
     68  const latest = points?.length ? points[points.length - 1] : null;
     69  const latestPct = latest ? Number(latest.value ?? 0) : 0;
     70  const latestBase = latest ? Number(latest.base ?? 0) : 0;
     71
    472  return (
    573    <div className="card bg-base-200 border border-base-300">
    674      <div className="card-body">
    775        <div className="flex items-center justify-between gap-4">
    8           <div>
    9             <h2 className="text-lg font-semibold">Progress</h2>
    10             <p className="opacity-80">
    11               Graph placeholder — calorie intake over time will appear here.
    12             </p>
     76          <h2 className="card-title">Progress</h2>
     77          <div className="flex items-center gap-3">
     78            <span className="badge badge-ghost">
     79              {latestPct >= 0 ? "+" : ""}
     80              {latestPct.toFixed(1)}% (last)
     81            </span>
     82            <TimeRangeToggle value={range} onChange={setRange} />
    1383          </div>
    14           <span className="badge badge-ghost">Graph placeholder</span>
    1584        </div>
    16         <div className="mt-4 overflow-hidden rounded-xl bg-base-300/30 border border-base-300">
    17           <div className="p-4">
    18             <img
    19               src={graphSrc}
    20               alt="Weight progress graph placeholder"
    21               className="w-full max-h-64 object-contain opacity-90"
     85
     86        <div className="mt-4 w-full overflow-hidden rounded-xl border border-base-300 bg-base-100 p-2">
     87          {points.length < 2 ? (
     88            <div className="flex h-65 items-center justify-center text-sm opacity-70">
     89              Add at least 2 daily intakes to see % change.
     90            </div>
     91          ) : (
     92            <PercentChangeAreaChart
     93              points={points}
     94              granularity={range}
     95              height={260}
     96              positiveColor="#60a5fa"
    2297            />
    23           </div>
     98          )}
     99        </div>
     100
     101        <div className="mt-2 text-xs opacity-70">
     102          Showing percentage change in goal-accuracy (closeness %) per {range} bucket.
     103          {isBulking === null ? (
     104            <> Trend is not adjusted (missing current/goal weight). </>
     105          ) : (
     106            <> Trend is adjusted for your goal ({isBulking ? "bulking" : "cutting"}). </>
     107          )}
     108          Latest bucket closeness: {Math.round(latestBase)}%.
    24109        </div>
    25110      </div>
Note: See TracChangeset for help on using the changeset viewer.