diff --git a/KPIDashboard/KPIDashboard/AppShell.xaml b/KPIDashboard/KPIDashboard/AppShell.xaml
index 4005238..2622e00 100644
--- a/KPIDashboard/KPIDashboard/AppShell.xaml
+++ b/KPIDashboard/KPIDashboard/AppShell.xaml
@@ -7,13 +7,10 @@
Shell.FlyoutBehavior="Disabled"
xmlns:pages="clr-namespace:KPIDashboard.DashboardPages">
-
-
-
-
diff --git a/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml b/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml
index 41cec61..5a5d658 100644
--- a/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml
+++ b/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml
@@ -7,10 +7,6 @@
xmlns:local="clr-namespace:KPIDashboard" Padding="12"
BackgroundColor="#0B1220">
-
-
-
-
#E5E7EB
@@ -49,7 +45,8 @@
-
+
+
@@ -57,8 +54,7 @@
HorizontalOptions="Start"
VerticalOptions="Center">
-
-
+
-
@@ -93,9 +88,9 @@
-
-
@@ -117,9 +112,9 @@
-
-
@@ -143,9 +138,9 @@
-
-
@@ -162,7 +157,7 @@
-
+
@@ -256,8 +251,8 @@
+ Margin="5"
+ FontFamily="PlaywriteAR-Regular"/>
diff --git a/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml.cs b/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml.cs
index 1249afa..0174a0e 100644
--- a/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml.cs
+++ b/KPIDashboard/KPIDashboard/DashboardPages/MainPageDesktop.xaml.cs
@@ -5,6 +5,9 @@ public partial class MainPageDesktop : ContentPage
public MainPageDesktop()
{
InitializeComponent();
+
+ BindingContext = MauiProgram.Services!
+ .GetRequiredService();
}
}
}
\ No newline at end of file
diff --git a/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml b/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml
index 0c2a185..8c3e639 100644
--- a/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml
+++ b/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml
@@ -8,10 +8,6 @@
Padding="8"
BackgroundColor="#0B1220">
-
-
-
-
#E5E7EB
@@ -126,7 +122,7 @@
-
+
diff --git a/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml.cs b/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml.cs
index 53c21a0..ade84d7 100644
--- a/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml.cs
+++ b/KPIDashboard/KPIDashboard/DashboardPages/MainPageMobile.xaml.cs
@@ -5,6 +5,9 @@ public partial class MainPageMobile : ContentPage
public MainPageMobile()
{
InitializeComponent();
+
+ BindingContext = MauiProgram.Services!
+ .GetRequiredService();
}
}
-}
+}
\ No newline at end of file
diff --git a/KPIDashboard/KPIDashboard/MainPage.xaml b/KPIDashboard/KPIDashboard/MainPage.xaml
deleted file mode 100644
index 50ab5ea..0000000
--- a/KPIDashboard/KPIDashboard/MainPage.xaml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/KPIDashboard/KPIDashboard/MainPage.xaml.cs b/KPIDashboard/KPIDashboard/MainPage.xaml.cs
deleted file mode 100644
index 3ba45bf..0000000
--- a/KPIDashboard/KPIDashboard/MainPage.xaml.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace KPIDashboard
-{
- public partial class MainPage : ContentPage
- {
- int count = 0;
-
- public MainPage()
- {
- InitializeComponent();
- }
-
- private void OnCounterClicked(object? sender, EventArgs e)
- {
- count++;
-
- if (count == 1)
- CounterBtn.Text = $"Clicked {count} time";
- else
- CounterBtn.Text = $"Clicked {count} times";
-
- SemanticScreenReader.Announce(CounterBtn.Text);
- }
- }
-}
diff --git a/KPIDashboard/KPIDashboard/MauiProgram.cs b/KPIDashboard/KPIDashboard/MauiProgram.cs
index 2f898ea..1c73261 100644
--- a/KPIDashboard/KPIDashboard/MauiProgram.cs
+++ b/KPIDashboard/KPIDashboard/MauiProgram.cs
@@ -5,9 +5,12 @@ namespace KPIDashboard
{
public static class MauiProgram
{
+ public static IServiceProvider? Services { get; private set; }
+
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
+
builder
.UseMauiApp()
.ConfigureSyncfusionToolkit()
@@ -17,11 +20,23 @@ public static MauiApp CreateMauiApp()
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
+ // Register services
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(sp =>
+ {
+ var db = sp.GetRequiredService();
+ var logger = sp.GetService>();
+ return new DashboardViewModel(db, logger);
+ });
+
#if DEBUG
- builder.Logging.AddDebug();
+ builder.Logging.AddDebug();
#endif
-
- return builder.Build();
+ var app = builder.Build();
+ Services = app.Services;
+ return app;
}
}
+
+
}
diff --git a/KPIDashboard/KPIDashboard/Models/Model.cs b/KPIDashboard/KPIDashboard/Models/Model.cs
index 3ac2528..72fd9e1 100644
--- a/KPIDashboard/KPIDashboard/Models/Model.cs
+++ b/KPIDashboard/KPIDashboard/Models/Model.cs
@@ -45,4 +45,5 @@ public class SalesRecord
public string? Region { get; set; }
public double UnitsSold { get; set; }
public double Revenue { get; set; }
+ public String? SourceInstanceId { get; set; }
}
\ No newline at end of file
diff --git a/KPIDashboard/KPIDashboard/Services/FiresbaseService.cs b/KPIDashboard/KPIDashboard/Services/FiresbaseService.cs
new file mode 100644
index 0000000..4295afe
--- /dev/null
+++ b/KPIDashboard/KPIDashboard/Services/FiresbaseService.cs
@@ -0,0 +1,452 @@
+using System.Text;
+using System.Text.Json;
+
+namespace KPIDashboard
+{
+ ///
+ /// Provides real-time connectivity to Firebase Realtime Database for the KPI dashboard demo.
+ /// Implements per-session isolation, an SSE (Server-Sent Events) listener for live updates,
+ /// and an optional simulator that pushes demo records at a configurable interval.
+ ///
+public sealed class FirebaseService : IAsyncDisposable
+ {
+ #region Fields and Events
+ ///
+ /// Shared HTTP client used for all network operations.
+ ///
+ private static readonly HttpClient HttpClientShared = new();
+
+ // Events
+ ///
+ /// Raised when a new sales record is received from Firebase.
+ ///
+ public event EventHandler? NewSalesRecord;
+
+ // Runtime state
+ private Task? StreamTask;
+ private Task? SimulationTask;
+ private CancellationTokenSource? StreamCancellationSource;
+
+ ///
+ /// Base path to the records (WITHOUT the .json suffix).
+ ///
+ private string? RecordsBasePath;
+
+ ///
+ /// Current date used by the simulator when pushing demo records.
+ ///
+ private DateTime SimulationCurrentDate;
+
+ ///
+ /// Random number generator used for demo data.
+ ///
+ private readonly Random RandomGenerator = new();
+
+ ///
+ /// Per-session instance ID (environment override via SESSION_ID or a generated GUID).
+ ///
+ private readonly string InstanceId =
+ Environment.GetEnvironmentVariable("SESSION_ID") ?? Guid.NewGuid().ToString("N");
+
+ #endregion
+
+ #region Methods
+ ///
+ /// Starts the SSE listener and (optionally) the demo data simulator for this session.
+ ///
+ /// Cancellation token provided by the caller.
+ public async Task StartListeningAsync(CancellationToken externalToken = default)
+ {
+ // Prevent double-start
+ if (StreamTask is not null && !StreamTask.IsCompleted)
+ {
+ return;
+ }
+
+ StreamCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(externalToken);
+ var linkedToken = StreamCancellationSource.Token;
+
+ // Base URL: never end with .json; trim trailing slash
+ var firebaseBaseUrl = Environment.GetEnvironmentVariable("FIREBASE_BASE")
+ ?? "https://kpi-dashboard-demo-aa2ed-default-rtdb.asia-southeast1.firebasedatabase.app";
+ firebaseBaseUrl = firebaseBaseUrl.TrimEnd('/');
+
+ // Per-session path (append .json ONLY to data path)
+ var sessionPathJson = $"/sessions/{InstanceId}/salesrecords.json";
+ var fullJsonUrl = firebaseBaseUrl + sessionPathJson;
+
+ // Store base path WITHOUT .json
+ RecordsBasePath = fullJsonUrl.Substring(0, fullJsonUrl.Length - ".json".Length);
+
+ // Optional: clear only this session path
+ var clearOnStart = Environment.GetEnvironmentVariable("FIREBASE_CLEAR_ON_START") == "1";
+ if (clearOnStart)
+ {
+ try
+ {
+ await ClearPathAsync(RecordsBasePath + ".json", linkedToken).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // Swallow for demo stability; in production, log the exception.
+ }
+ }
+
+ // Simulator start date
+ var startDateStr = Environment.GetEnvironmentVariable("FIREBASE_START_DATE");
+ SimulationCurrentDate = (!string.IsNullOrEmpty(startDateStr) && DateTime.TryParse(startDateStr, out var parsedDate))
+ ? parsedDate
+ : DateTime.UtcNow;
+
+ // Start SSE listener (read-only) for THIS session
+ StreamTask = StartSseStreamAsync(RecordsBasePath + ".json", linkedToken);
+
+ // Simulator delay (milliseconds), default 1000
+ var simulatorDelayMs = int.TryParse(Environment.GetEnvironmentVariable("FIREBASE_SIM_DELAY_MS"), out var parsedDelay)
+ ? parsedDelay
+ : 1000;
+
+ // Push demo records in the background
+ SimulationTask = Task.Run(async () =>
+ {
+ try
+ {
+ while (!linkedToken.IsCancellationRequested)
+ {
+ await PushSimulatedRecordAsync(linkedToken).ConfigureAwait(false);
+ SimulationCurrentDate = SimulationCurrentDate.AddDays(1);
+ try
+ {
+ await Task.Delay(simulatorDelayMs, linkedToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected during cancellation; no action required.
+ }
+ }, linkedToken);
+ }
+
+ ///
+ /// Opens a long-lived SSE stream to the specified Firebase RTDB URL and dispatches incoming records.
+ /// Includes exponential backoff on failures.
+ ///
+ /// The RTDB JSON URL to stream.
+ /// Cancellation token to stop the stream.
+ private async Task StartSseStreamAsync(string url, CancellationToken cancellationToken)
+ {
+ var backoffMs = 1000;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Accept.Clear();
+ request.Headers.Accept.ParseAdd("text/event-stream");
+
+ using var response = await HttpClientShared.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+
+ backoffMs = 1000;
+
+ string? currentLine;
+ var dataBuilder = new StringBuilder();
+
+ while (!cancellationToken.IsCancellationRequested && (currentLine = await reader.ReadLineAsync().ConfigureAwait(false)) is not null)
+ {
+ if (currentLine.StartsWith(":"))
+ {
+ continue;
+ }
+
+ if (currentLine.StartsWith("data:"))
+ {
+ var payload = currentLine.Length > 5 ? currentLine.Substring(5).TrimStart() : string.Empty; // safe slice
+ if (dataBuilder.Length > 0)
+ {
+ dataBuilder.Append('\n');
+ }
+ dataBuilder.Append(payload);
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(currentLine))
+ {
+ if (dataBuilder.Length > 0)
+ {
+ var json = dataBuilder.ToString();
+ dataBuilder.Clear();
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ root.TryGetProperty("path", out var pathElement);
+ var pathString = pathElement.ValueKind == JsonValueKind.String ? pathElement.GetString() ?? string.Empty : string.Empty;
+
+ if (root.TryGetProperty("data", out var dataElement))
+ {
+ if (dataElement.ValueKind == JsonValueKind.Null)
+ {
+ continue;
+ }
+ else if (dataElement.ValueKind == JsonValueKind.Object)
+ {
+ if (!string.IsNullOrEmpty(pathString) && pathString != "/")
+ {
+ if (TryParseFbRecord(dataElement, out var fbRecord))
+ {
+ try
+ {
+ NewSalesRecord?.Invoke(this, ToSalesRecord(fbRecord));
+ }
+ catch (Exception)
+ {
+ // Swallow for demo; consider logging in production.
+ }
+ }
+ }
+ else
+ {
+ foreach (var property in dataElement.EnumerateObject())
+ {
+ var value = property.Value;
+ if (value.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ if (TryParseFbRecord(value, out var fbRecord))
+ {
+ try
+ {
+ NewSalesRecord?.Invoke(this, ToSalesRecord(fbRecord));
+ }
+ catch (Exception)
+ {
+ // Swallow for demo; consider logging in production.
+ }
+ }
+ }
+ }
+ }
+
+ }
+ }
+ catch (Exception)
+ {
+ // Swallow for demo stability; consider logging.
+ }
+ }
+
+ continue;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception)
+ {
+ try
+ {
+ await Task.Delay(backoffMs, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ backoffMs = Math.Min(backoffMs * 2, 15000);
+ }
+ }
+ }
+
+ ///
+ /// Pushes a simulated sales record to the current RecordsBasePath.
+ ///
+ /// Cancellation token for the operation.
+ private async Task PushSimulatedRecordAsync(CancellationToken cancellationToken)
+ {
+ var record = new Dictionary
+ {
+ ["Date"] = SimulationCurrentDate.ToString("o"),
+ ["SalesChannel"] = new[] { "Retail", "In-Store", "Online" }[RandomGenerator.Next(0, 3)],
+ ["Region"] = new[] { "East", "South", "West", "North" }[RandomGenerator.Next(0, 4)],
+ ["Sales"] = Math.Round(100 + RandomGenerator.NextDouble() * 1000.0, 2),
+ ["Quantity"] = RandomGenerator.Next(1, 20),
+ ["SourceInstanceId"] = InstanceId
+ };
+
+ try
+ {
+ if (string.IsNullOrEmpty(RecordsBasePath))
+ {
+ return;
+ }
+
+ var postUrl = RecordsBasePath + ".json";
+ var content = new StringContent(JsonSerializer.Serialize(record), Encoding.UTF8, "application/json");
+
+ using var response = await HttpClientShared.PostAsync(postUrl, content, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // Swallow for demo stability; consider logging in production.
+ }
+ }
+
+ ///
+ /// Clears the specified RTDB JSON path using HTTP DELETE.
+ ///
+ /// The full JSON URL to delete.
+ /// Cancellation token.
+ private async Task ClearPathAsync(string jsonUrl, CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Delete, jsonUrl);
+ using var response = await HttpClientShared.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+
+ ///
+ /// Disposes the service by cancelling background tasks and waiting for their completion.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ StreamCancellationSource?.Cancel();
+
+ if (StreamTask is not null)
+ {
+ await StreamTask;
+ }
+
+ if (SimulationTask is not null)
+ {
+ await SimulationTask;
+ }
+ }
+ catch (Exception)
+ {
+ // Swallow for demo stability; consider logging.
+ }
+ finally
+ {
+ StreamCancellationSource?.Dispose();
+ }
+ }
+
+ ///
+ /// No-op seed method (reserved for future use).
+ ///
+ public Task EnsureSeedAsync(CancellationToken ct = default) => Task.CompletedTask;
+ #endregion
+
+ #region Parsing helpers
+
+ ///
+ /// Internal DTO used to hold raw Firebase sales record data.
+ ///
+ private sealed class FbSalesRecord
+ {
+ public DateTime Date { get; set; }
+ public string? SalesChannel { get; set; }
+ public string? Region { get; set; }
+ public double Sales { get; set; }
+ public int? Quantity { get; set; }
+ public string? SourceInstanceId { get; set; }
+ }
+
+ ///
+ /// Attempts to parse a single Firebase record object.
+ ///
+ /// JSON element representing the record.
+ /// Parsed record output (if successful).
+ /// True if parsing succeeded; otherwise false.
+ private static bool TryParseFbRecord(JsonElement element, out FbSalesRecord record)
+ {
+ record = default!;
+
+ try
+ {
+ var parsedDate = DateTime.MinValue;
+ if (element.TryGetProperty("Date", out var dateEl))
+ {
+ if (dateEl.ValueKind == JsonValueKind.String)
+ {
+ parsedDate = DateTime.Parse(dateEl.GetString()!);
+ }
+ else
+ {
+ parsedDate = dateEl.GetDateTime();
+ }
+ }
+ else
+ {
+ return false;
+ }
+
+ var salesChannel = element.TryGetProperty("SalesChannel", out var scEl) && scEl.ValueKind == JsonValueKind.String ? scEl.GetString() : null;
+ var region = element.TryGetProperty("Region", out var regionEl) && regionEl.ValueKind == JsonValueKind.String ? regionEl.GetString() : null;
+
+ double salesValue = 0.0;
+ if (element.TryGetProperty("Sales", out var salesEl))
+ {
+ if (salesEl.ValueKind == JsonValueKind.Number)
+ {
+ salesValue = salesEl.GetDouble();
+ }
+ else if (salesEl.ValueKind == JsonValueKind.String && double.TryParse(salesEl.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsedDouble))
+ {
+ salesValue = parsedDouble;
+ }
+ }
+
+ var quantity = element.TryGetProperty("Quantity", out var qtyEl) && qtyEl.ValueKind == JsonValueKind.Number ? qtyEl.GetInt32() : (int?)null;
+ var sourceInstanceId = element.TryGetProperty("SourceInstanceId", out var srcEl) && srcEl.ValueKind == JsonValueKind.String ? srcEl.GetString() : null;
+
+ record = new FbSalesRecord
+ {
+ Date = parsedDate,
+ SalesChannel = salesChannel,
+ Region = region,
+ Sales = salesValue,
+ Quantity = quantity,
+ SourceInstanceId = sourceInstanceId
+ };
+
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Converts an internal into the public model.
+ ///
+ private static SalesRecord ToSalesRecord(FbSalesRecord input) => new()
+ {
+ Date = input.Date,
+ Channel = input.SalesChannel,
+ Region = input.Region,
+ UnitsSold = input.Quantity ?? 1,
+ Revenue = input.Sales,
+ SourceInstanceId = input.SourceInstanceId
+ };
+
+ #endregion
+ }
+}
diff --git a/KPIDashboard/KPIDashboard/ViewModels/DashBoardViewModels.cs b/KPIDashboard/KPIDashboard/ViewModels/DashBoardViewModels.cs
index dd4cdf1..742c631 100644
--- a/KPIDashboard/KPIDashboard/ViewModels/DashBoardViewModels.cs
+++ b/KPIDashboard/KPIDashboard/ViewModels/DashBoardViewModels.cs
@@ -1,38 +1,43 @@
-using System.Collections.ObjectModel;
+using System.Collections.ObjectModel;
using System.ComponentModel;
-using System.Globalization;
+using Microsoft.Extensions.Logging;
namespace KPIDashboard
{
+ ///
+ /// ViewModel that listens to FirebaseService for live sales records and keeps chart collections in sync.
+ /// Maintains rolling buffers (capped at 60 points) for trend and region computations, and updates insights.
+ ///
public class DashboardViewModel : INotifyPropertyChanged, IAsyncDisposable
{
- private readonly PeriodicTimer _timer = new(TimeSpan.FromSeconds(1));
private readonly CancellationTokenSource _cts = new();
+ private const int MaxPoints = 60;
+ // Chart-bound collections
public ObservableCollection RevenueTrend { get; } = new();
- public ObservableCollection LeadsByChannel { get; } = new();
+ public ObservableCollection LeadsByChannel { get; } = new();
public ObservableCollection RevenueByRegion { get; } = new();
public ObservableCollection SalesActual { get; } = new();
public ObservableCollection SalesRemaining { get; } = new();
public ObservableCollection Insights { get; } = new();
+ // Brushes for charts (examples)
public List LeadsBrushes { get; set; } = new();
public List CustomBrushes { get; set; }
- private const double SalesTarget = 100000;
- private double _currentActual = 0;
+ // KPI target/actual
+ private double _target = 100000; // will be loaded from DB view
+ private double _currentActual = 0;
- public double Target => SalesTarget;
+ public double Target => _target;
public double Actual => _currentActual;
- private List _streamData = new();
- private int _currentIndex = 0;
+ private readonly FirebaseService _db;
- private readonly TimeSpan _windowSpan = TimeSpan.FromDays(60);
- private readonly LinkedList _window = new();
-
- public DashboardViewModel()
+ public DashboardViewModel(FirebaseService db)
{
+ _db = db;
+
CustomBrushes = new List
{
new SolidColorBrush(Color.FromArgb("#FF6FAE")),
@@ -54,183 +59,40 @@ public DashboardViewModel()
_ = InitializeAsync();
}
- private async Task InitializeAsync()
+ public DashboardViewModel(FirebaseService db, ILogger? logger) : this(db)
{
- await LoadCsvDataFromResourcesAsync("sales_data.csv");
-
- if (_streamData.Count == 0)
- return;
-
- // Seed with the first chunk and construct initial window based on last record date within that chunk
- int seedCount = Math.Min(100, _streamData.Count);
- _currentIndex = seedCount;
-
- DateTime newest = _streamData[seedCount - 1].Date;
- DateTime cutoff = newest - _windowSpan;
-
- // Fill window with records in [cutoff, newest]
- foreach (var r in _streamData.Take(seedCount))
- {
- if (r.Date >= cutoff && r.Date <= newest)
- _window.AddLast(r);
- }
-
- await MainThread.InvokeOnMainThreadAsync(RecomputeFromWindow);
-
- // start realtime
- _ = StartRealtimeLoopAsync();
+ // Logger parameter kept for backward compatibility; not used.
}
- // LOAD CSV FROM Resources/Raw
- private async Task LoadCsvDataFromResourcesAsync(string fileNameInRaw)
+ ///
+ /// Initializes realtime listening by subscribing to NewSalesRecord, seeding the database if needed,
+ /// and starting the Firebase SSE stream. Emits initial records, then one per second in demo mode.
+ ///
+ private async Task InitializeAsync()
{
- using var stream = await FileSystem.OpenAppPackageFileAsync(fileNameInRaw);
- using var reader = new StreamReader(stream);
-
- var header = await reader.ReadLineAsync();
- if (string.IsNullOrWhiteSpace(header)) return;
-
- var headers = header.Split(',');
-
- int idxRegion = Array.FindIndex(headers, h => h.Equals("Region", StringComparison.OrdinalIgnoreCase));
- int idxSales = Array.FindIndex(headers, h => h.Equals("Sales", StringComparison.OrdinalIgnoreCase));
- int idxDate = Array.FindIndex(headers, h => h.Equals("Date", StringComparison.OrdinalIgnoreCase));
- int idxQuantity = Array.FindIndex(headers, h => h.Equals("Quantity", StringComparison.OrdinalIgnoreCase));
- int idxSalesChannel = Array.FindIndex(headers, h => h.Equals("SalesChannel", StringComparison.OrdinalIgnoreCase));
-
- // minimal columns needed
- if (idxRegion < 0 || idxSales < 0 || idxDate < 0 || idxQuantity < 0 || idxSalesChannel < 0)
- return;
-
- var culture = CultureInfo.InvariantCulture;
-
- string? line;
- var all = new List();
- while ((line = await reader.ReadLineAsync()) != null)
+ try
{
- var parts = line.Split(',');
- if (parts.Length < headers.Length) continue;
-
- if (!DateTime.TryParse(parts[idxDate], culture, DateTimeStyles.None, out var date))
- continue;
-
- var region = parts[idxRegion];
- if (string.IsNullOrWhiteSpace(region) || region.Equals("nan", StringComparison.OrdinalIgnoreCase))
- region = "Others";
-
- var channel = parts[idxSalesChannel];
-
- // parse numbers (allow blanks => 0)
- double revenue = 0;
- if (!string.IsNullOrWhiteSpace(parts[idxSales]))
- double.TryParse(parts[idxSales], NumberStyles.Number | NumberStyles.AllowDecimalPoint, culture, out revenue);
-
- double quantity = 0;
- if (!string.IsNullOrWhiteSpace(parts[idxQuantity]))
- double.TryParse(parts[idxQuantity], NumberStyles.Number, culture, out quantity);
-
- all.Add(new SalesRecord
- {
- Date = date,
- Channel = channel,
- Region = region,
- UnitsSold = quantity,
- Revenue = revenue
- });
+ _db.NewSalesRecord += (_, rec) => OnNewSalesRecord(rec);
+ await _db.EnsureSeedAsync(_cts.Token);
+ await _db.StartListeningAsync(_cts.Token);
}
-
- _streamData = all.OrderBy(r => r.Date).ToList();
- }
-
- private async Task StartRealtimeLoopAsync()
- {
- try
+ catch (Exception ex)
{
- while (await _timer.WaitForNextTickAsync(_cts.Token))
+ await MainThread.InvokeOnMainThreadAsync(() =>
{
- if (_currentIndex < _streamData.Count)
- {
- await MainThread.InvokeOnMainThreadAsync(UpdateRealtime);
- }
- else
- {
- _cts.Cancel();
- }
- }
+ Insights.Clear();
+ Insights.Add(new InsightItem { Name = "Init Error", Value = ex.Message });
+ });
}
- catch (OperationCanceledException) { }
}
- // Add the next record, update the rolling window [newest-60d, newest], then recompute all visuals from the window.
- private void UpdateRealtime()
- {
- if (_currentIndex >= _streamData.Count) return;
-
- var record = _streamData[_currentIndex];
- _currentIndex++;
-
- // add new record to window
- _window.AddLast(record);
-
- // roll window by time
- var cutoff = record.Date - _windowSpan;
- while (_window.First != null && _window.First.Value.Date < cutoff)
- _window.RemoveFirst();
-
- RecomputeFromWindow();
- }
-
- // Recompute all collections from the current window
- private void RecomputeFromWindow()
+ private void EnsureInsightsInitialized()
{
- // Revenue trend (within window) - plot raw points in chronological order
- RevenueTrend.Clear();
- foreach (var r in _window)
- RevenueTrend.Add(new TimePoint { Time = r.Date, Value = r.Revenue });
-
- // Region share (percent) within the same window
- RevenueByRegion.Clear();
- var byRegion = _window
- .GroupBy(r => r.Region)
- .Select(g => new { Region = g.Key, Sum = g.Sum(x => x.Revenue) })
- .Where(x => x.Sum > 0)
- .OrderByDescending(x => x.Sum)
- .ToList();
-
- double totalRevenue = byRegion.Sum(x => x.Sum);
- if (totalRevenue > 0)
+ if (Insights.Count > 0)
{
- foreach (var x in byRegion)
- {
- var pct = (x.Sum / totalRevenue) * 100.0; // no clamping to 1%
- RevenueByRegion.Add(new CategoryPoint { Category = x.Region, Value = pct });
- }
+ return;
}
- // Units by Channel (within window)
- LeadsByChannel.Clear();
- var byChannel = _window
- .GroupBy(r => r.Channel)
- .Select(g => new CategoryPoint { Category = g.Key, Value = g.Sum(x => x.UnitsSold) })
- .OrderByDescending(cp => cp.Value);
-
- foreach (var cp in byChannel)
- LeadsByChannel.Add(cp);
-
- // Actual vs Target (actual = revenue inside window)
- _currentActual = _window.Sum(r => r.Revenue);
- SalesActual.Clear();
- SalesActual.Add(new CategoryPoint { Category = "Sales", Value = _currentActual });
- SalesRemaining.Clear();
- SalesRemaining.Add(new CategoryPoint { Category = "Sales", Value = Math.Max(0, SalesTarget - _currentActual) });
-
- // Insights from the same window
- UpdateInsightsValues();
- }
-
- private void EnsureInsightsInitialized()
- {
- if (Insights.Count > 0) return;
Insights.Add(new InsightItem { Name = "Momentum", Value = "+0" });
Insights.Add(new InsightItem { Name = "Top Region", Value = "-" });
Insights.Add(new InsightItem { Name = "Top Channel", Value = "-" });
@@ -238,12 +100,16 @@ private void EnsureInsightsInitialized()
private void UpdateInsightsValues()
{
- if (Insights.Count < 3) return;
+ if (Insights.Count < 3)
+ {
+ return;
+ }
- // Momentum: change over last 10 points in the synchronized trend window
- if (RevenueTrend.Count >= 10)
+ int count = RevenueTrend.Count;
+ if (count >= 2)
{
- double first = RevenueTrend[^10].Value;
+ int window = Math.Min(count, 10);
+ double first = RevenueTrend[count - window].Value;
double last = RevenueTrend[^1].Value;
double delta = last - first;
Insights[0].Value = delta >= 0 ? $"+{delta:0}" : $"{delta:0}";
@@ -253,30 +119,189 @@ private void UpdateInsightsValues()
Insights[0].Value = "+0";
}
- // Top Region from synchronized window (percent)
var maxRegion = RevenueByRegion.OrderByDescending(r => r.Value).FirstOrDefault();
if (maxRegion != null)
+ {
Insights[1].Value = $"{maxRegion.Category} {maxRegion.Value:0.#}%";
+ }
else
+ {
Insights[1].Value = "-";
+ }
- // Top Channel from synchronized window (units)
var topChannel = LeadsByChannel.OrderByDescending(c => c.Value).FirstOrDefault();
if (topChannel != null)
+ {
Insights[2].Value = $"{topChannel.Category} ({topChannel.Value:0} units)";
+ }
else
+ {
Insights[2].Value = "-";
+ }
}
public event PropertyChangedEventHandler? PropertyChanged;
+ ///
+ /// Disposes the ViewModel by cancelling its token and disposing the FirebaseService.
+ ///
public async ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
- await Task.CompletedTask;
+ await _db.DisposeAsync();
}
- }
-}
+ private readonly Dictionary _unitsByChannel = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _trendBuffer = new(); // to cap last 60
+ private readonly List _recordBuffer = new(); // rolling buffer for last 60 records
+ private readonly object _bufferLock = new();
+
+ ///
+ /// Handles a newly received sales record: updates rolling buffers, chart collections, and insights.
+ ///
+ /// The incoming sales record.
+ private void OnNewSalesRecord(SalesRecord rec)
+ {
+ TimePoint[] trendSnapshot;
+ List regionSnapshot;
+
+ // Append to buffers under lock; take snapshots for UI thread
+ lock (_bufferLock)
+ {
+ if (_trendBuffer.Count >= MaxPoints)
+ {
+ _trendBuffer.RemoveAt(0);
+ }
+
+ _trendBuffer.Add(new TimePoint { Time = rec.Date, Value = (double)rec.Revenue });
+ trendSnapshot = _trendBuffer.ToArray();
+ if (_recordBuffer.Count >= MaxPoints)
+ {
+ _recordBuffer.RemoveAt(0);
+ }
+
+ _recordBuffer.Add(rec);
+
+ regionSnapshot = BuildRevenueByRegionFromBuffer(_recordBuffer);
+ }
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ // Revenue trend (append/remove instead of full clear to reduce churn)
+ if (RevenueTrend.Count == 0)
+ {
+ foreach (var p in trendSnapshot)
+ {
+ RevenueTrend.Add(p);
+ }
+ }
+ else
+ {
+ var last = trendSnapshot[^1];
+ RevenueTrend.Add(last);
+ if (RevenueTrend.Count > MaxPoints)
+ {
+ RevenueTrend.RemoveAt(0);
+ }
+ }
+
+ var channelKey = rec.Channel ?? "(unknown)";
+ _unitsByChannel.TryGetValue(channelKey, out var units);
+ _unitsByChannel[channelKey] = units + rec.UnitsSold;
+ Rebind(LeadsByChannel, _unitsByChannel);
+
+ _currentActual += (double)rec.Revenue;
+ if (SalesActual.Count == 0)
+ {
+ SalesActual.Add(new CategoryPoint { Category = "Sales", Value = _currentActual });
+ }
+ else
+ {
+ SalesActual[0].Value = _currentActual;
+ }
+
+ if (SalesRemaining.Count == 0)
+ {
+ SalesRemaining.Add(new CategoryPoint { Category = "Sales", Value = Math.Max(0, _target - _currentActual) });
+ }
+ else
+ {
+ SalesRemaining[0].Value = Math.Max(0, _target - _currentActual);
+ }
+
+ RevenueByRegion.Clear();
+ foreach (var regionPoint in regionSnapshot)
+ {
+ RevenueByRegion.Add(regionPoint);
+ }
+
+ UpdateInsightsValues();
+ });
+ }
+
+ ///
+ /// Rebinds a category collection from a dictionary source, sorting by value descending.
+ ///
+ /// Target observable collection to populate.
+ /// Dictionary of category to numeric value.
+ private static void Rebind(ObservableCollection target, Dictionary source)
+ {
+ target.Clear();
+ foreach (var kv in source.OrderByDescending(kv => kv.Value))
+ {
+ target.Add(new CategoryPoint { Category = kv.Key, Value = kv.Value });
+ }
+ }
+
+ ///
+ /// Computes regional revenue share (%) from the rolling buffer of recent records.
+ /// Falls back to share by count when revenue totals are zero.
+ ///
+ /// Recent sales records to aggregate.
+ /// List of region category points with percentage values.
+ private static List BuildRevenueByRegionFromBuffer(IEnumerable records)
+ {
+ static string Normalize(string? input)
+ {
+ var trimmed = input?.Trim();
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ return "Unknown";
+ }
+
+ if (string.Equals(trimmed, "nan", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Unknown";
+ }
+
+ return trimmed!;
+ }
+
+ var groups = records
+ .GroupBy(r => Normalize(r.Region))
+ .Select(g => new { Region = g.Key, Sum = g.Sum(r => r.Revenue), Count = g.Count() })
+ .OrderByDescending(x => x.Sum)
+ .ToList();
+
+ var totalRevenue = groups.Sum(g => g.Sum);
+ if (totalRevenue > 0)
+ {
+ return groups
+ .Select(g => new CategoryPoint { Category = g.Region, Value = (g.Sum / totalRevenue) * 100.0 })
+ .ToList();
+ }
+
+ var totalCount = groups.Sum(g => g.Count);
+ if (totalCount <= 0)
+ {
+ return new List();
+ }
+
+ return groups
+ .Select(g => new CategoryPoint { Category = g.Region, Value = (g.Count / (double)totalCount) * 100.0 })
+ .ToList();
+ }
+ }
+}
\ No newline at end of file