diff --git a/backend/prisma/migrations/20250417000000_add_subject_info/migration.sql b/backend/prisma/migrations/20250417000000_add_subject_info/migration.sql new file mode 100644 index 0000000..a0170f4 --- /dev/null +++ b/backend/prisma/migrations/20250417000000_add_subject_info/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "experiments" ADD COLUMN "subject_info_template" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "animals" ADD COLUMN "subject_info" JSONB; diff --git a/backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql b/backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql new file mode 100644 index 0000000..51185ef --- /dev/null +++ b/backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql @@ -0,0 +1,21 @@ +CREATE TABLE "daily_analyses" ( + "id" TEXT NOT NULL, + "daily_status_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "file_name" TEXT, + "timestamp_col" TEXT NOT NULL, + "note_col" TEXT NOT NULL, + "classifications" JSONB NOT NULL, + "total_note_rows" INTEGER NOT NULL, + "sequence" JSONB NOT NULL, + "category_counts" JSONB NOT NULL, + "consecutive_stats" JSONB NOT NULL, + "runs" JSONB NOT NULL, + CONSTRAINT "daily_analyses_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "daily_analyses" + ADD CONSTRAINT "daily_analyses_daily_status_id_fkey" + FOREIGN KEY ("daily_status_id") + REFERENCES "daily_statuses"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql b/backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql new file mode 100644 index 0000000..47eb986 --- /dev/null +++ b/backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "daily_analyses" ADD COLUMN "session_end_ts" DOUBLE PRECISION; diff --git a/backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql b/backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql new file mode 100644 index 0000000..ea12a0d --- /dev/null +++ b/backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "daily_statuses" ADD COLUMN "analysis_summary" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5c861dc..4700952 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,11 +9,12 @@ datasource db { } model Experiment { - id String @id @default(uuid()) - title String - created_at DateTime @default(now()) - template Json @default("[]") - animals Animal[] + id String @id @default(uuid()) + title String + created_at DateTime @default(now()) + template Json @default("[]") + subject_info_template Json @default("[]") + animals Animal[] @@map("experiments") } @@ -23,6 +24,7 @@ model Animal { experiment_id String animal_id_string String animal_name String + subject_info Json? experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade) daily_statuses DailyStatus[] @@ -30,19 +32,40 @@ model Animal { } model DailyStatus { - id String @id @default(uuid()) + id String @id @default(uuid()) animal_id String - date DateTime @db.Date + date DateTime @db.Date experiment_description String? vitals String? treatment String? notes String? custom_fields Json? - animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) + analysis_summary Json? + animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) + analyses DailyAnalysis[] @@map("daily_statuses") } +model DailyAnalysis { + id String @id @default(uuid()) + daily_status_id String + daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade) + created_at DateTime @default(now()) + file_name String? + timestamp_col String + note_col String + classifications Json + total_note_rows Int + session_end_ts Float? + sequence Json + category_counts Json + consecutive_stats Json + runs Json + + @@map("daily_analyses") +} + model AuditLog { id String @id @default(uuid()) table_name String diff --git a/backend/src/app.js b/backend/src/app.js index 4a410db..b66cd70 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -9,6 +9,7 @@ const experimentsRouter = require('./routes/experiments'); const animalsRouter = require('./routes/animals'); const dailyStatusesRouter = require('./routes/dailyStatuses'); const auditLogsRouter = require('./routes/auditLogs'); +const analysesRouter = require('./routes/analyses'); const app = express(); @@ -43,6 +44,17 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// DB info — returns database timezone (no rate limit) +const prisma = require('./lib/prisma'); +app.get('/api/info', async (req, res) => { + try { + const [{ timezone }] = await prisma.$queryRaw`SELECT current_setting('TimeZone') AS timezone`; + res.json({ dbTimezone: timezone }); + } catch { + res.json({ dbTimezone: null }); + } +}); + // Apply rate limiter to all API routes app.use('/api', apiLimiter); @@ -50,6 +62,7 @@ app.use('/api/experiments', experimentsRouter); app.use('/api/animals', animalsRouter); app.use('/api/daily-statuses', dailyStatusesRouter); app.use('/api/audit-logs', auditLogsRouter); +app.use('/api', analysesRouter); app.use((req, res) => { res.status(404).json({ error: `Route ${req.method} ${req.path} not found` }); diff --git a/backend/src/lib/defaultTemplate.js b/backend/src/lib/defaultTemplate.js index 7b078f8..824cc07 100644 --- a/backend/src/lib/defaultTemplate.js +++ b/backend/src/lib/defaultTemplate.js @@ -1,22 +1,20 @@ /** * The default daily-record template applied to every new experiment. * - * Builtin fields have FIXED fieldIds so they are stable across all experiments - * and can never be confused with user-created fields, even if a user creates a - * custom field with the same label. - * - * `fieldId` — stable UUID used as the storage key; never changes even if - * the field is renamed, reordered, or temporarily removed - * `key` — human-readable slug (display only, not used for storage) - * `builtin` — true: value stored in the dedicated column of daily_statuses - * false: value stored in daily_statuses.custom_fields[fieldId] - * `active` — false: field is hidden (data preserved) + * `fieldId` — stable UUID; used as the storage key in custom_fields. + * Builtin fields have fixed canonical UUIDs so they are + * recognised across all experiments. + * `key` — human-readable slug (display / validation only) + * `builtin` — true → value stored in the dedicated DB column + * false → value stored in daily_statuses.custom_fields[fieldId] + * `active` — false → hidden from forms (data preserved) + * `tags` — quick-fill presets, stored per field per experiment */ const DEFAULT_TEMPLATE = [ - { fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true }, - { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true }, - { fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true }, - { fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true }, + { fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, tags: [] }, + { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] }, + { fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true, tags: [] }, + { fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, tags: [] }, ]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/backend/src/routes/analyses.js b/backend/src/routes/analyses.js new file mode 100644 index 0000000..a1e2b20 --- /dev/null +++ b/backend/src/routes/analyses.js @@ -0,0 +1,79 @@ +const router = require('express').Router(); +const prisma = require('../lib/prisma'); + +// GET /api/daily-statuses/:statusId/analyses +router.get('/daily-statuses/:statusId/analyses', async (req, res, next) => { + try { + const analyses = await prisma.dailyAnalysis.findMany({ + where: { daily_status_id: req.params.statusId }, + orderBy: { created_at: 'desc' }, + select: { + id: true, + created_at: true, + file_name: true, + timestamp_col: true, + note_col: true, + classifications: true, + total_note_rows: true, + session_end_ts: true, + category_counts: true, + consecutive_stats: true, + runs: true, + // sequence omitted from list — fetch individually when needed + }, + }); + res.json(analyses); + } catch (err) { next(err); } +}); + +// POST /api/daily-statuses/:statusId/analyses +router.post('/daily-statuses/:statusId/analyses', async (req, res, next) => { + try { + const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.statusId } }); + if (!status) return res.status(404).json({ error: 'Daily status not found' }); + + const { file_name, timestamp_col, note_col, classifications, total_note_rows, + session_end_ts, sequence, category_counts, consecutive_stats, runs } = req.body; + + if (!timestamp_col || !note_col || classifications == null || total_note_rows == null + || !sequence || !category_counts || !consecutive_stats || !runs) { + return res.status(422).json({ error: 'Missing required analysis fields' }); + } + + const analysis = await prisma.dailyAnalysis.create({ + data: { + daily_status_id: req.params.statusId, + file_name: file_name ?? null, + timestamp_col, + note_col, + classifications, + total_note_rows, + session_end_ts: session_end_ts != null ? Number(session_end_ts) : null, + sequence, + category_counts, + consecutive_stats, + runs, + }, + }); + res.status(201).json(analysis); + } catch (err) { next(err); } +}); + +// GET /api/analyses/:id (full record including sequence) +router.get('/analyses/:id', async (req, res, next) => { + try { + const analysis = await prisma.dailyAnalysis.findUnique({ where: { id: req.params.id } }); + if (!analysis) return res.status(404).json({ error: 'Analysis not found' }); + res.json(analysis); + } catch (err) { next(err); } +}); + +// DELETE /api/analyses/:id +router.delete('/analyses/:id', async (req, res, next) => { + try { + await prisma.dailyAnalysis.delete({ where: { id: req.params.id } }); + res.status(204).send(); + } catch (err) { next(err); } +}); + +module.exports = router; diff --git a/backend/src/routes/animals.js b/backend/src/routes/animals.js index 2d29d81..0c645b8 100644 --- a/backend/src/routes/animals.js +++ b/backend/src/routes/animals.js @@ -13,6 +13,7 @@ const animalValidation = [ const animalUpdateValidation = [ body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'), body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'), + body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'), ]; // GET /api/animals?experimentId= — list for experiment @@ -80,10 +81,11 @@ router.put( validateRequest, async (req, res, next) => { try { - const { animal_id_string, animal_name } = req.body; + const { animal_id_string, animal_name, subject_info } = req.body; const data = {}; if (animal_id_string !== undefined) data.animal_id_string = animal_id_string; if (animal_name !== undefined) data.animal_name = animal_name; + if (subject_info !== undefined) data.subject_info = subject_info; const animal = await prisma.animal.update({ where: { id: req.params.id }, diff --git a/backend/src/routes/dailyStatuses.js b/backend/src/routes/dailyStatuses.js index efb09eb..e35c0ae 100644 --- a/backend/src/routes/dailyStatuses.js +++ b/backend/src/routes/dailyStatuses.js @@ -7,20 +7,20 @@ const { validateRequest } = require('../middleware/errorHandler'); const statusValidation = [ body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'), body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'), - body('experiment_description').optional().isString(), - body('vitals').optional().isString(), - body('treatment').optional().isString(), - body('notes').optional().isString(), - body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'), + body('experiment_description').optional({ nullable: true }).isString(), + body('vitals').optional({ nullable: true }).isString(), + body('treatment').optional({ nullable: true }).isString(), + body('notes').optional({ nullable: true }).isString(), + body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'), ]; const statusUpdateValidation = [ body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'), - body('experiment_description').optional().isString(), - body('vitals').optional().isString(), - body('treatment').optional().isString(), - body('notes').optional().isString(), - body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'), + body('experiment_description').optional({ nullable: true }).isString(), + body('vitals').optional({ nullable: true }).isString(), + body('treatment').optional({ nullable: true }).isString(), + body('notes').optional({ nullable: true }).isString(), + body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'), ]; // GET /api/daily-statuses?animalId= @@ -98,6 +98,33 @@ router.put( } ); +// PUT /api/daily-statuses/:id/analysis-summary +router.put('/:id/analysis-summary', async (req, res, next) => { + try { + const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } }); + if (!status) return res.status(404).json({ error: 'Daily status not found' }); + + const { counts, total, success_rate, source_analysis_id } = req.body; + if (!counts || total == null) { + return res.status(422).json({ error: 'counts and total are required' }); + } + + const summary = { + counts, + total, + success_rate: success_rate ?? null, + source_analysis_id: source_analysis_id ?? null, + computed_at: new Date().toISOString(), + }; + + const updated = await prisma.dailyStatus.update({ + where: { id: req.params.id }, + data: { analysis_summary: summary }, + }); + res.json(updated); + } catch (err) { next(err); } +}); + // DELETE /api/daily-statuses/:id router.delete( '/:id', diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 9b4ceca..2e9f106 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -55,6 +55,33 @@ function validateTemplate(template) { return `field "${field.key}" is a builtin field and cannot be made non-builtin`; if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false) return `custom field "${field.key}" must have builtin: false`; + + // ── abbr / unit / width ──────────────────────────────────────────────────── + if (field.abbr !== undefined && field.abbr !== null) { + if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`; + if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`; + } + if (field.unit !== undefined && field.unit !== null) { + if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`; + if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`; + } + if (field.width !== undefined && field.width !== null) { + if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100) + return `width for "${field.key}" must be an integer between 1 and 100`; + } + + // ── tags ─────────────────────────────────────────────────────────────────── + if (field.tags !== undefined) { + if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`; + if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`; + for (const tag of field.tags) { + if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`; + if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`; + } + } + + if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean') + return `tagsDisabled for "${field.key}" must be a boolean`; } return null; } @@ -68,6 +95,7 @@ function ensureFieldIds(template) { return template.map((f) => ({ ...f, fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()), + tags: f.tags ?? [], // always return tags array (empty if not yet set) })); } @@ -138,6 +166,63 @@ router.delete( } ); +// ── Subject-template validation ──────────────────────────────────────────────── + +function validateSubjectTemplate(template) { + if (!Array.isArray(template)) return 'subject_info_template must be an array'; + if (template.length > 50) return 'subject_info_template may have at most 50 fields'; + + const seenFieldIds = new Set(); + const seenKeys = new Set(); + + for (const field of template) { + if (!field.fieldId || !UUID_RE.test(field.fieldId)) + return `each field must have a valid UUID fieldId (got: ${field.fieldId})`; + if (seenFieldIds.has(field.fieldId)) return `duplicate fieldId "${field.fieldId}"`; + seenFieldIds.add(field.fieldId); + + if (!field.key || typeof field.key !== 'string') return 'each field must have a string key'; + if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`; + if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`; + if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`; + seenKeys.add(field.key); + + if (!field.label || typeof field.label !== 'string' || !field.label.trim()) + return `field "${field.key}" must have a non-empty label`; + if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`; + + if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`; + if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`; + if (field.builtin !== false) return `subject field "${field.key}" must have builtin: false`; + + if (field.abbr !== undefined && field.abbr !== null) { + if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`; + if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`; + } + if (field.unit !== undefined && field.unit !== null) { + if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`; + if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`; + } + if (field.width !== undefined && field.width !== null) { + if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100) + return `width for "${field.key}" must be an integer between 1 and 100`; + } + + if (field.tags !== undefined) { + if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`; + if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`; + for (const tag of field.tags) { + if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`; + if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`; + } + } + + if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean') + return `tagsDisabled for "${field.key}" must be a boolean`; + } + return null; +} + // ── Template endpoints ───────────────────────────────────────────────────────── // GET /api/experiments/:id/template @@ -181,4 +266,106 @@ router.put('/:id/template', async (req, res, next) => { } catch (err) { next(err); } }); +// GET /api/experiments/:id/subject-template +router.get('/:id/subject-template', async (req, res, next) => { + try { + const experiment = await prisma.experiment.findUnique({ + where: { id: req.params.id }, + select: { id: true, subject_info_template: true }, + }); + if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); + const tmpl = Array.isArray(experiment.subject_info_template) ? experiment.subject_info_template : []; + res.json(tmpl.map((f) => ({ ...f, tags: f.tags ?? [] }))); + } catch (err) { next(err); } +}); + +// PUT /api/experiments/:id/subject-template +router.put('/:id/subject-template', async (req, res, next) => { + try { + const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } }); + if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); + + const template = req.body; + const validationError = validateSubjectTemplate(template); + if (validationError) return res.status(422).json({ error: validationError }); + + const updated = await prisma.experiment.update({ + where: { id: req.params.id }, + data: { subject_info_template: template }, + }); + + await prisma.auditLog.create({ + data: { + table_name: 'experiments', + record_id: experiment.id, + action: 'UPDATE', + changes: { before: { subject_info_template: experiment.subject_info_template }, after: { subject_info_template: template } }, + }, + }); + + res.json(template); + } catch (err) { next(err); } +}); + +// GET /api/experiments/:id/calendar?field= +// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day. +router.get('/:id/calendar', async (req, res, next) => { + try { + const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } }); + if (!exp) return res.status(404).json({ error: 'Experiment not found' }); + + const field = req.query.field ?? ''; + const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); + const isBuiltin = BUILTIN_KEYS_SET.has(field); + + const statuses = await prisma.dailyStatus.findMany({ + where: { animal: { experiment_id: req.params.id } }, + select: { + date: true, + experiment_description: true, + vitals: true, + treatment: true, + notes: true, + custom_fields: true, + }, + }); + + const days = {}; + for (const s of statuses) { + const value = isBuiltin ? s[field] : s.custom_fields?.[field]; + if (value !== null && value !== undefined && String(value).trim() !== '') { + const dateKey = new Date(s.date).toISOString().slice(0, 10); + days[dateKey] = (days[dateKey] || 0) + 1; + } + } + + res.json({ field, days }); + } catch (err) { next(err); } +}); + +// GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included +router.get('/:id/daily-statuses', async (req, res, next) => { + try { + const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } }); + if (!exp) return res.status(404).json({ error: 'Experiment not found' }); + + const statuses = await prisma.dailyStatus.findMany({ + where: { animal: { experiment_id: req.params.id } }, + select: { + id: true, + animal_id: true, + date: true, + analysis_summary: true, + experiment_description: true, + vitals: true, + treatment: true, + notes: true, + custom_fields: true, + }, + orderBy: { date: 'asc' }, + }); + res.json(statuses); + } catch (err) { next(err); } +}); + module.exports = router; diff --git a/backend/tests/calendar.test.js b/backend/tests/calendar.test.js new file mode 100644 index 0000000..2c3574b --- /dev/null +++ b/backend/tests/calendar.test.js @@ -0,0 +1,129 @@ +const request = require('supertest'); + +jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma')); +const prisma = require('../src/lib/prisma'); +const app = require('../src/app'); + +const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; + +const EXPERIMENT = { + id: EXP_ID, + title: 'Calendar Test Study', + created_at: new Date('2024-01-01').toISOString(), +}; + +// Helper: build a DailyStatus record +function makeStatus({ date, vitals = null, customFields = null }) { + return { + date: new Date(date), + experiment_description: null, + vitals, + treatment: null, + notes: null, + custom_fields: customFields, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('GET /api/experiments/:id/calendar', () => { + it('returns 404 when experiment does not exist', async () => { + prisma.experiment.findUnique.mockResolvedValue(null); + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`); + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/not found/i); + }); + + it('returns empty days object when no statuses exist', async () => { + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([]); + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`); + expect(res.status).toBe(200); + expect(res.body.days).toEqual({}); + expect(res.body.field).toBe('vitals'); + }); + + it('counts subjects with non-empty builtin field per day', async () => { + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([ + makeStatus({ date: '2026-03-10', vitals: '120/80' }), + makeStatus({ date: '2026-03-10', vitals: '110/70' }), + makeStatus({ date: '2026-03-11', vitals: '115/75' }), + makeStatus({ date: '2026-03-12', vitals: null }), // null — not counted + makeStatus({ date: '2026-03-12', vitals: '' }), // empty — not counted + ]); + + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`); + expect(res.status).toBe(200); + expect(res.body.days).toEqual({ + '2026-03-10': 2, + '2026-03-11': 1, + }); + }); + + it('counts subjects with non-empty custom field per day', async () => { + const FIELD_ID = 'cccccccc-0000-0000-0000-000000000099'; + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([ + makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '320' } }), + makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '310' } }), + makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: null } }), // null — not counted + makeStatus({ date: '2026-04-02', customFields: { [FIELD_ID]: '315' } }), + ]); + + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`); + expect(res.status).toBe(200); + expect(res.body.days).toEqual({ + '2026-04-01': 2, + '2026-04-02': 1, + }); + }); + + it('ignores whitespace-only custom field values', async () => { + const FIELD_ID = 'dddddddd-0000-0000-0000-000000000099'; + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([ + makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: ' ' } }), + makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: '280' } }), + ]); + + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`); + expect(res.status).toBe(200); + expect(res.body.days).toEqual({ '2026-04-05': 1 }); + }); + + it('returns days from multiple months correctly', async () => { + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([ + makeStatus({ date: '2026-02-15', vitals: '100/60' }), + makeStatus({ date: '2026-03-01', vitals: '105/65' }), + makeStatus({ date: '2026-03-01', vitals: '108/68' }), + ]); + + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`); + expect(res.status).toBe(200); + expect(res.body.days['2026-02-15']).toBe(1); + expect(res.body.days['2026-03-01']).toBe(2); + }); + + it('returns field key in response body', async () => { + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([]); + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=treatment`); + expect(res.status).toBe(200); + expect(res.body.field).toBe('treatment'); + }); + + it('handles missing field param gracefully (empty days)', async () => { + prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); + prisma.dailyStatus.findMany.mockResolvedValue([ + makeStatus({ date: '2026-04-01', vitals: 'present' }), + ]); + // No ?field= → all values undefined, nothing counted + const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar`); + expect(res.status).toBe(200); + expect(res.body.days).toEqual({}); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 9124540..1ed8da8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: POSTGRES_USER: ${POSTGRES_USER:-expuser} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword} POSTGRES_DB: ${POSTGRES_DB:-experiments_db} + TZ: America/New_York + command: ["postgres", "-c", "timezone=America/New_York"] volumes: - postgres_data:/var/lib/postgresql/data # Port NOT exposed to host — only reachable by backend via internal Docker network @@ -34,6 +36,7 @@ services: NODE_ENV: production PORT: 3001 CORS_ORIGIN: "http://localhost:52867" + TZ: America/New_York # Non-root user set in Dockerfile; entrypoint runs migrations then starts server entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"] ports: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3408cd6..a535f56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,8 @@ "date-fns": "^3.6.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.24.0" + "react-router-dom": "^6.24.0", + "recharts": "^2.12.7" }, "devDependencies": { "@babel/core": "^7.24.7", @@ -1757,7 +1758,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -2832,6 +2832,60 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3598,6 +3652,14 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3739,9 +3801,117 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } }, "node_modules/date-fns": { "version": "3.6.0", @@ -3775,6 +3945,11 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3917,6 +4092,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -4156,6 +4340,11 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -4181,6 +4370,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4642,6 +4839,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -6282,6 +6487,11 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -6501,7 +6711,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6950,6 +7159,21 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -7089,6 +7313,35 @@ "react-dom": ">=16.8" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "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" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "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" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7110,6 +7363,41 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "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" + }, + "engines": { + "node": ">=14" + }, + "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" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7758,6 +8046,11 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7969,6 +8262,27 @@ "node": ">=10.12.0" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "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" + } + }, "node_modules/vite": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", @@ -9407,8 +9721,7 @@ "@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" }, "@babel/template": { "version": "7.28.6", @@ -10153,6 +10466,60 @@ "@babel/types": "^7.28.2" } }, + "@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -10711,6 +11078,11 @@ "wrap-ansi": "^7.0.0" } }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10820,9 +11192,84 @@ "csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, + "d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" }, "date-fns": { "version": "3.6.0", @@ -10844,6 +11291,11 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, + "decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -10946,6 +11398,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -11116,6 +11577,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -11135,6 +11601,11 @@ "jest-util": "^29.7.0" } }, + "fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==" + }, "fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -11457,6 +11928,11 @@ "side-channel": "^1.1.0" } }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, "is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -12669,6 +13145,11 @@ "p-locate": "^4.1.0" } }, + "lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -12835,8 +13316,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-hash": { "version": "3.0.0", @@ -13101,6 +13581,23 @@ "sisteransi": "^1.0.5" } }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, "proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -13185,6 +13682,27 @@ "react-router": "6.30.3" } }, + "react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "requires": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + } + }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -13203,6 +13721,36 @@ "picomatch": "^2.2.1" } }, + "recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "requires": { + "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" + }, + "dependencies": { + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + } + } + }, + "recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "requires": { + "decimal.js-light": "^2.4.1" + } + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -13674,6 +14222,11 @@ "thenify": ">= 3.1.0 < 4" } }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -13815,6 +14368,27 @@ "convert-source-map": "^2.0.0" } }, + "victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "requires": { + "@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" + } + }, "vite": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 452ca84..bbf2455 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "axios": "^1.7.2", + "recharts": "^2.12.7", "date-fns": "^3.6.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cdf60e3..7b88139 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Routes, Route, Link, useLocation } from 'react-router-dom'; +import { systemApi } from './api/client'; import Dashboard from './pages/Dashboard'; import ExperimentDetail from './pages/ExperimentDetail'; import AnimalDetail from './pages/AnimalDetail'; +import DailyStatusDetail from './pages/DailyStatusDetail'; function Navbar() { return ( @@ -33,18 +35,34 @@ function NotFound() { ); } +function Footer() { + const [dbTimezone, setDbTimezone] = useState(null); + useEffect(() => { + systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {}); + }, []); + return ( +
+
+ Database timezone: {dbTimezone ?? '…'} +
+
+ ); +} + export default function App() { return ( -
+
-
+
} /> } /> } /> + } /> } />
+
); } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 4845d8c..121963c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -31,6 +31,10 @@ export const experimentsApi = { delete: (id) => api.delete(`/experiments/${id}`), getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data), updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data), + getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data), + updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data), + getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).then((r) => r.data), + getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data), }; // ── Animals ─────────────────────────────────────────────────────────────────── @@ -51,9 +55,23 @@ export const dailyStatusesApi = { create: (data) => api.post('/daily-statuses', data).then((r) => r.data), update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data), delete: (id) => api.delete(`/daily-statuses/${id}`), + updateAnalysisSummary: (id, data) => api.put(`/daily-statuses/${id}/analysis-summary`, data).then((r) => r.data), }; // ── Audit Logs ──────────────────────────────────────────────────────────────── export const auditLogsApi = { list: (params) => api.get('/audit-logs', { params }).then((r) => r.data), }; + +// ── System ──────────────────────────────────────────────────────────────────── +export const systemApi = { + info: () => api.get('/info').then((r) => r.data), +}; + +// ── Analyses ────────────────────────────────────────────────────────────────── +export const analysesApi = { + listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data), + create: (statusId, data) => api.post(`/daily-statuses/${statusId}/analyses`, data).then((r) => r.data), + get: (id) => api.get(`/analyses/${id}`).then((r) => r.data), + delete: (id) => api.delete(`/analyses/${id}`), +}; diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx new file mode 100644 index 0000000..7bbc4b6 --- /dev/null +++ b/frontend/src/components/AnalysisCharts.jsx @@ -0,0 +1,413 @@ +import React, { useState, useMemo } from 'react'; +import { format } from 'date-fns'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + Legend, ResponsiveContainer, +} from 'recharts'; + +// ── Color palettes ───────────────────────────────────────────────────────────── + +const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' }; +const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316']; +const GROUP_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#ec4899', '#f97316', '#84cc16', '#06b6d4']; + +function catColor(cat, idx = 0) { + return CAT_COLORS[cat] ?? EXTRA_CAT[idx % EXTRA_CAT.length]; +} + +// ── Shared tooltip ───────────────────────────────────────────────────────────── + +function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = '' }) { + if (!active || !payload?.length) return null; + const header = payload[0]?.payload?.[labelKey] ?? `Day ${label}`; + return ( +
+

