Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
199099f
feat: add rebound and compression charts
JamesMoyles-intelliam Mar 9, 2026
fc7b23b
merge: resolve merge conflict
JamesMoyles-intelliam Mar 17, 2026
b96edd2
merge: resolve merge conflicts
JamesMoyles-intelliam Mar 17, 2026
c8ae035
feat(analysis): store suspension length alongside profiles, new proce…
JamesMoyles-intelliam Mar 22, 2026
bfb8cec
trim runs
eoinohal Apr 6, 2026
c6f93e0
feat(graphs): support changes to rebound and compression graphs
JamesMoyles-intelliam Apr 28, 2026
f8cb5fc
merge: resolve merge conflict
JamesMoyles-intelliam Apr 28, 2026
7275efa
trim runs
eoinohal Apr 28, 2026
ef7ab28
rebase trim feature
eoinohal Apr 28, 2026
07b8903
Small update
eoinohal Apr 28, 2026
424d7dc
Merge pull request #146 from wfelliss/131-feature-trim-runs-by-time
wfelliss Apr 28, 2026
fa698fc
chore(plots): remove the unused eslint skip comments
JamesMoyles-intelliam May 4, 2026
994e2d4
merge: merge from main
JamesMoyles-intelliam May 4, 2026
0f59779
feat: resolve merge conflicts
JamesMoyles-intelliam May 4, 2026
e6fea56
Merge pull request #154 from wfelliss/feat/reb-comp
rngf4 May 4, 2026
f19f9fc
compression rebound speed histogram
eoinohal May 13, 2026
8b3d670
fix
eoinohal May 13, 2026
4a95f44
Update apps/frontend/app/components/graphs/base/LineHistogram.tsx
eoinohal May 13, 2026
55dd0c3
review fixes
eoinohal May 13, 2026
2333f4d
Merge branch '129-feature-add-compression-vs-rebound-speed-chart' of …
eoinohal May 13, 2026
cf885e8
Merge pull request #155 from wfelliss/129-feature-add-compression-vs-…
wfelliss May 16, 2026
2a2c33c
feat: add cursor-pointer thoughtout where it is nessasarry
wfelliss May 16, 2026
a5dc259
fix: moved cursor-pointer to be in the tailwind.css file
wfelliss May 16, 2026
6d6db66
Merge pull request #164 from wfelliss/157-bug-trim-runs-buttons-do-no…
wfelliss May 16, 2026
7da2f00
refactor: move summary section to the top
wfelliss May 16, 2026
2522241
fix code review changes
wfelliss May 16, 2026
c33b8f5
Merge pull request #165 from wfelliss/130-refactor-summary-section-re…
wfelliss May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/backend/src/runs/dto/update-run.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// apps/backend/src/runs/dto/update-run.dto.ts
import { IsString, IsOptional, IsNumber } from 'class-validator';
import { IsString, IsOptional, IsNumber, IsInt, Min } from 'class-validator';

