Added report manager for seizure logging

This commit is contained in:
2026-05-04 19:56:06 +00:00
parent df543bc3bc
commit ecc1804519
3 changed files with 814 additions and 2 deletions

View File

@@ -97,7 +97,7 @@ public class LogManager {
private boolean mLogRemote;
private boolean mLogRemoteMobile;
private String mAuthToken;
static private SQLiteDatabase mOsdDb = null; // SQLite Database for data and log entries.
static public SQLiteDatabase mOsdDb = null; // SQLite Database for data and log entries.
private RemoteLogTimer mRemoteLogTimer;
private boolean mLogNDA;
public NDATimer mNDATimer;
@@ -568,7 +568,7 @@ public class LogManager {
//long endDateMillis = currentDateMillis - 3600*1000* mDataRetentionPeriod; // Using hours rather than days for testing
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String endDateStr = dateFormat.format(new Date(endDateMillis));
String[] tableNames = new String[]{mDpTableName, mEventsTableName};
String[] tableNames = new String[]{mDpTableName};
for (String tableName : tableNames) {
Log.i(TAG, "pruneLocalDb - pruning table " + tableName);
try {

View File

@@ -0,0 +1,779 @@
package uk.org.openseizuredetector;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import org.json.JSONObject;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
public class ReportManager {
private static final String TAG = "ReportManager";
private static final int GROUP_THRESHOLD_SECS = 30;
private static final int MONTHS_TO_SHOW = 12;
public static String generateHtmlReport(SQLiteDatabase db, int days) {
if (db == null) {
return "<html><body><h1>Error: Database not available</h1></body></html>";
}
// Fetch full calendar months for the report selector. This avoids the month dropdown
// losing the previous month when the app moves into a new month.
String query = "SELECT dataTime, status, type, notes, dataJSON " +
"FROM events " +
"WHERE dataTime >= date('now', 'start of month', '-" + (MONTHS_TO_SHOW - 1) + " months') " +
"AND status IN (1, 2, 3, 5) " +
"ORDER BY dataTime ASC";
Cursor cursor = null;
ArrayList<SeizureEvent> rawEvents = new ArrayList<>();
try {
cursor = db.rawQuery(query, null);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.UK);
while (cursor.moveToNext()) {
SeizureEvent event = new SeizureEvent();
event.dataTime = cursor.getString(0);
event.status = cursor.getInt(1);
event.type = cursor.getString(2);
event.notes = cursor.getString(3);
try {
String dataJson = cursor.getString(4);
if (dataJson != null) {
JSONObject jo = new JSONObject(dataJson);
event.hr = jo.optDouble("hr", 0.0);
event.alarmPhrase = jo.optString("alarmPhrase", "");
event.alarmCause = jo.optString("alarmCause", "").trim();
}
} catch (Exception e) {
Log.w(TAG, "Error parsing dataJSON: " + e.getMessage());
}
try {
event.date = sdf.parse(event.dataTime);
} catch (ParseException e) {
Log.w(TAG, "Error parsing date: " + event.dataTime);
}
rawEvents.add(event);
}
} catch (Exception e) {
Log.e(TAG, "Error querying database: " + e.getMessage());
return "<html><body><h1>Error querying database: " + e.getMessage() + "</h1></body></html>";
} finally {
if (cursor != null) cursor.close();
}
ArrayList<SeizureGroup> groups = groupEvents(rawEvents);
return buildCalendarHtml(groups, days);
}
private static ArrayList<SeizureGroup> groupEvents(ArrayList<SeizureEvent> events) {
ArrayList<SeizureGroup> groups = new ArrayList<>();
if (events.isEmpty()) return groups;
SeizureGroup currentGroup = null;
for (SeizureEvent event : events) {
if (currentGroup == null) {
currentGroup = new SeizureGroup(event);
} else {
long diffSecs = 0;
if (event.date != null && currentGroup.lastDate != null) {
diffSecs = (event.date.getTime() - currentGroup.lastDate.getTime()) / 1000;
}
if (diffSecs <= GROUP_THRESHOLD_SECS) {
currentGroup.lastDate = event.date;
currentGroup.lastEvent = event;
if (event.notes != null && event.notes.contains("Duration:")) {
currentGroup.durationStr = extractDuration(event.notes);
}
if (event.hr > 0) {
currentGroup.hr = event.hr;
}
} else {
groups.add(currentGroup);
currentGroup = new SeizureGroup(event);
}
}
}
if (currentGroup != null) {
groups.add(currentGroup);
}
return groups;
}
private static String extractDuration(String notes) {
try {
int start = notes.indexOf("Duration:") + 9;
int end = notes.indexOf("HR:");
if (end == -1) end = notes.length();
return notes.substring(start, end).trim();
} catch (Exception e) {
return "unknown";
}
}
private static String getTimeOfDayColor(Date date) {
if (date == null) return "#888888";
Calendar cal = Calendar.getInstance();
cal.setTime(date);
int hour = cal.get(Calendar.HOUR_OF_DAY);
if (hour >= 0 && hour < 6) return "#3b4a6b"; // Night - dark blue
if (hour >= 6 && hour < 12) return "#e6a817"; // Morning - yellow
if (hour >= 12 && hour < 18) return "#e07b2a"; // Afternoon - orange
return "#7b4fa6"; // Evening - purple
}
private static String getTimeOfDayLabel(Date date) {
if (date == null) return "";
Calendar cal = Calendar.getInstance();
cal.setTime(date);
int hour = cal.get(Calendar.HOUR_OF_DAY);
if (hour >= 0 && hour < 6) return "Night";
if (hour >= 6 && hour < 12) return "Morning";
if (hour >= 12 && hour < 18) return "Afternoon";
return "Evening";
}
private static int getMinutesOfDay(Date date) {
if (date == null) return 0;
Calendar cal = Calendar.getInstance();
cal.setTime(date);
return cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE);
}
private static int parseDurationMinutes(String durationStr) {
if (durationStr == null) return 5;
String s = durationStr.trim().toLowerCase(Locale.UK);
if (s.isEmpty() || s.equals("n/a") || s.equals("unknown")) {
return 5;
}
try {
// Supports HH:MM:SS
if (s.matches("\\d{1,2}:\\d{1,2}:\\d{1,2}")) {
String[] parts = s.split(":");
int hours = Integer.parseInt(parts[0]);
int mins = Integer.parseInt(parts[1]);
int secs = Integer.parseInt(parts[2]);
int totalSeconds = hours * 3600 + mins * 60 + secs;
return Math.max(1, (int) Math.ceil(totalSeconds / 60.0));
}
// Supports MM:SS
if (s.matches("\\d{1,2}:\\d{1,2}")) {
String[] parts = s.split(":");
int mins = Integer.parseInt(parts[0]);
int secs = Integer.parseInt(parts[1]);
int totalSeconds = mins * 60 + secs;
return Math.max(1, (int) Math.ceil(totalSeconds / 60.0));
}
// Supports text like "2 min 30 sec" or "1 hour 5 min"
int totalMinutes = 0;
String[] tokens = s.replace(",", " ").split("\\s+");
for (int i = 0; i < tokens.length - 1; i++) {
try {
int value = Integer.parseInt(tokens[i]);
String unit = tokens[i + 1];
if (unit.startsWith("hour") || unit.startsWith("hr")) {
totalMinutes += value * 60;
} else if (unit.startsWith("min")) {
totalMinutes += value;
} else if (unit.startsWith("sec")) {
if (value > 0) totalMinutes += 1;
}
} catch (NumberFormatException ignored) {
// Keep checking the rest of the duration text.
}
}
if (totalMinutes > 0) return totalMinutes;
} catch (Exception ignored) {
}
return 5;
}
private static String getStatusColor(int status) {
switch (status) {
case 1: return "#f59e0b"; // WARNING
case 2: return "#ef4444"; // ALARM
case 3: return "#8b5cf6"; // FALL
case 5: return "#06b6d4"; // MANUAL
default: return "#6b7280";
}
}
private static String formatPct(double value) {
return String.format(Locale.UK, "%.2f", value);
}
private static String statusToString(int status) {
switch (status) {
case 1: return "WARNING";
case 2: return "ALARM";
case 3: return "FALL";
case 5: return "MANUAL";
default: return "UNKNOWN";
}
}
private static String statusToCssClass(int status) {
switch (status) {
case 1: return "warning";
case 2: return "alarm";
case 3: return "fall";
case 5: return "manual";
default: return "unknown";
}
}
private static String escapeHtml(String value) {
if (value == null) return "";
return value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
private static void appendSummaryBar(StringBuilder sb, String label, String range, String cssClass,
int count, int maxCount) {
double pct = maxCount > 0 ? (count / (double) maxCount) * 100.0 : 0.0;
sb.append("<div class='time-summary-row'>");
sb.append("<div>").append(label).append("<br><span style='color:#6b7280;font-size:11px'>")
.append(range).append("</span></div>");
sb.append("<div class='summary-bar-track'><div class='summary-bar ").append(cssClass)
.append("' style='width:").append(formatPct(pct)).append("%'></div></div>");
sb.append("<div class='summary-count'>").append(count).append("</div>");
sb.append("</div>");
}
private static String buildCalendarHtml(ArrayList<SeizureGroup> groups, int days) {
SimpleDateFormat dayKeyFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.UK);
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss", Locale.UK);
SimpleDateFormat monthFormat = new SimpleDateFormat("MMMM yyyy", Locale.UK);
SimpleDateFormat monthIdFormat = new SimpleDateFormat("yyyy-MM", Locale.UK);
// Group seizures by day and by month.
Map<String, ArrayList<SeizureGroup>> byDay = new HashMap<>();
Map<String, Integer> eventsByMonth = new HashMap<>();
for (SeizureGroup g : groups) {
if (g.startDate != null) {
String dayKey = dayKeyFormat.format(g.startDate);
if (!byDay.containsKey(dayKey)) {
byDay.put(dayKey, new ArrayList<SeizureGroup>());
}
byDay.get(dayKey).add(g);
String monthKey = monthIdFormat.format(g.startDate);
Integer count = eventsByMonth.get(monthKey);
eventsByMonth.put(monthKey, count == null ? 1 : count + 1);
}
}
Calendar todayCal = Calendar.getInstance();
Date today = todayCal.getTime();
// Build a fixed list of recent full months so the dropdown remains useful across
// month boundaries, for example allowing April to be selected after May begins.
Calendar firstMonth = Calendar.getInstance();
firstMonth.add(Calendar.MONTH, -(MONTHS_TO_SHOW - 1));
firstMonth.set(Calendar.DAY_OF_MONTH, 1);
firstMonth.set(Calendar.HOUR_OF_DAY, 0);
firstMonth.set(Calendar.MINUTE, 0);
firstMonth.set(Calendar.SECOND, 0);
firstMonth.set(Calendar.MILLISECOND, 0);
Calendar lastMonth = Calendar.getInstance();
lastMonth.set(Calendar.DAY_OF_MONTH, 1);
lastMonth.set(Calendar.HOUR_OF_DAY, 0);
lastMonth.set(Calendar.MINUTE, 0);
lastMonth.set(Calendar.SECOND, 0);
lastMonth.set(Calendar.MILLISECOND, 0);
ArrayList<Calendar> reportMonths = new ArrayList<>();
Calendar monthCursor = (Calendar) firstMonth.clone();
while (!monthCursor.after(lastMonth)) {
reportMonths.add((Calendar) monthCursor.clone());
monthCursor.add(Calendar.MONTH, 1);
}
String defaultMonthId = monthIdFormat.format(today);
StringBuilder sb = new StringBuilder();
sb.append("<!DOCTYPE html><html><head>");
sb.append("<meta charset='UTF-8'>");
sb.append("<meta name='viewport' content='width=device-width, initial-scale=1'>");
sb.append("<title>ClinX02 Seizure Report</title>");
sb.append("<style>");
sb.append("* { box-sizing: border-box; }");
sb.append("body { font-family: Arial, sans-serif; margin: 0; padding: 16px; background: #eef1f5; color: #222; }");
sb.append(".container { max-width: 1100px; margin: 0 auto; }");
sb.append("h1 { color: #1f2937; margin: 0 0 6px 0; font-size: 28px; }");
sb.append("h2 { color: #374151; margin-top: 24px; }");
sb.append(".subtitle { color: #6b7280; margin-top: 0; margin-bottom: 18px; }");
sb.append(".summary { background: white; border-radius: 12px; padding: 18px; margin-bottom: 18px; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }");
sb.append(".stats { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }");
sb.append(".stat-card { flex: 1 1 160px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; }");
sb.append(".stat-label { font-size: 12px; color: #6b7280; }");
sb.append(".stat-value { font-size: 22px; font-weight: bold; color: #111827; margin-top: 4px; }");
sb.append(".download-btn { display: inline-block; margin-top: 14px; background: #2563eb; color: white; text-decoration: none; padding: 10px 14px; border-radius: 8px; font-weight: bold; }");
sb.append(".download-btn:hover { background: #1d4ed8; }");
sb.append(".legend { display: flex; gap: 16px; margin: 12px 0 18px 0; flex-wrap: wrap; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
sb.append(".legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #374151; }");
sb.append(".legend-dot { width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto; }");
sb.append(".month-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 16px 0; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
sb.append(".month-controls label { font-weight: bold; color: #374151; }");
sb.append(".month-controls select { padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; background: white; font-size: 14px; min-width: 190px; }");
sb.append(".month-count { color: #6b7280; font-size: 13px; }");
sb.append(".month-panel { display: none; }");
sb.append(".month-panel.active { display: block; }");
sb.append(".month-summary-panel { display: none; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 14px; margin: 14px 0 18px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
sb.append(".month-summary-panel.active { display: block; }");
sb.append(".summary-chart-title { font-weight: bold; color: #374151; margin-bottom: 10px; }");
sb.append(".time-summary-row { display: grid; grid-template-columns: 90px 1fr 44px; gap: 10px; align-items: center; margin: 8px 0; font-size: 13px; }");
sb.append(".summary-bar-track { height: 18px; background: #f3f4f6; border-radius: 999px; overflow: hidden; border: 1px solid #e5e7eb; }");
sb.append(".summary-bar { height: 100%; min-width: 0; border-radius: 999px; }");
sb.append(".summary-bar.night { background: #9ca3af; }");
sb.append(".summary-bar.morning { background: #f59e0b; }");
sb.append(".summary-bar.afternoon { background: #f97316; }");
sb.append(".summary-bar.evening { background: #8b5cf6; }");
sb.append(".summary-count { text-align: right; color: #374151; font-weight: bold; }");
sb.append(".table-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 12px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }");
sb.append(".table-controls label { font-weight: bold; color: #374151; font-size: 13px; }");
sb.append(".table-controls select { padding: 7px 9px; border: 1px solid #d1d5db; border-radius: 8px; background: white; font-size: 13px; }");
sb.append(".pager { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }");
sb.append(".pager button { padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 8px; background: #f9fafb; cursor: pointer; }");
sb.append(".pager button:disabled { opacity: 0.45; cursor: not-allowed; }");
sb.append(".table-info { color: #6b7280; font-size: 13px; }");
sb.append(".calendar { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-top: 16px; }");
sb.append(".day-header { text-align: center; font-weight: bold; padding: 6px; color: #555; font-size: 13px; }");
sb.append(".day-box { background: white; border-radius: 8px; padding: 6px; min-height: 86px; border: 1px solid #ddd; box-shadow: 0 1px 4px rgba(0,0,0,0.04); }");
sb.append(".day-box.today { border: 2px solid #2563eb; }");
sb.append(".day-box.has-seizures { background: #fff8f8; border-color: #fecaca; }");
sb.append(".day-box.empty { background: transparent; border: none; box-shadow: none; }");
sb.append(".day-num { font-size: 13px; font-weight: bold; color: #333; margin-bottom: 4px; }");
sb.append(".timeline { position: relative; height: 28px; margin-top: 6px; border-radius: 6px; overflow: hidden; border: 1px solid #d1d5db; background: white; isolation: isolate; }");
sb.append(".time-bg { position: absolute; inset: 0; display: flex; z-index: 0; }");
sb.append(".time-segment { height: 100%; flex: 1 1 25%; }");
sb.append(".time-night { background: #e5e7eb; }");
sb.append(".time-morning { background: #fde68a; }");
sb.append(".time-afternoon { background: #fdba74; }");
sb.append(".time-evening { background: #c4b5fd; }");
sb.append(".timeline-grid { position: absolute; inset: 0; pointer-events: none; z-index: 1; }");
sb.append(".time-marker { position: absolute; top: 0; bottom: 0; width: 1px; background: rgba(0,0,0,0.16); }");
sb.append(".event-bar { position: absolute; top: 5px; height: 18px; border-radius: 4px; opacity: 0.96; border: 1px solid rgba(0,0,0,0.18); z-index: 2; }");
sb.append(".time-scale { display: flex; justify-content: space-between; font-size: 9px; color: #6b7280; margin-top: 3px; }");
sb.append(".seizure-count { font-size: 11px; color: #6b7280; margin-top: 4px; }");
sb.append(".table-wrap { width: 100%; overflow-x: auto; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }");
sb.append(".detail-table { width: 100%; border-collapse: collapse; background: white; overflow: hidden; min-width: 680px; }");
sb.append(".detail-table th { background: #2563eb; color: white; padding: 12px; text-align: left; font-size: 13px; }");
sb.append(".detail-table td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }");
sb.append(".detail-table tr:hover { background: #f9fafb; }");
sb.append(".detail-table tr:last-child td { border-bottom: none; }");
sb.append(".status-pill { display: inline-block; padding: 4px 8px; border-radius: 999px; font-size: 12px; font-weight: bold; }");
sb.append(".alarm { background: #fee2e2; color: #b91c1c; }");
sb.append(".warning { background: #ffedd5; color: #c2410c; }");
sb.append(".fall { background: #ede9fe; color: #6d28d9; }");
sb.append(".manual { background: #dcfce7; color: #15803d; }");
sb.append(".unknown { background: #e5e7eb; color: #374151; }");
sb.append(".no-month-events { display: none; background: white; border: 1px solid #ddd; border-radius: 12px; padding: 14px; color: #6b7280; }");
sb.append(".footer { color: #6b7280; margin-top: 18px; }");
sb.append("@media (max-width: 700px) {");
sb.append("body { padding: 10px; }");
sb.append("h1 { font-size: 23px; }");
sb.append(".summary { padding: 14px; }");
sb.append(".time-summary-row { grid-template-columns: 78px 1fr 34px; gap: 6px; font-size: 11px; }");
sb.append(".table-controls { align-items: stretch; }");
sb.append(".pager { width: 100%; }");
sb.append(".calendar { gap: 2px; }");
sb.append(".day-box { min-height: 64px; padding: 4px; border-radius: 6px; }");
sb.append(".day-header { font-size: 11px; padding: 4px; }");
sb.append(".day-num { font-size: 11px; }");
sb.append(".timeline { height: 22px; }");
sb.append(".event-bar { top: 4px; height: 14px; }");
sb.append(".time-scale { font-size: 8px; }");
sb.append(".seizure-count { font-size: 10px; }");
sb.append(".detail-table th, .detail-table td { font-size: 11px; padding: 6px; }");
sb.append("}");
sb.append("</style></head><body>");
sb.append("<div class='container'>");
sb.append("<h1>ClinX02 Seizure Report</h1>");
sb.append("<p class='subtitle'>Calendar timeline overview and detailed event log</p>");
// Summary box
sb.append("<div class='summary'>");
sb.append("<div class='stats'>");
sb.append("<div class='stat-card'>");
sb.append("<div class='stat-label'>Calendar range</div>");
sb.append("<div class='stat-value'>").append(MONTHS_TO_SHOW).append(" months</div>");
sb.append("</div>");
sb.append("<div class='stat-card'>");
sb.append("<div class='stat-label'>Total seizure events</div>");
sb.append("<div class='stat-value'>").append(groups.size()).append("</div>");
sb.append("</div>");
sb.append("<div class='stat-card'>");
sb.append("<div class='stat-label'>Days with seizures</div>");
sb.append("<div class='stat-value'>").append(byDay.size()).append("</div>");
sb.append("</div>");
sb.append("</div>");
sb.append("<a class='download-btn' href='/report/download?days=").append(days)
.append("&token=clinx02secure' download='seizure_report.html'>Download Report</a>");
sb.append("</div>");
// Legend
sb.append("<div class='legend'>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#e5e7eb'></div>Night background (00-06)</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#fde68a'></div>Morning background (06-12)</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#fdba74'></div>Afternoon background (12-18)</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#c4b5fd'></div>Evening background (18-24)</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#f59e0b'></div>Warning bar</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#ef4444'></div>Alarm bar</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#8b5cf6'></div>Fall bar</div>");
sb.append("<div class='legend-item'><div class='legend-dot' style='background:#06b6d4'></div>Manual bar</div>");
sb.append("</div>");
// Month selector
sb.append("<div class='month-controls'>");
sb.append("<label for='monthSelect'>Month:</label>");
sb.append("<select id='monthSelect' onchange='showMonth(this.value)'>");
for (Calendar reportMonth : reportMonths) {
String monthId = monthIdFormat.format(reportMonth.getTime());
String selected = monthId.equals(defaultMonthId) ? " selected" : "";
Integer eventCount = eventsByMonth.get(monthId);
int count = eventCount == null ? 0 : eventCount;
sb.append("<option value='").append(monthId).append("'").append(selected).append(">");
sb.append(monthFormat.format(reportMonth.getTime())).append(" (").append(count)
.append(count == 1 ? " event" : " events").append(")");
sb.append("</option>");
}
sb.append("</select>");
sb.append("<span id='selectedMonthCount' class='month-count'></span>");
sb.append("</div>");
// Monthly time-of-day summary chart
for (Calendar reportMonth : reportMonths) {
String monthId = monthIdFormat.format(reportMonth.getTime());
boolean activeMonth = monthId.equals(defaultMonthId);
int nightCount = 0;
int morningCount = 0;
int afternoonCount = 0;
int eveningCount = 0;
for (SeizureGroup g : groups) {
if (g.startDate != null && monthId.equals(monthIdFormat.format(g.startDate))) {
Calendar eventCal = Calendar.getInstance();
eventCal.setTime(g.startDate);
int hour = eventCal.get(Calendar.HOUR_OF_DAY);
if (hour < 6) {
nightCount++;
} else if (hour < 12) {
morningCount++;
} else if (hour < 18) {
afternoonCount++;
} else {
eveningCount++;
}
}
}
int maxTimeCount = Math.max(Math.max(nightCount, morningCount), Math.max(afternoonCount, eveningCount));
if (maxTimeCount < 1) maxTimeCount = 1;
sb.append("<div class='month-summary-panel");
if (activeMonth) sb.append(" active");
sb.append("' id='summary-").append(monthId).append("' data-month='").append(monthId).append("'>");
sb.append("<div class='summary-chart-title'>Seizures by time of day</div>");
appendSummaryBar(sb, "Night", "00-06", "night", nightCount, maxTimeCount);
appendSummaryBar(sb, "Morning", "06-12", "morning", morningCount, maxTimeCount);
appendSummaryBar(sb, "Afternoon", "12-18", "afternoon", afternoonCount, maxTimeCount);
appendSummaryBar(sb, "Evening", "18-24", "evening", eveningCount, maxTimeCount);
sb.append("</div>");
}
// Calendar months
for (Calendar reportMonth : reportMonths) {
String monthId = monthIdFormat.format(reportMonth.getTime());
boolean activeMonth = monthId.equals(defaultMonthId);
Calendar cal = (Calendar) reportMonth.clone();
int currentMonth = cal.get(Calendar.MONTH);
int currentYear = cal.get(Calendar.YEAR);
int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
int firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK); // 1=Sun, 2=Mon...
sb.append("<div class='month-panel");
if (activeMonth) sb.append(" active");
sb.append("' id='month-").append(monthId).append("' data-month='").append(monthId).append("'>");
sb.append("<h2>").append(monthFormat.format(reportMonth.getTime())).append("</h2>");
sb.append("<div class='calendar'>");
String[] dayNames = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
for (String d : dayNames) {
sb.append("<div class='day-header'>").append(d).append("</div>");
}
for (int i = 1; i < firstDayOfWeek; i++) {
sb.append("<div class='day-box empty'></div>");
}
Calendar today2 = Calendar.getInstance();
for (int day = 1; day <= daysInMonth; day++) {
String dayKey = String.format(Locale.UK, "%04d-%02d-%02d", currentYear, currentMonth + 1, day);
ArrayList<SeizureGroup> dayGroups = byDay.get(dayKey);
boolean isToday = (day == today2.get(Calendar.DAY_OF_MONTH)
&& currentMonth == today2.get(Calendar.MONTH)
&& currentYear == today2.get(Calendar.YEAR));
String boxClass = "day-box";
if (isToday) boxClass += " today";
if (dayGroups != null && !dayGroups.isEmpty()) boxClass += " has-seizures";
sb.append("<div class='").append(boxClass).append("'>");
sb.append("<div class='day-num'>").append(day).append("</div>");
if (dayGroups != null && !dayGroups.isEmpty()) {
sb.append("<div class='timeline'>");
sb.append("<div class='time-bg'>");
sb.append("<div class='time-segment time-night'></div>");
sb.append("<div class='time-segment time-morning'></div>");
sb.append("<div class='time-segment time-afternoon'></div>");
sb.append("<div class='time-segment time-evening'></div>");
sb.append("</div>");
sb.append("<div class='timeline-grid'>");
sb.append("<span class='time-marker' style='left:25%'></span>");
sb.append("<span class='time-marker' style='left:50%'></span>");
sb.append("<span class='time-marker' style='left:75%'></span>");
sb.append("</div>");
for (SeizureGroup g : dayGroups) {
int startMinutes = getMinutesOfDay(g.startDate);
int durationMinutes = parseDurationMinutes(g.durationStr);
double leftPct = (startMinutes / 1440.0) * 100.0;
double widthPct = Math.max((durationMinutes / 1440.0) * 100.0, 1.4);
if (leftPct + widthPct > 100.0) {
widthPct = Math.max(0.8, 100.0 - leftPct);
}
String timeStr = g.startDate != null ? timeFormat.format(g.startDate) : "";
String label = getTimeOfDayLabel(g.startDate);
String tip = label + " " + timeStr + " - " + statusToString(g.firstEvent.status)
+ " (" + g.durationStr + ")";
sb.append("<div class='event-bar' style='left:")
.append(formatPct(leftPct))
.append("%;width:")
.append(formatPct(widthPct))
.append("%;background:")
.append(getStatusColor(g.firstEvent.status))
.append(";' title='")
.append(escapeHtml(tip))
.append("'></div>");
}
sb.append("</div>");
sb.append("<div class='time-scale'><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>");
sb.append("<div class='seizure-count'>").append(dayGroups.size())
.append(dayGroups.size() == 1 ? " event" : " events").append("</div>");
}
sb.append("</div>");
}
sb.append("</div>"); // calendar
sb.append("</div>"); // month-panel
}
// Detailed table below. JavaScript filters this table to the selected month.
sb.append("<h2>Event Details</h2>");
if (groups.isEmpty()) {
sb.append("<p>No seizure events recorded in this period.</p>");
} else {
sb.append("<p id='emptyMonthMessage' class='no-month-events'>No seizure events recorded for the selected month.</p>");
sb.append("<div class='table-controls' id='eventTableControls'>");
sb.append("<div><label for='rowsPerPage'>Entries per page: </label>");
sb.append("<select id='rowsPerPage' onchange='changePageSize()'>");
sb.append("<option value='5'>5</option>");
sb.append("<option value='10' selected>10</option>");
sb.append("<option value='25'>25</option>");
sb.append("<option value='50'>50</option>");
sb.append("<option value='all'>All</option>");
sb.append("</select></div>");
sb.append("<div class='pager'>");
sb.append("<button type='button' id='prevPageBtn' onclick='previousPage()'>Previous</button>");
sb.append("<label for='pageSelect'>Page: </label>");
sb.append("<select id='pageSelect' onchange='changePage()'></select>");
sb.append("<button type='button' id='nextPageBtn' onclick='nextPage()'>Next</button>");
sb.append("</div>");
sb.append("<div id='tableInfo' class='table-info'></div>");
sb.append("</div>");
sb.append("<div class='table-wrap' id='eventTableWrap'><table class='detail-table'>");
sb.append("<tr><th>#</th><th>Date &amp; Time</th><th>Status</th><th>Duration</th><th>Heart Rate</th><th>Cause</th></tr>");
SimpleDateFormat displaySdf = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.UK);
int i = 1;
for (SeizureGroup g : groups) {
String timeStr = g.startDate != null ? displaySdf.format(g.startDate) : g.firstEvent.dataTime;
String statusStr = statusToString(g.firstEvent.status);
String cssClass = statusToCssClass(g.firstEvent.status);
String hrStr = g.hr > 0 ? String.format(Locale.UK, "%.1f bpm", g.hr) : "N/A";
String cause = g.firstEvent.alarmCause != null && !g.firstEvent.alarmCause.isEmpty()
? g.firstEvent.alarmCause : "Unknown";
String dotColor = getTimeOfDayColor(g.startDate);
String eventMonth = g.startDate != null ? monthIdFormat.format(g.startDate) : "";
sb.append("<tr class='event-row' data-month='").append(eventMonth).append("'>");
sb.append("<td>").append(i++).append("</td>");
sb.append("<td><span style='display:inline-block;width:10px;height:10px;border-radius:50%;background:")
.append(dotColor).append(";margin-right:6px'></span>").append(timeStr).append("</td>");
sb.append("<td><span class='status-pill ").append(cssClass).append("'>").append(statusStr).append("</span></td>");
sb.append("<td>").append(escapeHtml(g.durationStr)).append("</td>");
sb.append("<td>").append(hrStr).append("</td>");
sb.append("<td>").append(escapeHtml(cause)).append("</td>");
sb.append("</tr>");
}
sb.append("</table></div>");
}
sb.append("<p class='footer'><small>Generated by ClinX02 OpenSeizureDetector</small></p>");
sb.append("</div>");
sb.append("<script>");
sb.append("var currentPage = 1;");
sb.append("function getSelectedMonth() {");
sb.append("var select = document.getElementById('monthSelect');");
sb.append("return select ? select.value : ''; }");
sb.append("function showMonth(monthId) {");
sb.append("var panels = document.querySelectorAll('.month-panel');");
sb.append("for (var i = 0; i < panels.length; i++) { panels[i].classList.remove('active'); }");
sb.append("var selectedPanel = document.getElementById('month-' + monthId);");
sb.append("if (selectedPanel) { selectedPanel.classList.add('active'); }");
sb.append("var summaries = document.querySelectorAll('.month-summary-panel');");
sb.append("for (var s = 0; s < summaries.length; s++) { summaries[s].classList.remove('active'); }");
sb.append("var selectedSummary = document.getElementById('summary-' + monthId);");
sb.append("if (selectedSummary) { selectedSummary.classList.add('active'); }");
sb.append("currentPage = 1;");
sb.append("updateTable();");
sb.append("}");
sb.append("function changePageSize() { currentPage = 1; updateTable(); }");
sb.append("function changePage() {");
sb.append("var pageSelect = document.getElementById('pageSelect');");
sb.append("if (pageSelect) { currentPage = parseInt(pageSelect.value, 10) || 1; }");
sb.append("updateTable();");
sb.append("}");
sb.append("function previousPage() { if (currentPage > 1) { currentPage--; updateTable(); } }");
sb.append("function nextPage() {");
sb.append("var pageSelect = document.getElementById('pageSelect');");
sb.append("var totalPages = pageSelect ? pageSelect.options.length : 1;");
sb.append("if (currentPage < totalPages) { currentPage++; updateTable(); }");
sb.append("}");
sb.append("function updateTable() {");
sb.append("var monthId = getSelectedMonth();");
sb.append("var rows = document.querySelectorAll('.event-row');");
sb.append("var matchingRows = [];");
sb.append("for (var r = 0; r < rows.length; r++) {");
sb.append("var matches = rows[r].getAttribute('data-month') === monthId;");
sb.append("rows[r].style.display = 'none';");
sb.append("if (matches) { matchingRows.push(rows[r]); }");
sb.append("}");
sb.append("var rowsPerPageSelect = document.getElementById('rowsPerPage');");
sb.append("var rowsPerPageValue = rowsPerPageSelect ? rowsPerPageSelect.value : '10';");
sb.append("var rowsPerPage = rowsPerPageValue === 'all' ? Math.max(matchingRows.length, 1) : parseInt(rowsPerPageValue, 10);");
sb.append("if (!rowsPerPage || rowsPerPage < 1) { rowsPerPage = 10; }");
sb.append("var totalPages = Math.max(1, Math.ceil(matchingRows.length / rowsPerPage));");
sb.append("if (currentPage > totalPages) { currentPage = totalPages; }");
sb.append("if (currentPage < 1) { currentPage = 1; }");
sb.append("var startIndex = (currentPage - 1) * rowsPerPage;");
sb.append("var endIndex = Math.min(startIndex + rowsPerPage, matchingRows.length);");
sb.append("for (var i = startIndex; i < endIndex; i++) { matchingRows[i].style.display = ''; }");
sb.append("var pageSelect = document.getElementById('pageSelect');");
sb.append("if (pageSelect) {");
sb.append("pageSelect.innerHTML = '';");
sb.append("for (var p = 1; p <= totalPages; p++) {");
sb.append("var option = document.createElement('option'); option.value = p; option.textContent = p + ' of ' + totalPages;");
sb.append("if (p === currentPage) { option.selected = true; }");
sb.append("pageSelect.appendChild(option); }");
sb.append("}");
sb.append("var wrap = document.getElementById('eventTableWrap');");
sb.append("var empty = document.getElementById('emptyMonthMessage');");
sb.append("var controls = document.getElementById('eventTableControls');");
sb.append("var hasRows = matchingRows.length > 0;");
sb.append("if (wrap) { wrap.style.display = hasRows ? '' : 'none'; }");
sb.append("if (controls) { controls.style.display = hasRows ? '' : 'none'; }");
sb.append("if (empty) { empty.style.display = hasRows ? 'none' : 'block'; }");
sb.append("var countText = document.getElementById('selectedMonthCount');");
sb.append("if (countText) { countText.textContent = matchingRows.length + (matchingRows.length === 1 ? ' event in selected month' : ' events in selected month'); }");
sb.append("var info = document.getElementById('tableInfo');");
sb.append("if (info) {");
sb.append("if (hasRows) { info.textContent = 'Showing ' + (startIndex + 1) + '-' + endIndex + ' of ' + matchingRows.length; }");
sb.append("else { info.textContent = ''; }");
sb.append("}");
sb.append("var prev = document.getElementById('prevPageBtn');");
sb.append("var next = document.getElementById('nextPageBtn');");
sb.append("if (prev) { prev.disabled = currentPage <= 1; }");
sb.append("if (next) { next.disabled = currentPage >= totalPages; }");
sb.append("}");
sb.append("document.addEventListener('DOMContentLoaded', function() {");
sb.append("var select = document.getElementById('monthSelect');");
sb.append("if (select) { showMonth(select.value); }");
sb.append("});");
sb.append("</script>");
sb.append("</body></html>");
return sb.toString();
}
static class SeizureEvent {
String dataTime;
int status;
String type;
String notes;
String alarmPhrase;
String alarmCause;
double hr = 0.0;
Date date;
}
static class SeizureGroup {
SeizureEvent firstEvent;
SeizureEvent lastEvent;
Date startDate;
Date lastDate;
String durationStr = "N/A";
double hr = 0.0;
SeizureGroup(SeizureEvent first) {
this.firstEvent = first;
this.lastEvent = first;
this.startDate = first.date;
this.lastDate = first.date;
if (first.notes != null && first.notes.contains("Duration:")) {
this.durationStr = extractDuration(first.notes);
}
this.hr = first.hr;
}
}
}

View File

@@ -26,12 +26,18 @@ import fi.iki.elonen.NanoHTTPD;
*/
public class SdWebServer extends NanoHTTPD {
private String TAG = "WebServer";
private static final String REPORT_TOKEN = "clinx02secure";
private SdData mSdData;
private SdServer mSdServer;
private Context mContext;
private Handler mHandler;
private OsdUtil mUtil;
private boolean isValidToken(Map<String, String> parameters) {
String token = parameters.get("token");
return REPORT_TOKEN.equals(token);
}
public SdWebServer(Context context, SdData sdData, SdServer sdServer) {
// Set the port to listen on (8080)
super(8080);
@@ -192,6 +198,33 @@ public class SdWebServer extends NanoHTTPD {
answer = "{'msg' : 'Alarm Accepted'}";
break;
case "/report":
if (!isValidToken(parameters)) {
return new NanoHTTPD.Response(NanoHTTPD.Response.Status.FORBIDDEN,
"text/html", "<h1>403 Forbidden - Invalid or missing token</h1>");
}
String days = parameters.get("days");
if (days == null) days = "1";
answer = ReportManager.generateHtmlReport(
LogManager.mOsdDb, Integer.parseInt(days));
responseMimeType = "text/html";
break;
case "/report/download":
if (!isValidToken(parameters)) {
return new NanoHTTPD.Response(NanoHTTPD.Response.Status.FORBIDDEN,
"text/html", "<h1>403 Forbidden - Invalid or missing token</h1>");
}
String dlDays = parameters.get("days");
if (dlDays == null) dlDays = "1";
String reportHtml = ReportManager.generateHtmlReport(
LogManager.mOsdDb, Integer.parseInt(dlDays));
NanoHTTPD.Response dlRes = new NanoHTTPD.Response(
NanoHTTPD.Response.Status.OK, "text/html", reportHtml);
dlRes.addHeader("Content-Disposition",
"attachment; filename=\"seizure_report.html\"");
return dlRes;
default:
if (uri.startsWith("/index.html") ||
uri.startsWith("/logfiles.html") ||