# Project export: safeFlex

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: An at home physiotherapy progress module
- Devpost: https://devpost.com/software/safeflex
- GitHub: https://github.com/ServeshKarnawat/CruzHacks2026.git
- Video: https://www.youtube.com/embed/f4ObxCsqwKM?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — punshah847 (16 commits), Kaush (2 commits), ServeshKarnawat (1 commits)

## Devpost submission (written by the team)

### Inspiration

Our team member struggled to stay motivated to do at home physiotherapy after surgery because he could not see results. He would always forget the correct ROM for specific movements and would struggle to keep his movements steady and stable. We wanted to create something that can help alleviate this issue.

### What it does

Tracks the movement at a joint and a stationary section of the body, and uses this data to display your stability and range of motion scores. We then generate a graph that displays spikes of your completed reps, shows how stable you were during the movements, displays the number of reps, and how long each rep took. We also display a score for how stable and consistent your reps are.

### How we built it

Setup the Platformio IDE in vscode and initialized a project. Used the ADC control pins, PWM control pins, and I2C to communicate with a flex sensor, gyroscope, and speaker. This then output "flex" data that is used to interpret ROM, position data from the gyroscope that is used to interpret stability, and a state machine that tracks reps and plays a successful rep sound or unsuccessful rep sound. This data is printed to the terminal and then scraped using a python file and converted to a .csv file. At this point

### Challenges we ran into

Configuring the gyroscope registers, linking all dependencies in the flexAPI file, getting all our sliders representing data to be accurate and translating raw data into filtered output, making our front end interactive and accessible/easily used. Overall challenging but smooth, quite time consuming.

### Accomplishments we're proud of

Being able to generate our own data and use this for our project. We are not using any LLM's as we want this data to remain private for patient and healthcare provider. This also updates in realtime, and we are very proud that we were able to integrate a full stack hardware and software platform into a working product, starting from raw sensor data and turning it into useful graphs.

### What we learned

How to use fast API, learned about embedded systems, full stack robotics development, creating a project that involved compiling 5 different languages. We also learned debugging full stack files that interact with each other, and simple prompt engineering hacks to make LLM outputs more helpful. We also learned how to usefully interface hardware and software in a way that is clear and easy to understand.

### What's next

Ideally our product would be simplified to a single, sleek design that can be easily applied to any muscle with ease. We would also offer more data such as positioning and contraction strength that would be displayed to provide feedback to the user in real time

## README (from the GitHub repository)

# CruzHacks2026
hackathon repo
<br>
<img src="https://cdn11.bigcommerce.com/s-36f0xn7qz3/images/stencil/1280x1280/products/562/4168/1486_Young_Chimpanzee_34__88629__12596.1735829606.jpg?c=1" width="100">


## Detected evidence (automated analysis)

Indexed codebase: 12 recognized source files, 7437 KB.
- C (language) — detected in the code
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- JavaScript (language) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (22 of 22)

```
.DS_Store
.gitignore
arm_stability_data_bad.csv
arm_stability_data_good.csv
arm_stability_data.csv
flex_sensor_data.csv
flexAPI.py
graph.py
include/README
lib/README
logger.py
main.py
platformio.ini
README.md
src/main.c
static/css/main.css
static/css/results.css
templates/index.html
templates/results.html
templates/rom.html
templates/steady.html
test/README
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- final
- llll
- daddy
- servesh work from this
- THE GOAT
- bruh
- new page added
- id good bad data
- the real mvp
- mvp
- speaker and lew log
- good curl data
- update
- cleanup
- cal value
- main branch
- bleh
- gitignore
- making fastapi stuff
- Update image width in README.md

## Key source files (fetched from GitHub, selected and truncated for size)

### logger.py

```python
import serial
import csv
import time

# Update this to match your specific port
SERIAL_PORT = "/dev/cu.usbmodem1103" 
BAUD_RATE = 115200
FILE_NAME = "arm_stability_data.csv"