{header}

+ {payload.map((p) => ( +

+ {p.name}: {p.value != null ? `${p.value}${unit}` : '—'} +

+ ))} +
+ ); +} + +// ── Shared helpers ───────────────────────────────────────────────────────────── + +const DAYS_REACH_RE = /#?\s*days?\s*reach/i; + +/** Pick the default xField from a list of active daily-template fields. */ +function defaultXField(fields) { + return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__'; +} + +/** Read a template field's value from a status row (numeric, or null). */ +function numericFieldVal(status, field) { + if (!field) return null; + const raw = field.builtin ? status[field.key] : status.custom_fields?.[field.fieldId]; + const n = parseFloat(raw); + return isNaN(n) ? null : n; +} + +const SELECT_CLS = 'border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400'; + +const STEP_OPTIONS = [1, 2, 5, 10]; + +/** Build explicit integer ticks for a numeric x-axis so every step value is shown. */ +function buildTicks(data, step) { + const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x)); + if (!xs.length) return undefined; + const lo = Math.floor(Math.min(...xs)); + const hi = Math.ceil(Math.max(...xs)); + const ticks = []; + for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t); + if (!ticks.length || ticks[0] > lo) ticks.unshift(lo); + if (ticks[ticks.length - 1] < hi) ticks.push(hi); + return ticks; +} + +function TickStepControl({ value, onChange }) { + return ( +
+ Tick every +
+ {STEP_OPTIONS.map((s) => ( + + ))} +
+
+ ); +} + +// ── Subject charts ───────────────────────────────────────────────────────────── + +export function SubjectAnalysisCharts({ statuses, dailyTemplate }) { + const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active); + const [xField, setXField] = useState(() => defaultXField(xAxisFields)); + const [tickStep, setTickStep] = useState(1); + + const { data, cats, xLabel } = useMemo(() => { + const xAxisField = xAxisFields.find((f) => f.fieldId === xField); + const xLabel = xAxisField ? xAxisField.label : '# Days Reach'; + + const withMetrics = statuses + .filter((s) => s.analysis_summary?.total != null) + .sort((a, b) => String(a.date).localeCompare(String(b.date))); + + const rows = withMetrics.map((s, i) => { + const xVal = xField === '__days__' || !xAxisField + ? i + 1 + : numericFieldVal(s, xAxisField); + const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d'); + const { counts = {}, total, success_rate } = s.analysis_summary; + return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts }; + }).filter((r) => r._x != null).sort((a, b) => a._x - b._x); + + const data = rows.map((r) => ({ ...r, x: r._x })); + + const catSet = new Set(); + for (const d of data) { + for (const k of Object.keys(d)) { + if (!['x', '_x', 'dateStr', 'total', 'rate'].includes(k)) catSet.add(k); + } + } + return { data, cats: [...catSet], xLabel }; + }, [statuses, xField, dailyTemplate]); + + const ticks = useMemo(() => buildTicks(data, tickStep), [data, tickStep]); + + if (data.length === 0) return null; + + const xAxisProps = { + dataKey: 'x', type: 'number', tick: { fontSize: 10 }, + ticks, + domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'], + label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 }, + allowDecimals: false, + }; + + return ( +
+
+

