dynamic graph

This commit is contained in:
Siwat Sirichai 2025-06-21 12:55:27 +07:00
parent a606796d9e
commit 5e029ff99c
17 changed files with 1707 additions and 569 deletions

2
.gitignore vendored
View file

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/data

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { SensorDataStorage } from '@/services/SensorDataStorage';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sensorId = searchParams.get('sensorId');
const timespan = parseInt(searchParams.get('timespan') || '86400000'); // Default 24 hours in ms
if (!sensorId) {
return NextResponse.json({
success: false,
error: 'sensorId parameter is required'
}, { status: 400 });
}
const sensorDataStorage = SensorDataStorage.getInstance();
const sensorData = await sensorDataStorage.getDataForSensor(sensorId, timespan);
const timeSeriesData = sensorDataStorage.generateTimeSeriesData(sensorData, timespan);
return NextResponse.json({
success: true,
sensorId,
timespan,
dataPoints: sensorData.length,
data: timeSeriesData,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Sensor history API error:', error);
return NextResponse.json({
success: false,
error: 'Failed to get sensor history data'
}, { status: 500 });
}
}

View file

@ -1,37 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { BedHardware, PinState, PinChange } from '@/services/BedHardware';
import { SensorDataStorage, SensorDataPoint } from '@/services/SensorDataStorage';
// Complete sensor configuration with positions and pin mappings
// Complete sensor configuration with positions, pin mappings, and thresholds
const SENSOR_CONFIG = [
// Head area
{ id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 15 },
{ id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 12 },
{ id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 200, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 },
{ id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 150, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 },
// Shoulder area
{ id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 20 },
{ id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 18 },
{ id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 250, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 },
{ id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 220, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 },
// Upper back
{ id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 25 },
{ id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 30 },
{ id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 22 },
{ id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 300, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
{ id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 350, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
{ id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 280, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
// Lower back/Hip area
{ id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 35 },
{ id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 40 },
{ id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 32 },
{ id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 400, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
{ id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 450, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
{ id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 380, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
// Thigh area
{ id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 28 },
{ id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 26 },
{ id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 320, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 },
{ id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 300, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 },
// Calf area (mock data)
{ id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 15 },
{ id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 18 },
{ id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 200, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 },
{ id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 220, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 },
// Feet (mock data)
{ id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 10 },
{ id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 12 },
{ id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 150, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 },
{ id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 160, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 },
];
// Create pin mapping from sensor config
@ -43,19 +44,24 @@ SENSOR_CONFIG.forEach(sensor => {
});
let bedHardware: BedHardware | null = null;
const sensorDataStorage = SensorDataStorage.getInstance();
const sensorData: Record<string, {
id: string;
x: number;
y: number;
label: string;
zone: string;
pressure: number;
value: number; // Changed from pressure to value (0-4095)
pin?: number;
digitalState?: number;
timestamp: string;
source: 'hardware' | 'mock';
data: Array<{ time: string; timestamp: number; pressure: number }>;
data: Array<{ time: string; timestamp: number; value: number }>;
status: string;
warningStartTime?: number; // Track when warning state started
warningThreshold: number;
alarmThreshold: number;
warningDelayMs: number;
}> = {};
let isHardwareConnected = false;
@ -69,12 +75,15 @@ function initializeSensorData() {
y: sensor.y,
label: sensor.label,
zone: sensor.zone,
pressure: 30 + Math.random() * 20, // Start with baseline pressure
value: 1000 + Math.random() * 500, // Start with baseline analog value (1000-1500)
pin: sensor.pin,
timestamp: new Date().toISOString(),
source: sensor.pin ? 'hardware' : 'mock',
data: generateTimeSeriesData(),
status: 'normal'
status: 'normal',
warningThreshold: sensor.warningThreshold,
alarmThreshold: sensor.alarmThreshold,
warningDelayMs: sensor.warningDelayMs
};
}
});
@ -90,7 +99,7 @@ function generateTimeSeriesData(hours = 1) {
data.push({
time: time.toLocaleTimeString("en-US", { hour12: false }),
timestamp: time.getTime(),
pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40,
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range
});
}
return data;
@ -139,47 +148,81 @@ async function initializeHardware() {
}
}
// Convert digital pin state to analog pressure value with noise
function digitalToPressure(pinState: number, baseNoise: number): number {
// Base pressure from digital state
const basePressure = pinState === 1 ? 60 : 20; // High when pin is HIGH, low when LOW
// Convert digital pin state to analog value with noise
function digitalToAnalogValue(pinState: number, baseNoise: number): number {
// Base value from digital state
const baseValue = pinState === 1 ? 3000 : 1000; // High when pin is HIGH, low when LOW
// Add realistic noise and variation
const timeNoise = Math.sin(Date.now() / 10000) * 10; // Slow oscillation
const timeNoise = Math.sin(Date.now() / 10000) * 200; // Slow oscillation
const randomNoise = (Math.random() - 0.5) * baseNoise;
const sensorDrift = (Math.random() - 0.5) * 5; // Small drift
const sensorDrift = (Math.random() - 0.5) * 50; // Small drift
const pressure = basePressure + timeNoise + randomNoise + sensorDrift;
const value = baseValue + timeNoise + randomNoise + sensorDrift;
// Clamp between 0 and 100
return Math.max(0, Math.min(100, pressure));
// Clamp between 0 and 4095
return Math.max(0, Math.min(4095, Math.floor(value)));
}
// Update sensor data from pin change
function updateSensorFromPin(pin: number, state: number) {
async function updateSensorFromPin(pin: number, state: number) {
const mapping = PIN_SENSOR_MAP[pin];
if (!mapping) return;
const pressure = digitalToPressure(state, mapping.baseNoise);
const value = digitalToAnalogValue(state, mapping.baseNoise);
const timestamp = Date.now();
const time = new Date(timestamp).toLocaleTimeString("en-US", { hour12: false });
// Save to persistent storage
const dataPoint: SensorDataPoint = {
sensorId: mapping.id,
value,
timestamp,
time,
source: 'hardware',
pin,
digitalState: state
};
await sensorDataStorage.addDataPoint(dataPoint);
if (sensorData[mapping.id]) {
// Update existing sensor data
const currentData = sensorData[mapping.id];
// Determine status based on thresholds
let status = 'normal';
let warningStartTime = currentData.warningStartTime;
if (value >= mapping.alarmThreshold) {
status = 'alarm';
warningStartTime = undefined; // Clear warning timer for immediate alarm
} else if (value >= mapping.warningThreshold) {
status = 'warning';
if (!warningStartTime) {
warningStartTime = timestamp; // Start warning timer
} else if (timestamp - warningStartTime >= mapping.warningDelayMs) {
status = 'alarm'; // Escalate to alarm after delay
}
} else {
warningStartTime = undefined; // Clear warning timer
}
sensorData[mapping.id] = {
...currentData,
pressure,
value,
digitalState: state,
timestamp: new Date().toISOString(),
source: 'hardware',
data: [
...currentData.data.slice(-287), // Keep last ~24 hours (288 points at 5min intervals)
{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
pressure: pressure,
time,
timestamp,
value: value,
}
],
status: pressure > 80 ? 'critical' : pressure > 60 ? 'warning' : 'normal'
status,
warningStartTime
};
}
}
@ -190,22 +233,23 @@ function updateMockSensorData() {
if (!sensor.pin && sensorData[sensor.id]) {
// This is a mock sensor, update with variation
const currentSensor = sensorData[sensor.id];
const variation = (Math.random() - 0.5) * 10;
const newPressure = Math.max(0, Math.min(100, currentSensor.pressure + variation));
const variation = (Math.random() - 0.5) * 200; // Larger variation for analog values
const newValue = Math.max(0, Math.min(4095, currentSensor.value + variation));
sensorData[sensor.id] = {
...currentSensor,
pressure: newPressure,
value: newValue,
timestamp: new Date().toISOString(),
data: [
...currentSensor.data.slice(-287), // Keep last ~24 hours
{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
pressure: newPressure,
value: newValue,
}
],
status: newPressure > 80 ? 'critical' : newPressure > 60 ? 'warning' : 'normal'
status: newValue >= sensor.alarmThreshold ? 'alarm' :
newValue >= sensor.warningThreshold ? 'warning' : 'normal'
};
}
});
@ -229,9 +273,9 @@ export async function GET() {
// If hardware is connected, get current pin states
if (isHardwareConnected && bedHardware) {
const pinStates = bedHardware.getAllPinStates();
pinStates.forEach(pinState => {
updateSensorFromPin(pinState.pin, pinState.state);
});
for (const pinState of pinStates) {
await updateSensorFromPin(pinState.pin, pinState.state);
}
}
// Return all sensor data

View file

@ -4,6 +4,7 @@
"": {
"name": "m2-inno-bedpressure",
"dependencies": {
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@types/serialport": "^10.2.0",
"class-variance-authority": "^0.7.1",
@ -11,23 +12,25 @@
"lucide-react": "^0.519.0",
"next": "15.3.4",
"port": "^0.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.4",
"serial": "^0.0.9",
"serialport": "^13.0.0",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.5",
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.10",
"@types/node": "^20.19.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint": "^9.29.0",
"eslint-config-next": "15.3.4",
"tailwindcss": "^4",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5",
"typescript": "^5.8.3",
},
},
},
@ -62,6 +65,14 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="],
"@floating-ui/core": ["@floating-ui/core@1.7.1", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.3", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
@ -154,10 +165,58 @@
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="],
@ -326,6 +385,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
@ -438,6 +499,8 @@
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
@ -534,6 +597,8 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
@ -780,8 +845,14 @@
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
@ -910,6 +981,10 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@ -928,6 +1003,8 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zustand": ["zustand@5.0.5", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="],

View file

@ -1,524 +1,39 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { Activity, AlertTriangle, Download, Pause, Play, Settings, User } from "lucide-react"
// Mock data generator
const generateTimeSeriesData = (hours = 24) => {
const data = []
const now = new Date()
for (let i = hours * 60; i >= 0; i -= 5) {
const time = new Date(now.getTime() - i * 60 * 1000)
data.push({
time: time.toLocaleTimeString("en-US", { hour12: false }),
timestamp: time.getTime(),
pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40,
})
}
return data
}
// Sensor configuration interface
interface SensorConfig {
id: string;
x: number;
y: number;
zone: string;
label: string;
pin?: number;
}
const getPressureColor = (pressure: number) => {
if (pressure < 30) return "#22c55e" // Green - Low pressure
if (pressure < 50) return "#eab308" // Yellow - Medium pressure
if (pressure < 70) return "#f97316" // Orange - High pressure
return "#ef4444" // Red - Very high pressure
}
const getPressureLevel = (pressure: number) => {
if (pressure < 30) return "Low"
if (pressure < 50) return "Medium"
if (pressure < 70) return "High"
return "Critical"
}
interface SensorData {
id: string;
x: number;
y: number;
zone: string;
label: string;
currentPressure: number;
data: Array<{ time: string; timestamp: number; pressure: number }>;
status: string;
source?: 'hardware' | 'mock';
pin?: number;
digitalState?: number;
}
import { BedPressureHeader } from "./bed-pressure/BedPressureHeader"
import { StatsCards } from "./bed-pressure/StatsCards"
import { BedVisualization } from "./bed-pressure/BedVisualization"
import { AlertsPanel } from "./bed-pressure/AlertsPanel"
import { SensorDetailModal } from "./bed-pressure/SensorDetailModal"
import { useBedPressureData } from "@/hooks/useBedPressureData"
export default function Component() {
const [sensorData, setSensorData] = useState<Record<string, SensorData>>({})
const [sensorConfig, setSensorConfig] = useState<SensorConfig[]>([])
const [selectedSensor, setSelectedSensor] = useState<string | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isMonitoring, setIsMonitoring] = useState(true)
const [alerts, setAlerts] = useState<Array<{ id: string; message: string; time: string }>>([])
// Initialize sensor configuration
useEffect(() => {
const fetchSensorConfig = async () => {
try {
const response = await fetch('/api/sensors/config')
const data = await response.json()
if (data.success && data.sensors) {
setSensorConfig(data.sensors)
}
} catch (error) {
console.error('Failed to fetch sensor config:', error)
}
}
fetchSensorConfig()
}, [])
// Initialize sensor data
useEffect(() => {
if (sensorConfig.length === 0) return
const initialData: Record<string, SensorData> = {}
sensorConfig.forEach((sensor) => {
initialData[sensor.id] = {
...sensor,
currentPressure: Math.random() * 100,
data: generateTimeSeriesData(),
status: "normal",
}
})
setSensorData(initialData)
}, [sensorConfig])
// Fetch sensor data from API
useEffect(() => {
if (!isMonitoring) return
const fetchSensorData = async () => {
try {
const response = await fetch('/api/sensors')
const data = await response.json()
if (data.success && data.sensors) {
setSensorData((prev) => {
const updated = { ...prev }
const newAlerts: Array<{ id: string; message: string; time: string }> = []
data.sensors.forEach((sensor: {
id: string;
label: string;
zone: string;
pressure: number;
source: 'hardware' | 'mock';
pin?: number;
digitalState?: number;
}) => {
const currentSensor = updated[sensor.id]
const newPressure = sensor.pressure
// Check for alerts
if (newPressure > 80 && currentSensor && currentSensor.currentPressure <= 80) {
newAlerts.push({
id: `${sensor.id}-${Date.now()}`,
message: `High pressure detected at ${sensor.label}`,
time: new Date().toLocaleTimeString(),
})
}
// Update sensor data
updated[sensor.id] = {
...sensorConfig.find(s => s.id === sensor.id),
id: sensor.id,
x: sensorConfig.find(s => s.id === sensor.id)?.x || 50,
y: sensorConfig.find(s => s.id === sensor.id)?.y || 50,
zone: sensor.zone,
label: sensor.label,
currentPressure: newPressure,
data: currentSensor ? [
...currentSensor.data.slice(1),
{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
pressure: newPressure,
},
] : [{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
pressure: newPressure,
}],
status: newPressure > 80 ? "critical" : newPressure > 60 ? "warning" : "normal",
source: sensor.source,
pin: sensor.pin,
digitalState: sensor.digitalState
}
})
if (newAlerts.length > 0) {
setAlerts((prev) => [...newAlerts, ...prev].slice(0, 10))
}
return updated
})
}
} catch (error) {
console.error('Failed to fetch sensor data:', error)
}
}
// Initial fetch
fetchSensorData()
// Set up polling
const interval = setInterval(fetchSensorData, 2000)
return () => clearInterval(interval)
}, [isMonitoring, sensorConfig])
const averagePressure =
Object.values(sensorData).reduce((sum: number, sensor: SensorData) => sum + (sensor.currentPressure || 0), 0) /
Object.keys(sensorData).length
const criticalSensors = Object.values(sensorData).filter((sensor: SensorData) => sensor.currentPressure > 80).length
// Initialize data fetching
useBedPressureData()
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Activity className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Bed Pressure Monitor</h1>
</div>
<Badge variant={isMonitoring ? "default" : "secondary"} className="px-3 py-1">
{isMonitoring ? "Live" : "Paused"}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsMonitoring(!isMonitoring)}>
{isMonitoring ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{isMonitoring ? "Pause" : "Resume"}
</Button>
<Button variant="outline" size="sm">
<Download className="w-4 h-4" />
Export
</Button>
<Button variant="outline" size="sm">
<Settings className="w-4 h-4" />
Settings
</Button>
</div>
</div>
<BedPressureHeader />
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm text-gray-600">Patient</p>
<p className="font-semibold">John Doe - Room 204</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Average Pressure</p>
<p className="text-2xl font-bold" style={{ color: getPressureColor(averagePressure) }}>
{averagePressure.toFixed(1)} mmHg
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Active Sensors</p>
<p className="text-2xl font-bold text-green-600">{Object.keys(sensorData).length}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${criticalSensors > 0 ? "text-red-600" : "text-gray-400"}`} />
<div>
<p className="text-sm text-gray-600">Critical Alerts</p>
<p className={`text-2xl font-bold ${criticalSensors > 0 ? "text-red-600" : "text-gray-600"}`}>
{criticalSensors}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<StatsCards />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Bed Visualization */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Pressure Distribution Map</CardTitle>
<p className="text-sm text-gray-600">Click on any sensor point to view detailed pressure graphs</p>
</CardHeader>
<CardContent>
<div className="relative">
{/* Bed outline */}
<svg viewBox="0 0 100 100" className="w-full h-96 border-2 border-gray-200 rounded-lg bg-white">
{/* Bed frame */}
<rect x="25" y="10" width="50" height="80" fill="none" stroke="#e5e7eb" strokeWidth="2" rx="8" />
{/* Pillow area */}
<rect x="30" y="12" width="40" height="15" fill="#f3f4f6" stroke="#d1d5db" strokeWidth="1" rx="4" />
{/* Pressure sensors */}
{sensorConfig.map((sensor) => {
const sensorInfo = sensorData[sensor.id]
if (!sensorInfo) return null
return (
<circle
key={sensor.id}
cx={sensor.x}
cy={sensor.y}
r="3"
fill={getPressureColor(sensorInfo.currentPressure)}
stroke="white"
strokeWidth="1"
className="cursor-pointer transition-all duration-200 hover:r-4 hover:opacity-80"
onClick={() => {
setSelectedSensor(sensor.id)
setIsModalOpen(true)
}}
/>
)
})}
</svg>
{/* Pressure Legend */}
<div className="mt-4 flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span>Low ({"<"}30)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<span>Medium (30-50)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
<span>High (50-70)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span>Critical ({">"}70)</span>
</div>
</div>
</div>
</CardContent>
</Card>
<BedVisualization />
</div>
{/* Sensor Details & Alerts */}
{/* Alerts Panel */}
<div className="space-y-6">
{/* Pressure Graph Modal */}
{isModalOpen && selectedSensor && sensorData[selectedSensor] && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">{sensorData[selectedSensor].label}</h2>
<p className="text-gray-600">Pressure Monitoring Details</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsModalOpen(false)}
className="text-gray-500 hover:text-gray-700"
>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Current Pressure</p>
<p
className="text-3xl font-bold"
style={{ color: getPressureColor(sensorData[selectedSensor].currentPressure) }}
>
{sensorData[selectedSensor].currentPressure.toFixed(1)}
</p>
<p className="text-sm text-gray-500">mmHg</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Status Level</p>
<Badge
className="text-lg px-3 py-1 mt-2"
variant={
sensorData[selectedSensor].status === "critical"
? "destructive"
: sensorData[selectedSensor].status === "warning"
? "secondary"
: "default"
}
>
{getPressureLevel(sensorData[selectedSensor].currentPressure)}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Body Zone</p>
<p className="text-xl font-semibold capitalize mt-2">{sensorData[selectedSensor].zone}</p>
</CardContent>
</Card>
</div>
{/* Large Pressure Chart */}
<Card>
<CardHeader>
<CardTitle>24-Hour Pressure Trend</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sensorData[selectedSensor].data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
label={{ value: "Pressure (mmHg)", angle: -90, position: "insideLeft" }}
/>
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)} mmHg`, "Pressure"]}
labelFormatter={(label) => `Time: ${label}`}
/>
<Line
type="monotone"
dataKey="pressure"
stroke={getPressureColor(sensorData[selectedSensor].currentPressure)}
strokeWidth={3}
dot={false}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Additional Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Max Pressure</p>
<p className="text-lg font-bold text-red-600">
{Math.max(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Min Pressure</p>
<p className="text-lg font-bold text-green-600">
{Math.min(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Average</p>
<p className="text-lg font-bold text-blue-600">
{(
sensorData[selectedSensor].data.reduce((sum: number, d: { time: string; timestamp: number; pressure: number }) => sum + d.pressure, 0) /
sensorData[selectedSensor].data.length
).toFixed(1)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Data Points</p>
<p className="text-lg font-bold text-gray-600">{sensorData[selectedSensor].data.length}</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
Close
</Button>
<Button>
<Download className="w-4 h-4 mr-2" />
Export Data
</Button>
</div>
</div>
</div>
</div>
)}
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Recent Alerts
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-64 overflow-y-auto">
{alerts.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No recent alerts</p>
) : (
alerts.map((alert) => (
<div
key={alert.id}
className="flex items-start gap-3 p-3 bg-red-50 rounded-lg border border-red-200"
>
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-red-800">{alert.message}</p>
<p className="text-xs text-red-600">{alert.time}</p>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
<AlertsPanel />
</div>
</div>
{/* Sensor Detail Modal */}
<SensorDetailModal />
</div>
</div>
)

View file

@ -0,0 +1,186 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { AlertTriangle, VolumeX, Clock, CheckCircle } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
import { useEffect } from "react"
export function AlertsPanel() {
const {
alerts,
activeAlarms,
alarmManager,
acknowledgeAlarm,
silenceAlarm,
silenceAllAlarms
} = useBedPressureStore()
// Update active alarms periodically
useEffect(() => {
const interval = setInterval(() => {
// The store will handle updating alarms automatically
}, 1000)
return () => clearInterval(interval)
}, [alarmManager])
const handleAcknowledge = (alarmId: string) => {
acknowledgeAlarm(alarmId)
}
const handleSilence = (alarmId: string) => {
silenceAlarm(alarmId, 300000) // Silence for 5 minutes
}
const handleSilenceAll = () => {
silenceAllAlarms(300000) // Silence all for 5 minutes
}
const getAlarmIcon = (type: 'warning' | 'alarm') => {
return type === 'alarm' ? (
<AlertTriangle className="w-4 h-4 text-red-600 animate-pulse" />
) : (
<AlertTriangle className="w-4 h-4 text-yellow-600" />
)
}
const getAlarmBgColor = (type: 'warning' | 'alarm', silenced: boolean) => {
if (silenced) return "bg-gray-100 border-gray-300"
return type === 'alarm' ? "bg-red-50 border-red-200" : "bg-yellow-50 border-yellow-200"
}
const hasActiveAlarms = activeAlarms.some(alarm => !alarm.silenced)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${hasActiveAlarms ? "text-red-600 animate-pulse" : "text-gray-400"}`} />
Active Alarms ({activeAlarms.length})
</CardTitle>
{activeAlarms.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleSilenceAll}
className="text-orange-600 hover:text-orange-700"
>
<VolumeX className="w-4 h-4 mr-1" />
Silence All
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-80 overflow-y-auto">
{activeAlarms.length === 0 ? (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-2" />
<p className="text-sm text-gray-500">No active alarms</p>
<p className="text-xs text-gray-400">System monitoring normally</p>
</div>
) : (
activeAlarms.map((alarm) => (
<div
key={alarm.id}
className={`p-3 rounded-lg border ${getAlarmBgColor(alarm.type, alarm.silenced)}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{getAlarmIcon(alarm.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className={`text-sm font-medium ${
alarm.type === 'alarm' ? 'text-red-800' : 'text-yellow-800'
}`}>
{alarm.sensorLabel}
</p>
<span className={`text-xs px-2 py-1 rounded ${
alarm.type === 'alarm'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{alarm.type.toUpperCase()}
</span>
{alarm.silenced && (
<span className="text-xs px-2 py-1 rounded bg-gray-100 text-gray-600 flex items-center gap-1">
<VolumeX className="w-3 h-3" />
SILENCED
</span>
)}
</div>
<p className={`text-xs mb-2 ${
alarm.type === 'alarm' ? 'text-red-700' : 'text-yellow-700'
}`}>
Value: {alarm.value.toFixed(0)} (Threshold: {alarm.threshold})
</p>
<div className="flex items-center gap-2 text-xs text-gray-600">
<Clock className="w-3 h-3" />
<span>{alarm.time}</span>
{alarm.acknowledged && (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
ACK
</span>
)}
{alarm.silenced && alarm.silencedUntil && (
<span className="text-gray-500">
Until {new Date(alarm.silencedUntil).toLocaleTimeString()}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-1 ml-2">
{!alarm.acknowledged && (
<Button
variant="outline"
size="sm"
onClick={() => handleAcknowledge(alarm.id)}
className="text-xs h-6 px-2"
>
ACK
</Button>
)}
{!alarm.silenced && (
<Button
variant="outline"
size="sm"
onClick={() => handleSilence(alarm.id)}
className="text-xs h-6 px-2 text-orange-600 hover:text-orange-700"
>
<VolumeX className="w-3 h-3" />
</Button>
)}
</div>
</div>
</div>
))
)}
</div>
{/* Legacy Alerts Section */}
{alerts.length > 0 && (
<div className="mt-6 pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-3">Recent Alerts</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{alerts.slice(0, 3).map((alert) => (
<div
key={alert.id}
className="flex items-start gap-2 p-2 bg-blue-50 rounded border border-blue-200"
>
<AlertTriangle className="w-3 h-3 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-blue-800">{alert.message}</p>
<p className="text-xs text-blue-600">{alert.time}</p>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,29 @@
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Activity, Pause, Play } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
export function BedPressureHeader() {
const { isMonitoring, setIsMonitoring } = useBedPressureStore()
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Activity className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Bed Pressure Monitor</h1>
</div>
<Badge variant={isMonitoring ? "default" : "secondary"} className="px-3 py-1">
{isMonitoring ? "Live" : "Paused"}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsMonitoring(!isMonitoring)}>
{isMonitoring ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{isMonitoring ? "Pause" : "Resume"}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,79 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useBedPressureStore } from "@/stores/bedPressureStore"
const getValueColor = (value: number) => {
if (value < 1500) return "#22c55e" // Green - Low value
if (value < 2500) return "#eab308" // Yellow - Medium value
if (value < 3500) return "#f97316" // Orange - High value
return "#ef4444" // Red - Very high value
}
export function BedVisualization() {
const { sensorConfig, sensorData, setSelectedSensor, setIsModalOpen } = useBedPressureStore()
const handleSensorClick = (sensorId: string) => {
setSelectedSensor(sensorId)
setIsModalOpen(true)
}
return (
<Card>
<CardHeader>
<CardTitle>Sensor Value Distribution Map</CardTitle>
<p className="text-sm text-gray-600">Click on any sensor point to view detailed value graphs</p>
</CardHeader>
<CardContent>
<div className="relative">
{/* Bed outline */}
<svg viewBox="0 0 100 100" className="w-full h-96 border-2 border-gray-200 rounded-lg bg-white">
{/* Bed frame */}
<rect x="25" y="10" width="50" height="80" fill="none" stroke="#e5e7eb" strokeWidth="2" rx="8" />
{/* Pillow area */}
<rect x="30" y="12" width="40" height="15" fill="#f3f4f6" stroke="#d1d5db" strokeWidth="1" rx="4" />
{/* Pressure sensors */}
{sensorConfig.map((sensor) => {
const sensorInfo = sensorData[sensor.id]
if (!sensorInfo) return null
return (
<circle
key={sensor.id}
cx={sensor.x}
cy={sensor.y}
r="3"
fill={getValueColor(sensorInfo.currentValue)}
stroke="white"
strokeWidth="1"
className="cursor-pointer transition-all duration-200 hover:r-4 hover:opacity-80"
onClick={() => handleSensorClick(sensor.id)}
/>
)
})}
</svg>
{/* Value Legend */}
<div className="mt-4 flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span>Low ({"<"}1500)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<span>Medium (1500-2500)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
<span>High (2500-3500)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span>Critical ({">"}3500)</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,230 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { Download, Clock } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
import { useEffect } from "react"
const getValueColor = (value: number) => {
if (value < 1500) return "#22c55e" // Green - Low value
if (value < 2500) return "#eab308" // Yellow - Medium value
if (value < 3500) return "#f97316" // Orange - High value
return "#ef4444" // Red - Very high value
}
const getValueLevel = (value: number) => {
if (value < 1500) return "Low"
if (value < 2500) return "Medium"
if (value < 3500) return "High"
return "Critical"
}
export function SensorDetailModal() {
const {
isModalOpen,
setIsModalOpen,
selectedSensor,
sensorData,
selectedTimespan,
setSelectedTimespan,
fetchSensorHistory
} = useBedPressureStore()
const sensor = selectedSensor && sensorData[selectedSensor] ? sensorData[selectedSensor] : null
// Fetch historical data when modal opens or timespan changes
useEffect(() => {
if (isModalOpen && selectedSensor && sensor?.source === 'hardware') {
fetchSensorHistory(selectedSensor, selectedTimespan)
}
}, [isModalOpen, selectedSensor, selectedTimespan, fetchSensorHistory, sensor?.source])
const handleTimespanChange = (value: string) => {
const newTimespan = parseInt(value)
setSelectedTimespan(newTimespan)
}
const getTimespanLabel = (timespan: number) => {
if (timespan < 60000) return `${timespan / 1000}s`
if (timespan < 3600000) return `${timespan / 60000}m`
if (timespan < 86400000) return `${timespan / 3600000}h`
return `${timespan / 86400000}d`
}
if (!isModalOpen || !selectedSensor || !sensor) {
return null
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">{sensor.label}</h2>
<p className="text-gray-600">Pressure Monitoring Details</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsModalOpen(false)}
className="text-gray-500 hover:text-gray-700"
>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Current Value</p>
<p
className="text-3xl font-bold"
style={{ color: getValueColor(sensor.currentValue) }}
>
{sensor.currentValue.toFixed(0)}
</p>
<p className="text-sm text-gray-500">ADC Units</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Status Level</p>
<Badge
className="text-lg px-3 py-1 mt-2"
variant={
sensor.status === "critical" || sensor.status === "alarm"
? "destructive"
: sensor.status === "warning"
? "secondary"
: "default"
}
>
{getValueLevel(sensor.currentValue)}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Body Zone</p>
<p className="text-xl font-semibold capitalize mt-2">{sensor.zone}</p>
</CardContent>
</Card>
</div>
{/* Large Value Chart */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Value Trend</CardTitle>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-500" />
<Select value={selectedTimespan.toString()} onValueChange={handleTimespanChange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000">30s</SelectItem>
<SelectItem value="60000">1m</SelectItem>
<SelectItem value="300000">5m</SelectItem>
<SelectItem value="600000">10m</SelectItem>
<SelectItem value="1800000">30m</SelectItem>
<SelectItem value="3600000">1h</SelectItem>
<SelectItem value="7200000">2h</SelectItem>
<SelectItem value="21600000">6h</SelectItem>
<SelectItem value="43200000">12h</SelectItem>
<SelectItem value="86400000">24h</SelectItem>
<SelectItem value="259200000">3d</SelectItem>
<SelectItem value="604800000">7d</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-gray-600">
Showing data for the last {getTimespanLabel(selectedTimespan)}
{sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Mock data)'}
</p>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sensor.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
label={{ value: "ADC Value", angle: -90, position: "insideLeft" }}
/>
<Tooltip
formatter={(value: number) => [`${value.toFixed(0)}`, "Value"]}
labelFormatter={(label) => `Time: ${label}`}
/>
<Line
type="monotone"
dataKey="value"
stroke={getValueColor(sensor.currentValue)}
strokeWidth={3}
dot={false}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Additional Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Max Value</p>
<p className="text-lg font-bold text-red-600">
{Math.max(...sensor.data.map(d => d.value)).toFixed(0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Min Value</p>
<p className="text-lg font-bold text-green-600">
{Math.min(...sensor.data.map(d => d.value)).toFixed(0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Average</p>
<p className="text-lg font-bold text-blue-600">
{(sensor.data.reduce((sum, d) => sum + d.value, 0) / sensor.data.length).toFixed(0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Data Points</p>
<p className="text-lg font-bold text-gray-600">{sensor.data.length}</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
Close
</Button>
<Button>
<Download className="w-4 h-4 mr-2" />
Export Data
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,68 @@
import { Card, CardContent } from "@/components/ui/card"
import { AlertTriangle, User } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
const getValueColor = (value: number) => {
if (value < 1500) return "#22c55e" // Green - Low value
if (value < 2500) return "#eab308" // Yellow - Medium value
if (value < 3500) return "#f97316" // Orange - High value
return "#ef4444" // Red - Very high value
}
export function StatsCards() {
const { sensorData, averageValue, criticalSensors } = useBedPressureStore()
const avgValue = averageValue()
const criticalCount = criticalSensors()
const activeSensors = Object.keys(sensorData).length
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm text-gray-600">Patient</p>
<p className="font-semibold">John Doe - Room 204</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Average Value</p>
<p className="text-2xl font-bold" style={{ color: getValueColor(avgValue) }}>
{avgValue.toFixed(0)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Active Sensors</p>
<p className="text-2xl font-bold text-green-600">{activeSensors}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${criticalCount > 0 ? "text-red-600" : "text-gray-400"}`} />
<div>
<p className="text-sm text-gray-600">Critical Alerts</p>
<p className={`text-2xl font-bold ${criticalCount > 0 ? "text-red-600" : "text-gray-600"}`}>
{criticalCount}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

185
components/ui/select.tsx Normal file
View file

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,69 @@
import { useEffect } from 'react'
import { useBedPressureStore, SensorData } from '@/stores/bedPressureStore'
// Mock data generator
const generateTimeSeriesData = (hours = 24) => {
const data = []
const now = new Date()
for (let i = hours * 60; i >= 0; i -= 5) {
const time = new Date(now.getTime() - i * 60 * 1000)
data.push({
time: time.toLocaleTimeString("en-US", { hour12: false }),
timestamp: time.getTime(),
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range
})
}
return data
}
export function useBedPressureData() {
const {
sensorConfig,
sensorData,
isMonitoring,
fetchSensorConfig,
fetchSensorData,
setSensorData
} = useBedPressureStore()
// Initialize sensor configuration
useEffect(() => {
fetchSensorConfig()
}, [fetchSensorConfig])
// Initialize sensor data
useEffect(() => {
if (sensorConfig.length === 0) return
const initialData: Record<string, SensorData> = {}
sensorConfig.forEach((sensor) => {
initialData[sensor.id] = {
...sensor,
currentValue: Math.floor(Math.random() * 1000 + 1000), // Start with baseline analog value (1000-2000)
data: generateTimeSeriesData(),
status: "normal",
}
})
setSensorData(initialData)
}, [sensorConfig, setSensorData])
// Fetch sensor data from API
useEffect(() => {
if (!isMonitoring) return
// Initial fetch
fetchSensorData()
// Set up polling
const interval = setInterval(fetchSensorData, 2000)
return () => clearInterval(interval)
}, [isMonitoring, fetchSensorData])
return {
sensorData,
sensorConfig,
isMonitoring
}
}

View file

@ -1,4 +1,4 @@
import { clsx, type ClassValue } from "clsx"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {

View file

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@types/serialport": "^10.2.0",
"class-variance-authority": "^0.7.1",
@ -16,22 +17,24 @@
"lucide-react": "^0.519.0",
"next": "15.3.4",
"port": "^0.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.4",
"serial": "^0.0.9",
"tailwind-merge": "^3.3.1"
"serialport": "^13.0.0",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.10",
"@types/node": "^20.19.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint": "^9.29.0",
"eslint-config-next": "15.3.4",
"tailwindcss": "^4",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
"typescript": "^5.8.3"
}
}

207
services/AlarmManager.ts Normal file
View file

@ -0,0 +1,207 @@
export interface AlarmEvent {
id: string;
sensorId: string;
sensorLabel: string;
type: 'warning' | 'alarm';
value: number;
threshold: number;
timestamp: number;
time: string;
acknowledged: boolean;
silenced: boolean;
silencedUntil?: number;
}
export class AlarmManager {
private static instance: AlarmManager;
private activeAlarms: Map<string, AlarmEvent> = new Map();
private alarmHistory: AlarmEvent[] = [];
private alarmCallbacks: ((alarm: AlarmEvent) => void)[] = [];
private audioContext: AudioContext | null = null;
private alarmSound: AudioBuffer | null = null;
private isPlayingAlarm = false;
private constructor() {
this.initializeAudio();
}
static getInstance(): AlarmManager {
if (!AlarmManager.instance) {
AlarmManager.instance = new AlarmManager();
}
return AlarmManager.instance;
}
private async initializeAudio() {
try {
// Check if we're in browser environment
if (typeof window !== 'undefined') {
this.audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
// Generate alarm sound programmatically
await this.generateAlarmSound();
}
} catch (error) {
console.warn('Audio context not available:', error);
}
}
private async generateAlarmSound() {
if (!this.audioContext) return;
const sampleRate = this.audioContext.sampleRate;
const duration = 1; // 1 second
const buffer = this.audioContext.createBuffer(1, sampleRate * duration, sampleRate);
const data = buffer.getChannelData(0);
// Generate a beeping sound (sine wave with modulation)
for (let i = 0; i < data.length; i++) {
const t = i / sampleRate;
const frequency = 800; // Hz
const envelope = Math.sin(t * Math.PI * 4) > 0 ? 1 : 0; // Beeping pattern
data[i] = Math.sin(2 * Math.PI * frequency * t) * envelope * 0.3;
}
this.alarmSound = buffer;
}
private async playAlarmSound() {
if (!this.audioContext || !this.alarmSound || this.isPlayingAlarm) return;
try {
// Resume audio context if suspended
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
const source = this.audioContext.createBufferSource();
source.buffer = this.alarmSound;
source.connect(this.audioContext.destination);
source.start();
this.isPlayingAlarm = true;
source.onended = () => {
this.isPlayingAlarm = false;
};
} catch (error) {
console.warn('Failed to play alarm sound:', error);
}
}
addAlarm(sensorId: string, sensorLabel: string, type: 'warning' | 'alarm', value: number, threshold: number) {
const alarmId = `${sensorId}-${type}`;
const timestamp = Date.now();
const alarm: AlarmEvent = {
id: alarmId,
sensorId,
sensorLabel,
type,
value,
threshold,
timestamp,
time: new Date(timestamp).toLocaleTimeString(),
acknowledged: false,
silenced: false
};
// Check if alarm is already active and silenced
const existingAlarm = this.activeAlarms.get(alarmId);
if (existingAlarm?.silenced && existingAlarm.silencedUntil && timestamp < existingAlarm.silencedUntil) {
// Update values but keep silenced state
alarm.silenced = true;
alarm.silencedUntil = existingAlarm.silencedUntil;
}
this.activeAlarms.set(alarmId, alarm);
this.alarmHistory.unshift(alarm);
// Keep only last 100 history items
if (this.alarmHistory.length > 100) {
this.alarmHistory = this.alarmHistory.slice(0, 100);
}
// Play sound for new alarms
if (type === 'alarm' && !alarm.silenced) {
this.playAlarmSound();
}
// Notify callbacks
this.alarmCallbacks.forEach(callback => callback(alarm));
return alarm;
}
clearAlarm(sensorId: string, type?: 'warning' | 'alarm') {
if (type) {
const alarmId = `${sensorId}-${type}`;
this.activeAlarms.delete(alarmId);
} else {
// Clear all alarms for this sensor
this.activeAlarms.delete(`${sensorId}-warning`);
this.activeAlarms.delete(`${sensorId}-alarm`);
}
}
acknowledgeAlarm(alarmId: string) {
const alarm = this.activeAlarms.get(alarmId);
if (alarm) {
alarm.acknowledged = true;
this.activeAlarms.set(alarmId, alarm);
}
}
silenceAlarm(alarmId: string, durationMs: number = 300000) { // Default 5 minutes
const alarm = this.activeAlarms.get(alarmId);
if (alarm) {
alarm.silenced = true;
alarm.silencedUntil = Date.now() + durationMs;
this.activeAlarms.set(alarmId, alarm);
}
}
silenceAllAlarms(durationMs: number = 300000) {
const timestamp = Date.now();
this.activeAlarms.forEach((alarm, alarmId) => {
alarm.silenced = true;
alarm.silencedUntil = timestamp + durationMs;
this.activeAlarms.set(alarmId, alarm);
});
}
getActiveAlarms(): AlarmEvent[] {
const now = Date.now();
const activeAlarms: AlarmEvent[] = [];
this.activeAlarms.forEach((alarm, alarmId) => {
// Check if silence period has expired
if (alarm.silenced && alarm.silencedUntil && now >= alarm.silencedUntil) {
alarm.silenced = false;
alarm.silencedUntil = undefined;
this.activeAlarms.set(alarmId, alarm);
}
activeAlarms.push(alarm);
});
return activeAlarms.sort((a, b) => b.timestamp - a.timestamp);
}
getAlarmHistory(): AlarmEvent[] {
return this.alarmHistory;
}
hasUnacknowledgedAlarms(): boolean {
return Array.from(this.activeAlarms.values()).some(alarm => !alarm.acknowledged);
}
onAlarm(callback: (alarm: AlarmEvent) => void) {
this.alarmCallbacks.push(callback);
}
offAlarm(callback: (alarm: AlarmEvent) => void) {
const index = this.alarmCallbacks.indexOf(callback);
if (index > -1) {
this.alarmCallbacks.splice(index, 1);
}
}
}

View file

@ -0,0 +1,131 @@
import fs from 'fs/promises';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const SENSOR_DATA_FILE = path.join(DATA_DIR, 'sensor-data.json');
export interface SensorDataPoint {
sensorId: string;
value: number; // Changed from pressure to value (0-4095)
timestamp: number;
time: string;
source: 'hardware' | 'mock';
pin?: number;
digitalState?: number;
}
export class SensorDataStorage {
private static instance: SensorDataStorage;
private dataCache: SensorDataPoint[] = [];
private constructor() {
this.initializeStorage();
}
static getInstance(): SensorDataStorage {
if (!SensorDataStorage.instance) {
SensorDataStorage.instance = new SensorDataStorage();
}
return SensorDataStorage.instance;
}
private async initializeStorage() {
try {
// Ensure data directory exists
await fs.mkdir(DATA_DIR, { recursive: true });
// Load existing data
await this.loadData();
} catch (error) {
console.warn('Failed to initialize sensor data storage:', error);
}
}
private async loadData() {
try {
const data = await fs.readFile(SENSOR_DATA_FILE, 'utf8');
this.dataCache = JSON.parse(data);
console.log(`Loaded ${this.dataCache.length} sensor data points from storage`);
} catch {
// File doesn't exist or is corrupted, start with empty cache
this.dataCache = [];
console.log('Starting with empty sensor data cache');
}
}
private async saveData() {
try {
await fs.writeFile(SENSOR_DATA_FILE, JSON.stringify(this.dataCache, null, 2));
} catch (error) {
console.error('Failed to save sensor data:', error);
}
}
async addDataPoint(dataPoint: SensorDataPoint) {
this.dataCache.push(dataPoint);
// Keep only last 7 days of data to prevent unlimited storage growth
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
this.dataCache = this.dataCache.filter(point => point.timestamp > sevenDaysAgo);
// Save to disk every 10 data points to reduce I/O
if (this.dataCache.length % 10 === 0) {
await this.saveData();
}
}
async getDataForSensor(
sensorId: string,
timespan: number = 24 * 60 * 60 * 1000 // Default 24 hours in milliseconds
): Promise<SensorDataPoint[]> {
const cutoffTime = Date.now() - timespan;
return this.dataCache
.filter(point => point.sensorId === sensorId && point.timestamp > cutoffTime)
.sort((a, b) => a.timestamp - b.timestamp);
}
async getAllRecentData(timespan: number = 24 * 60 * 60 * 1000): Promise<SensorDataPoint[]> {
const cutoffTime = Date.now() - timespan;
return this.dataCache
.filter(point => point.timestamp > cutoffTime)
.sort((a, b) => a.timestamp - b.timestamp);
}
async forceSave() {
await this.saveData();
}
// Generate time series data for a specific timespan
generateTimeSeriesData(
sensorData: SensorDataPoint[],
timespan: number = 24 * 60 * 60 * 1000
): Array<{ time: string; timestamp: number; value: number }> {
if (sensorData.length === 0) {
// Generate mock data if no real data exists
return this.generateMockTimeSeriesData(timespan);
}
return sensorData.map(point => ({
time: point.time,
timestamp: point.timestamp,
value: point.value
}));
}
private generateMockTimeSeriesData(timespan: number): Array<{ time: string; timestamp: number; value: number }> {
const data = [];
const now = Date.now();
const interval = Math.max(1000, timespan / 288); // At least 1 second intervals, up to 288 points
for (let i = timespan; i >= 0; i -= interval) {
const timestamp = now - i;
const time = new Date(timestamp);
data.push({
time: time.toLocaleTimeString("en-US", { hour12: false }),
timestamp: timestamp,
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60000) * 500 + 2000), // 0-4095 range
});
}
return data;
}
}

275
stores/bedPressureStore.ts Normal file
View file

@ -0,0 +1,275 @@
import { create } from 'zustand'
import { AlarmManager, AlarmEvent } from '@/services/AlarmManager'
export interface SensorConfig {
id: string;
x: number;
y: number;
zone: string;
label: string;
pin?: number;
}
export interface SensorData {
id: string;
x: number;
y: number;
zone: string;
label: string;
currentValue: number; // Changed from currentPressure to currentValue (0-4095)
data: Array<{ time: string; timestamp: number; value: number }>; // Changed from pressure to value
status: string;
source?: 'hardware' | 'mock';
pin?: number;
digitalState?: number;
warningThreshold?: number;
alarmThreshold?: number;
warningDelayMs?: number;
}
export interface Alert {
id: string;
message: string;
time: string;
}
interface BedPressureStore {
// State
sensorData: Record<string, SensorData>;
sensorConfig: SensorConfig[];
selectedSensor: string | null;
isModalOpen: boolean;
isMonitoring: boolean;
alerts: Alert[];
isConnected: boolean;
selectedTimespan: number; // in milliseconds
activeAlarms: AlarmEvent[];
alarmManager: AlarmManager;
// Actions
setSensorData: (data: Record<string, SensorData>) => void;
updateSensorData: (updater: (prev: Record<string, SensorData>) => Record<string, SensorData>) => void;
setSensorConfig: (config: SensorConfig[]) => void;
setSelectedSensor: (sensorId: string | null) => void;
setIsModalOpen: (isOpen: boolean) => void;
setIsMonitoring: (isMonitoring: boolean) => void;
addAlerts: (newAlerts: Alert[]) => void;
setIsConnected: (isConnected: boolean) => void;
setSelectedTimespan: (timespan: number) => void;
setActiveAlarms: (alarms: AlarmEvent[]) => void;
// Computed values
averageValue: () => number; // Changed from averagePressure
criticalSensors: () => number;
// API actions
fetchSensorConfig: () => Promise<void>;
fetchSensorData: () => Promise<void>;
fetchSensorHistory: (sensorId: string, timespan?: number) => Promise<void>;
// Alarm actions
acknowledgeAlarm: (alarmId: string) => void;
silenceAlarm: (alarmId: string, durationMs?: number) => void;
silenceAllAlarms: (durationMs?: number) => void;
}
export const useBedPressureStore = create<BedPressureStore>((set, get) => ({
// Initial state
sensorData: {},
sensorConfig: [],
selectedSensor: null,
isModalOpen: false,
isMonitoring: true,
alerts: [],
isConnected: false,
selectedTimespan: 24 * 60 * 60 * 1000, // Default 24 hours
activeAlarms: [],
alarmManager: AlarmManager.getInstance(),
// Actions
setSensorData: (data) => set({ sensorData: data }),
updateSensorData: (updater) => set((state) => ({
sensorData: updater(state.sensorData)
})),
setSensorConfig: (config) => set({ sensorConfig: config }),
setSelectedSensor: (sensorId) => set({ selectedSensor: sensorId }),
setIsModalOpen: (isOpen) => set({ isModalOpen: isOpen }),
setIsMonitoring: (isMonitoring) => set({ isMonitoring }),
addAlerts: (newAlerts) => set((state) => ({
alerts: [...newAlerts, ...state.alerts].slice(0, 10)
})),
setIsConnected: (isConnected) => set({ isConnected }),
setSelectedTimespan: (timespan) => set({ selectedTimespan: timespan }),
setActiveAlarms: (alarms) => set({ activeAlarms: alarms }),
// Computed values
averageValue: () => {
const { sensorData } = get();
const sensors = Object.values(sensorData) as SensorData[];
if (sensors.length === 0) return 0;
return sensors.reduce((sum: number, sensor: SensorData) => sum + sensor.currentValue, 0) / sensors.length;
},
criticalSensors: () => {
const { sensorData } = get();
return (Object.values(sensorData) as SensorData[]).filter((sensor: SensorData) =>
sensor.status === 'alarm' || sensor.status === 'critical'
).length;
},
// API actions
fetchSensorConfig: async () => {
try {
const response = await fetch('/api/sensors/config');
const data = await response.json();
if (data.success && data.sensors) {
set({ sensorConfig: data.sensors });
}
} catch (error) {
console.error('Failed to fetch sensor config:', error);
}
},
fetchSensorData: async () => {
try {
const response = await fetch('/api/sensors');
const data = await response.json();
if (data.success && data.sensors) {
const { sensorData, sensorConfig, alarmManager } = get();
const updated = { ...sensorData };
const newAlerts: Alert[] = [];
data.sensors.forEach((sensor: {
id: string;
label: string;
zone: string;
value: number; // Changed from pressure to value
source: 'hardware' | 'mock';
pin?: number;
digitalState?: number;
warningThreshold?: number;
alarmThreshold?: number;
status?: string;
}) => {
const currentSensor = updated[sensor.id];
const newValue = sensor.value;
// Check for alarms based on thresholds
if (sensor.alarmThreshold && newValue >= sensor.alarmThreshold) {
alarmManager.addAlarm(sensor.id, sensor.label, 'alarm', newValue, sensor.alarmThreshold);
} else if (sensor.warningThreshold && newValue >= sensor.warningThreshold) {
alarmManager.addAlarm(sensor.id, sensor.label, 'warning', newValue, sensor.warningThreshold);
} else {
alarmManager.clearAlarm(sensor.id);
}
// Check for alerts (legacy alert system)
if (newValue > 3000 && currentSensor && currentSensor.currentValue <= 3000) {
newAlerts.push({
id: `${sensor.id}-${Date.now()}`,
message: `High value detected at ${sensor.label}`,
time: new Date().toLocaleTimeString(),
});
}
// Update sensor data
updated[sensor.id] = {
...sensorConfig.find(s => s.id === sensor.id),
id: sensor.id,
x: sensorConfig.find(s => s.id === sensor.id)?.x || 50,
y: sensorConfig.find(s => s.id === sensor.id)?.y || 50,
zone: sensor.zone,
label: sensor.label,
currentValue: newValue,
data: currentSensor ? [
...currentSensor.data.slice(1),
{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
value: newValue,
},
] : [{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
value: newValue,
}],
status: sensor.status || (newValue > 3000 ? "critical" : newValue > 2500 ? "warning" : "normal"),
source: sensor.source,
pin: sensor.pin,
digitalState: sensor.digitalState,
warningThreshold: sensor.warningThreshold,
alarmThreshold: sensor.alarmThreshold
};
});
set({
sensorData: updated,
isConnected: data.connected,
activeAlarms: alarmManager.getActiveAlarms()
});
if (newAlerts.length > 0) {
get().addAlerts(newAlerts);
}
}
} catch (error) {
console.error('Failed to fetch sensor data:', error);
}
},
fetchSensorHistory: async (sensorId: string, timespan?: number) => {
try {
const { selectedTimespan } = get();
const timespanToUse = timespan || selectedTimespan;
const response = await fetch(`/api/sensors/history?sensorId=${sensorId}&timespan=${timespanToUse}`);
const data = await response.json();
if (data.success && data.data) {
const { sensorData } = get();
const updated = { ...sensorData };
if (updated[sensorId]) {
updated[sensorId] = {
...updated[sensorId],
data: data.data
};
set({ sensorData: updated });
}
}
} catch (error) {
console.error('Failed to fetch sensor history:', error);
}
},
// Alarm actions
acknowledgeAlarm: (alarmId: string) => {
const { alarmManager } = get();
alarmManager.acknowledgeAlarm(alarmId);
set({ activeAlarms: alarmManager.getActiveAlarms() });
},
silenceAlarm: (alarmId: string, durationMs?: number) => {
const { alarmManager } = get();
alarmManager.silenceAlarm(alarmId, durationMs);
set({ activeAlarms: alarmManager.getActiveAlarms() });
},
silenceAllAlarms: (durationMs?: number) => {
const { alarmManager } = get();
alarmManager.silenceAllAlarms(durationMs);
set({ activeAlarms: alarmManager.getActiveAlarms() });
}
}));