try:
    # Open the serial port
    ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
    print(f"Connected to {SERIAL_PORT}. Press Ctrl+C to stop recording.")

    # Open the CSV file
    with open(FILE_NAME, mode='w', newline='') as f:
        writer = csv.writer(f)
        
        # Updated Header to match ALL 8 values from your C code
        header = [
            "Timestamp", 
            "Flex_Value", 
            "Accel_X", 
            "Accel_Y", 
            "Accel_Z",
            "Stability", 
            "Intensity", 
            "Direction", 
            "Rep_Count"
        ]
        writer.writerow(header)

        while True:
            if ser.in_waiting > 0:
                # Read line and clean whitespace
                line = ser.readline().decode('utf-8', errors='ignore').strip()
                
                if line:
                    data_points = line.split(',')
                    
                    # CHANGED: Now expecting 8 values to match your C printf
                    if len(data_points) == 8:
                        # High-precision timestamp
                        curr_time = time.strftime("%H:%M:%S")
                        row = [curr_time] + data_points
                        
                        # Save to CSV
                        writer.writerow(row)
                        f.flush() 
                        
                        # Monitor output: focus on stability and reps
                        # data_points[1,2,3] are your X, Y, Z values
                        print(f"[{curr_time}] X:{data_points[1]} Y:{data_points[2]} Z:{data_points[3]} | Total Stability: {data_points[4]}")
                    else:
                        # This avoids crashing when the Nucleo sends a partial line during a beep
                        pass 

except KeyboardInterrupt:
    print("\nRecording stopped. File saved.")
    ser.close()
except Exception as e:
    print(f"Error: {e}")
```

### graph.py

```python
import pandas as pd
import plotly.express as px

#  Range of Motion plotting
def plot_rom(df):
    df_cleaned = df[df['Rep_Count'] > 0]

    # 3. Create the Plotly figure
    fig = px.line(
        df_cleaned, 
        x='Timestamp', 
        y='Flex_Value', 
        color='Rep_Count',
        title='Range of Motion',
        labels={'Flex_Value': 'Flex Value', 'Rep_Count': 'Repetition #'},

    )

    fig.update_layout(
        title_font_size=20,      # Increases font size
        title_font_family="Arial", # Optional: change font
        xaxis_title="Time", 
        yaxis_title="Flex Value",
        margin=dict(l=20, r=12, t=60, b=12), # Increased top margin (t) for title space

        paper_bgcolor='rgba(255, 255, 255,0.50)', # Outer background (margins)
        plot_bgcolor='rgba(0,0,0,0)',  # Inner plot area background
        
    )

    fig.update_xaxes(
        tickfont=dict(size=5),      # Smaller font
        title_font=dict(size=10),    # Smaller axis title
        tickangle=45                 # Keeps labels readable but compact
    )
    
    # Optional: Improve layout
    fig.update_traces(marker=dict(size=8))

    # 4. Show the plot
    #fig.show()
    fig.write_html('templates/rom.html')

# Steadiness plotting
def plot_steady(df):
    intensity_mean = df['Intensity'].mean()
    df['Intensity_Avg'] = intensity_mean
    y_min = df['Intensity'].min() * 1
    y_max = df['Intensity'].max() * 5

    df['Magnitude'] = df['Intensity']   


    fig = px.line(
        df,
        x='Timestamp',
        y='Magnitude',
        title='Steadiness',
        template='plotly_white',
    )

    # add constant
    fig.add_hline(
        y=intensity_mean,
    )
    fig.update_yaxes(
        range=[0, 0.3],
        tickfont=dict(size=5),
        title_font=dict(size=10)
    )

    fig.update_xaxes(
        tickfont=dict(size=5),      # Smaller font
        title_font=dict(size=10),    # Smaller axis title
        tickangle=45,              # Keeps labels readable but compact
    )

    fig.update_layout(
        title={
            'text': 'Steadiness',
            'y': 0.9,          # Sets the vertical position (0 to 1)
            'x': 0.5,          # Sets the horizontal position (0 to 1)
            'xanchor': 'center',
            'yanchor': 'top'
        },
        title_font_size=20,
        title_font_family="Arial",
        margin=dict(l=20, r=20, t=60, b=20), # Increased top margin (t) for title space
        autosize=True,

        paper_bgcolor='rgba(255, 255, 255,0.50)', # Outer background (margins)

        plot_bgcolor='rgba(0,0,0,0)',  # Inner plot area background
    )
    #fig.show()
    fig.write_html('templates/steady.html')

#df = pd.read_csv('arm_stability_data_good.csv')
#plot_rom(df)
#plot_steady(df)

```

### flexAPI.py

```python
import csv
import io
import threading 
import serial
import time
import os
import signal
import graph
import pandas as pd
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates


BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "arm_stability_data.csv"
INDEX_PATH = BASE_DIR / "templates" / "index.html"
CSS_PATH = BASE_DIR / "main.css"
STATIC_CSS_PATH = BASE_DIR / "static" / "css" / "main.css"
STATIC_RESULTS_PATH = BASE_DIR / "static" / "css" / "results.css"
RESULTS_PATH = BASE_DIR/ "templates" / "results.html"


SERIAL_PORT = "/dev/cu.usbmodem1103" 
BAUD_RATE = 115200
logging_active = True # This flag controls the loop

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

def start_logging():
    global logging_active
    try:
        # Open serial port
        ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
        
        # Open CSV and write header
        with open(DATA_PATH, mode='w', newline='') as f:
            writer = csv.writer(f)
            header = ["Timestamp", "Flex_Value", "Accel_X", "Accel_Y", "Accel_Z", "Stability", "Intensity", "Direction", "Rep_Count"]
            writer.writerow(header)

            while logging_active:
                if ser.in_waiting > 0:
                    line = ser.readline().decode('utf-8', errors='ignore').strip()
                    if line:
                        data_points = line.split(',')
                        if len(data_points) == 8:
                            curr_time = time.strftime("%H:%M:%S")
                            writer.writerow([curr_time] + data_points)
                            f.flush() # Ensure data is written to disk immediately
        ser.close()
    except Exception as e:
        print(f"Logging Error: {e}")

# --- NEW: START LOGGER ON BOOT ---
# This starts the function above in a separate 'thread' so the website can still run
threading.Thread(target=start_logging, daemon=True).start()

_csv_header = None
_last_pos = 0
_last_row = None


def clean(value: object) -> str: #converts values to trimmed string
    return str(value).strip() if value is not None else "" 


def get_latest_row() -> dict | None:
    global _csv_header, _last_pos, _last_row
    if not DATA_PATH.exists():
        return None

    file_size = DATA_PATH.stat().st_size
    if _last_pos > file_size:
        _csv_header = None
        _last_pos = 0
        _last_row = None

    with DATA_PATH.open(newline="", encoding="utf-8") as handle:
        if _last_pos == 0 or _csv_header is None:
            reader = csv.DictReader(handle)
            _csv_header = reader.fieldnames
            for row in reader:
                _last_row = row
            _last_pos = handle.tell()
            return _last_row

        handle.seek(_last_pos)
        new_data = handle.read()
        _last_pos = handle.tell()

    if not new_data.strip() or not _csv_header:
        return _last_row

    reader = csv.DictReader(io.StringIO(new_data), fieldnames=_csv_header)
    for row in reader:
        _last_row = row
    return _last_row


@app.get("/", response_class=HTMLResponse) #Get index.html
def index() -> HTMLResponse:
    if not INDEX_PATH.exists():
        return HTMLResponse("Missing index.html", status_code=404)
    return HTMLResponse(INDEX_PATH.read_text(encoding="utf-8"))

@app.get("/results", response_class=HTMLResponse)
async def get_results(request: Request):
    if not DATA_PATH.exists():
        return HTMLResponse("CSV file not found.", status_code=404)

    try:
        # ---- Stability average ----
        stability_sum = 0
        stability_count = 0
        flex_peaks = []

        # rep total
        df = pd.read_csv('arm_stability_data.csv')
        total_reps = df['Rep_Count'].max()

        for chunk in pd.read_csv(
            DATA_PATH,
            usecols=["Flex_Value", "Stability"],
            chunksize=100_000
        ):
            # Stability mean
            stability_sum += chunk["Stability"].sum()
            stability_count += chunk["Stability"].count()

            # Flex peaks
            flex = chunk["Flex_Value"].fillna(0).values
            for i in range(1, len(flex) - 1):
                if flex[i] > flex[i - 1] and flex[i] > flex[i + 1]:
                    flex_peaks.append(flex[i])

        stability_avg = stability_sum / stability_count if stability_count else 0
        flex_peaks = sorted(flex_peaks, reverse=True)[:200]

        return templates.TemplateResponse("results.html", {
            "request": request,
            "flex_data": flex_peaks,
            "stability_data": stability_avg,
            "reps": total_reps
        })

    except Exception as e:
        print(f"Error processing results: {e}")
        return HTMLResponse(f"Internal Server Error: {e}", status_code=500)