Session metrics over time

+
+ X axis: + +
+ +
+ + {/* Chart 1 — counts */} +
+

Attempts per session

+ + + + + + } /> + + {cats.map((cat, i) => ( + + ))} + + + +
+ + {/* Chart 2 — success rate */} +
+

Success rate (%)

+ + + + + `${v}%`} /> + } /> + + + +
+
+ ); +} + +// ── Experiment charts ────────────────────────────────────────────────────────── + +const METRIC_OPTIONS = [ + { value: 'total', label: 'Total attempts' }, + { value: 'Success', label: 'Success count' }, + { value: 'Failure', label: 'Failure count' }, +]; + +export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) { + const groupableFields = (subjectTemplate ?? []).filter((f) => f.active); + const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active); + + const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? ''; + const [groupBy, setGroupBy] = useState(defaultGroup); + const [metric, setMetric] = useState('total'); + const [xField, setXField] = useState(() => defaultXField(xAxisFields)); + const [tickStep, setTickStep] = useState(1); + + const { countData, rateData, lines, xLabel } = useMemo(() => { + // animalId → group label + const animalGroup = {}; + for (const animal of animals) { + const raw = groupBy ? animal.subject_info?.[groupBy] : null; + animalGroup[animal.id] = raw != null && raw !== '' ? String(raw) : '—'; + } + const groups = [...new Set(Object.values(animalGroup))].sort(); + + // animalId → sorted statuses that have saved metrics (full status rows) + const statusesByAnimal = {}; + for (const s of experimentStatuses) { + if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = []; + statusesByAnimal[s.animal_id].push(s); + } + const animalRows = {}; // full status rows, sorted by date + for (const animal of animals) { + animalRows[animal.id] = (statusesByAnimal[animal.id] ?? []) + .filter((s) => s.analysis_summary?.total != null) + .sort((a, b) => String(a.date).localeCompare(String(b.date))); + } + + const lines = animals + .filter((a) => animalRows[a.id].length > 0) + .map((a) => ({ + id: a.id, + name: a.animal_name, + group: animalGroup[a.id], + color: GROUP_COLORS[groups.indexOf(animalGroup[a.id]) % GROUP_COLORS.length], + })); + + if (lines.length === 0) return { countData: [], rateData: [], lines: [], xLabel: '' }; + + const xAxisField = xAxisFields.find((f) => f.fieldId === xField); + const xLabel = xAxisField ? xAxisField.label : '# Days Reach'; + + function getX(row, dayIndex) { + if (xField === '__days__') return dayIndex + 1; + return numericFieldVal(row, xAxisField); + } + + function getCountVal(summary) { + return metric === 'total' + ? summary.total + : (summary.counts?.[metric] ?? null); + } + + if (xField === '__days__') { + // Ordinal alignment: position by day index + const maxDay = Math.max(...lines.map((l) => animalRows[l.id].length)); + const countData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 })); + const rateData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 })); + for (const line of lines) { + animalRows[line.id].forEach((row, i) => { + const s = row.analysis_summary; + countData[i][line.id] = getCountVal(s); + rateData[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null; + }); + } + return { countData, rateData, lines, xLabel }; + } + + // Field-based x axis: merge all unique x values across animals + const allPts = []; + for (const line of lines) { + animalRows[line.id].forEach((row, i) => { + const x = getX(row, i); + if (x !== null) allPts.push({ x, animalId: line.id, summary: row.analysis_summary }); + }); + } + const sortedX = [...new Set(allPts.map((p) => p.x))].sort((a, b) => a - b); + if (sortedX.length === 0) return { countData: [], rateData: [], lines, xLabel }; + + const countData = sortedX.map((x) => { + const pt = { x }; + for (const p of allPts.filter((p) => p.x === x)) { + pt[p.animalId] = getCountVal(p.summary); + } + return pt; + }); + const rateData = sortedX.map((x) => { + const pt = { x }; + for (const p of allPts.filter((p) => p.x === x)) { + const s = p.summary; + pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null; + } + return pt; + }); + + return { countData, rateData, lines, xLabel }; + }, [animals, experimentStatuses, subjectTemplate, dailyTemplate, groupBy, metric, xField]); + + const hasData = lines.length > 0; + const countTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]); + const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]); + + function xAxisProps(ticks) { + return { + dataKey: 'x', type: 'number', tick: { fontSize: 10 }, allowDecimals: false, + ticks, + domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'], + label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 }, + }; + } + + function ExpTooltip({ active, payload, label, unit = '' }) { + if (!active || !payload?.length) return null; + return ( +
+

