Changeset a8381b1 for frontend/src
- Timestamp:
- 05/06/26 19:00:44 (2 months ago)
- Branches:
- master
- Children:
- 42da64d
- Parents:
- b3d5fed
- Location:
- frontend/src
- Files:
-
- 3 added
- 12 edited
-
components/graphs/MultiPercentChangeAreaChart.jsx (added)
-
pages/Dashboard/DashboardLayout.jsx (modified) (1 diff)
-
pages/Dashboard/components/DashboardSidebar.jsx (modified) (4 diffs)
-
pages/Dashboard/components/DashboardTopbar.jsx (modified) (4 diffs)
-
pages/Dashboard/pages/ControlCenter/ControlCenter.jsx (modified) (1 diff)
-
pages/Dashboard/pages/Discipline/DisciplineTracking.jsx (modified) (7 diffs)
-
pages/Dashboard/pages/Discipline/components/DisciplineProgressCard.jsx (added)
-
pages/Dashboard/pages/Finance/FinanceTracking.jsx (modified) (8 diffs)
-
pages/Dashboard/pages/Finance/components/FinanceProgressCard.jsx (added)
-
pages/Dashboard/pages/Investing/InvestingTracking.jsx (modified) (9 diffs)
-
pages/Dashboard/pages/Investing/components/InvestingProgressCard.jsx (modified) (1 diff)
-
pages/Dashboard/pages/Training/TrainingTracking.jsx (modified) (6 diffs)
-
pages/Dashboard/pages/Weight/WeightTracking.jsx (modified) (7 diffs)
-
pages/Dashboard/pages/Weight/components/WeightIntakesTable.jsx (modified) (1 diff)
-
pages/Dashboard/pages/Weight/components/WeightProgressCard.jsx (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/pages/Dashboard/DashboardLayout.jsx
rb3d5fed ra8381b1 29 29 return ( 30 30 <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 31 39 <DashboardSidebar /> 40 32 41 <SidebarInset className="pt-16 lg:pt-0"> 33 42 <DashboardTopbar username={username} onLogout={onLogout} /> 43 34 44 <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> 37 51 </div> 38 52 </div> -
frontend/src/pages/Dashboard/components/DashboardSidebar.jsx
rb3d5fed ra8381b1 89 89 return ( 90 90 <Sidebar 91 className="py-6 "91 className="py-6 border-r border-white/10 bg-base-100/60 backdrop-blur-xl" 92 92 closeButtonClassName="fixed top-4 right-4" 93 93 collapsible="none" … … 114 114 className={ 115 115 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!" 118 118 } 119 119 > … … 156 156 className={ 157 157 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!" 160 160 } 161 161 > … … 185 185 <Button 186 186 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!" 188 188 type="button" 189 189 onClick={() => setCustomOpen(true)} -
frontend/src/pages/Dashboard/components/DashboardTopbar.jsx
rb3d5fed ra8381b1 10 10 <> 11 11 {/* 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-6lg: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"> 13 13 <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"> 14 14 <div className="flex items-center gap-3"></div> … … 18 18 <button 19 19 type="button" 20 className="btn btn-ghost btn-sm "20 className="btn btn-ghost btn-sm hover:bg-white/10 transition" 21 21 onClick={onLogout} 22 22 aria-label="Log out" … … 30 30 31 31 {/* 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-100px-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"> 33 33 <div className="flex items-center gap-4"> 34 34 <Link … … 44 44 <button 45 45 type="button" 46 className="btn btn-ghost btn-sm "46 className="btn btn-ghost btn-sm hover:bg-white/10 transition" 47 47 onClick={onLogout} 48 48 aria-label="Log out" -
frontend/src/pages/Dashboard/pages/ControlCenter/ControlCenter.jsx
rb3d5fed ra8381b1 1 import React from "react"; 2 3 const ControlCenter = () => { 1 import React, { useCallback, useEffect, useMemo, useState } from "react"; 2 3 import api from "../../../../api/axios"; 4 import { getFinanceStatus, getIncomes } from "../../../../api/finance"; 5 import { getDisciplineStatus } from "../../../../api/discipline"; 6 import { getDailyCompletions } from "../../../../api/dailyCompletion"; 7 8 import MultiPercentChangeAreaChart from "../../../../components/graphs/MultiPercentChangeAreaChart.jsx"; 9 import TimeRangeToggle from "../../../../components/graphs/TimeRangeToggle.jsx"; 10 import { percentChangeSeries, sumByTimeBucket } from "../../../../utils/timeSeries.js"; 11 12 import TrainingProgressCard from "../Training/components/TrainingProgressCard.jsx"; 13 import WeightProgressCard from "../Weight/components/WeightProgressCard.jsx"; 14 import FinanceProgressCard from "../Finance/components/FinanceProgressCard.jsx"; 15 import DisciplineProgressCard from "../Discipline/components/DisciplineProgressCard.jsx"; 16 import InvestingProgressCard from "../Investing/components/InvestingProgressCard.jsx"; 17 18 function Legend({ items, enabledKeys, onToggle, onShowAll, onHideAll }) { 19 if (!items?.length) return null; 20 4 21 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 })} 11 65 </div> 12 66 </div> 13 67 ); 14 }; 15 16 export default ControlCenter; 68 } 69 70 export 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 1 1 import React, { useCallback, useEffect, useMemo, useState } from "react"; 2 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";3 2 4 3 import { … … 16 15 17 16 import TaskList from "./components/TaskList.jsx"; 17 import DisciplineProgressCard from "./components/DisciplineProgressCard.jsx"; 18 18 19 19 export default function DisciplineTracking() { 20 20 const pageSize = 50; 21 21 const completionPageSize = 14; 22 const graphCompletionPageSize = 500; 22 23 23 24 const todayIso = useMemo(() => { … … 65 66 nextHasMore: Boolean(data?.hasMore), 66 67 }; 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; 67 87 }, []); 68 88 … … 96 116 setIsLoadingCompletions(true); 97 117 setCompletionError(""); 98 const { nextCompletions, nextHasMore } = await fetchCompletionsPage(0); 118 const [{ nextCompletions, nextHasMore }, allForGraph] = await Promise.all([ 119 fetchCompletionsPage(0), 120 fetchAllCompletionsForGraph(), 121 ]); 99 122 if (cancelled) return; 100 123 setDailyCompletions(nextCompletions); 124 setGraphCompletions(allForGraph); 101 125 setCompletionHasMore(nextHasMore); 102 126 setCompletionPage(0); … … 115 139 cancelled = true; 116 140 }; 117 }, [fetch CompletionsPage]);141 }, [fetchAllCompletionsForGraph, fetchCompletionsPage]); 118 142 119 143 const onLoadMoreCompletions = async () => { … … 240 264 setCompletionHasMore(nextHasMore); 241 265 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 } 242 276 } catch { 243 277 // ignore … … 316 350 ) : null} 317 351 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} /> 331 353 332 354 <div className="card bg-base-200 border border-base-300"> -
frontend/src/pages/Dashboard/pages/Finance/FinanceTracking.jsx
rb3d5fed ra8381b1 2 2 import { useNavigate } from "react-router-dom"; 3 3 4 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";5 4 import { 6 5 getFinanceProfile, … … 12 11 import FinanceIncomesTable from "./components/FinanceIncomesTable.jsx"; 13 12 import FinanceStartForm from "./components/FinanceStartForm.jsx"; 13 import FinanceProgressCard from "./components/FinanceProgressCard.jsx"; 14 14 15 15 function toNumber(value) { … … 21 21 const navigate = useNavigate(); 22 22 const pageSize = 5; 23 const graphPageSize = 500; 23 24 24 25 const [profile, setProfile] = useState(null); … … 76 77 }, []); 77 78 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 78 98 useEffect(() => { 79 99 let cancelled = false; … … 83 103 setError(""); 84 104 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 ]); 86 110 if (cancelled) return; 87 111 setProfile(p); … … 94 118 }); 95 119 setIncomes(inc.nextIncomes); 120 setGraphIncomes(allInc); 96 121 setHasMore(inc.nextHasMore); 97 122 setPage(0); … … 107 132 cancelled = true; 108 133 }; 109 }, [fetch Page]);134 }, [fetchAllIncomesForGraph, fetchPage]); 110 135 111 136 const onEdit = () => { … … 205 230 )} 206 231 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} /> 216 233 217 234 <FinanceIncomesTable -
frontend/src/pages/Dashboard/pages/Investing/InvestingTracking.jsx
rb3d5fed ra8381b1 3 3 4 4 import api from "../../../../api/axios"; 5 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";6 5 import { fetchCurrentPricesBySymbol } from "../../../../api/twelveData"; 7 6 … … 35 34 const navigate = useNavigate(); 36 35 const pageSize = 5; 36 const graphPageSize = 200; 37 37 38 38 const [assets, setAssets] = useState([]); … … 62 62 ); 63 63 64 const fetchPage = useCallback(async (nextPage ) => {64 const fetchPage = useCallback(async (nextPage, size = pageSize) => { 65 65 const resp = await api.get("/investing/assets", { 66 params: { page: nextPage, size : pageSize},66 params: { page: nextPage, size }, 67 67 }); 68 68 const data = resp?.data ?? {}; … … 72 72 }, []); 73 73 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 74 92 useEffect(() => { 75 93 let cancelled = false; … … 78 96 setIsLoading(true); 79 97 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); 84 106 setPage(0); 85 107 } catch (e) { … … 93 115 cancelled = true; 94 116 }; 95 }, [fetch Page]);117 }, [fetchAllAssetsForGraph, fetchPage]); 96 118 97 119 const tickerSymbols = useMemo(() => { … … 102 124 return Array.from(set).sort(); 103 125 }, [assets]); 126 127 const tickerSymbolsKey = useMemo(() => tickerSymbols.join(","), [tickerSymbols]); 104 128 105 129 useEffect(() => { … … 143 167 if (intervalId) clearInterval(intervalId); 144 168 }; 145 }, [tickerSymbols .join(",")]);169 }, [tickerSymbolsKey, tickerSymbols]); 146 170 147 171 const onLoadMore = async () => { … … 190 214 onAddAsset={() => navigate("/dashboard/investing/assets/new")} 191 215 /> 192 <InvestingProgressCard graphSrc={graphPlaceholder} />216 <InvestingProgressCard assets={graphAssets} /> 193 217 194 218 {quotesError ? ( -
frontend/src/pages/Dashboard/pages/Investing/components/InvestingProgressCard.jsx
rb3d5fed ra8381b1 1 import React from "react";1 import React, { useMemo, useState } from "react"; 2 2 3 export default function InvestingProgressCard({ graphSrc }) { 3 import TimeRangeToggle from "../../../../../components/graphs/TimeRangeToggle.jsx"; 4 import PercentChangeAreaChart from "../../../../../components/graphs/PercentChangeAreaChart.jsx"; 5 import { percentChangeSeries, sumByTimeBucket } from "../../../../../utils/timeSeries.js"; 6 7 export 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 4 27 return ( 5 28 <div className="card bg-base-200 border border-base-300"> 6 29 <div className="card-body"> 7 30 <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} /> 13 38 </div> 14 39 </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" 21 52 /> 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)}. 23 58 </div> 24 59 </div> -
frontend/src/pages/Dashboard/pages/Training/TrainingTracking.jsx
rb3d5fed ra8381b1 27 27 const navigate = useNavigate(); 28 28 const pageSize = 5; 29 const graphPageSize = 500; 29 30 30 31 const [sessions, setSessions] = useState([]); … … 45 46 ); 46 47 47 const fetchPage = useCallback(async (nextPage ) => {48 const fetchPage = useCallback(async (nextPage, size = pageSize) => { 48 49 const resp = await api.get("/training/sessions", { 49 params: { page: nextPage, size : pageSize},50 params: { page: nextPage, size }, 50 51 }); 51 52 const data = resp?.data ?? {}; … … 55 56 }, []); 56 57 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 57 77 useEffect(() => { 58 78 let cancelled = false; … … 61 81 setIsLoading(true); 62 82 setError(""); 63 const { nextSessions, nextHasMore } = await fetchPage(0); 83 const [tableFirstPage, allForGraph] = await Promise.all([ 84 fetchPage(0, pageSize), 85 fetchAllForGraph(), 86 ]); 64 87 if (cancelled) return; 65 setSessions(nextSessions); 66 setHasMore(nextHasMore); 88 setSessions(tableFirstPage.nextSessions); 89 setHasMore(tableFirstPage.nextHasMore); 90 setGraphSessions(allForGraph); 67 91 setPage(0); 68 92 } catch (e) { … … 78 102 cancelled = true; 79 103 }; 80 }, [fetch Page]);104 }, [fetchAllForGraph, fetchPage]); 81 105 82 106 const onLoadMore = async () => { … … 102 126 onAddSession={() => navigate("/dashboard/training/sessions/new")} 103 127 /> 104 <TrainingProgressCard sessions={ sessions} />128 <TrainingProgressCard sessions={graphSessions} /> 105 129 <TrainingSessionsTable 106 130 columns={columns} -
frontend/src/pages/Dashboard/pages/Weight/WeightTracking.jsx
rb3d5fed ra8381b1 3 3 4 4 import api from "../../../../api/axios"; 5 import graphPlaceholder from "../../../../assets/graph-placeholder.svg";6 5 7 6 import WeightProgressCard from "./components/WeightProgressCard.jsx"; … … 37 36 const navigate = useNavigate(); 38 37 const pageSize = 5; 38 const graphPageSize = 500; 39 39 40 40 const [profile, setProfile] = useState(null); … … 53 53 const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); 54 54 55 const fetchPage = useCallback(async (nextPage ) => {55 const fetchPage = useCallback(async (nextPage, size = pageSize) => { 56 56 const [profileRes, intakesRes, trainingRes] = await Promise.all([ 57 57 api.get("/weight/profile"), 58 58 api.get("/weight/intakes", { 59 params: { page: nextPage, size : pageSize},59 params: { page: nextPage, size }, 60 60 }), 61 61 api.get("/weight/today-training"), … … 69 69 }, []); 70 70 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 71 90 useEffect(() => { 72 91 let cancelled = false; … … 75 94 setIsLoading(true); 76 95 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 ]); 78 100 if (cancelled) return; 79 101 setProfile(profileData); 80 102 setIntakes(Array.isArray(intakesData.intakes) ? intakesData.intakes : []); 103 setGraphIntakes(allIntakes); 81 104 setHasMore(Boolean(intakesData.hasMore)); 82 105 setHasTodayIntake(Boolean(intakesData.hasTodayIntake)); … … 100 123 cancelled = true; 101 124 }; 102 }, [fetch Page, navigate]);125 }, [fetchAllIntakesForGraph, fetchPage, navigate]); 103 126 104 127 const onLoadMore = async () => { … … 213 236 /> 214 237 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 /> 216 247 217 248 <WeightIntakesTable -
frontend/src/pages/Dashboard/pages/Weight/components/WeightIntakesTable.jsx
rb3d5fed ra8381b1 136 136 137 137 <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> 138 <button139 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 intake145 </button>146 138 147 139 <button -
frontend/src/pages/Dashboard/pages/Weight/components/WeightProgressCard.jsx
rb3d5fed ra8381b1 1 import React from "react";1 import React, { useMemo, useState } from "react"; 2 2 3 export default function WeightProgressCard({ graphSrc }) { 3 import TimeRangeToggle from "../../../../../components/graphs/TimeRangeToggle.jsx"; 4 import PercentChangeAreaChart from "../../../../../components/graphs/PercentChangeAreaChart.jsx"; 5 import { percentChangeSeries, sumByTimeBucket } from "../../../../../utils/timeSeries.js"; 6 7 function 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 13 export 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 4 72 return ( 5 73 <div className="card bg-base-200 border border-base-300"> 6 74 <div className="card-body"> 7 75 <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} /> 13 83 </div> 14 <span className="badge badge-ghost">Graph placeholder</span>15 84 </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" 22 97 /> 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)}%. 24 109 </div> 25 110 </div>
Note:
See TracChangeset
for help on using the changeset viewer.