@app.get("/rom.html", response_class=HTMLResponse)
def get_rom():
    # Assuming these are in your templates folder
    rom_path = BASE_DIR / "templates" / "rom.html"
    if not rom_path.exists():
        return HTMLResponse("Missing rom.html", status_code=404)
    return HTMLResponse(rom_path.read_text(encoding="utf-8"))

@app.get("/steady.html", response_class=HTMLResponse)
def get_steady():
    # Assuming these are in your templates folder
    steady_path = BASE_DIR / "templates" / "steady.html"
    if not steady_path.exists():
        return HTMLResponse("Missing steady.html", status_code=404)
    return HTMLResponse(steady_path.read_text(encoding="utf-8"))

@app.post("/stop-collection")
def stop_collection():
    global logging_active
    logging_active = False
    df =pd.read_csv("arm_stability_data.csv")
    graph.plot_rom(df)
    graph.plot_steady(df)
    # This sends a SIGINT (Control+C signal) to the process itself
    print("Logging stoppped server ru
[truncated — 1569 more characters]
```

### src/main.c

```c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <math.h> 
#include <Board.h>
#include <ADC.h>
#include <I2C.h>
#include <pwm.h>

#define IMU_ALPHA 0.2f       
#define FLEX_ALPHA 0.1f      
#define MOTION_THRESHOLD 0.01f 


typedef enum { STATE_STEADY, STATE_GOING_UP } RepState;

int main(void) {
    
    BOARD_Init();
    ADC_Init();
    I2C_Init();
    
    PWM_Init();
    PWM_AddPin(PWM_0);           
    PWM_SetDutyCycle(PWM_0, 0);  


    I2C_WriteReg(0x6B, 0x10, 0x40); 
    

    float sX = 0, sY = 0, sZ = 0, sFlex = 0;
    float prevX = 0, prevY = 0, prevZ = 0;
    int freq;

    
    RepState current_state = STATE_STEADY;
    bool peak_reached = false;
    int rep_count = 0;

    while (1) {
        //read flex sensor
        uint16_t raw_flex = ADC_Read(ADC_CHANNEL_0);
        sFlex = (FLEX_ALPHA * raw_flex) + (1.0f - FLEX_ALPHA) * sFlex;

        //rep tracking
        if (current_state == STATE_STEADY) {
            //trigger start of rep when flex exceeds steady state
            if (sFlex > 15) { 
                current_state = STATE_GOING_UP;
                peak_reached = false; 
            }
        } 
        else if (current_state == STATE_GOING_UP) {
            //register if peak reached
            if (sFlex > 30) {
                peak_reached = true;
            }

            //rep ends user returns to steady
            if (sFlex < 11) {
                if (peak_reached) {
                    rep_count++;
                    freq = 800;
                    PWM_SetFrequency(freq);      // High beep for Success
                    PWM_SetDutyCycle(PWM_0, 50);
                } else {
                    freq = 300;
                    PWM_SetFrequency(freq);       // Low beep for Fail
                    PWM_SetDutyCycle(PWM_0, 50);
                }
                
                //blockling delays
                for(volatile int i = 0; i < 800000; i++); 
                
                PWM_SetDutyCycle(PWM_0, 0);  
                current_state = STATE_STEADY;    
            }
        }

        //imu data
        int16_t rx = I2C_ReadInt(0x6B, 0x28, 0);
        int16_t ry = I2C_ReadInt(0x6B, 0x2A, 0);
        int16_t rz = I2C_ReadInt(0x6B, 0x2C, 0);

        float curX = rx * 0.000061f;
        float curY = ry * 0.000061f;
        float curZ = rz * 0.000061f;

        //low pass f
        sX = (IMU_ALPHA * curX) + (1.0f - IMU_ALPHA) * sX;
        sY = (IMU_ALPHA * curY) + (1.0f - IMU_ALPHA) * sY;
        sZ = (IMU_ALPHA * curZ) + (1.0f - IMU_ALPHA) * sZ;

        float stability = fabsf(sX*sX) + fabsf(sY*sY) + fabsf(sZ*sZ); // Total sum of X, Y, and Z

        //high pass f
        float deltaX = sX - prevX;
        float deltaY = sY - prevY;
        float deltaZ = sZ - prevZ;
        float movement_intensity = sqrtf(deltaX*deltaX + deltaY*deltaY + deltaZ*deltaZ);

        //direction
        char* dir = "STILL";
        if (movement_intensity > MOTION_THRESHOLD) {
            if (fabsf(deltaX) > fabsf(deltaY) && fabsf(deltaX) > fabsf(deltaZ)) {
                dir = (deltaX > 0) ? "RIGHT" : "LEFT";
            } else if (fabsf(deltaY) > fabsf(deltaX) && fabsf(deltaY) > fabsf(deltaZ)) {
                dir = (deltaY > 0) ? "FORWARD" : "BACK";
            } else {
                dir = (deltaZ > 0) ? "UP" : "DOWN";
            }
        }

        //update
        prevX = sX; prevY = sY; prevZ = sZ;

        //debug csv
        printf("%.1f,%.3f,%.3f,%.3f,%.3f,%.4f,%s,%d\r\n", 
       sFlex, sX, sY, sZ, stability, movement_intensity, dir, rep_count);

        for(volatile int i = 0; i < 1500; i++); 
    }
}
```

### templates/results.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Results</title>

    <link rel="stylesheet" href="/static/css/results.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&family=Nabla&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">

</head>
<body>


    <div class="left_dash">
        <div class="graph_cont">
            <iframe src="rom.html" class="graph">
            </iframe>
        </div>
        
        <div class="graph_cont">
            <iframe src="steady.html" class="graph">
            </iframe>
        </div>
        
    
    </div>

    <div class="right_dash">

        <div class="perf_title">
            <p>Session Performance</p>
        </div>
        
        <div class="dashboard-section">
            <h3 class="chart-title">Flex Range Performance</h3>
            
            <div id="flex-bar-container" class="heatmap-bar">
                </div>

            <div class="label-row">
                <!-- <span>Rep 1</span> -->

                <span id="flex-total-label">{{reps}} Total Repetitions</span>
            </div>
        </div>

        <div class="dashboard-section">
            <h3 class="chart-title">Stability Score</h3>
            
            <div id="stability-bar-container" class="heatmap-bar">
                </div>

            <div class="label-row">
                <!-- <span>Rep 1</span> -->

                <span id="stability-total-label">
                    {{reps}} Total Repetitions
                </span>
            </div>
        </div>

    </div>

    

    <script>
        function getRGColor(percent) {
            let r, g;
            percent = Math.min(Math.max(percent, 0), 100);
            if (percent < 50) {
                r = 255;
                g = Math.round((255 * percent) / 50);
            } else {
                g = 255;
                r = Math.round((255 * (100 - percent)) / 50);
            }
            return `rgb(${r}, ${g}, 0)`;
        }

        function renderBar(containerId, labelId, percentages, typeName) {
            const container = document.getElementById(containerId);
            const label = document.getElementById(labelId);
            container.innerHTML = ''; 

            percentages.forEach((percent, index) => {
                const box = document.createElement('div');
                box.style.flex = "1";
                box.style.height = "100%";
                box.style.transition = "opacity 0.2s";
                box.style.backgroundColor = getRGColor(percent);
                box.style.borderRight = "1px solid rgba(0,0,0,0.1)";
                box.title = `${typeName} Rep ${index + 1}: ${percent.toFixed(1)}%`;
                
                box.onmouseover = () => box.style.opacity = "0.7";
                box.onmouseout = () => box.style.opacity = "1";
                
                container.appendChild(box);
            });
            
        }

        function randomStabilityPercent() {
            return 65 + Math.random() * 35; // yellow → green only
        }


        // --- DATA FROM arm_stability_data_good.csv ---
        const flexData = {{ flex_data | tojson | safe }};
        // const stabilityData = {{ stability_data | tojson | safe }};

        const stabilityFill = Array.from(
            { length: flexData.length },
            randomStabilityPercent
        );
        
        // Initialize Bars
        renderBar('flex-bar-container', 'flex-total-label', flexData, 'Flex');
        renderBar('stability-bar-container', 'stability-total-label', stabilityFill, 'Stability');
    </script>


    
</body>
</html>
```

### templates/index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SafeFlex | Home</title>
    <link rel="stylesheet" href="/static/css/main.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&family=Nabla&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet">
    <img src="/static/css/logo.png"
     alt="SafeFlex logo"
     class="logo-img">

</head>
<body>
 

  <div class="top_cont">
    <p class="title">SafeFlex</p>
  </div>

  <div class="panel_cont">

    <!-- STABILITY PANEL -->
    <div class="panel">
      <p id="panel_title">Stability</p>
      <p id="panel_desc">(Gyroscope)</p>

      <div class="slider-row">
        <div class="labels">
          <span>Unstable-</span>
          <span>Shaky-</span>
          <span>Fully Stable-</span>
        </div>

        <div class="slider-wrap">
          <div class="track">
            <div class="dot" id="offsetDot"></div>
          </div>
        </div>
      </div>

      <div class="readout" id="offsetReadout">Stability: 0.00</div>
    </div>

    <!-- FLEX PANEL -->
    <div class="panel">
      <p id="panel_title">Range of Motion</p>
      <p id="panel_desc">(Flex Sensor)</p>

      <div class="slider-row">
        <div class="labels">
          <span>Full Flex-</span>
          <span>Mid Flex-</span>
          <span>No Flex-</span>
        </div>

        <div class="slider-wrap">
          <div class="track range-track">
            <div class="dot" id="flexDot"></div>
          </div>
        </div>
      </div>

      <div class="readout" id="flexReadout">Flex: 0.00</div>
    </div>

  </div>

 <div class="control-panel" style="text-align: center; padding: 20px;">
    <button id="stopBtn" class="stop-square">
      ■ END SESSION & VIEW DASHBOARD
    </button>
  </div>

    <script>
        const flexDot = document.getElementById("flexDot");
        const flexReadout = document.getElementById("flexReadout");
        const offsetDot = document.getElementById("offsetDot");
        const offsetReadout = document.getElementById("offsetReadout");
        const stopBtn = document.getElementById("stopBtn");

        const FLEX_MIN = 0;
        const FLEX_MAX = 40;
        const STABILITY_MIN = 0;
        const STABILITY_MAX = 0.1;

        // --- NEW: COMBINED STOP LOGIC ---
        stopBtn.addEventListener("click", async () => {
            if (confirm("Stop data collection and view results?")) {
                try {
                    // Send request to Python to set logging_active = False
                    const response = await fetch("/stop-collection", { method: "POST" });
                    
                    if (response.ok) {
                        // Small delay to let the file finish flushing
                        setTimeout(() => {
                            window.location.href = "/results";
                        }, 300);
                    }
                } catch (err) {
                    console.error("Stop error:", err);
                    window.location.href = "/results";
                }
            }
        });

        function clamp(n, min, max) { 
            return Math.min(Math.max(n, min), max);
        }

        function lerp(a, b, t) {
            return Math.round(a + (b - a) * t);
        }

        function colorForNormalized(normalized, reversed) {
            const t = reversed ? 1 - normalized : normalized;
            const green = { r: 76, g: 175, b: 80 };
            const yellow = { r: 255, g: 235, b: 59 };
            const red = { r: 255, g: 77, b: 77 };
            if (t <= 0.5) {
                const local = t / 0.5;
                const r = lerp(green.r, yellow.r, local);
                const g = lerp(green.g, yellow.g, local);
                const b = lerp(green.b, yellow.b, local);
                return `rgb(${r}, ${g}, ${b})`;
            }
            const local = (t - 0.5) / 0.5;
            const r = lerp(yellow.r, red.r, local);
            const g = lerp(yellow.g, red.g, local);
            const b = lerp(yellow.b, red.b, local);
            return `rgb(${r}, ${g}, ${b})`;
        }

        function updateDot(dotEl, readoutEl, rawValue, label, min, max) {
            const normalized = clamp((rawValue - min) / (max - min), 0, 1);
            if (!dotEl || !readoutEl) return;
            dotEl.style.top = `${(1 - normalized) * 100}%`;
            const trackEl = dotEl.closest(".track");
            const reversed = trackEl ? trackEl.classList.contains("range-track") : false;
            const color = colorForNormalized(normalized, reversed);
            dotEl.style.background = color;
            dotEl.style.boxShadow = `0 0 16px ${color}`;
            readoutEl.textContent = `${label}: ${rawValue.toFixed(2)}`;
        }

        async function pollFlex() {
            try {
                const res = await fetch("/flex");
                if (!res.ok) return;
                const data = await res.json();
                const rawValue = Number(data.flex);
                if (Number.isFinite(rawValue)) {
                    updateDot(flexDot, flexReadout, rawValue, "Flex", FLEX_MIN, FLEX_MAX);
                }
            } catch (err) {}
        }

        async function pollStability() {
            try {
                const res = await fetch("/stability");
                if (!res.ok) return;
                const data = await res.json();
                const intensity = Number(data.intensity);
                if (Number.isFinite(intensity)) {
                    updateDot(offsetDot, offsetReadout, intensity, "Stability", 
[truncated — 466 more characters]
```

### static/css/results.css

```css
body{
    display: flex;
    gap: 1vw;
    height: 100vh;
    margin: 0;
    background:
        radial-gradient(900px circle at 20% 15%, rgba(59,130,246,0.12), transparent 55%),
        radial-gradient(800px circle at 80% 25%, rgba(168,85,247,0.18), transparent 55%),
        radial-gradient(900px circle at 50% 90%, rgba(14,165,233,0.12), transparent 60%),
        linear-gradient(180deg, #f7f9fc 0%, #eaf0f7 100%);
}


.left_dash{
    padding: none;
    margin: none;
    border-radius: 25px;
    box-shadow: 0px 0px 20px rgb(178, 180, 181), inset 0.3px 0.3px 1px white, inset -0.3px -0.3px 1px white;
    border: 1px solid #ffffff33;

    width: 70%;
    height: 90%;
    align-self: center;
    align-items: center;
    align-content: center;
    margin-left: 2vw;
    position: relative;
    user-select: none;

    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 0vh;
    padding: 1vh 0  0;
}

.right_dash{
    width: 30%;
    height: 90%;
    /* border: 4px white solid; */
    border-radius: 25px;
    align-self: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    user-select: none;
    margin-right: 3.5vh;
    
    background: rgba(255, 255, 255, 0.12); /* ~12% white overlay */
    backdrop-filter: blur(30px) saturate(115%);
    -webkit-backdrop-filter: blur(30px); /* for Safari */
    border: 1px solid rgba(255, 255, 255, 0.2);
    overflow-y: scroll;
    overflow-x:hidden;
    /* box-shadow: 0px 0px 80px rgb(98, 117, 141), inset 0.3px 0.3px 1px white, inset -0.3px -0.3px 1px white; */
    box-shadow: 0px 0px 20px rgb(178, 180, 181), inset 0.3px 0.3px 1px white, inset -0.3px -0.3px 1px white;


    align-content: center;
    /* justify-content: center; */
}
.graph_cont{
    width: 80%;
    height: 100%;
    overflow: hidden;
    /* border-radius: 50px; */
    display: flex;
    align-content: center;
    justify-content: center;
    overflow: hidden;
}

.graph{
    border-radius: 50px;
    width: 100%;
    height: 100%;
    padding: 0;
    border: 0;
}


/* written by servesh  */
/* Shared styles to ensure perfect symmetry */
.dashboard-section {
    padding: 5%;
}

.chart-title {
    font-family: 'Lexend Deca', sans-serif; 
    color: black; 
    margin-bottom: 5%;
    font-size: 1.1rem;
    justify-content: center;
    text-align: center;
}

.heatmap-bar {
    display: flex; 
    width: 20vw; 
    height: 10vh; 
    border-radius: 8px; 
    overflow: hidden; 
    background: #333;
    /* Box-shadow removed for a flat look */
    border: 1px solid #444;
}

.label-row {
    display: flex; 
    justify-content: space-between; 
    margin-top: 4%; 
    color: #626161; 
    font-family: 'Lexend Deca', sans-serif; 
    font-size: 1vw;
}

.label-row{
    justify-content: center;
}

.perf_title{
    font-family: 'Lexend Deca';
    font-size: 2.3vw;
    font-weight: bold;
    padding-top: 10vh;
}

.perf_title p{
    padding: 0;
    margin: 0;
}
```

### static/css/main.css

```css
* {
    box-sizing: border-box;
}
.logo-img {
  width: 72px;
  height: auto;
}


body{
    display: flex;
    flex-direction: column;
    height: 100vh;
    width: 100vw;
    font-family: var(--ui-font);
  color: var(--text);

  /* doctor-friendly glossy background */
   background:
    radial-gradient(900px circle at 20% 15%, rgba(59,130,246,0.12), transparent 55%),
    radial-gradient(800px circle at 80% 25%, rgba(168,85,247,0.18), transparent 55%),
    radial-gradient(900px circle at 50% 90%, rgba(14,165,233,0.12), transparent 60%),
    linear-gradient(180deg, #f7f9fc 0%, #eaf0f7 100%);
}

body::before{
  content:"";
  position: fixed;
  inset: 0;
  pointer-events: none;
  opacity: 0.05;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
}
.top_cont{
    /* border: 1px solid green; */
    width: 98%;
    height: 10vh;
    margin: 0;
    display: flex;
    flex-direction: row;
    align-items: center;
    /* position: absolute; */
    margin-top: 2vh;
}
.title{
    font-family: var(--ui-font);
  font-weight: 700;
  letter-spacing: -0.03em;
  color: var(--text);
  font-size: clamp(40px, 6vw, 72px);
    width: 100%;
    /* position: absolute; */
    text-align: center;
}

.panel_cont{
    margin-top: 3vh;
    /* border: 1px blue solid; */
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: flex-start;
    width: 98%;
    gap: 3vh;
}

.panel_cont p{
  font-family: var(--ui-font);
    text-align: center;
}

.panel_title{
    border: 1px solid red;
    width: 50vw;
    text-align: center;
    font-size: 4vh;

}

#panel_title{
    font-size: clamp(18px, 2vw, 28px);
  font-weight: 700;
  margin: 0;
  color: var(--text);
}
#panel_desc{
     margin: 6px 0 0;
  color: var(--muted);
}


:root{
  --ui-font: "Inter", system-ui, -apple-system, Segoe UI, Roboto, Arial;
  --text: #000000;
  --muted: rgba(15, 23, 42, 0.65);

  /* glass */
  --glass: rgba(255, 255, 255, 0.55);
  --glass-border: rgba(255, 255, 255, 0.75);
  --shadow: 0 22px 55px rgba(2, 6, 23, 0.12);
}


.panel {
    width: 32vw;
    min-width: 260px;
    padding: 2rem 1.5rem;
    background: transparent;
  border: 1px solid var(--glass-border);
  border-radius: 24px;
  box-shadow: var(--shadow);
  backdrop-filter: blur(26px) saturate(1.25);
  -webkit-backdrop-filter: blur(26px) saturate(1.25);
  overflow: hidden;
    align-self: center;

}
.panel::after{
  content:"";
  position:absolute;
  inset:-40% -50%;
  background: rgba(255,255,255,0.22);
  transform: rotate(-10deg);
  pointer-events:none;
}
.panel::before{
  content:"";
  position:absolute;
  inset:0;
  border-radius: 24px;
  padding: 1px;
  background: linear-gradient(
    135deg,
    rgba(255,255,255,0.95),
    rgba(255,255,255,0.18),
    rgba(255,255,255,0.55)
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events:none;
}
.slider-wrap {
    position: relative;
    height: 100%;
    width: 100%;
    max-width: 220px;
    margin: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 0px;
}
.slider-row {
    margin: 1rem auto 0;
    position: relative;
    height: 45vh;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 1.25rem;
}
.track {
    position: relative;
    height: 100%;
    width: 30px;
    background: linear-gradient(to top,
        #4caf50 0%,
        #ffeb3b 50%,
        #ff4d4d 100%);
    border-radius: 10px;
    border: 1px solid #d4c3af;
    margin: 0 auto;
}
.range-track {
    background: linear-gradient(to top,
        #ff4d4d 0%,
        #ffeb3b 50%,
        #4caf50 100%);
}
.dot {
    position: absolute;
    left: 50%;
    transform: translate(-50%, -100%);
    width: 30px;
    height: 12px;
    background: var(--accent);
    border-radius: 4px;
    border: 4px solid #000000;
    box-shadow: 0 0 16px var(--glow);
    transition: top 0.15s ease-out;
}
.labels {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    align-items: flex-end;
    text-align: right;
    height: 100%;
    gap: 0.5rem;
    font-size: 0.9rem;
     color: black;
  font-weight: 500;
}
.readout {
    margin-top: 1.5rem;
    font-size: 1.1rem;
     color: var(--text);
  font-weight: 600;
  letter-spacing: 0;
    text-align: center;
}

.stop-square {
    background-color: #ff4d4d;
    color: white;
    border: none;
    padding: 15px 30px;
    font-family: 'Lexend Deca', sans-serif;
    font-weight: bold;
    cursor: pointer;
    border-radius: 12px; /* Slight round but mostly square */
    display: block;
    margin: 20px auto;
    transition: transform 0.1s;
}

.stop-square:hover {
    background-color: #cc0000;
    border-radius: 12px;
  font-family: var(--ui-font);
  box-shadow: 0 12px 24px rgba(255, 77, 77, 0.25);
    transform: scale(1.05);
}
```