diff --git a/Services/DashboardCache.cs b/Services/DashboardCache.cs
new file mode 100644
index 0000000..4aca88b
--- /dev/null
+++ b/Services/DashboardCache.cs
@@ -0,0 +1,115 @@
+using Disco.Plugins.ServiceTracker.Models;
+using System;
+using System.IO;
+
+namespace Disco.Plugins.ServiceTracker.Services
+{
+ ///
+ /// Static in-memory cache for the dashboard model.
+ /// Instead of running full EF queries + JSON reads + Google Sheet fetches on every page load,
+ /// this checks file modification timestamps to detect changes.
+ /// If nothing changed, returns the cached model instantly (near-zero CPU/memory cost).
+ ///
+ public static class DashboardCache
+ {
+ private static DashboardViewModel _cachedModel;
+ private static DateTime _cacheBuiltAt = DateTime.MinValue;
+ private static DateTime _ticketsFileTime = DateTime.MinValue;
+ private static DateTime _externalFileTime = DateTime.MinValue;
+ private static DateTime _sheetCacheTime = DateTime.MinValue;
+ private static DateTime _configFileTime = DateTime.MinValue;
+ private static string _lastSortBy;
+ private static string _lastFilter;
+ private static readonly object _lock = new object();
+
+ ///
+ /// Maximum age of cache in seconds before forced rebuild, even if no files changed.
+ /// Acts as a safety net for database-only changes (e.g. Disco job opened/closed externally).
+ ///
+ public static int MaxCacheAgeSeconds = 30;
+
+ ///
+ /// Returns cached model if still valid, or null if rebuild is needed.
+ /// A rebuild is needed when:
+ /// - Cache doesn't exist yet
+ /// - Any data file has been modified since last build
+ /// - Cache is older than MaxCacheAgeSeconds
+ /// - Sort/filter parameters changed
+ ///
+ public static DashboardViewModel GetIfValid(string dataDirectory, string sortBy, string filterKey)
+ {
+ lock (_lock)
+ {
+ if (_cachedModel == null) return null;
+
+ // Different sort/filter = must rebuild
+ if (_lastSortBy != sortBy || _lastFilter != filterKey) return null;
+
+ // Check age
+ var age = (DateTime.Now - _cacheBuiltAt).TotalSeconds;
+ if (age > MaxCacheAgeSeconds) return null;
+
+ // Check if any data files have been modified
+ if (HasFileChanged(Path.Combine(dataDirectory, "tickets.json"), ref _ticketsFileTime)) return null;
+ if (HasFileChanged(Path.Combine(dataDirectory, "external_tickets.json"), ref _externalFileTime)) return null;
+ if (HasFileChanged(Path.Combine(dataDirectory, "sheet_cache.csv"), ref _sheetCacheTime)) return null;
+ if (HasFileChanged(Path.Combine(dataDirectory, "config.json"), ref _configFileTime)) return null;
+
+ return _cachedModel;
+ }
+ }
+
+ ///
+ /// Store a freshly built model in the cache.
+ ///
+ public static void Store(DashboardViewModel model, string dataDirectory, string sortBy, string filterKey)
+ {
+ lock (_lock)
+ {
+ _cachedModel = model;
+ _cacheBuiltAt = DateTime.Now;
+ _lastSortBy = sortBy;
+ _lastFilter = filterKey;
+
+ // Snapshot current file times
+ _ticketsFileTime = GetFileTime(Path.Combine(dataDirectory, "tickets.json"));
+ _externalFileTime = GetFileTime(Path.Combine(dataDirectory, "external_tickets.json"));
+ _sheetCacheTime = GetFileTime(Path.Combine(dataDirectory, "sheet_cache.csv"));
+ _configFileTime = GetFileTime(Path.Combine(dataDirectory, "config.json"));
+ }
+ }
+
+ ///
+ /// Force invalidate the cache (called after writes if needed).
+ ///
+ public static void Invalidate()
+ {
+ lock (_lock)
+ {
+ _cachedModel = null;
+ _cacheBuiltAt = DateTime.MinValue;
+ }
+ }
+
+ private static bool HasFileChanged(string path, ref DateTime lastKnownTime)
+ {
+ var currentTime = GetFileTime(path);
+ if (currentTime != lastKnownTime)
+ {
+ lastKnownTime = currentTime;
+ return true;
+ }
+ return false;
+ }
+
+ private static DateTime GetFileTime(string path)
+ {
+ try
+ {
+ if (File.Exists(path)) return File.GetLastWriteTimeUtc(path);
+ }
+ catch { }
+ return DateTime.MinValue;
+ }
+ }
+}