export class UpdateRunDto {
@IsOptional()
Expand All @@ -13,4 +13,14 @@ export class UpdateRunDto {
@IsOptional()
@IsString()
location?: string;

@IsOptional()
@IsInt()
@Min(0)
lower_bound_idx?: number;

@IsOptional()
@IsInt()
@Min(0)
upper_bound_idx?: number;
}
53 changes: 50 additions & 3 deletions apps/backend/src/runs/runs.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { RunsService } from './runs.service';

describe('RunsService', () => {
Expand Down Expand Up @@ -84,11 +84,11 @@ describe('RunsService', () => {
expect(res2).toBeNull();
});

it('createRun throws when run with srcPath exists', async () => {
it('createRun throws ConflictException when run with srcPath exists', async () => {
const data = { srcPath: 'x', length: 10 } as any;
jest.spyOn(service, 'findBySrcPath').mockResolvedValue({ id: 1 } as any);

await expect(service.createRun(data)).rejects.toThrow('Run with this srcPath already exists');
await expect(service.createRun(data)).rejects.toBeInstanceOf(ConflictException);
});

it('createRun inserts and returns created row', async () => {
Expand All @@ -104,6 +104,21 @@ describe('RunsService', () => {
expect(res).toEqual(inserted);
});

it('createRun defaults bounds to full run range when omitted', async () => {
const data = { srcPath: 'bounds-default', length: 10 } as any;
jest.spyOn(service, 'findBySrcPath').mockResolvedValue(null);
mockDb.insert().values().returning.mockResolvedValue([{ id: 7, ...data }]);

await service.createRun(data);

expect(mockDb.insert().values).toHaveBeenCalledWith(
expect.objectContaining({
lower_bound_idx: 0,
upper_bound_idx: 9,
}),
);
});

it('updateRun throws BadRequestException when updates empty', async () => {
await expect(service.updateRun(1, {} as any)).rejects.toBeInstanceOf(BadRequestException);
});
Expand All @@ -117,6 +132,38 @@ describe('RunsService', () => {
expect(res).toEqual(updated);
});

it('updateRun rejects invalid trim bounds', async () => {
mockDb.query.runs.findFirst.mockResolvedValue({
id: 1,
length: 10,
lower_bound_idx: 0,
upper_bound_idx: 9,
});

await expect(
service.updateRun(1, { lower_bound_idx: 8, upper_bound_idx: 4 } as any),
).rejects.toBeInstanceOf(BadRequestException);
});

it('updateRun allows valid trim bounds', async () => {
mockDb.query.runs.findFirst.mockResolvedValue({
id: 1,
length: 10,
lower_bound_idx: 0,
upper_bound_idx: 9,
});
mockDb.update().set().where().returning.mockResolvedValue([
{ id: 1, lower_bound_idx: 2, upper_bound_idx: 8 },
]);

const res = await service.updateRun(1, {
lower_bound_idx: 2,
upper_bound_idx: 8,
} as any);

expect(res).toEqual({ id: 1, lower_bound_idx: 2, upper_bound_idx: 8 });
});

it('updateRun returns null when no rows updated', async () => {
mockDb.update().set().where().returning.mockResolvedValue([]);

Expand Down
60 changes: 57 additions & 3 deletions apps/backend/src/runs/runs.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE_CONNECTION } from "../database/database.module";
import { runs } from "@repo/database";
Expand Down Expand Up @@ -39,19 +39,26 @@ export class RunsService {
date?: Date;
location?: string;
profile?: number;
lower_bound_idx?: number;
upper_bound_idx?: number;
front_freq?: number;
rear_freq?: number;
}) {
// enforce uniqueness at service level to avoid duplicate runs
const existing = await this.findBySrcPath(data.srcPath);
if (existing) {
throw new Error("Run with this srcPath already exists");
throw new ConflictException("Run with this srcPath already exists");
}

const resolvedLowerBound = data.lower_bound_idx ?? 0;
const resolvedUpperBound = data.upper_bound_idx ?? Math.max(data.length - 1, 0);

const inserted = await this.db
.insert(runs)
.values({
...data,
lower_bound_idx: resolvedLowerBound,
upper_bound_idx: resolvedUpperBound,
date: data.date ?? new Date(), // default to now if not provided
})
.returning();
Expand All @@ -60,11 +67,58 @@ export class RunsService {
}

// Partial update for a run (e.g., update comments)
async updateRun(id: number, updates: Partial<{ comments: string; length: number; location: string }>) {
async updateRun(
id: number,
updates: Partial<{
comments: string;
length: number;
location: string;
lower_bound_idx: number;
upper_bound_idx: number;
}>,
) {
if (Object.keys(updates).length === 0) {
throw new BadRequestException("No updates provided for the run.");
}

const hasBoundsUpdate =
updates.lower_bound_idx !== undefined ||
updates.upper_bound_idx !== undefined ||
updates.length !== undefined;

if (hasBoundsUpdate) {
const currentRun = await this.getRunById(id);
if (!currentRun) {
return null;
}

const effectiveLength = updates.length ?? currentRun.length;
const effectiveLowerBound =
updates.lower_bound_idx ?? currentRun.lower_bound_idx ?? 0;
const effectiveUpperBound =
updates.upper_bound_idx ?? currentRun.upper_bound_idx ?? Math.max(effectiveLength - 1, 0);

if (!Number.isInteger(effectiveLowerBound) || !Number.isInteger(effectiveUpperBound)) {
throw new BadRequestException("Trim bounds must be integers.");
}

if (effectiveLength <= 0) {
throw new BadRequestException("Run length must be greater than zero.");
}

if (effectiveLowerBound < 0) {
throw new BadRequestException("lower_bound_idx must be greater than or equal to zero.");
}

if (effectiveUpperBound < effectiveLowerBound) {
throw new BadRequestException("upper_bound_idx must be greater than or equal to lower_bound_idx.");
}

if (effectiveUpperBound >= effectiveLength) {
throw new BadRequestException("upper_bound_idx must be less than run length.");
}
}

const updated = await this.db
.update(runs)
.set(updates)
Expand Down
10 changes: 9 additions & 1 deletion apps/frontend/app/api/runs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { apiClient } from "./client";
import { Run } from "@repo/database";

export interface RunUpdatePayload {
comments?: string;
length?: number;
location?: string;
lower_bound_idx?: number;
upper_bound_idx?: number;
}

export function getRuns() {
return apiClient.get<Run[]>("/runs");
}
Expand All @@ -9,6 +17,6 @@ export function getRunById(id: number) {
return apiClient.get<Run>(`/runs/${id}`);
}

export function updateRun(id: number, payload: Partial<Pick<Run, 'comments' | 'length' | 'location'>>) {
export function updateRun(id: number, payload: RunUpdatePayload) {
return apiClient.patch(`/runs/${id}`, payload);
}
14 changes: 8 additions & 6 deletions apps/frontend/app/components/graphs/base/Histogram.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import * as d3 from 'd3';
import { html } from 'd3';
import { getSeriesColor } from '../../../lib/graphColors';

export interface HistogramBin {
Expand Down Expand Up @@ -53,8 +52,8 @@ export const Histogram: React.FC<HistogramProps> = ({
}, []);

// Main D3 rendering
const finalSeries: HistogramSeries[] = useMemo(
() =>
const finalSeries: HistogramSeries[] = useMemo(() =>
// If series prop provided map each series item
series && series.length > 0
? series
.filter((s) => Array.isArray(s.data))
Expand All @@ -68,14 +67,17 @@ export const Histogram: React.FC<HistogramProps> = ({
);

useEffect(() => {
// Missing container guard
if (!containerRef.current || width === 0) return;

// Mising data guard
const hasData = finalSeries.some((s) => s.data.length > 0);
if (!hasData) {
d3.select(containerRef.current).selectAll("svg").remove();
return;
}

// Dimensions
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
Expand All @@ -89,15 +91,13 @@ export const Histogram: React.FC<HistogramProps> = ({
.thresholds(binCount);
const binsBySeries = finalSeries.map((s) => binGenerator(s.data));

if (binsBySeries.length === 0 || binsBySeries[0]?.length === 0) return;

const firstSeriesBins = binsBySeries[0];
if (!firstSeriesBins) return;
const firstBin = firstSeriesBins[0];
const lastBin = firstSeriesBins[firstSeriesBins.length - 1];
if (!firstBin || !lastBin || firstBin.x0 == null || lastBin.x1 == null) return;

// Build scales for bars and axes
// Scales
const x = d3
.scaleLinear()
.domain([firstBin.x0, lastBin.x1])
Expand All @@ -116,6 +116,7 @@ export const Histogram: React.FC<HistogramProps> = ({
.domain([0, yMax])
.range([innerHeight, 0]);

// SVG setup
const svg = d3
.select(containerRef.current)
.selectAll<SVGSVGElement, null>("svg")
Expand Down Expand Up @@ -147,6 +148,7 @@ export const Histogram: React.FC<HistogramProps> = ({
label: string;
};

// Prepare data for rendering
const seriesCount = Math.max(1, finalSeries.length);
const bars: RenderBar[] = binsBySeries.flatMap((seriesBins, seriesIndex) => {
const seriesConfig = finalSeries[seriesIndex];
Expand Down
Loading
Loading