From 417cfa362e7d9ae798df9b8f3b9e82c2c88a187e Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 5 Mar 2026 00:40:49 +0800 Subject: [PATCH] 0.3.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 噪音数据历史记录,引入数据库 --- .../ComponentSystem/BuiltInComponentIds.cs | 1 + .../ComponentSystem/ComponentRegistry.cs | 10 + LanMountainDesktop/LanMountainDesktop.csproj | 1 + LanMountainDesktop/Localization/en-US.json | 21 + LanMountainDesktop/Localization/zh-CN.json | 21 + .../Models/AppSettingsSnapshot.cs | 3 +- LanMountainDesktop/Models/AttendanceModels.cs | 20 + .../Models/StudyAnalyticsModels.cs | 23 + .../Services/AppDatabaseService.cs | 146 ++++ .../Services/AttendanceDataStore.cs | 130 +++ .../Services/IStudyAnalyticsService.cs | 19 +- .../Services/StudyAnalyticsInternals.cs | 2 + .../Services/StudyAnalyticsService.cs | 275 ++++++- LanMountainDesktop/Services/StudyDataStore.cs | 450 +++++++++++ .../DesktopComponentRuntimeRegistry.cs | 5 + .../StudySessionHistoryWidget.axaml | 95 +++ .../StudySessionHistoryWidget.axaml.cs | 738 ++++++++++++++++++ .../Views/MainWindow.ComponentSystem.cs | 28 +- .../Views/MainWindow.DesktopPaging.cs | 262 ++++++- LanMountainDesktop/Views/MainWindow.axaml | 4 - LanMountainDesktop/Views/MainWindow.axaml.cs | 1 + 21 files changed, 2228 insertions(+), 27 deletions(-) create mode 100644 LanMountainDesktop/Models/AttendanceModels.cs create mode 100644 LanMountainDesktop/Services/AppDatabaseService.cs create mode 100644 LanMountainDesktop/Services/AttendanceDataStore.cs create mode 100644 LanMountainDesktop/Services/StudyDataStore.cs create mode 100644 LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index a1fd5f2..39d5b27 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -20,6 +20,7 @@ public static class BuiltInComponentIds public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons"; public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity"; public const string DesktopStudySessionControl = "DesktopStudySessionControl"; + public const string DesktopStudySessionHistory = "DesktopStudySessionHistory"; public const string Blank2x4 = "Blank2x4"; public const string Date = "Date"; public const string MonthCalendar = "MonthCalendar"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index cd26dc5..9ecb747 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -140,6 +140,16 @@ public sealed class ComponentRegistry MinHeightCells: 1, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudySessionHistory, + "Session History", + "History", + "Study", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), new DesktopComponentDefinition( BuiltInComponentIds.DesktopStudyNoiseCurve, "Noise Curve", diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 0d2fa66..1d37bb0 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -53,6 +53,7 @@ + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 348f1ae..66f7d19 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -235,6 +235,7 @@ "component.holiday_calendar": "Holiday Calendar", "component.study_environment": "Environment", "component.study_session_control": "Study Session Control", + "component.study_session_history": "Session History", "component.study_noise_curve": "Noise Curve", "component.study_noise_distribution": "Noise Distribution", "component.study_score_overview": "Study Score Overview", @@ -300,6 +301,26 @@ "study.session_control.last_session_format": "Last {0}", "study.session_control.start_failed": "Unable to start session", "study.session_control.stop_failed": "Unable to stop session", + "study.session_history.title": "Session History", + "study.session_history.empty": "No session history", + "study.session_history.select_failed": "Unable to switch session", + "study.session_history.rename_failed": "Unable to rename session", + "study.session_history.delete_failed": "Unable to delete session", + "study.session_history.rename_placeholder": "Enter session name", + "study.session_history.rename_confirm": "Confirm rename", + "study.session_history.rename_cancel": "Cancel rename", + "study.session_history.loading": "Loading data...", + "study.session_history.loaded": "Data loaded", + "study.session_history.duration_format": "{0:hh\\:mm\\:ss}", + "study.session_history.meta_format": "{0} · Avg {1:F1}", + "study.session_history.action.view": "View", + "study.session_history.action.rename": "Rename", + "study.session_history.action.delete": "Delete", + "study.session_history.dialog.rename_title": "Rename Session", + "study.session_history.dialog.rename_message": "Enter a new name for \"{0}\".", + "study.session_history.dialog.delete_title": "Delete Session", + "study.session_history.dialog.delete_message": "Delete \"{0}\"? This cannot be undone.", + "study.session_history.dialog.delete_confirm": "Delete", "study.noise_curve.value_format": "{0:F1} dB", "study.noise_curve.axis.now": "Now", "study.noise_distribution.title": "Noise Level Distribution", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 7613248..75ad4a4 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -235,6 +235,7 @@ "component.holiday_calendar": "节假日日历", "component.study_environment": "环境", "component.study_session_control": "自习时段控制", + "component.study_session_history": "历史时段数据", "component.study_noise_curve": "噪音曲线", "component.study_noise_distribution": "噪音等级分布", "component.study_score_overview": "自习评分总览", @@ -300,6 +301,26 @@ "study.session_control.last_session_format": "上次时段 {0}", "study.session_control.start_failed": "启动失败", "study.session_control.stop_failed": "结束失败", + "study.session_history.title": "历史时段", + "study.session_history.empty": "暂无历史时段", + "study.session_history.select_failed": "切换失败", + "study.session_history.rename_failed": "重命名失败", + "study.session_history.delete_failed": "删除失败", + "study.session_history.rename_placeholder": "输入时段名称", + "study.session_history.rename_confirm": "确认重命名", + "study.session_history.rename_cancel": "取消重命名", + "study.session_history.loading": "加载数据中...", + "study.session_history.loaded": "数据已加载", + "study.session_history.duration_format": "{0:hh\\:mm\\:ss}", + "study.session_history.meta_format": "{0} · 均分 {1:F1}", + "study.session_history.action.view": "查看", + "study.session_history.action.rename": "重命名", + "study.session_history.action.delete": "删除", + "study.session_history.dialog.rename_title": "重命名时段", + "study.session_history.dialog.rename_message": "请为“{0}”输入新名称。", + "study.session_history.dialog.delete_title": "删除时段", + "study.session_history.dialog.delete_message": "确认删除“{0}”?此操作无法撤销。", + "study.session_history.dialog.delete_confirm": "确认删除", "study.noise_curve.value_format": "{0:F1} dB", "study.noise_curve.axis.now": "现在", "study.noise_distribution.title": "噪音等级分布", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 9ba1c7d..fd5a35c 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace LanMountainDesktop.Models; @@ -75,4 +75,5 @@ public sealed class AppSettingsSnapshot public bool StudyEnvironmentShowDisplayDb { get; set; } = true; public bool StudyEnvironmentShowDbfs { get; set; } + } diff --git a/LanMountainDesktop/Models/AttendanceModels.cs b/LanMountainDesktop/Models/AttendanceModels.cs new file mode 100644 index 0000000..e7305ac --- /dev/null +++ b/LanMountainDesktop/Models/AttendanceModels.cs @@ -0,0 +1,20 @@ +using System; + +namespace LanMountainDesktop.Models; + +public sealed record AttendanceSessionRecord( + string SessionId, + string Label, + DateTimeOffset StartedAt, + DateTimeOffset? EndedAt, + string Status, + double? Score, + string? PayloadJson); + +public sealed record AttendanceEventRecord( + string EventId, + string SessionId, + string EventType, + DateTimeOffset OccurredAt, + string? PayloadJson); + diff --git a/LanMountainDesktop/Models/StudyAnalyticsModels.cs b/LanMountainDesktop/Models/StudyAnalyticsModels.cs index a2cb7f0..b647a7a 100644 --- a/LanMountainDesktop/Models/StudyAnalyticsModels.cs +++ b/LanMountainDesktop/Models/StudyAnalyticsModels.cs @@ -92,6 +92,18 @@ public sealed record NoiseSliceSummary( double Score, NoiseScoreBreakdown ScoreDetail); +public enum NoiseSliceSourceType +{ + Realtime = 0, + Session = 1 +} + +public sealed record NoiseSliceTimelineEntry( + long TimelineId, + NoiseSliceSourceType SourceType, + string? SessionId, + NoiseSliceSummary Slice); + public sealed record StudySessionOptions( string? Label = null, DateTimeOffset? PlannedEndAt = null); @@ -125,6 +137,15 @@ public sealed record StudySessionReport( StudySessionMetrics Metrics, IReadOnlyList Slices); +public sealed record StudySessionHistoryEntry( + string SessionId, + string Label, + DateTimeOffset StartedAt, + DateTimeOffset EndedAt, + TimeSpan Duration, + double AverageScore, + int SliceCount); + public sealed record StudyAnalyticsSnapshot( StudyAnalyticsRuntimeState State, NoiseStreamStatus StreamStatus, @@ -135,4 +156,6 @@ public sealed record StudyAnalyticsSnapshot( IReadOnlyList RealtimeBuffer, StudySessionSnapshot Session, StudySessionReport? LastSessionReport, + string? SelectedSessionReportId, + IReadOnlyList SessionHistory, string LastError); diff --git a/LanMountainDesktop/Services/AppDatabaseService.cs b/LanMountainDesktop/Services/AppDatabaseService.cs new file mode 100644 index 0000000..b98d26e --- /dev/null +++ b/LanMountainDesktop/Services/AppDatabaseService.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using Microsoft.Data.Sqlite; + +namespace LanMountainDesktop.Services; + +public static class AppDatabaseServiceFactory +{ + private static readonly Lazy SharedService = new( + () => new AppDatabaseService(), + isThreadSafe: true); + + public static AppDatabaseService CreateDefault() + { + return SharedService.Value; + } +} + +public sealed class AppDatabaseService +{ + private readonly object _schemaSyncRoot = new(); + private readonly string _databasePath; + private bool _schemaInitialized; + + public AppDatabaseService() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var dataDirectory = Path.Combine(appData, "LanMountainDesktop"); + _databasePath = Path.Combine(dataDirectory, "app.db"); + } + + public SqliteConnection OpenConnection() + { + var directory = Path.GetDirectoryName(_databasePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var connection = new SqliteConnection($"Data Source={_databasePath};Cache=Shared;Mode=ReadWriteCreate"); + connection.Open(); + + using (var command = connection.CreateCommand()) + { + command.CommandText = "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;"; + command.ExecuteNonQuery(); + } + + EnsureSchema(connection); + return connection; + } + + private void EnsureSchema(SqliteConnection connection) + { + if (_schemaInitialized) + { + return; + } + + lock (_schemaSyncRoot) + { + if (_schemaInitialized) + { + return; + } + + using var command = connection.CreateCommand(); + command.CommandText = """ + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NULL + ); + + CREATE TABLE IF NOT EXISTS study_session_reports ( + session_id TEXT NOT NULL PRIMARY KEY, + label TEXT NOT NULL, + started_at_utc_ms INTEGER NOT NULL, + ended_at_utc_ms INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + avg_score REAL NOT NULL, + slice_count INTEGER NOT NULL, + report_json TEXT NOT NULL, + updated_at_utc_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_study_session_reports_ended_at + ON study_session_reports(ended_at_utc_ms DESC); + + CREATE TABLE IF NOT EXISTS study_noise_slices ( + timeline_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + start_at_utc_ms INTEGER NOT NULL, + end_at_utc_ms INTEGER NOT NULL, + source_type INTEGER NOT NULL, + session_id TEXT NULL, + score REAL NOT NULL, + avg_db REAL NOT NULL, + p95_db REAL NOT NULL, + p50_dbfs REAL NOT NULL, + over_ratio_dbfs REAL NOT NULL, + segment_count INTEGER NOT NULL, + slice_json TEXT NOT NULL, + created_at_utc_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_study_noise_slices_end_at + ON study_noise_slices(end_at_utc_ms DESC); + + CREATE INDEX IF NOT EXISTS idx_study_noise_slices_source_time + ON study_noise_slices(source_type, end_at_utc_ms DESC); + + CREATE TABLE IF NOT EXISTS attendance_sessions ( + session_id TEXT NOT NULL PRIMARY KEY, + label TEXT NOT NULL, + started_at_utc_ms INTEGER NOT NULL, + ended_at_utc_ms INTEGER NULL, + status TEXT NOT NULL, + score REAL NULL, + payload_json TEXT NULL, + created_at_utc_ms INTEGER NOT NULL, + updated_at_utc_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_attendance_sessions_started_at + ON attendance_sessions(started_at_utc_ms DESC); + + CREATE TABLE IF NOT EXISTS attendance_events ( + event_id TEXT NOT NULL PRIMARY KEY, + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + occurred_at_utc_ms INTEGER NOT NULL, + payload_json TEXT NULL, + created_at_utc_ms INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES attendance_sessions(session_id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_attendance_events_session_time + ON attendance_events(session_id, occurred_at_utc_ms DESC); + """; + command.ExecuteNonQuery(); + _schemaInitialized = true; + } + } +} diff --git a/LanMountainDesktop/Services/AttendanceDataStore.cs b/LanMountainDesktop/Services/AttendanceDataStore.cs new file mode 100644 index 0000000..9db1193 --- /dev/null +++ b/LanMountainDesktop/Services/AttendanceDataStore.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +public sealed class AttendanceDataStore +{ + private readonly AppDatabaseService _databaseService; + + public AttendanceDataStore(AppDatabaseService? databaseService = null) + { + _databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault(); + } + + public IReadOnlyList LoadSessions(int limit = 200) + { + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = limit > 0 + ? """ + SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json + FROM attendance_sessions + ORDER BY started_at_utc_ms DESC + LIMIT $limit; + """ + : """ + SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json + FROM attendance_sessions + ORDER BY started_at_utc_ms DESC; + """; + if (limit > 0) + { + command.Parameters.AddWithValue("$limit", limit); + } + + var sessions = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var sessionId = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); + if (string.IsNullOrWhiteSpace(sessionId)) + { + continue; + } + + var label = reader.IsDBNull(1) ? string.Empty : reader.GetString(1); + var startedAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(2)); + DateTimeOffset? endedAt = reader.IsDBNull(3) + ? null + : DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)); + var status = reader.IsDBNull(4) ? string.Empty : reader.GetString(4); + double? score = reader.IsDBNull(5) ? null : reader.GetDouble(5); + var payload = reader.IsDBNull(6) ? null : reader.GetString(6); + + sessions.Add(new AttendanceSessionRecord( + SessionId: sessionId, + Label: label, + StartedAt: startedAt, + EndedAt: endedAt, + Status: status, + Score: score, + PayloadJson: payload)); + } + + return sessions; + } + catch + { + return Array.Empty(); + } + } + + public void UpsertSession(AttendanceSessionRecord record) + { + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO attendance_sessions( + session_id, + label, + started_at_utc_ms, + ended_at_utc_ms, + status, + score, + payload_json, + created_at_utc_ms, + updated_at_utc_ms) + VALUES( + $sessionId, + $label, + $startedAtUtcMs, + $endedAtUtcMs, + $status, + $score, + $payloadJson, + $createdAtUtcMs, + $updatedAtUtcMs) + ON CONFLICT(session_id) DO UPDATE SET + label = excluded.label, + started_at_utc_ms = excluded.started_at_utc_ms, + ended_at_utc_ms = excluded.ended_at_utc_ms, + status = excluded.status, + score = excluded.score, + payload_json = excluded.payload_json, + updated_at_utc_ms = excluded.updated_at_utc_ms; + """; + var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + command.Parameters.AddWithValue("$sessionId", record.SessionId); + command.Parameters.AddWithValue("$label", record.Label); + command.Parameters.AddWithValue("$startedAtUtcMs", record.StartedAt.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$endedAtUtcMs", record.EndedAt?.ToUnixTimeMilliseconds() ?? (object)DBNull.Value); + command.Parameters.AddWithValue("$status", record.Status); + command.Parameters.AddWithValue("$score", record.Score ?? (object)DBNull.Value); + command.Parameters.AddWithValue("$payloadJson", record.PayloadJson ?? (object)DBNull.Value); + command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs); + command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs); + command.ExecuteNonQuery(); + } + catch + { + // Keep runtime resilient when persistence is unavailable. + } + } +} + diff --git a/LanMountainDesktop/Services/IStudyAnalyticsService.cs b/LanMountainDesktop/Services/IStudyAnalyticsService.cs index f6228dc..80631e9 100644 --- a/LanMountainDesktop/Services/IStudyAnalyticsService.cs +++ b/LanMountainDesktop/Services/IStudyAnalyticsService.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using LanMountainDesktop.Models; namespace LanMountainDesktop.Services; @@ -40,9 +41,25 @@ public interface IStudyAnalyticsService : IDisposable void ClearLastSessionReport(); + bool SelectSessionReport(string sessionId); + + bool RenameSessionReport(string sessionId, string label); + + bool DeleteSessionReport(string sessionId); + + IReadOnlyList QueryNoiseSliceTimeline( + DateTimeOffset? startAt = null, + DateTimeOffset? endAt = null, + int limit = 720, + bool includeRealtimeSlices = true, + bool includeSessionSlices = true); + + void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null); + event EventHandler? SnapshotUpdated; event EventHandler? SliceClosed; event EventHandler? SessionCompleted; } + diff --git a/LanMountainDesktop/Services/StudyAnalyticsInternals.cs b/LanMountainDesktop/Services/StudyAnalyticsInternals.cs index c13fff8..7c8227e 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsInternals.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsInternals.cs @@ -358,6 +358,8 @@ internal sealed class SessionAccumulator public bool IsRunning => _state == StudySessionRuntimeState.Running; + public string? CurrentSessionId => _sessionId; + public bool Start(DateTimeOffset now, StudySessionOptions options) { if (IsRunning) diff --git a/LanMountainDesktop/Services/StudyAnalyticsService.cs b/LanMountainDesktop/Services/StudyAnalyticsService.cs index 9572614..1f91640 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsService.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsService.cs @@ -1,4 +1,6 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using LanMountainDesktop.Models; @@ -18,7 +20,9 @@ public static class StudyAnalyticsServiceFactory public sealed class StudyAnalyticsService : IStudyAnalyticsService { + private const int MaxPersistedSessionReports = 120; private readonly object _syncRoot = new(); + private readonly StudyDataStore _studyDataStore = new(); private readonly IAudioRecorderService _audioRecorderService; private readonly Timer _samplingTimer; private readonly NoiseFramePipeline _pipeline; @@ -31,6 +35,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService private NoiseRealtimePoint? _latestRealtime; private NoiseSliceSummary? _latestSlice; private StudySessionReport? _lastSessionReport; + private readonly List _sessionHistory = []; + private string? _selectedSessionReportId; private string _lastError = string.Empty; private bool _disposed; @@ -53,6 +59,9 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService _streamStatus = NoiseStreamStatus.Error; _lastError = audioSnapshot.LastError; } + + RestoreSessionHistoryFromDatabaseLocked(); + UpdateDataModeLocked(); } public event EventHandler? SnapshotUpdated; @@ -172,7 +181,10 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService if (_sessionAccumulator.IsRunning) { finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow); - _lastSessionReport = finishedReport; + if (finishedReport is not null) + { + UpsertSessionReportLocked(finishedReport, selectReport: true); + } } UpdateDataModeLocked(); @@ -214,6 +226,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService } _lastSessionReport = null; + _selectedSessionReportId = null; + PersistSessionHistoryLocked(); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); started = true; @@ -237,7 +251,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService return false; } - _lastSessionReport = report; + UpsertSessionReportLocked(report, selectReport: true); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); } @@ -273,6 +287,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService { ThrowIfDisposedLocked(); _lastSessionReport = null; + _selectedSessionReportId = null; + PersistSessionHistoryLocked(); UpdateDataModeLocked(); snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); } @@ -280,6 +296,144 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); } + public bool SelectSessionReport(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return false; + } + + StudyAnalyticsSnapshot snapshot; + lock (_syncRoot) + { + ThrowIfDisposedLocked(); + if (_sessionAccumulator.IsRunning) + { + return false; + } + + if (!TryFindSessionReportLocked(sessionId, out var report)) + { + return false; + } + + _selectedSessionReportId = report.SessionId; + _lastSessionReport = report; + PersistSessionHistoryLocked(); + UpdateDataModeLocked(); + snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); + } + + SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); + return true; + } + + public bool RenameSessionReport(string sessionId, string label) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return false; + } + + var normalizedLabel = string.IsNullOrWhiteSpace(label) + ? string.Empty + : label.Trim(); + if (string.IsNullOrWhiteSpace(normalizedLabel)) + { + return false; + } + + StudyAnalyticsSnapshot snapshot; + lock (_syncRoot) + { + ThrowIfDisposedLocked(); + var index = FindSessionReportIndexLocked(sessionId); + if (index < 0) + { + return false; + } + + var updated = _sessionHistory[index] with { Label = normalizedLabel }; + _sessionHistory[index] = updated; + if (string.Equals(_selectedSessionReportId, updated.SessionId, StringComparison.OrdinalIgnoreCase)) + { + _lastSessionReport = updated; + } + + PersistSessionHistoryLocked(); + UpdateDataModeLocked(); + snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); + } + + SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); + return true; + } + + public bool DeleteSessionReport(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return false; + } + + StudyAnalyticsSnapshot snapshot; + lock (_syncRoot) + { + ThrowIfDisposedLocked(); + var index = FindSessionReportIndexLocked(sessionId); + if (index < 0) + { + return false; + } + + var removed = _sessionHistory[index]; + _sessionHistory.RemoveAt(index); + + if (string.Equals(_selectedSessionReportId, removed.SessionId, StringComparison.OrdinalIgnoreCase)) + { + _selectedSessionReportId = null; + _lastSessionReport = null; + } + + PersistSessionHistoryLocked(); + UpdateDataModeLocked(); + snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow); + } + + SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); + return true; + } + + public IReadOnlyList QueryNoiseSliceTimeline( + DateTimeOffset? startAt = null, + DateTimeOffset? endAt = null, + int limit = 720, + bool includeRealtimeSlices = true, + bool includeSessionSlices = true) + { + lock (_syncRoot) + { + ThrowIfDisposedLocked(); + } + + return _studyDataStore.LoadNoiseSliceTimeline( + startAt: startAt, + endAt: endAt, + limit: limit, + includeRealtimeSlices: includeRealtimeSlices, + includeSessionSlices: includeSessionSlices); + } + + public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null) + { + lock (_syncRoot) + { + ThrowIfDisposedLocked(); + } + + _studyDataStore.ClearNoiseSliceTimeline(olderThan); + } + public void Dispose() { lock (_syncRoot) @@ -299,6 +453,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService { StudyAnalyticsSnapshot? snapshot = null; NoiseSliceSummary? closedSlice = null; + string? closedSliceSessionId = null; + var closedSliceSourceType = NoiseSliceSourceType.Realtime; lock (_syncRoot) { if (_disposed || _state != StudyAnalyticsRuntimeState.Running) @@ -350,6 +506,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService if (_sessionAccumulator.IsRunning) { _sessionAccumulator.AddSlice(closedSlice); + closedSliceSessionId = _sessionAccumulator.CurrentSessionId; + closedSliceSourceType = NoiseSliceSourceType.Session; } } @@ -359,6 +517,14 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService } } + if (closedSlice is not null) + { + _studyDataStore.AppendNoiseSlice( + slice: closedSlice, + sessionId: closedSliceSessionId, + sourceType: closedSliceSourceType); + } + if (snapshot is not null) { SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot)); @@ -425,6 +591,18 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now) { + var historyEntries = _sessionHistory + .OrderByDescending(report => report.EndedAt) + .Select(report => new StudySessionHistoryEntry( + SessionId: report.SessionId, + Label: report.Label, + StartedAt: report.StartedAt, + EndedAt: report.EndedAt, + Duration: report.Duration, + AverageScore: report.Metrics.AvgScore, + SliceCount: report.Metrics.SliceCount)) + .ToArray(); + return new StudyAnalyticsSnapshot( State: _state, StreamStatus: _streamStatus, @@ -435,6 +613,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(), Session: _sessionAccumulator.GetSnapshot(now), LastSessionReport: _lastSessionReport, + SelectedSessionReportId: _selectedSessionReportId, + SessionHistory: historyEntries, LastError: _lastError); } @@ -490,4 +670,93 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService throw new ObjectDisposedException(nameof(StudyAnalyticsService)); } } + + private void UpsertSessionReportLocked(StudySessionReport report, bool selectReport) + { + var index = FindSessionReportIndexLocked(report.SessionId); + if (index >= 0) + { + _sessionHistory[index] = report; + } + else + { + _sessionHistory.Add(report); + } + + NormalizeSessionHistoryLocked(); + + if (selectReport) + { + _selectedSessionReportId = report.SessionId; + _lastSessionReport = report; + } + + PersistSessionHistoryLocked(); + } + + private bool TryFindSessionReportLocked(string sessionId, out StudySessionReport report) + { + var index = FindSessionReportIndexLocked(sessionId); + if (index >= 0) + { + report = _sessionHistory[index]; + return true; + } + + report = null!; + return false; + } + + private int FindSessionReportIndexLocked(string sessionId) + { + return _sessionHistory.FindIndex(report => + string.Equals(report.SessionId, sessionId, StringComparison.OrdinalIgnoreCase)); + } + + private void NormalizeSessionHistoryLocked() + { + _sessionHistory.Sort((left, right) => right.EndedAt.CompareTo(left.EndedAt)); + if (_sessionHistory.Count > MaxPersistedSessionReports) + { + _sessionHistory.RemoveRange(MaxPersistedSessionReports, _sessionHistory.Count - MaxPersistedSessionReports); + } + } + + private void PersistSessionHistoryLocked() + { + var orderedReports = _sessionHistory + .OrderByDescending(report => report.EndedAt) + .Take(MaxPersistedSessionReports) + .ToList(); + _studyDataStore.ReplaceSessionReports(orderedReports); + _studyDataStore.SetSelectedSessionReportId(_selectedSessionReportId); + } + + private void RestoreSessionHistoryFromDatabaseLocked() + { + _sessionHistory.Clear(); + + var restored = _studyDataStore.LoadSessionReports(MaxPersistedSessionReports) + .Where(report => + !string.IsNullOrWhiteSpace(report.SessionId) && + report.EndedAt >= report.StartedAt) + .GroupBy(report => report.SessionId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderByDescending(report => report.EndedAt) + .Take(MaxPersistedSessionReports); + _sessionHistory.AddRange(restored); + + _selectedSessionReportId = _studyDataStore.GetSelectedSessionReportId(); + + if (!string.IsNullOrWhiteSpace(_selectedSessionReportId) && + TryFindSessionReportLocked(_selectedSessionReportId, out var selectedReport)) + { + _lastSessionReport = selectedReport; + return; + } + + _selectedSessionReportId = null; + _lastSessionReport = null; + } } + diff --git a/LanMountainDesktop/Services/StudyDataStore.cs b/LanMountainDesktop/Services/StudyDataStore.cs new file mode 100644 index 0000000..f7102f6 --- /dev/null +++ b/LanMountainDesktop/Services/StudyDataStore.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using LanMountainDesktop.Models; +using Microsoft.Data.Sqlite; + +namespace LanMountainDesktop.Services; + +public sealed class StudyDataStore +{ + private const string SelectedSessionReportIdMetaKey = "study.selected_session_report_id"; + private const int DefaultNoiseSliceCapacity = 50000; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AppDatabaseService _databaseService; + + public StudyDataStore(AppDatabaseService? databaseService = null) + { + _databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault(); + } + + public IReadOnlyList LoadSessionReports(int limit = 120) + { + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = limit > 0 + ? """ + SELECT report_json + FROM study_session_reports + ORDER BY ended_at_utc_ms DESC + LIMIT $limit; + """ + : """ + SELECT report_json + FROM study_session_reports + ORDER BY ended_at_utc_ms DESC; + """; + if (limit > 0) + { + command.Parameters.AddWithValue("$limit", limit); + } + + var reports = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + if (reader.IsDBNull(0)) + { + continue; + } + + var json = reader.GetString(0); + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + var report = JsonSerializer.Deserialize(json, JsonOptions); + if (report is not null) + { + reports.Add(report); + } + } + + return reports; + } + catch + { + return Array.Empty(); + } + } + + public bool TryGetSessionReport(string sessionId, out StudySessionReport report) + { + report = null!; + if (string.IsNullOrWhiteSpace(sessionId)) + { + return false; + } + + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT report_json + FROM study_session_reports + WHERE session_id = $sessionId + LIMIT 1; + """; + command.Parameters.AddWithValue("$sessionId", sessionId.Trim()); + + var json = command.ExecuteScalar() as string; + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + var parsed = JsonSerializer.Deserialize(json, JsonOptions); + if (parsed is null) + { + return false; + } + + report = parsed; + return true; + } + catch + { + return false; + } + } + + public void ReplaceSessionReports(IReadOnlyList reports) + { + try + { + using var connection = _databaseService.OpenConnection(); + using var transaction = connection.BeginTransaction(); + + using (var clearCommand = connection.CreateCommand()) + { + clearCommand.Transaction = transaction; + clearCommand.CommandText = "DELETE FROM study_session_reports;"; + clearCommand.ExecuteNonQuery(); + } + + for (var i = 0; i < reports.Count; i++) + { + InsertOrUpdateSessionReport(connection, transaction, reports[i]); + } + + transaction.Commit(); + } + catch + { + // Keep runtime resilient when persistence is unavailable. + } + } + + public string? GetSelectedSessionReportId() + { + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT value + FROM app_meta + WHERE key = $key + LIMIT 1; + """; + command.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey); + var value = command.ExecuteScalar() as string; + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim(); + } + catch + { + return null; + } + } + + public void SetSelectedSessionReportId(string? sessionId) + { + try + { + using var connection = _databaseService.OpenConnection(); + if (string.IsNullOrWhiteSpace(sessionId)) + { + using var deleteCommand = connection.CreateCommand(); + deleteCommand.CommandText = "DELETE FROM app_meta WHERE key = $key;"; + deleteCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey); + deleteCommand.ExecuteNonQuery(); + return; + } + + using var upsertCommand = connection.CreateCommand(); + upsertCommand.CommandText = """ + INSERT INTO app_meta(key, value) + VALUES ($key, $value) + ON CONFLICT(key) DO UPDATE SET value = excluded.value; + """; + upsertCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey); + upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim()); + upsertCommand.ExecuteNonQuery(); + } + catch + { + // Keep runtime resilient when persistence is unavailable. + } + } + + public void AppendNoiseSlice(NoiseSliceSummary slice, string? sessionId, NoiseSliceSourceType sourceType) + { + try + { + using var connection = _databaseService.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var sliceJson = JsonSerializer.Serialize(slice, JsonOptions); + + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = """ + INSERT INTO study_noise_slices( + start_at_utc_ms, + end_at_utc_ms, + source_type, + session_id, + score, + avg_db, + p95_db, + p50_dbfs, + over_ratio_dbfs, + segment_count, + slice_json, + created_at_utc_ms) + VALUES( + $startAtUtcMs, + $endAtUtcMs, + $sourceType, + $sessionId, + $score, + $avgDb, + $p95Db, + $p50Dbfs, + $overRatioDbfs, + $segmentCount, + $sliceJson, + $createdAtUtcMs); + """; + command.Parameters.AddWithValue("$startAtUtcMs", slice.StartAt.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$endAtUtcMs", slice.EndAt.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$sourceType", (int)sourceType); + command.Parameters.AddWithValue("$sessionId", string.IsNullOrWhiteSpace(sessionId) ? (object)DBNull.Value : sessionId.Trim()); + command.Parameters.AddWithValue("$score", slice.Score); + command.Parameters.AddWithValue("$avgDb", slice.Display.AvgDb); + command.Parameters.AddWithValue("$p95Db", slice.Display.P95Db); + command.Parameters.AddWithValue("$p50Dbfs", slice.Raw.P50Dbfs); + command.Parameters.AddWithValue("$overRatioDbfs", slice.Raw.OverRatioDbfs); + command.Parameters.AddWithValue("$segmentCount", Math.Max(0, slice.Raw.SegmentCount)); + command.Parameters.AddWithValue("$sliceJson", sliceJson); + command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs); + command.ExecuteNonQuery(); + } + + using (var trimCommand = connection.CreateCommand()) + { + trimCommand.Transaction = transaction; + trimCommand.CommandText = """ + DELETE FROM study_noise_slices + WHERE timeline_id NOT IN ( + SELECT timeline_id + FROM study_noise_slices + ORDER BY end_at_utc_ms DESC + LIMIT $limit + ); + """; + trimCommand.Parameters.AddWithValue("$limit", DefaultNoiseSliceCapacity); + trimCommand.ExecuteNonQuery(); + } + + transaction.Commit(); + } + catch + { + // Keep runtime resilient when persistence is unavailable. + } + } + + public IReadOnlyList LoadNoiseSliceTimeline( + DateTimeOffset? startAt = null, + DateTimeOffset? endAt = null, + int limit = 720, + bool includeRealtimeSlices = true, + bool includeSessionSlices = true) + { + if (!includeRealtimeSlices && !includeSessionSlices) + { + return Array.Empty(); + } + + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + var whereParts = new List(); + if (startAt is not null) + { + whereParts.Add("end_at_utc_ms >= $startAtUtcMs"); + command.Parameters.AddWithValue("$startAtUtcMs", startAt.Value.ToUnixTimeMilliseconds()); + } + + if (endAt is not null) + { + whereParts.Add("start_at_utc_ms <= $endAtUtcMs"); + command.Parameters.AddWithValue("$endAtUtcMs", endAt.Value.ToUnixTimeMilliseconds()); + } + + if (includeRealtimeSlices != includeSessionSlices) + { + var sourceType = includeSessionSlices + ? (int)NoiseSliceSourceType.Session + : (int)NoiseSliceSourceType.Realtime; + whereParts.Add("source_type = $sourceType"); + command.Parameters.AddWithValue("$sourceType", sourceType); + } + + var whereClause = whereParts.Count == 0 + ? string.Empty + : $"WHERE {string.Join(" AND ", whereParts)}"; + var normalizedLimit = Math.Clamp(limit, 1, DefaultNoiseSliceCapacity); + + command.CommandText = $""" + SELECT timeline_id, source_type, session_id, slice_json + FROM study_noise_slices + {whereClause} + ORDER BY end_at_utc_ms DESC + LIMIT $limit; + """; + command.Parameters.AddWithValue("$limit", normalizedLimit); + + var entries = new List(Math.Min(normalizedLimit, 2048)); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var timelineId = reader.GetInt64(0); + var sourceTypeRaw = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); + var sourceType = sourceTypeRaw == (int)NoiseSliceSourceType.Session + ? NoiseSliceSourceType.Session + : NoiseSliceSourceType.Realtime; + var sessionId = reader.IsDBNull(2) ? null : reader.GetString(2); + if (reader.IsDBNull(3)) + { + continue; + } + + var sliceJson = reader.GetString(3); + if (string.IsNullOrWhiteSpace(sliceJson)) + { + continue; + } + + var slice = JsonSerializer.Deserialize(sliceJson, JsonOptions); + if (slice is null) + { + continue; + } + + entries.Add(new NoiseSliceTimelineEntry( + TimelineId: timelineId, + SourceType: sourceType, + SessionId: sessionId, + Slice: slice)); + } + + return entries; + } + catch + { + return Array.Empty(); + } + } + + public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null) + { + try + { + using var connection = _databaseService.OpenConnection(); + using var command = connection.CreateCommand(); + if (olderThan is null) + { + command.CommandText = "DELETE FROM study_noise_slices;"; + } + else + { + command.CommandText = "DELETE FROM study_noise_slices WHERE end_at_utc_ms <= $olderThanUtcMs;"; + command.Parameters.AddWithValue("$olderThanUtcMs", olderThan.Value.ToUnixTimeMilliseconds()); + } + + command.ExecuteNonQuery(); + } + catch + { + // Keep runtime resilient when persistence is unavailable. + } + } + + private static void InsertOrUpdateSessionReport( + SqliteConnection connection, + SqliteTransaction transaction, + StudySessionReport report) + { + var json = JsonSerializer.Serialize(report, JsonOptions); + var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + INSERT INTO study_session_reports( + session_id, + label, + started_at_utc_ms, + ended_at_utc_ms, + duration_ms, + avg_score, + slice_count, + report_json, + updated_at_utc_ms) + VALUES ( + $sessionId, + $label, + $startedAtUtcMs, + $endedAtUtcMs, + $durationMs, + $avgScore, + $sliceCount, + $reportJson, + $updatedAtUtcMs) + ON CONFLICT(session_id) DO UPDATE SET + label = excluded.label, + started_at_utc_ms = excluded.started_at_utc_ms, + ended_at_utc_ms = excluded.ended_at_utc_ms, + duration_ms = excluded.duration_ms, + avg_score = excluded.avg_score, + slice_count = excluded.slice_count, + report_json = excluded.report_json, + updated_at_utc_ms = excluded.updated_at_utc_ms; + """; + command.Parameters.AddWithValue("$sessionId", report.SessionId); + command.Parameters.AddWithValue("$label", report.Label); + command.Parameters.AddWithValue("$startedAtUtcMs", report.StartedAt.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$endedAtUtcMs", report.EndedAt.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$durationMs", (long)Math.Max(0, report.Duration.TotalMilliseconds)); + command.Parameters.AddWithValue("$avgScore", report.Metrics.AvgScore); + command.Parameters.AddWithValue("$sliceCount", report.Metrics.SliceCount); + command.Parameters.AddWithValue("$reportJson", json); + command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs); + command.ExecuteNonQuery(); + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 885e051..09140f7 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -184,6 +184,11 @@ public sealed class DesktopComponentRuntimeRegistry "component.study_session_control", () => new StudySessionControlWidget(), cellSize => Math.Clamp(cellSize * 0.36, 10, 24)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudySessionHistory, + "component.study_session_history", + () => new StudySessionHistoryWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 10, 24)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopStudyNoiseCurve, "component.study_noise_curve", diff --git a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml new file mode 100644 index 0000000..6373378 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + +