< 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: 298
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)
 135            await _navigator.GoToArticleAsync(article);
 136    }
 137
 138    /// <summary>
 139    /// Formats a <see cref="DateTime"/> as a human-readable relative string (e.g. "3h ago").
 140    /// </summary>
 141    public static string FormatRelativeTime(DateTime dateTime)
 142    {
 21143        var timeSpan = DateTime.Now - dateTime;
 144
 21145        if (timeSpan.TotalMinutes < 1)
 1146            return "just now";
 20147        if (timeSpan.TotalMinutes < 60)
 1148            return $"{(int)timeSpan.TotalMinutes}m ago";
 19149        if (timeSpan.TotalHours < 24)
 1150            return $"{(int)timeSpan.TotalHours}h ago";
 18151        if (timeSpan.TotalDays < 7)
 16152            return $"{(int)timeSpan.TotalDays}d ago";
 2153        if (timeSpan.TotalDays < 30)
 1154            return $"{(int)(timeSpan.TotalDays / 7)}w ago";
 155
 1156        return dateTime.ToString("MMM d, yyyy");
 157    }
 158
 159    // -------------------------------------------------------------------------
 160    // Private helpers
 161    // -------------------------------------------------------------------------
 162
 163    private async Task LoadArticleByPathAsync(string path)
 164    {
 165        try
 166        {
 167            var article = await _articleApi.GetArticleByPathAsync(path);
 168
 169            if (article != null)
 170            {
 171                if (article.Id != _treeState.SelectedArticleId)
 172                    _treeState.ExpandPathToAndSelect(article.Id);
 173            }
 174            else if (!_treeState.SelectedArticleId.HasValue)
 175            {
 176                _logger.LogWarningSanitized("Article not found for path: {Path}", path);
 177                _navigator.NavigateTo("/", replace: true);
 178            }
 179        }
 180        catch (Exception ex)
 181        {
 182            _logger.LogErrorSanitized(ex, "Error loading article by path: {Path}", path);
 183            if (!_treeState.SelectedArticleId.HasValue)
 184                _navigator.NavigateTo("/", replace: true);
 185        }
 186    }
 187
 188    private async Task LoadQuoteAsync()
 189    {
 190        LoadingQuote = true;
 191        try
 192        {
 193            Quote = await _quoteService.GetRandomQuoteAsync();
 194        }
 195        catch (Exception ex)
 196        {
 197            _logger.LogErrorSanitized(ex, "Error loading quote");
 198        }
 199        finally
 200        {
 201            LoadingQuote = false;
 202        }
 203    }
 204
 205    private async Task LoadDashboardDataAsync()
 206    {
 207        IsLoadingRecent = true;
 208        try
 209        {
 210            var allArticles = await _articleApi.GetRootArticlesAsync();
 211            RecentArticles = await GetRecentArticlesRecursiveAsync(allArticles);
 212
 213            Stats = new CampaignStats
 214            {
 215                TotalArticles = CountTotalArticles(allArticles),
 216                RootArticles = allArticles.Count,
 217                RecentlyModified = RecentArticles.Count(a =>
 218                    (a.ModifiedAt ?? a.CreatedAt) > DateTime.Now.AddDays(-7)),
 219                DaysSinceStart = allArticles.Any()
 220                    ? (int)(DateTime.Now - allArticles.Min(a => a.CreatedAt)).TotalDays
 221                    : 0,
 222            };
 223        }
 224        catch (Exception ex)
 225        {
 226            _logger.LogErrorSanitized(ex, "Error loading dashboard data");
 227        }
 228        finally
 229        {
 230            IsLoadingRecent = false;
 231        }
 232    }
 233
 234    private async Task<List<ArticleDto>> GetRecentArticlesRecursiveAsync(
 235        List<ArticleTreeDto> articles,
 236        int maxResults = 5)
 237    {
 238        var allArticles = new List<ArticleDto>();
 239
 240        foreach (var node in articles)
 241        {
 242            var full = await _articleApi.GetArticleAsync(node.Id);
 243            if (full != null)
 244                allArticles.Add(full);
 245
 246            if (node.HasChildren && node.Children != null)
 247            {
 248                var childResults = await GetRecentArticlesRecursiveAsync(
 249                    node.Children.ToList(), maxResults);
 250                allArticles.AddRange(childResults);
 251            }
 252        }
 253
 254        return allArticles
 255            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 256            .Take(maxResults)
 257            .ToList();
 258    }
 259
 260    private static int CountTotalArticles(List<ArticleTreeDto> articles)
 261    {
 23262        int count = articles.Count;
 82263        foreach (var article in articles)
 264        {
 18265            if (article.Children != null)
 1266                count += CountTotalArticles(article.Children.ToList());
 267        }
 23268        return count;
 269    }
 270
 1271    private void OnTreeStateChanged() => RaisePropertyChanged(nameof(Stats));
 272
 273    /// <inheritdoc />
 274    public void Dispose()
 275    {
 11276        _treeState.OnStateChanged -= OnTreeStateChanged;
 11277    }
 278
 279    // -------------------------------------------------------------------------
 280    // Nested types
 281    // -------------------------------------------------------------------------
 282
 283    /// <summary>Aggregated campaign statistics shown in the stat cards.</summary>
 284    public sealed record CampaignStats
 285    {
 286        /// <summary>Total number of articles across all levels of the hierarchy.</summary>
 287        public int TotalArticles { get; init; }
 288
 289        /// <summary>Number of top-level (root) articles.</summary>
 290        public int RootArticles { get; init; }
 291
 292        /// <summary>Number of articles modified in the last 7 days.</summary>
 293        public int RecentlyModified { get; init; }
 294
 295        /// <summary>Days since the oldest article was created.</summary>
 296        public int DaysSinceStart { get; init; }
 297    }
 298}