diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a57956c..436387b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,7 +43,7 @@ android:required="false" /> @@ -61,18 +61,18 @@ - - - - - - + android:name=".StartupActivity" + android:exported="true" + android:icon="@drawable/floga_app_icon"> + + + + +

Error: Database not available

"; } - // 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. + // Read locally stored alarm events. The report's month selector is built + // from the timestamps actually present in this result, so older months do not vanish + // just because the current calendar month changes. 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) " + + "WHERE status IN (2, 5) " + "ORDER BY dataTime ASC"; Cursor cursor = null; @@ -47,11 +53,19 @@ public class ReportManager { event.type = cursor.getString(2); event.notes = cursor.getString(3); + event.durationSeconds = parseDurationSecondsFromNotes(event.notes); + event.hr = parseHeartRateFromNotes(event.notes); + try { String dataJson = cursor.getString(4); - if (dataJson != null) { + if (dataJson != null && dataJson.trim().length() > 0) { JSONObject jo = new JSONObject(dataJson); - event.hr = jo.optDouble("hr", 0.0); + double jsonHr = optPositiveDouble(jo, "hr"); + if (jsonHr <= 0.0) jsonHr = optPositiveDouble(jo, "HR"); + if (jsonHr <= 0.0) jsonHr = optPositiveDouble(jo, "mHR"); + if (jsonHr <= 0.0) jsonHr = optPositiveDouble(jo, "heartRate"); + if (jsonHr > 0.0) event.hr = jsonHr; + event.alarmPhrase = jo.optString("alarmPhrase", ""); event.alarmCause = jo.optString("alarmCause", "").trim(); } @@ -61,6 +75,11 @@ public class ReportManager { try { event.date = sdf.parse(event.dataTime); + if (event.date != null && event.durationSeconds > 0) { + event.inferredStartDate = new Date(event.date.getTime() - event.durationSeconds * 1000L); + } else { + event.inferredStartDate = event.date; + } } catch (ParseException e) { Log.w(TAG, "Error parsing date: " + event.dataTime); } @@ -73,11 +92,102 @@ public class ReportManager { } finally { if (cursor != null) cursor.close(); } + + if (ADD_DEMO_EVENTS) { + addDemoEvents(rawEvents); + } + + Collections.sort(rawEvents, (event1, event2) -> { + if (event1.date == null && event2.date == null) return 0; + if (event1.date == null) return 1; + if (event2.date == null) return -1; + return event1.date.compareTo(event2.date); + }); ArrayList groups = groupEvents(rawEvents); return buildCalendarHtml(groups, days); } + private static void addDemoEvents(ArrayList events) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.UK); + + Calendar monthStart = Calendar.getInstance(); + monthStart.set(Calendar.YEAR, 2026); + monthStart.set(Calendar.MONTH, Calendar.APRIL); // April is month 3 internally + monthStart.set(Calendar.DAY_OF_MONTH, 1); + monthStart.set(Calendar.HOUR_OF_DAY, 0); + monthStart.set(Calendar.MINUTE, 0); + monthStart.set(Calendar.SECOND, 0); + monthStart.set(Calendar.MILLISECOND, 0); + + Calendar monthEnd = (Calendar) monthStart.clone(); + monthEnd.add(Calendar.MONTH, 1); + monthEnd.add(Calendar.SECOND, -1); + + int daysInMonth = monthStart.getActualMaximum(Calendar.DAY_OF_MONTH); + + + // 7 evening events, 2 night events, 1 afternoon event. + // Night: 00-06 + // Afternoon: 12-18 + // Evening: 18-24 + int[] demoDays = { + 1, 4, 4, 7, 12, + 15, 19, 22, 28, 29 + }; + + // 7 evening events, 2 night events, 1 afternoon event. + // Day 4 has two events. + int[] demoHours = { + 23, 2, 21, 19, 4, + 22, 20, 15, 18, 23 + }; + + int[] demoMinutes = { + 41, 16, 53, 9, 34, + 27, 48, 22, 5, 37 + }; + + int[] demoDurationsSecs = { + 95, 125, 65, 140, 85, + 110, 180, 100, 55, 75 + }; + + int[] demoHr = { + 99, 107, 88, 112, 86, + 104, 121, 100, 91, 95 + }; + + int count = Math.min(DEMO_EVENT_COUNT, demoDays.length); + + for (int i = 0; i < count; i++) { + int day = demoDays[i]; + if (day > daysInMonth) { + continue; + } + + Calendar eventCal = (Calendar) monthStart.clone(); + eventCal.set(Calendar.DAY_OF_MONTH, day); + eventCal.set(Calendar.HOUR_OF_DAY, demoHours[i]); + eventCal.set(Calendar.MINUTE, demoMinutes[i]); + eventCal.set(Calendar.SECOND, 0); + eventCal.set(Calendar.MILLISECOND, 0); + + SeizureEvent event = new SeizureEvent(); + event.date = eventCal.getTime(); + event.dataTime = sdf.format(event.date); + event.status = 2; // ALARM only + event.type = "seizure"; + event.durationSeconds = demoDurationsSecs[i]; + event.inferredStartDate = new Date(event.date.getTime() - event.durationSeconds * 1000L); + event.notes = "Duration: " + demoDurationsSecs[i] + "s HR: " + demoHr[i]; + event.hr = demoHr[i]; + event.alarmPhrase = "Demo alarm"; + event.alarmCause = "Demo data"; + + events.add(event); + } + } private static ArrayList groupEvents(ArrayList events) { ArrayList groups = new ArrayList<>(); if (events.isEmpty()) return groups; @@ -87,25 +197,11 @@ public class ReportManager { for (SeizureEvent event : events) { if (currentGroup == null) { currentGroup = new SeizureGroup(event); + } else if (shouldMergeIntoGroup(currentGroup, event)) { + currentGroup.addEvent(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); - } + groups.add(currentGroup); + currentGroup = new SeizureGroup(event); } } @@ -116,10 +212,172 @@ public class ReportManager { return groups; } + private static boolean shouldMergeIntoGroup(SeizureGroup currentGroup, SeizureEvent event) { + if (currentGroup == null || event == null) return false; + + // Preferred rule: rows from the same continuing seizure usually have a similar + // inferred start time because alarmDuration grows as the same alarm continues. + // This avoids chaining two distinct seizures together just because their log rows + // are near each other in time. + if (currentGroup.inferredStartDate != null && event.inferredStartDate != null + && currentGroup.hasDurationEvidence && event.durationSeconds > 0) { + long startDiffSecs = Math.abs(event.inferredStartDate.getTime() + - currentGroup.inferredStartDate.getTime()) / 1000L; + return startDiffSecs <= SAME_SEIZURE_START_TOLERANCE_SECS; + } + + // Fallback for old/manual rows with no duration in notes. Keep this deliberately + // short so nearby but separate seizures are less likely to be merged. + if (event.date != null && currentGroup.lastDate != null) { + long diffSecs = (event.date.getTime() - currentGroup.lastDate.getTime()) / 1000L; + return diffSecs >= 0 && diffSecs <= FALLBACK_GROUP_THRESHOLD_SECS; + } + + return false; + } + + private static double optPositiveDouble(JSONObject jo, String key) { + try { + if (jo == null || !jo.has(key) || jo.isNull(key)) return 0.0; + double value = jo.optDouble(key, 0.0); + return value > 0.0 ? value : 0.0; + } catch (Exception ignored) { + return 0.0; + } + } + + private static int parseDurationSecondsFromNotes(String notes) { + String duration = extractDuration(notes); + if (duration == null || duration.equals("unknown") || duration.equals("N/A")) return 0; + return parseDurationSeconds(duration); + } + + private static double parseHeartRateFromNotes(String notes) { + if (notes == null) return 0.0; + String lower = notes.toLowerCase(Locale.UK); + int idx = lower.indexOf("hr:"); + if (idx < 0) idx = lower.indexOf("heart rate:"); + if (idx < 0) return 0.0; + + int start = notes.indexOf(':', idx); + if (start < 0 || start + 1 >= notes.length()) return 0.0; + + StringBuilder number = new StringBuilder(); + for (int i = start + 1; i < notes.length(); i++) { + char ch = notes.charAt(i); + if ((ch >= '0' && ch <= '9') || ch == '.') { + number.append(ch); + } else if (number.length() > 0) { + break; + } + } + + try { + double value = Double.parseDouble(number.toString()); + return value > 0.0 ? value : 0.0; + } catch (Exception ignored) { + return 0.0; + } + } + + private static int parseDurationSeconds(String durationStr) { + if (durationStr == null) return 0; + + String s = durationStr.trim().toLowerCase(Locale.UK); + if (s.isEmpty() || s.equals("n/a") || s.equals("unknown")) { + return 0; + } + + 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]); + return Math.max(1, hours * 3600 + mins * 60 + secs); + } + + // 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]); + return Math.max(1, mins * 60 + secs); + } + + // Supports compact values like "123s", "123 sec", "2 min 30 sec". + int totalSeconds = 0; + String[] tokens = s.replace(",", " ") + .replace("seconds", " sec") + .replace("second", " sec") + .replace("secs", " sec") + .replace("minutes", " min") + .replace("minute", " min") + .replace("mins", " min") + .replace("hours", " hour") + .replace("hrs", " hour") + .replace("hr", " hour") + .split("\\s+"); + + for (int i = 0; i < tokens.length; i++) { + String token = tokens[i]; + int value = -1; + try { + if (token.endsWith("s") && token.length() > 1) { + value = Integer.parseInt(token.substring(0, token.length() - 1)); + totalSeconds += value; + continue; + } + value = Integer.parseInt(token); + } catch (NumberFormatException ignored) { + continue; + } + + String unit = (i + 1 < tokens.length) ? tokens[i + 1] : "sec"; + if (unit.startsWith("hour")) { + totalSeconds += value * 3600; + } else if (unit.startsWith("min")) { + totalSeconds += value * 60; + } else if (unit.startsWith("sec") || unit.equals("s")) { + totalSeconds += value; + } else { + // Bare number in the existing local notes is normally seconds. + totalSeconds += value; + } + } + + if (totalSeconds > 0) return totalSeconds; + } catch (Exception ignored) { + } + + return 0; + } + + private static String formatDurationSeconds(int durationSeconds) { + if (durationSeconds <= 0) return "N/A"; + if (durationSeconds < 60) return durationSeconds + "s"; + + int hours = durationSeconds / 3600; + int mins = (durationSeconds % 3600) / 60; + int secs = durationSeconds % 60; + + if (hours > 0) { + return String.format(Locale.UK, "%dh %02dm %02ds", hours, mins, secs); + } + if (secs > 0) { + return String.format(Locale.UK, "%dm %02ds", mins, secs); + } + return mins + "m"; + } + private static String extractDuration(String notes) { try { - int start = notes.indexOf("Duration:") + 9; - int end = notes.indexOf("HR:"); + if (notes == null) return "unknown"; + int durationIdx = notes.indexOf("Duration:"); + if (durationIdx < 0) return "unknown"; + int start = durationIdx + 9; + int end = notes.indexOf("HR:", start); if (end == -1) end = notes.length(); return notes.substring(start, end).trim(); } catch (Exception e) { @@ -157,57 +415,9 @@ public class ReportManager { } 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; + int seconds = parseDurationSeconds(durationStr); + if (seconds <= 0) return DEFAULT_MARKER_DURATION_SECS / 60; + return Math.max(1, (int) Math.ceil(seconds / 60.0)); } private static String getStatusColor(int status) { @@ -271,7 +481,7 @@ public class ReportManager { SimpleDateFormat monthFormat = new SimpleDateFormat("MMMM yyyy", Locale.UK); SimpleDateFormat monthIdFormat = new SimpleDateFormat("yyyy-MM", Locale.UK); - // Group seizures by day and by month. + // Group alarms by day and by month. Map> byDay = new HashMap<>(); Map eventsByMonth = new HashMap<>(); for (SeizureGroup g : groups) { @@ -291,38 +501,41 @@ public class ReportManager { 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); + // Build the selector from months that actually have event data. Newest months are + // shown first. The current month is selected only if it has events; otherwise the + // latest available month is selected. + ArrayList monthIds = new ArrayList<>(eventsByMonth.keySet()); + Collections.sort(monthIds); + Collections.reverse(monthIds); - 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); + String currentMonthId = monthIdFormat.format(today); + String defaultMonthId = monthIds.contains(currentMonthId) + ? currentMonthId + : (monthIds.isEmpty() ? currentMonthId : monthIds.get(0)); ArrayList reportMonths = new ArrayList<>(); - Calendar monthCursor = (Calendar) firstMonth.clone(); - while (!monthCursor.after(lastMonth)) { - reportMonths.add((Calendar) monthCursor.clone()); - monthCursor.add(Calendar.MONTH, 1); + for (String monthId : monthIds) { + try { + Date monthDate = monthIdFormat.parse(monthId); + Calendar cal = Calendar.getInstance(); + cal.setTime(monthDate); + cal.set(Calendar.DAY_OF_MONTH, 1); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + reportMonths.add(cal); + } catch (ParseException e) { + Log.w(TAG, "Error parsing report month: " + monthId); + } } - String defaultMonthId = monthIdFormat.format(today); - StringBuilder sb = new StringBuilder(); sb.append(""); sb.append(""); sb.append(""); - sb.append("ClinX02 Seizure Report"); - sb.append(""); sb.append("
"); - sb.append("

ClinX02 Seizure Report

"); - sb.append("

Calendar timeline overview and detailed event log

"); + sb.append("

FLOGA Seizure Report

"); + sb.append("

Calendar timeline overview and detailed alarm log

"); // Summary box sb.append("
"); sb.append("
"); sb.append("
"); - sb.append("
Calendar range
"); - sb.append("
").append(MONTHS_TO_SHOW).append(" months
"); + sb.append("
Months with events
"); + sb.append("
").append(eventsByMonth.size()).append("
"); sb.append("
"); sb.append("
"); - sb.append("
Total seizure events
"); + sb.append("
Total alarm events
"); sb.append("
").append(groups.size()).append("
"); sb.append("
"); sb.append("
"); - sb.append("
Days with seizures
"); + sb.append("
Days with alarms
"); sb.append("
").append(byDay.size()).append("
"); sb.append("
"); @@ -443,18 +656,6 @@ public class ReportManager { .append("&token=clinx02secure' download='seizure_report.html'>Download Report"); sb.append("
"); - // Legend - sb.append("
"); - sb.append("
Night background (00-06)
"); - sb.append("
Morning background (06-12)
"); - sb.append("
Afternoon background (12-18)
"); - sb.append("
Evening background (18-24)
"); - sb.append("
Warning bar
"); - sb.append("
Alarm bar
"); - sb.append("
Fall bar
"); - sb.append("
Manual bar
"); - sb.append("
"); - // Month selector sb.append("
"); sb.append(""); @@ -466,7 +667,7 @@ public class ReportManager { int count = eventCount == null ? 0 : eventCount; sb.append(""); } sb.append(""); @@ -505,7 +706,7 @@ public class ReportManager { sb.append("
"); - sb.append("
Seizures by time of day
"); + sb.append("
Alarms by time of day
"); appendSummaryBar(sb, "Night", "00-06", "night", nightCount, maxTimeCount); appendSummaryBar(sb, "Morning", "06-12", "morning", morningCount, maxTimeCount); appendSummaryBar(sb, "Afternoon", "12-18", "afternoon", afternoonCount, maxTimeCount); @@ -513,6 +714,16 @@ public class ReportManager { sb.append("
"); } + // Calendar legend + sb.append("
"); + sb.append("
Night background (00-06)
"); + sb.append("
Morning background (06-12)
"); + sb.append("
Afternoon background (12-18)
"); + sb.append("
Evening background (18-24)
"); + sb.append("
Alarm bar
"); + sb.append("
Manual alarm bar
"); + sb.append("
"); + // Calendar months for (Calendar reportMonth : reportMonths) { String monthId = monthIdFormat.format(reportMonth.getTime()); @@ -549,7 +760,7 @@ public class ReportManager { String boxClass = "day-box"; if (isToday) boxClass += " today"; - if (dayGroups != null && !dayGroups.isEmpty()) boxClass += " has-seizures"; + if (dayGroups != null && !dayGroups.isEmpty()) boxClass += " has-alarms"; sb.append("
"); sb.append("
").append(day).append("
"); @@ -570,9 +781,12 @@ public class ReportManager { for (SeizureGroup g : dayGroups) { int startMinutes = getMinutesOfDay(g.startDate); - int durationMinutes = parseDurationMinutes(g.durationStr); + int timelineSeconds = g.durationSeconds > 0 + ? Math.min(g.durationSeconds, MAX_TIMELINE_DURATION_SECS) + : DEFAULT_MARKER_DURATION_SECS; + int durationMinutes = Math.max(1, (int) Math.ceil(timelineSeconds / 60.0)); - double leftPct = (startMinutes / 1440.0) * 100.0; + double leftPct = Math.max(0.0, Math.min(100.0, (startMinutes / 1440.0) * 100.0)); double widthPct = Math.max((durationMinutes / 1440.0) * 100.0, 1.4); if (leftPct + widthPct > 100.0) { @@ -597,8 +811,8 @@ public class ReportManager { sb.append("
"); sb.append("
0006121824
"); - sb.append("
").append(dayGroups.size()) - .append(dayGroups.size() == 1 ? " event" : " events").append("
"); + sb.append("
").append(dayGroups.size()) + .append(dayGroups.size() == 1 ? " alarm" : " alarms").append("
"); } sb.append("
"); @@ -609,19 +823,18 @@ public class ReportManager { } // Detailed table below. JavaScript filters this table to the selected month. - sb.append("

Event Details

"); + sb.append("

Alarm Details

"); if (groups.isEmpty()) { - sb.append("

No seizure events recorded in this period.

"); + sb.append("

No alarm events recorded in this period.

"); } else { - sb.append("

No seizure events recorded for the selected month.

"); + sb.append("

No alarm events recorded for the selected month.

"); sb.append("
"); sb.append("
"); sb.append("
"); sb.append("
"); sb.append(""); @@ -632,16 +845,15 @@ public class ReportManager { sb.append("
"); sb.append("
"); sb.append("
"); - sb.append(""); + sb.append(""); SimpleDateFormat displaySdf = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.UK); int i = 1; - for (SeizureGroup g : groups) { + for (int gi = groups.size() - 1; gi >= 0; gi--) { + SeizureGroup g = groups.get(gi); 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 hrStr = g.hr > 0 ? String.format(Locale.UK, "%d bpm", Math.round(g.hr)) : "N/A"; String dotColor = getTimeOfDayColor(g.startDate); String eventMonth = g.startDate != null ? monthIdFormat.format(g.startDate) : ""; @@ -652,13 +864,12 @@ public class ReportManager { sb.append(""); sb.append(""); sb.append(""); - sb.append(""); sb.append(""); } sb.append("
#Date & TimeStatusDurationHeart RateCause
#Date & TimeStatusDurationHeart Rate
").append(statusStr).append("").append(escapeHtml(g.durationStr)).append("").append(hrStr).append("").append(escapeHtml(cause)).append("
"); } - sb.append(""); + sb.append(""); sb.append("
"); sb.append("