{xLabel}: {label}

+ {payload.map((p) => { + const line = lines.find((l) => l.id === p.dataKey); + return p.value != null ? ( +

+ {line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit} +

+ ) : null; + })} +
+ ); + } + + return ( +
+
+

Cross-subject metrics

+ + {/* X-axis field selector */} +
+ X axis: + +
+ + {/* Color-by subject info field */} + {groupableFields.length > 0 && ( +
+ Color by: + +
+ )} + +
+ + {!hasData && ( +

No sessions with saved metrics yet. Upload a CSV on a daily status page and click "Save metrics to daily status".

+ )} + + {hasData && ( + <> + {/* Chart 1 — counts */} +
+
+

Count per session — each line is one subject

+
+ {METRIC_OPTIONS.map((opt) => ( + + ))} +
+
+ + + + + + } /> + lines.find((l) => l.id === value)?.name ?? value} /> + {lines.map((l) => ( + + ))} + + +
+ + {/* Chart 2 — success rate */} +
+

Success rate (%) — each line is one subject

+ + + + + `${v}%`} /> + } /> + lines.find((l) => l.id === value)?.name ?? value} /> + {lines.map((l) => ( + + ))} + + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/AuditLogSection.jsx b/frontend/src/components/AuditLogSection.jsx index 7cd2b14..785841c 100644 --- a/frontend/src/components/AuditLogSection.jsx +++ b/frontend/src/components/AuditLogSection.jsx @@ -36,7 +36,7 @@ const ACTION_BADGE = { DELETE: 'bg-red-100 text-red-800', }; -export default function AuditLogSection({ tableName, recordId }) { +export default function AuditLogSection({ tableName, recordId, limit = 50 }) { const [logs, setLogs] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -46,14 +46,14 @@ export default function AuditLogSection({ tableName, recordId }) { useEffect(() => { setLoading(true); auditLogsApi - .list({ tableName, recordId, limit: 50 }) + .list({ tableName, recordId, limit }) .then(({ logs, total }) => { setLogs(logs); setTotal(total); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); - }, [tableName, recordId]); + }, [tableName, recordId, limit]); return (
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) { {logs.length > 0 && (
- {logs.map((log) => ( + {logs.slice(0, limit).map((log) => (
); } diff --git a/frontend/src/components/CsvAnalysis.jsx b/frontend/src/components/CsvAnalysis.jsx new file mode 100644 index 0000000..6b6b730 --- /dev/null +++ b/frontend/src/components/CsvAnalysis.jsx @@ -0,0 +1,602 @@ +import React, { useState, useRef, useCallback } from 'react'; +import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive } from '../lib/csvAnalysis'; +import { analysesApi, dailyStatusesApi } from '../api/client'; +import Button from './ui/Button'; +import RunSequenceView from './RunSequenceView'; + +// ── Constants ────────────────────────────────────────────────────────────────── + +const DEFAULT_CATEGORIES = ['Success', 'Failure', 'Other']; + +const CATEGORY_COLORS = { + Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500' }, + Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500' }, + Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400' }, +}; + +const EXTRA_COLORS = [ + { bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500' }, + { bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500' }, + { bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500' }, + { bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500' }, + { bg: 'bg-teal-100', border: 'border-teal-300', text: 'text-teal-800', dot: 'bg-teal-500' }, +]; + +function getCategoryColors(cat, customCategories) { + if (CATEGORY_COLORS[cat]) return CATEGORY_COLORS[cat]; + const idx = customCategories.indexOf(cat) % EXTRA_COLORS.length; + return EXTRA_COLORS[Math.max(0, idx)]; +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function NoteChip({ value, category, colors, selected, onSelect, draggable, onDragStart }) { + return ( + + {value} + {category && ( + + )} + + ); +} + +function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign }) { + return ( +
{ e.preventDefault(); onDragOver(); }} + onDragLeave={onDragLeave} + onDrop={(e) => { e.preventDefault(); onDrop(); }} + onClick={onClickAssign} + className={` + rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer + ${isOver ? `${colors.border} ${colors.bg} scale-[1.02]` : 'border-gray-200 hover:border-gray-300'} + `} + > +

{name}

+
+ {notes.length === 0 && ( + Drop here + )} + {notes.map((note) => ( + + {note} + + + ))} +
+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export default function CsvAnalysis({ dailyStatusId, onSaved, onSummaryPushed }) { + // Phase: 'upload' | 'columns' | 'classify' | 'results' + const [phase, setPhase] = useState('upload'); + + // Upload phase + const [isDragOver, setIsDragOver] = useState(false); + const [fileName, setFileName] = useState(''); + const [rawText, setRawText] = useState(''); + const [headers, setHeaders] = useState([]); + const fileInputRef = useRef(null); + + // Columns phase + const [timestampCol, setTimestampCol] = useState(''); + const [noteCol, setNoteCol] = useState(''); + const [frameCol, setFrameCol] = useState(''); + const [frameCheck, setFrameCheck] = useState(null); + const [parsePending, setParsePending] = useState(false); + + // Classify phase + const [rows, setRows] = useState([]); + const [uniqueNotes, setUniqueNotes] = useState([]); + const [categories, setCategories] = useState(DEFAULT_CATEGORIES); + const [customCategories, setCustomCategories] = useState([]); + const [classifications, setClassifications] = useState({}); + const [selectedNote, setSelectedNote] = useState(null); + const [draggedNote, setDraggedNote] = useState(null); + const [overBucket, setOverBucket] = useState(null); + const [newCatName, setNewCatName] = useState(''); + const [showNewCat, setShowNewCat] = useState(false); + + // Results phase + const [results, setResults] = useState(null); + const [savedAnalysisId, setSavedAnalysisId] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [summaryPushing, setSummaryPushing] = useState(false); + const [summaryPushed, setSummaryPushed] = useState(false); + const [summaryError, setSummaryError] = useState(null); + + // ── File reading ───────────────────────────────────────────────────────────── + + function readFile(file) { + if (!file || !file.name.endsWith('.csv')) { + alert('Please upload a .csv file.'); + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target.result; + const hdrs = parseCSVHeaders(text); + setFileName(file.name); + setRawText(text); + setHeaders(hdrs); + // Default columns + const tsDefault = hdrs.find(h => /timestamp|time/i.test(h)) ?? hdrs[0] ?? ''; + const noteDefault = hdrs.find(h => /note|label|event/i.test(h)) ?? hdrs[hdrs.length - 1] ?? ''; + const frameDefault = hdrs.find(h => /frame/i.test(h)) ?? ''; + setTimestampCol(tsDefault); + setNoteCol(noteDefault); + setFrameCol(frameDefault); + setFrameCheck(null); + setPhase('columns'); + }; + reader.readAsText(file); + } + + const handleDrop = useCallback((e) => { + e.preventDefault(); + setIsDragOver(false); + readFile(e.dataTransfer.files[0]); + }, []); + + // ── Column selection → parse data ──────────────────────────────────────────── + + function loadData() { + setParsePending(true); + setTimeout(() => { + const { rows: parsed } = parseCSV(rawText); + const uniq = getUniqueNoteValues(parsed, noteCol); + setRows(parsed); + setUniqueNotes(uniq); + setClassifications({}); + setSelectedNote(null); + if (frameCol) setFrameCheck(checkFrameConsecutive(parsed, frameCol)); + setParsePending(false); + setPhase('classify'); + }, 20); + } + + // ── Classification helpers ─────────────────────────────────────────────────── + + function assign(note, category) { + setClassifications(prev => ({ ...prev, [note]: category })); + setSelectedNote(null); + } + + function unassign(note) { + setClassifications(prev => { const n = { ...prev }; delete n[note]; return n; }); + } + + function addCategory() { + const name = newCatName.trim(); + if (!name || categories.includes(name)) return; + setCategories(prev => [...prev, name]); + setCustomCategories(prev => [...prev, name]); + setNewCatName(''); + setShowNewCat(false); + } + + const unassignedNotes = uniqueNotes.filter(n => !classifications[n]); + const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0; + + // ── Run analysis ───────────────────────────────────────────────────────────── + + async function compute() { + const res = runAnalysis(rows, timestampCol, noteCol, classifications); + setResults(res); + setPhase('results'); + + if (!dailyStatusId) return; + setSaving(true); + setSaveError(null); + try { + const saved = await analysesApi.create(dailyStatusId, { + file_name: fileName || null, + timestamp_col: timestampCol, + note_col: noteCol, + classifications, + total_note_rows: res.totalNoteRows, + session_end_ts: res.sessionEndTs, + sequence: res.sequence, + category_counts: res.categoryCounts, + consecutive_stats: res.consecutiveStats, + runs: res.runs, + }); + setSavedAnalysisId(saved.id); + onSaved?.(saved); + } catch (err) { + setSaveError(err.message ?? 'Failed to save analysis'); + } finally { + setSaving(false); + } + } + + // ── Push summary to daily status ───────────────────────────────────────────── + + async function pushSummary() { + if (!dailyStatusId || !results) return; + setSummaryPushing(true); + setSummaryPushed(false); + setSummaryError(null); + try { + const counts = {}; + for (const [cat, data] of Object.entries(results.categoryCounts)) { + counts[cat] = data.total; + } + const total = Object.values(counts).reduce((a, b) => a + b, 0); + const successRate = counts['Success'] != null ? counts['Success'] / total : null; + await dailyStatusesApi.updateAnalysisSummary(dailyStatusId, { + counts, + total, + success_rate: successRate, + source_analysis_id: savedAnalysisId ?? null, + }); + setSummaryPushed(true); + onSummaryPushed?.({ counts, total, success_rate: successRate }); + } catch (err) { + setSummaryError(err.message ?? 'Failed to update'); + } finally { + setSummaryPushing(false); + } + } + + // ── Reset ──────────────────────────────────────────────────────────────────── + + function reset() { + setPhase('upload'); + setRawText(''); + setHeaders([]); + setRows([]); + setUniqueNotes([]); + setClassifications({}); + setResults(null); + setSavedAnalysisId(null); + setSummaryPushed(false); + setSummaryError(null); + setCategories(DEFAULT_CATEGORIES); + setCustomCategories([]); + setFrameCheck(null); + } + + // ── Render ─────────────────────────────────────────────────────────────────── + + return ( +
+
+

CSV Analysis

+ {phase !== 'upload' && ( + + )} +
+ + {/* ── PHASE: upload ── */} + {phase === 'upload' && ( +
{ e.preventDefault(); setIsDragOver(true); }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + className={` + border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all + ${isDragOver ? 'border-indigo-400 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-gray-50'} + `} + > + readFile(e.target.files[0])} /> +

+ {isDragOver ? 'Drop CSV file to upload' : 'Drop a CSV file here, or click to browse'} +

+

Columns will be read; data stays in your browser

+
+ )} + + {/* ── PHASE: columns ── */} + {phase === 'columns' && ( +
+

+ {fileName} loaded — {headers.length} columns detected. Select the columns to analyse. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ )} + + {/* ── PHASE: classify ── */} + {phase === 'classify' && ( +
+ + {/* Frame gap check results */} + {frameCheck && ( +
+

+ Frame check ({frameCol}): {frameCheck.consecutive ? `✓ all ${frameCheck.total} frames consecutive` : `⚠ issues found in ${frameCheck.total} frames`} +

+ {frameCheck.gaps.length > 0 && ( +
+ {frameCheck.gaps.length} gap{frameCheck.gaps.length !== 1 ? 's' : ''}: +
    + {frameCheck.gaps.map((g, i) => ( +
  • row {g.rowIndex + 1}: expected {g.expected}, got {g.actual} (jump +{g.jump})
  • + ))} +
+
+ )} + {frameCheck.duplicates.length > 0 && ( +
+ {frameCheck.duplicates.length} duplicate{frameCheck.duplicates.length !== 1 ? 's' : ''}: +
    + {frameCheck.duplicates.map((d, i) => ( +
  • row {d.rowIndex + 1}: frame {d.value}
  • + ))} +
+
+ )} +
+ )} + +

+ Found {uniqueNotes.length} unique note value{uniqueNotes.length !== 1 ? 's' : ''}. + {selectedNote && ( + + Selected: {selectedNote} — click a category to assign + + )} +

+ + {/* Unassigned chips */} + {unassignedNotes.length > 0 && ( +
+

Unassigned

+
+ {unassignedNotes.map(note => ( + setSelectedNote(prev => prev === note ? null : note)} + draggable onDragStart={() => setDraggedNote(note)} + /> + ))} +
+
+ )} + + {/* Category buckets */} +
+ {categories.map(cat => { + const colors = getCategoryColors(cat, customCategories); + const notesInCat = uniqueNotes.filter(n => classifications[n] === cat); + return ( + setOverBucket(cat)} + onDragLeave={() => setOverBucket(null)} + onDrop={() => { + if (draggedNote) { assign(draggedNote, cat); setDraggedNote(null); setOverBucket(null); } + }} + onClickAssign={() => { if (selectedNote) assign(selectedNote, cat); }} + onRemoveNote={(note) => unassign(note)} + /> + ); + })} +
+ + {/* Add new category */} +
+ {showNewCat ? ( + <> + setNewCatName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowNewCat(false); }} + placeholder="Category name…" + className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40" /> + + + + ) : ( + + )} +
+ + {/* Unclassified warning */} + {!allAssigned && uniqueNotes.length > 0 && ( +

+ {unassignedNotes.length} unassigned note value{unassignedNotes.length !== 1 ? 's' : ''} will be excluded from the analysis. +

+ )} + + +
+ )} + + {/* ── PHASE: results ── */} + {phase === 'results' && results && ( +
+
+

+ Analysed {results.totalNoteRows} note rows + · columns: {timestampCol} / {noteCol} +

+ {dailyStatusId && ( + + {saving && Saving…} + {!saving && !saveError && results && ✓ Saved} + {saveError && ⚠ Save failed} + + )} +
+ + {/* Category counts */} +
+

Category Counts

+ + + + + + + + + + {Object.entries(results.categoryCounts) + .sort(([, a], [, b]) => b.total - a.total) + .map(([cat, data]) => { + const colors = getCategoryColors(cat, customCategories); + const breakdown = Object.entries(data.byNote) + .sort(([, a], [, b]) => b - a) + .map(([n, c]) => `${n}: ${c}`) + .join(' · '); + return ( + + + + + + ); + })} + {Object.keys(results.categoryCounts).length === 0 && ( + + )} + +
CategoryNote breakdownTotal
+ + + {cat} + + {breakdown}{data.total}
No classified notes
+
+ + {/* Consecutive stats */} + {Object.keys(results.consecutiveStats).length > 0 && ( +
+

Consecutive Runs

+

+ How many times each category appeared in a row (run-length encoding of the category sequence, ordered by timestamp). +

+ + + + + + + + + + + + {Object.entries(results.consecutiveStats) + .sort(([, a], [, b]) => b.totalRuns - a.totalRuns) + .map(([cat, s]) => { + const colors = getCategoryColors(cat, customCategories); + const runSummary = s.runLengths + .reduce((acc, len) => { + acc[len] = (acc[len] || 0) + 1; + return acc; + }, {}); + const runDisplay = Object.entries(runSummary) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([len, cnt]) => `${len}×${cnt}`) + .join(', '); + return ( + + + + + + + + ); + })} + +
CategoryTotal runsMax runAvg runRun lengths
+ + + {cat} + + {s.totalRuns}{s.maxRun}{s.avgRun.toFixed(2)}{runDisplay}
+
+ )} + + + + {/* Save metrics to daily status */} + {dailyStatusId && ( +
+
+ + {summaryError && {summaryError}} +
+ {!summaryPushed && ( +

+ Saves success / failure counts, total attempts, and success rate to this day's record for cross-day and cross-subject graphing. +

+ )} +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/DailyStatusForm.jsx b/frontend/src/components/DailyStatusForm.jsx index 4f08887..20bb2b6 100644 --- a/frontend/src/components/DailyStatusForm.jsx +++ b/frontend/src/components/DailyStatusForm.jsx @@ -1,12 +1,25 @@ import React, { useState, useEffect } from 'react'; import { format } from 'date-fns'; import Button from './ui/Button'; -import FormField, { Input, Textarea } from './ui/FormField'; +import { Input, Textarea } from './ui/FormField'; import Alert from './ui/Alert'; -import { dailyStatusesApi } from '../api/client'; +import { dailyStatusesApi, experimentsApi } from '../api/client'; const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); +// Maps field width % to a 12-column grid span +function widthToSpan(pct) { + if (pct <= 8) return 1; + if (pct <= 17) return 2; + if (pct <= 25) return 3; + if (pct <= 33) return 4; + if (pct <= 42) return 5; + if (pct <= 50) return 6; + if (pct <= 67) return 8; + if (pct <= 75) return 9; + return 12; +} + /** Extract the value for a field from a status record. * Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */ function getValue(status, field) { @@ -15,14 +28,14 @@ function getValue(status, field) { return status.custom_fields?.[field.fieldId] ?? ''; } -export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) { +export default function DailyStatusForm({ existing, animalId, experimentId, template = [], onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) { const today = format(new Date(), 'yyyy-MM-dd'); // Active fields from template (date is always first and handled separately) const activeFields = template.filter((f) => f.active); function buildInitialValues() { - const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today }; + const vals = { date: existing ? String(existing.date).slice(0, 10) : today }; for (const f of activeFields) { vals[f.key] = getValue(existing, f); } @@ -33,6 +46,7 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS const [errors, setErrors] = useState({}); const [apiError, setApiError] = useState(null); const [loading, setLoading] = useState(false); + const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved useEffect(() => { setValues(buildInitialValues()); @@ -42,6 +56,25 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value })); + async function saveTag(field) { + const value = (values[field.key] ?? '').trim(); + if (!value || !experimentId || !onTemplateUpdate) return; + setSavingTagFor(field.fieldId); + try { + const updatedTemplate = template.map((f) => + f.fieldId === field.fieldId + ? { ...f, tags: [...(f.tags ?? []), value] } + : f + ); + await experimentsApi.updateTemplate(experimentId, updatedTemplate); + onTemplateUpdate(updatedTemplate); + } catch (err) { + // non-critical — silently ignore tag save errors + } finally { + setSavingTagFor(null); + } + } + function validate() { const e = {}; if (!values.date) { @@ -53,38 +86,46 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS return Object.keys(e).length === 0; } + async function doSave() { + const builtin = {}; + const custom = {}; + for (const f of activeFields) { + const val = values[f.key] || null; + if (f.builtin) { + builtin[f.key] = val; + } else { + custom[f.fieldId] = val; + } + } + const existingCustom = existing?.custom_fields ?? {}; + const mergedCustom = { ...existingCustom, ...custom }; + const payload = { date: values.date, ...builtin, custom_fields: mergedCustom }; + return existing + ? await dailyStatusesApi.update(existing.id, payload) + : await dailyStatusesApi.create({ ...payload, animal_id: animalId }); + } + async function handleSubmit(e) { e.preventDefault(); if (!validate()) return; setLoading(true); setApiError(null); try { - // Separate builtin values from custom_fields - const builtin = {}; - const custom = {}; - for (const f of activeFields) { - const val = values[f.key] || null; - if (f.builtin) { - builtin[f.key] = val; - } else { - // Always key by fieldId so renaming or recreating a field never - // collides with or orphans data from a different field. - custom[f.fieldId] = val; - } - } + onSuccess(await doSave()); + } catch (err) { + setApiError(err); + } finally { + setLoading(false); + } + } - // Preserve custom_fields for fields not in the current active template - // (e.g. a field was hidden after this record was created). - const existingCustom = existing?.custom_fields ?? {}; - const mergedCustom = { ...existingCustom, ...custom }; - - const payload = { date: values.date, ...builtin, custom_fields: mergedCustom }; - - const result = existing - ? await dailyStatusesApi.update(existing.id, payload) - : await dailyStatusesApi.create({ ...payload, animal_id: animalId }); - - onSuccess(result); + async function handleNavigate(direction) { + if (!validate()) return; + setLoading(true); + setApiError(null); + try { + const result = await doSave(); + onNavigate(direction, result); } catch (err) { setApiError(err); } finally { @@ -93,36 +134,62 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS } return ( -
+ {apiError && ( setApiError(null)} /> )} - {/* Date — always present */} - - - +
+ {/* Date — always full width */} +
+ +
+ + {errors.date &&

{errors.date}

} +
+
- {/* Dynamic fields from template */} - {activeFields.map((field) => ( - - {field.type === 'textarea' ? ( -