< Summary

Information
Class: Chronicis.Client.ViewModels.CosmosViewModel
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/CosmosViewModel.cs
Line coverage
100%
Covered lines: 47
Uncovered lines: 0
Coverable lines: 47
Total lines: 301
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Stats()100%11100%
set_Stats(...)100%11100%
get_RecentArticles()100%11100%
set_RecentArticles(...)100%11100%
get_IsLoadingRecent()100%11100%
set_IsLoadingRecent(...)100%11100%
get_Quote()100%11100%
set_Quote(...)100%11100%
get_LoadingQuote()100%11100%
set_LoadingQuote(...)100%11100%
FormatRelativeTime(...)100%1010100%
CountTotalArticles(...)100%44100%
OnTreeStateChanged()100%11100%
Dispose()100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/CosmosViewModel.cs

#LineLine coverage
 1using Chronicis.Client.Abstractions;
 2using Chronicis.Client.Services;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.Extensions;
 5
 6namespace Chronicis.Client.ViewModels;
 7
 8/// <summary>
 9/// ViewModel for the Cosmos page (the welcome/dashboard shown when no article is selected).
 10/// Owns data loading, stat calculation, quote rotation, and article navigation.
 11/// Implements <see cref="IDisposable"/> to clean up the tree-state subscription.
 12/// </summary>
 13public sealed class CosmosViewModel : ViewModelBase, IDisposable
 14{
 15    private readonly IArticleApiService _articleApi;
 16    private readonly IQuoteService _quoteService;
 17    private readonly IBreadcrumbService _breadcrumbService;
 18    private readonly ITreeStateService _treeState;
 19    private readonly IAppNavigator _navigator;
 20    private readonly ILogger<CosmosViewModel> _logger;
 21
 22    private CampaignStats? _stats;
 23    private List<ArticleDto>? _recentArticles;
 3424    private bool _isLoadingRecent = true;
 25    private Quote? _quote;
 3426    private bool _loadingQuote = true;
 27
 28    /// <summary>Total articles count and related stats, or <c>null</c> before first load.</summary>
 29    public CampaignStats? Stats
 30    {
 16131        get => _stats;
 2232        private set => SetField(ref _stats, value);
 33    }
 34
 35    /// <summary>The five most-recently-modified articles across the entire tree.</summary>
 36    public List<ArticleDto>? RecentArticles
 37    {
 9738        get => _recentArticles;
 2239        private set => SetField(ref _recentArticles, value);
 40    }
 41
 42    /// <summary>Whether the recent-articles section is currently loading.</summary>
 43    public bool IsLoadingRecent
 44    {
 3745        get => _isLoadingRecent;
 5346        private set => SetField(ref _isLoadingRecent, value);
 47    }
 48
 49    /// <summary>The current inspirational quote, or <c>null</c> before first load.</summary>
 50    public Quote? Quote
 51    {
 4552        get => _quote;
 2853        private set => SetField(ref _quote, value);
 54    }
 55
 56    /// <summary>Whether the quote section is currently loading.</summary>
 57    public bool LoadingQuote
 58    {
 3759        get => _loadingQuote;
 5860        private set => SetField(ref _loadingQuote, value);
 61    }
 62
 3463    public CosmosViewModel(
 3464        IArticleApiService articleApi,
 3465        IQuoteService quoteService,
 3466        IBreadcrumbService breadcrumbService,
 3467        ITreeStateService treeState,
 3468        IAppNavigator navigator,
 3469        ILogger<CosmosViewModel> logger)
 70    {
 3471        _articleApi = articleApi;
 3472        _quoteService = quoteService;
 3473        _breadcrumbService = breadcrumbService;
 3474        _treeState = treeState;
 3475        _navigator = navigator;
 3476        _logger = logger;
 77
 3478        _treeState.OnStateChanged += OnTreeStateChanged;
 3479    }
 80
 81    /// <summary>
 82    /// Loads dashboard data and quote in parallel, and optionally navigates to an article by URL path.
 83    /// Call from <c>OnInitializedAsync</c>.
 84    /// </summary>
 85    public async Task InitializeAsync(string? path)
 86    {
 87        if (!string.IsNullOrEmpty(path))
 88            await LoadArticleByPathAsync(path);
 89
 90        await Task.WhenAll(LoadDashboardDataAsync(), LoadQuoteAsync());
 91    }
 92
 93    /// <summary>
 94    /// Handles URL changes (browser back/forward). Call from <c>OnParametersSetAsync</c>.
 95    /// </summary>
 96    public async Task OnParametersSetAsync(string? path)
 97    {
 98        if (!string.IsNullOrEmpty(path))
 99            await LoadArticleByPathAsync(path);
 100    }
 101
 102    /// <summary>Refreshes the inspirational quote.</summary>
 103    public async Task LoadNewQuoteAsync() => await LoadQuoteAsync();
 104
 105    /// <summary>Creates an article with an empty title (the "Start Your First Article" hero button).</summary>
 106    public async Task CreateFirstArticleAsync() => await CreateArticleWithTitleAsync(string.Empty);
 107
 108    /// <summary>Creates a new root article pre-populated with <paramref name="title"/> and navigates to it.</summary>
 109    public async Task CreateArticleWithTitleAsync(string title)
 110    {
 111        var createDto = new ArticleCreateDto
 112        {
 113            Title = title,
 114            Body = string.Empty,
 115            ParentId = null,
 116            EffectiveDate = DateTime.Now,
 117        };
 118
 119        var created = await _articleApi.CreateArticleAsync(createDto);
 120        if (created == null)
 121        {
 122            _logger.LogErrorSanitized("Failed to create article");
 123            return;
 124        }
 125
 126        await _treeState.RefreshAsync();
 127        _treeState.ExpandPathToAndSelect(created.Id);
 128    }
 129
 130    /// <summary>Navigates to <paramref name="articleId"/> by resolving its breadcrumb path.</summary>
 131    public async Task NavigateToArticleAsync(Guid articleId)
 132    {
 133        var article = await _articleApi.GetArticleDetailAsync(articleId);
 134        if (article != null && article.Breadcrumbs.Any())
 135        {
 136            var path = _breadcrumbService.BuildArticleUrl(article.Breadcrumbs);
 137            _navigator.NavigateTo(path);
 138        }
 139    }
 140
 141    /// <summary>
 142    /// Formats a <see cref="DateTime"/> as a human-readable relative string (e.g. "3h ago").
 143    /// </summary>
 144    public static string FormatRelativeTime(DateTime dateTime)
 145    {
 21146        var timeSpan = DateTime.Now - dateTime;
 147
 21148        if (timeSpan.TotalMinutes < 1)
 1149            return "just now";
 20150        if (timeSpan.TotalMinutes < 60)
 1151            return $"{(int)timeSpan.TotalMinutes}m ago";
 19152        if (timeSpan.TotalHours < 24)
 1153            return $"{(int)timeSpan.TotalHours}h ago";
 18154        if (timeSpan.TotalDays < 7)
 16155            return $"{(int)timeSpan.TotalDays}d ago";
 2156        if (timeSpan.TotalDays < 30)
 1157            return $"{(int)(timeSpan.TotalDays / 7)}w ago";
 158
 1159        return dateTime.ToString("MMM d, yyyy");
 160    }
 161
 162    // -------------------------------------------------------------------------
 163    // Private helpers
 164    // -------------------------------------------------------------------------
 165
 166    private async Task LoadArticleByPathAsync(string path)
 167    {
 168        try
 169        {
 170            var article = await _articleApi.GetArticleByPathAsync(path);
 171
 172            if (article != null)
 173            {
 174                if (article.Id != _treeState.SelectedArticleId)
 175                    _treeState.ExpandPathToAndSelect(article.Id);
 176            }
 177            else if (!_treeState.SelectedArticleId.HasValue)
 178            {
 179                _logger.LogWarningSanitized("Article not found for path: {Path}", path);
 180                _navigator.NavigateTo("/", replace: true);
 181            }
 182        }
 183        catch (Exception ex)
 184        {
 185            _logger.LogErrorSanitized(ex, "Error loading article by path: {Path}", path);
 186            if (!_treeState.SelectedArticleId.HasValue)
 187                _navigator.NavigateTo("/", replace: true);
 188        }
 189    }
 190
 191    private async Task LoadQuoteAsync()
 192    {
 193        LoadingQuote = true;
 194        try
 195        {
 196            Quote = await _quoteService.GetRandomQuoteAsync();
 197        }
 198        catch (Exception ex)
 199        {
 200            _logger.LogErrorSanitized(ex, "Error loading quote");
 201        }
 202        finally
 203        {
 204            LoadingQuote = false;
 205        }
 206    }
 207
 208    private async Task LoadDashboardDataAsync()
 209    {
 210        IsLoadingRecent = true;
 211        try
 212        {
 213            var allArticles = await _articleApi.GetRootArticlesAsync();
 214            RecentArticles = await GetRecentArticlesRecursiveAsync(allArticles);
 215
 216            Stats = new CampaignStats
 217            {
 218                TotalArticles = CountTotalArticles(allArticles),
 219                RootArticles = allArticles.Count,
 220                RecentlyModified = RecentArticles.Count(a =>
 221                    (a.ModifiedAt ?? a.CreatedAt) > DateTime.Now.AddDays(-7)),
 222                DaysSinceStart = allArticles.Any()
 223                    ? (int)(DateTime.Now - allArticles.Min(a => a.CreatedAt)).TotalDays
 224                    : 0,
 225            };
 226        }
 227        catch (Exception ex)
 228        {
 229            _logger.LogErrorSanitized(ex, "Error loading dashboard data");
 230        }
 231        finally
 232        {
 233            IsLoadingRecent = false;
 234        }
 235    }
 236
 237    private async Task<List<ArticleDto>> GetRecentArticlesRecursiveAsync(
 238        List<ArticleTreeDto> articles,
 239        int maxResults = 5)
 240    {
 241        var allArticles = new List<ArticleDto>();
 242
 243        foreach (var node in articles)
 244        {
 245            var full = await _articleApi.GetArticleAsync(node.Id);
 246            if (full != null)
 247                allArticles.Add(full);
 248
 249            if (node.HasChildren && node.Children != null)
 250            {
 251                var childResults = await GetRecentArticlesRecursiveAsync(
 252                    node.Children.ToList(), maxResults);
 253                allArticles.AddRange(childResults);
 254            }
 255        }
 256
 257        return allArticles
 258            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 259            .Take(maxResults)
 260            .ToList();
 261    }
 262
 263    private static int CountTotalArticles(List<ArticleTreeDto> articles)
 264    {
 23265        int count = articles.Count;
 82266        foreach (var article in articles)
 267        {
 18268            if (article.Children != null)
 1269                count += CountTotalArticles(article.Children.ToList());
 270        }
 23271        return count;
 272    }
 273
 1274    private void OnTreeStateChanged() => RaisePropertyChanged(nameof(Stats));
 275
 276    /// <inheritdoc />
 277    public void Dispose()
 278    {
 11279        _treeState.OnStateChanged -= OnTreeStateChanged;
 11280    }
 281
 282    // -------------------------------------------------------------------------
 283    // Nested types
 284    // -------------------------------------------------------------------------
 285
 286    /// <summary>Aggregated campaign statistics shown in the stat cards.</summary>
 287    public sealed record CampaignStats
 288    {
 289        /// <summary>Total number of articles across all levels of the hierarchy.</summary>
 290        public int TotalArticles { get; init; }
 291
 292        /// <summary>Number of top-level (root) articles.</summary>
 293        public int RootArticles { get; init; }
 294
 295        /// <summary>Number of articles modified in the last 7 days.</summary>
 296        public int RecentlyModified { get; init; }
 297
 298        /// <summary>Days since the oldest article was created.</summary>
 299        public int DaysSinceStart { get; init; }
 300    }
 301}