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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs
new file mode 100644
index 0000000..6a624b3
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs
@@ -0,0 +1,738 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using FluentIcons.Avalonia;
+using FluentIcons.Common;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.Services;
+using LanMountainDesktop.Theme;
+
+namespace LanMountainDesktop.Views.Components;
+
+public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
+{
+ private const double MinTextContrast = 4.5;
+ private enum HistoryDialogMode
+ {
+ None = 0,
+ Rename = 1,
+ Delete = 2
+ }
+
+ private static readonly Color[] PrimaryColorCandidates =
+ {
+ Color.Parse("#FFF8FAFC"),
+ Color.Parse("#FFEAF3FF"),
+ Color.Parse("#FF101C2A"),
+ Color.Parse("#FF1B2E45"),
+ Color.Parse("#FFFFFFFF")
+ };
+
+ private static readonly Color[] SecondaryColorCandidates =
+ {
+ Color.Parse("#FFDDE7F3"),
+ Color.Parse("#FFCBD9EA"),
+ Color.Parse("#FF24384F"),
+ Color.Parse("#FF2F4763"),
+ Color.Parse("#FF0F1D2D")
+ };
+
+ private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
+ private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
+
+ private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
+ private readonly AppSettingsService _settingsService = new();
+ private readonly LocalizationService _localizationService = new();
+
+ private double _currentCellSize = 48;
+ private string _languageCode = "zh-CN";
+ private bool _isAttached;
+ private bool _isOnActivePage = true;
+ private bool _isSubscribed;
+ private bool _isCompactMode;
+ private bool _isUltraCompactMode;
+ private string? _loadingSessionId;
+ private HistoryDialogMode _dialogMode;
+ private string? _dialogSessionId;
+ private string _dialogSessionLabel = string.Empty;
+ private StudyAnalyticsSnapshot? _currentSnapshot;
+ private string? _transientStatus;
+ private DateTimeOffset _transientStatusExpireAt;
+
+ public StudySessionHistoryWidget()
+ {
+ InitializeComponent();
+ AttachedToVisualTree += OnAttachedToVisualTree;
+ DetachedFromVisualTree += OnDetachedFromVisualTree;
+ SizeChanged += OnSizeChanged;
+ DialogCancelButton.Click += (_, _) => CloseDialog();
+ DialogConfirmButton.Click += (_, _) => ConfirmDialog();
+ DialogRenameTextBox.KeyDown += OnDialogRenameTextBoxKeyDown;
+
+ ReloadLanguageCode();
+ ApplyCellSize(_currentCellSize);
+ }
+
+ public void ApplyCellSize(double cellSize)
+ {
+ _currentCellSize = Math.Max(1, cellSize);
+ UpdateAdaptiveLayout();
+ if (_currentSnapshot is not null)
+ {
+ RenderSnapshot(_currentSnapshot);
+ }
+ }
+
+ public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
+ {
+ _ = isEditMode;
+ _isOnActivePage = isOnActivePage;
+ if (_isAttached && _isOnActivePage)
+ {
+ RefreshFromService();
+ }
+ }
+
+ private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _isAttached = true;
+ ReloadLanguageCode();
+
+ if (!_isSubscribed)
+ {
+ _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
+ _isSubscribed = true;
+ }
+
+ RefreshFromService();
+ }
+
+ private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _isAttached = false;
+ if (_isSubscribed)
+ {
+ _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
+ _isSubscribed = false;
+ }
+ }
+
+ private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ UpdateAdaptiveLayout();
+ if (_currentSnapshot is not null)
+ {
+ RenderSnapshot(_currentSnapshot);
+ }
+ }
+
+ private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (!_isAttached)
+ {
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(_loadingSessionId) &&
+ string.Equals(e.Snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase))
+ {
+ _loadingSessionId = null;
+ SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5);
+ }
+
+ _currentSnapshot = e.Snapshot;
+ if (_isOnActivePage)
+ {
+ RenderSnapshot(e.Snapshot);
+ }
+ }, DispatcherPriority.Background);
+ }
+
+ private void RefreshFromService()
+ {
+ _currentSnapshot = _studyAnalyticsService.GetSnapshot();
+ RenderSnapshot(_currentSnapshot);
+ }
+
+ private void RenderSnapshot(StudyAnalyticsSnapshot snapshot)
+ {
+ var panelColor = ResolvePanelBackgroundColor();
+ var panelSamples = BuildPanelBackgroundSamples(panelColor);
+ TitleTextBlock.Text = L("study.session_history.title", "Session History");
+ TitleTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, PrimaryColorCandidates, MinTextContrast);
+
+ if (_transientStatus is not null && DateTimeOffset.UtcNow > _transientStatusExpireAt)
+ {
+ _transientStatus = null;
+ }
+
+ SessionListPanel.Children.Clear();
+ var history = snapshot.SessionHistory;
+ if (history.Count == 0)
+ {
+ if (_dialogMode != HistoryDialogMode.None)
+ {
+ CloseDialog();
+ }
+
+ StatusTextBlock.Text = _transientStatus ?? L("study.session_history.empty", "No session history");
+ StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
+ UpdateDialogVisual(snapshot, panelColor);
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(_dialogSessionId))
+ {
+ var dialogEntry = FindHistoryEntry(history, _dialogSessionId);
+ if (dialogEntry is null)
+ {
+ CloseDialog();
+ }
+ else
+ {
+ _dialogSessionLabel = dialogEntry.Label;
+ }
+ }
+
+ foreach (var entry in history)
+ {
+ SessionListPanel.Children.Add(CreateSessionRow(entry, snapshot.SelectedSessionReportId, panelColor));
+ }
+
+ StatusTextBlock.Text = _transientStatus ?? string.Empty;
+ StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
+ UpdateDialogVisual(snapshot, panelColor);
+ }
+
+ private Control CreateSessionRow(StudySessionHistoryEntry entry, string? selectedSessionId, Color panelColor)
+ {
+ var isSelected = string.Equals(selectedSessionId, entry.SessionId, StringComparison.OrdinalIgnoreCase);
+ var isLoading = string.Equals(_loadingSessionId, entry.SessionId, StringComparison.OrdinalIgnoreCase);
+ var isDialogOpen = _dialogMode != HistoryDialogMode.None;
+
+ var rowBackground = isSelected
+ ? Color.Parse("#4A5FA9FF")
+ : Color.Parse("#2CFFFFFF");
+ var rowBorderColor = isSelected
+ ? Color.Parse("#99C7E0FF")
+ : Color.Parse("#33FFFFFF");
+
+ var rowBorder = new Border
+ {
+ CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.20, 8, 14)),
+ Background = new SolidColorBrush(rowBackground),
+ BorderBrush = new SolidColorBrush(rowBorderColor),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(Math.Clamp(8, 6, 12), Math.Clamp(6, 4, 10))
+ };
+
+ var panelComposite = ToOpaqueAgainst(panelColor, DarkSubstrate);
+ var rowComposite = ToOpaqueAgainst(rowBackground, panelComposite);
+ var rowPrimaryBrush = CreateAdaptiveBrush(new[] { rowComposite }, PrimaryColorCandidates, MinTextContrast);
+ var rowSecondaryBrush = CreateAdaptiveBrush(new[] { rowComposite }, SecondaryColorCandidates, MinTextContrast);
+
+ var rowGrid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto"),
+ ColumnSpacing = _isUltraCompactMode ? 4 : 6
+ };
+
+ var textStack = new StackPanel
+ {
+ Spacing = _isUltraCompactMode ? 0 : 2
+ };
+ textStack.Children.Add(new TextBlock
+ {
+ Text = entry.Label,
+ FontSize = Math.Clamp(12 * (_isCompactMode ? 0.92 : 1.0), 10, 17),
+ FontWeight = FontWeight.SemiBold,
+ MaxLines = 1,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ Foreground = rowPrimaryBrush
+ });
+
+ if (!_isUltraCompactMode)
+ {
+ var metaText = isLoading
+ ? L("study.session_history.loading", "Loading data...")
+ : string.Format(
+ CultureInfo.InvariantCulture,
+ L("study.session_history.meta_format", "{0} · Avg {1:F1}"),
+ FormatDuration(entry.Duration),
+ entry.AverageScore);
+
+ textStack.Children.Add(new TextBlock
+ {
+ Text = metaText,
+ FontSize = Math.Clamp(10.5 * (_isCompactMode ? 0.94 : 1.0), 9, 14),
+ MaxLines = 1,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ Foreground = rowSecondaryBrush
+ });
+ }
+ rowGrid.Children.Add(textStack);
+
+ var playButton = CreateActionIconButton(
+ L("study.session_history.action.view", "View"),
+ Symbol.Play,
+ isLoading || isDialogOpen,
+ isSelected ? Color.Parse("#5A7FDEFF") : Color.Parse("#3D649FFF"),
+ rowComposite,
+ () => SelectReport(entry.SessionId),
+ IconVariant.Filled);
+ Grid.SetColumn(playButton, 1);
+ rowGrid.Children.Add(playButton);
+
+ var renameButton = CreateActionIconButton(
+ L("study.session_history.action.rename", "Rename"),
+ Symbol.Edit,
+ isLoading || isDialogOpen,
+ Color.Parse("#2BFFFFFF"),
+ rowComposite,
+ () => ShowRenameDialog(entry.SessionId, entry.Label));
+ Grid.SetColumn(renameButton, 2);
+ rowGrid.Children.Add(renameButton);
+
+ var deleteButton = CreateActionIconButton(
+ L("study.session_history.action.delete", "Delete"),
+ Symbol.Delete,
+ isLoading || isDialogOpen,
+ Color.Parse("#5AC74E58"),
+ rowComposite,
+ () => ShowDeleteDialog(entry.SessionId, entry.Label));
+ Grid.SetColumn(deleteButton, 3);
+ rowGrid.Children.Add(deleteButton);
+
+ rowBorder.Child = rowGrid;
+ return rowBorder;
+ }
+
+ private Button CreateActionIconButton(
+ string tooltip,
+ Symbol symbol,
+ bool isDisabled,
+ Color buttonBackground,
+ Color rowComposite,
+ Action onClick,
+ IconVariant iconVariant = IconVariant.Regular)
+ {
+ var buttonComposite = ToOpaqueAgainst(buttonBackground, rowComposite);
+ var iconBrush = CreateAdaptiveBrush(new[] { buttonComposite }, PrimaryColorCandidates, MinTextContrast);
+
+ var iconSize = Math.Clamp(13 * (_isCompactMode ? 0.92 : 1.0), 11, 17);
+ var icon = new SymbolIcon
+ {
+ Symbol = symbol,
+ IconVariant = iconVariant,
+ FontSize = iconSize,
+ Width = iconSize,
+ Height = iconSize,
+ Foreground = iconBrush,
+ IsHitTestVisible = false
+ };
+
+ var button = new Button
+ {
+ MinWidth = _isUltraCompactMode ? 26 : 34,
+ Width = _isUltraCompactMode ? 26 : 34,
+ Height = Math.Clamp(26 * (_isCompactMode ? 0.90 : 1.0), 24, 30),
+ Padding = new Thickness(0),
+ CornerRadius = new CornerRadius(10),
+ Background = new SolidColorBrush(buttonBackground),
+ BorderBrush = Brushes.Transparent,
+ BorderThickness = new Thickness(0),
+ Content = icon,
+ IsEnabled = !isDisabled
+ };
+ button.Classes.Add("study-history-action-button");
+ ToolTip.SetTip(button, tooltip);
+ button.Click += (_, _) => onClick();
+ return button;
+ }
+
+ private void SelectReport(string sessionId)
+ {
+ CloseDialog();
+
+ _loadingSessionId = sessionId;
+ SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4);
+ if (_currentSnapshot is not null)
+ {
+ RenderSnapshot(_currentSnapshot);
+ }
+
+ if (_studyAnalyticsService.SelectSessionReport(sessionId))
+ {
+ return;
+ }
+
+ _loadingSessionId = null;
+ SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session"));
+ if (_currentSnapshot is not null)
+ {
+ RenderSnapshot(_currentSnapshot);
+ }
+ }
+
+ private void ShowRenameDialog(string sessionId, string label)
+ {
+ _dialogMode = HistoryDialogMode.Rename;
+ _dialogSessionId = sessionId;
+ _dialogSessionLabel = label;
+ DialogRenameTextBox.Text = label;
+ if (_currentSnapshot is not null)
+ {
+ RenderSnapshot(_currentSnapshot);
+ }
+ }
+
+ private void ShowDeleteDialog(string sessionId, string label)
+ {
+ _dialogMode = HistoryDialogMode.Delete;
+ _dialogSessionId = sessionId;
+ _dialogSessionLabel = label;
+ if (_currentSnapshot is not null)
+ {
+ RenderSnapshot(_currentSnapshot);
+ }
+ }
+
+ private void ConfirmDialog()
+ {
+ if (string.IsNullOrWhiteSpace(_dialogSessionId))
+ {
+ CloseDialog();
+ return;
+ }
+
+ if (_dialogMode == HistoryDialogMode.Rename)
+ {
+ ConfirmRename(_dialogSessionId);
+ return;
+ }
+
+ if (_dialogMode == HistoryDialogMode.Delete)
+ {
+ ConfirmDelete(_dialogSessionId);
+ return;
+ }
+
+ CloseDialog();
+ }
+
+ private void ConfirmRename(string sessionId)
+ {
+ var nextLabel = (DialogRenameTextBox.Text ?? string.Empty).Trim();
+ if (!_studyAnalyticsService.RenameSessionReport(sessionId, nextLabel))
+ {
+ SetTransientStatus(L("study.session_history.rename_failed", "Unable to rename session"));
+ }
+ else
+ {
+ SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.2);
+ }
+
+ CloseDialog();
+ }
+
+ private void ConfirmDelete(string sessionId)
+ {
+ if (!_studyAnalyticsService.DeleteSessionReport(sessionId))
+ {
+ SetTransientStatus(L("study.session_history.delete_failed", "Unable to delete session"));
+ }
+ else
+ {
+ SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.2);
+ }
+
+ CloseDialog();
+ }
+
+ private void CloseDialog()
+ {
+ _dialogMode = HistoryDialogMode.None;
+ _dialogSessionId = null;
+ _dialogSessionLabel = string.Empty;
+ DialogRenameTextBox.Text = string.Empty;
+ }
+
+ private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (_dialogMode != HistoryDialogMode.Rename)
+ {
+ return;
+ }
+
+ if (e.Key == Key.Enter)
+ {
+ ConfirmDialog();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CloseDialog();
+ e.Handled = true;
+ }
+ }
+
+ private void UpdateDialogVisual(StudyAnalyticsSnapshot snapshot, Color panelColor)
+ {
+ var isVisible = _dialogMode != HistoryDialogMode.None && !string.IsNullOrWhiteSpace(_dialogSessionId);
+ DialogOverlayBorder.IsVisible = isVisible;
+ if (!isVisible)
+ {
+ return;
+ }
+
+ var dialogBackground = Color.Parse("#D92A3E5D");
+ var dialogComposite = ToOpaqueAgainst(dialogBackground, ToOpaqueAgainst(panelColor, DarkSubstrate));
+ DialogCardBorder.Background = new SolidColorBrush(dialogBackground);
+ DialogCardBorder.BorderBrush = new SolidColorBrush(Color.Parse("#66FFFFFF"));
+ DialogTitleTextBlock.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, PrimaryColorCandidates, MinTextContrast);
+ DialogMessageTextBlock.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, SecondaryColorCandidates, MinTextContrast);
+ DialogRenameTextBox.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, PrimaryColorCandidates, MinTextContrast);
+ DialogRenameTextBox.Background = new SolidColorBrush(Color.Parse("#24FFFFFF"));
+ DialogRenameTextBox.BorderBrush = new SolidColorBrush(Color.Parse("#52FFFFFF"));
+
+ var cancelBackground = Color.Parse("#33FFFFFF");
+ var confirmBackground = _dialogMode == HistoryDialogMode.Delete
+ ? Color.Parse("#B7504D")
+ : Color.Parse("#4A73CC");
+
+ DialogCancelButton.Background = new SolidColorBrush(cancelBackground);
+ DialogCancelButton.BorderBrush = Brushes.Transparent;
+ DialogCancelButton.BorderThickness = new Thickness(0);
+ DialogCancelButton.Foreground = CreateAdaptiveBrush(
+ new[] { ToOpaqueAgainst(cancelBackground, dialogComposite) },
+ PrimaryColorCandidates,
+ MinTextContrast);
+
+ DialogConfirmButton.Background = new SolidColorBrush(confirmBackground);
+ DialogConfirmButton.BorderBrush = Brushes.Transparent;
+ DialogConfirmButton.BorderThickness = new Thickness(0);
+ DialogConfirmButton.Foreground = CreateAdaptiveBrush(
+ new[] { ToOpaqueAgainst(confirmBackground, dialogComposite) },
+ PrimaryColorCandidates,
+ MinTextContrast);
+
+ var entry = FindHistoryEntry(snapshot.SessionHistory, _dialogSessionId);
+ var label = entry?.Label ?? _dialogSessionLabel;
+ if (_dialogMode == HistoryDialogMode.Rename)
+ {
+ DialogTitleTextBlock.Text = L("study.session_history.dialog.rename_title", "Rename Session");
+ DialogMessageTextBlock.Text = string.Format(
+ CultureInfo.InvariantCulture,
+ L("study.session_history.dialog.rename_message", "Set a new name for \"{0}\"."),
+ label);
+ DialogRenameTextBox.Watermark = L("study.session_history.rename_placeholder", "Enter session name");
+ if (string.IsNullOrWhiteSpace(DialogRenameTextBox.Text))
+ {
+ DialogRenameTextBox.Text = label;
+ }
+
+ DialogRenameTextBox.IsVisible = true;
+ DialogConfirmButton.Content = L("study.session_history.rename_confirm", "Confirm rename");
+ DialogCancelButton.Content = L("study.session_history.rename_cancel", "Cancel rename");
+ }
+ else
+ {
+ DialogTitleTextBlock.Text = L("study.session_history.dialog.delete_title", "Delete Session");
+ DialogMessageTextBlock.Text = string.Format(
+ CultureInfo.InvariantCulture,
+ L("study.session_history.dialog.delete_message", "Delete \"{0}\"? This cannot be undone."),
+ label);
+ DialogRenameTextBox.IsVisible = false;
+ DialogConfirmButton.Content = L("study.session_history.dialog.delete_confirm", "Delete");
+ DialogCancelButton.Content = L("study.session_history.rename_cancel", "Cancel rename");
+ }
+ }
+
+ private void SetTransientStatus(string status, double seconds = 2.2)
+ {
+ _transientStatus = status;
+ _transientStatusExpireAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(0.6, seconds));
+ }
+
+ private void ReloadLanguageCode()
+ {
+ var snapshot = _settingsService.Load();
+ _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
+ }
+
+ private void UpdateAdaptiveLayout()
+ {
+ var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.2);
+ var widthScale = Bounds.Width > 1 ? Bounds.Width / 360d : cellScale;
+ var heightScale = Bounds.Height > 1 ? Bounds.Height / 180d : cellScale;
+ var scale = Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.68, 2.2);
+
+ _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 320) || (Bounds.Height > 1 && Bounds.Height < 145);
+ _isUltraCompactMode = scale < 0.78 || (Bounds.Width > 1 && Bounds.Width < 280) || (Bounds.Height > 1 && Bounds.Height < 120);
+
+ RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 12, 36));
+ RootBorder.Padding = new Thickness(
+ Math.Clamp(12 * scale, 7, 22),
+ Math.Clamp(9 * scale, 5, 16));
+
+ ContentRootGrid.RowSpacing = _isUltraCompactMode
+ ? Math.Clamp(4 * scale, 2, 6)
+ : Math.Clamp(7 * scale, 4, 10);
+
+ TitleTextBlock.FontSize = Math.Clamp(13 * scale, 10, 22);
+ StatusTextBlock.FontSize = Math.Clamp(11 * scale, 9, 18);
+ SessionListPanel.Spacing = _isUltraCompactMode
+ ? Math.Clamp(4 * scale, 2, 5)
+ : Math.Clamp(6 * scale, 3, 8);
+
+ DialogOverlayBorder.Padding = new Thickness(
+ Math.Clamp(12 * scale, 8, 20),
+ Math.Clamp(10 * scale, 8, 18));
+ DialogCardBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 10, 18));
+ DialogCardBorder.Padding = new Thickness(
+ Math.Clamp(12 * scale, 9, 20),
+ Math.Clamp(11 * scale, 8, 18));
+ DialogTitleTextBlock.FontSize = Math.Clamp(14 * scale, 11, 20);
+ DialogMessageTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
+ DialogRenameTextBox.FontSize = Math.Clamp(11.5 * scale, 10, 16);
+ DialogCancelButton.FontSize = Math.Clamp(11 * scale, 10, 16);
+ DialogConfirmButton.FontSize = Math.Clamp(11 * scale, 10, 16);
+ DialogCancelButton.Height = Math.Clamp(30 * scale, 26, 38);
+ DialogConfirmButton.Height = Math.Clamp(30 * scale, 26, 38);
+ }
+
+ private static StudySessionHistoryEntry? FindHistoryEntry(IReadOnlyList history, string? sessionId)
+ {
+ if (string.IsNullOrWhiteSpace(sessionId))
+ {
+ return null;
+ }
+
+ for (var i = 0; i < history.Count; i++)
+ {
+ var entry = history[i];
+ if (string.Equals(entry.SessionId, sessionId, StringComparison.OrdinalIgnoreCase))
+ {
+ return entry;
+ }
+ }
+
+ return null;
+ }
+
+ private string FormatDuration(TimeSpan duration)
+ {
+ if (duration < TimeSpan.Zero)
+ {
+ duration = TimeSpan.Zero;
+ }
+
+ if (duration.TotalHours >= 1)
+ {
+ var totalHours = (int)Math.Floor(duration.TotalHours);
+ return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00}", totalHours, duration.Minutes, duration.Seconds);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}", duration.Minutes, duration.Seconds);
+ }
+
+ private string L(string key, string fallback)
+ {
+ return _localizationService.GetString(_languageCode, key, fallback);
+ }
+
+ private Color ResolvePanelBackgroundColor()
+ {
+ if (RootBorder.Background is ISolidColorBrush solidBackground)
+ {
+ return solidBackground.Color;
+ }
+
+ if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) &&
+ resource is ISolidColorBrush solidBrush)
+ {
+ return solidBrush.Color;
+ }
+
+ return Color.Parse("#FF1E293B");
+ }
+
+ private static IReadOnlyList BuildPanelBackgroundSamples(Color panelColor)
+ {
+ var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
+ var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
+
+ return
+ [
+ opaqueOnDark,
+ opaqueOnLight,
+ ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.22),
+ ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.14),
+ ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08)
+ ];
+ }
+
+ private static Color ToOpaqueAgainst(Color foreground, Color background)
+ {
+ if (foreground.A >= 0xFF)
+ {
+ return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B);
+ }
+
+ var alpha = foreground.A / 255d;
+ var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
+ var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
+ var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
+ return Color.FromArgb(0xFF, red, green, blue);
+ }
+
+ private static IBrush CreateAdaptiveBrush(IReadOnlyList backgroundSamples, IReadOnlyList candidates, double minContrast)
+ {
+ var selected = candidates[0];
+ var bestRatio = double.MinValue;
+
+ foreach (var candidate in candidates)
+ {
+ var ratio = MinContrastRatio(candidate, backgroundSamples);
+ if (ratio >= minContrast)
+ {
+ selected = candidate;
+ bestRatio = ratio;
+ break;
+ }
+
+ if (ratio > bestRatio)
+ {
+ bestRatio = ratio;
+ selected = candidate;
+ }
+ }
+
+ return new SolidColorBrush(Color.FromArgb(0xFF, selected.R, selected.G, selected.B));
+ }
+
+ private static double MinContrastRatio(Color foreground, IReadOnlyList backgrounds)
+ {
+ var min = double.MaxValue;
+ for (var i = 0; i < backgrounds.Count; i++)
+ {
+ var ratio = ColorMath.ContrastRatio(foreground, backgrounds[i]);
+ if (ratio < min)
+ {
+ min = ratio;
+ }
+ }
+
+ return min;
+ }
+}
+
+
diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
index a5d8947..6a19049 100644
--- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
+++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
@@ -1479,14 +1479,15 @@ public partial class MainWindow
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
{
host.IsHitTestVisible = true;
+ var keepContentInteractive = ShouldKeepContentInteractiveInEditMode(host);
if (TryGetContentHost(host) is Border contentHost)
{
- // In edit mode, prefer drag interactions over component interactions.
- contentHost.IsHitTestVisible = !isEditMode;
+ // In edit mode, keep selected interactive widgets usable; drag/resize still uses host border/handles.
+ contentHost.IsHitTestVisible = !isEditMode || keepContentInteractive;
if (contentHost.Child is Control componentControl)
{
- componentControl.IsHitTestVisible = !isEditMode;
+ componentControl.IsHitTestVisible = !isEditMode || keepContentInteractive;
}
}
@@ -1494,6 +1495,27 @@ public partial class MainWindow
ApplySelectionStateToHost(host, isSelected);
}
+ private bool ShouldKeepContentInteractiveInEditMode(Border host)
+ {
+ if (!_isComponentLibraryOpen ||
+ host.Tag is not string placementId)
+ {
+ return false;
+ }
+
+ var placement = _desktopComponentPlacements.FirstOrDefault(p =>
+ string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
+ if (placement is null)
+ {
+ return false;
+ }
+
+ return string.Equals(
+ placement.ComponentId,
+ BuiltInComponentIds.DesktopStudySessionHistory,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!_isComponentLibraryOpen || _isDesktopComponentDragActive || _isDesktopComponentResizeActive)
diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs
index 7b5e0b3..0a9ee54 100644
--- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs
+++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs
@@ -33,8 +33,12 @@ public partial class MainWindow
private double _desktopSurfacePageWidth;
private TranslateTransform? _desktopPagesHostTransform;
private bool _isDesktopSwipeActive;
+ private bool _isDesktopSwipeDirectionLocked;
private Point _desktopSwipeStartPoint;
private Point _desktopSwipeCurrentPoint;
+ private Point _desktopSwipeLastPoint;
+ private long _desktopSwipeLastTimestamp;
+ private double _desktopSwipeVelocityX;
private double _desktopSwipeBaseOffset;
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
@@ -48,6 +52,15 @@ public partial class MainWindow
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
}
+ private void InitializeDesktopSurfaceSwipeHandlers()
+ {
+ // Capture swipe intent before child controls consume pointer events.
+ AddHandler(PointerPressedEvent, OnDesktopPagesPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
+ AddHandler(PointerMovedEvent, OnDesktopPagesPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
+ AddHandler(PointerReleasedEvent, OnDesktopPagesPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
+ AddHandler(PointerCaptureLostEvent, OnDesktopPagesPointerCaptureLost, RoutingStrategies.Tunnel, handledEventsToo: true);
+ }
+
private async void LoadLauncherEntriesAsync()
{
try
@@ -292,7 +305,12 @@ public partial class MainWindow
return;
}
- var target = Math.Clamp(_currentDesktopSurfaceIndex + delta, 0, LauncherSurfaceIndex);
+ MoveSurfaceTo(_currentDesktopSurfaceIndex + delta);
+ }
+
+ private void MoveSurfaceTo(int targetIndex)
+ {
+ var target = Math.Clamp(targetIndex, 0, LauncherSurfaceIndex);
if (target == _currentDesktopSurfaceIndex)
{
ApplyDesktopSurfaceOffset();
@@ -306,12 +324,16 @@ public partial class MainWindow
private bool CanSwipeDesktopSurface()
{
- return !_isSettingsOpen && !_isDesktopComponentDragActive && _desktopSurfacePageWidth > 1;
+ return !_isSettingsOpen &&
+ !_isComponentLibraryOpen &&
+ !_isDesktopComponentDragActive &&
+ !_isDesktopComponentResizeActive &&
+ _desktopSurfacePageWidth > 1;
}
private void OnDesktopPagesPointerPressed(object? sender, PointerPressedEventArgs e)
{
- if (DesktopPagesViewport is null)
+ if (!TryGetPointerPositionInDesktopViewport(e, out var pointerInViewport))
{
return;
}
@@ -336,16 +358,24 @@ public partial class MainWindow
return;
}
+ if (IsDesktopSwipeBlockedPointerSource(e.Source))
+ {
+ return;
+ }
+
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
{
return;
}
_isDesktopSwipeActive = true;
- _desktopSwipeStartPoint = e.GetPosition(DesktopPagesViewport);
+ _isDesktopSwipeDirectionLocked = false;
+ _desktopSwipeStartPoint = pointerInViewport;
_desktopSwipeCurrentPoint = _desktopSwipeStartPoint;
+ _desktopSwipeLastPoint = _desktopSwipeStartPoint;
+ _desktopSwipeVelocityX = 0;
+ _desktopSwipeLastTimestamp = Stopwatch.GetTimestamp();
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
- e.Pointer.Capture(DesktopPagesViewport);
}
private static bool IsInteractivePointerSource(object? source)
@@ -376,24 +406,172 @@ public partial class MainWindow
return false;
}
+ private static bool IsDesktopSwipeBlockedPointerSource(object? source)
+ {
+ if (source is not Visual visual)
+ {
+ return false;
+ }
+
+ var pendingNodes = new Stack