Spaces:
Running
Running
task-tracking-system / TaskTrackingSystem.WebApp /Components /Pages /Features /Dashboard /Home.razor
| @page "/" | |
| @page "/Home" | |
| @page "/dashboard" | |
| @using Microsoft.AspNetCore.Authorization | |
| @using Microsoft.AspNetCore.Components.Authorization | |
| @using TaskTrackingSystem.Shared.Enums | |
| @using TaskTrackingSystem.Shared.Models.Project | |
| @using TaskTrackingSystem.Shared.Models.Task | |
| @using TaskTrackingSystem.Shared.Models.User | |
| @using TaskTrackingSystem.Shared.Models.Dashboard | |
| @rendermode @(new InteractiveServerRenderMode(prerender: false)) | |
| @attribute [Microsoft.AspNetCore.Authorization.Authorize] | |
| @inject ApiClientService ApiClient | |
| @inject IJSRuntime JS | |
| @inject AuthenticationStateProvider AuthStateProvider | |
| @inject MenuAuthorizationService MenuAuthorization | |
| @inject NavigationManager NavigationManager | |
| <PageTitle>Dashboard</PageTitle> | |
| <div class="space-y-8 text-slate-900 dark:text-slate-100"> | |
| <div class="rounded-xl border border-slate-200 bg-white px-8 py-8 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> | |
| <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> | |
| <div> | |
| <p class="text-sm font-semibold uppercase tracking-[0.08em] text-teal-700 dark:text-teal-300">Overview</p> | |
| <h2 class="mt-2 text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">Welcome back, @currentUserFullName</h2> | |
| <p class="mt-3 max-w-2xl text-sm text-slate-600 dark:text-slate-300"> | |
| Projects, users, and team progress at a glance. | |
| </p> | |
| </div> | |
| <div class="flex flex-wrap gap-3"> | |
| @if (canCreateTask) | |
| { | |
| <a href="/tasks/all" class="inline-flex items-center rounded-lg bg-teal-700 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"> | |
| <i data-lucide="plus" class="mr-2 h-4 w-4"></i> | |
| New Task | |
| </a> | |
| } | |
| <a href="/reports/tasks" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"> | |
| <i data-lucide="bar-chart-3" class="mr-2 h-4 w-4"></i> | |
| Reports | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| <AdminOverview Metrics="SummaryMetrics" /> | |
| <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)]"> | |
| <div class="rounded-xl border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> | |
| <div class="flex items-start justify-between gap-4"> | |
| <div> | |
| <h3 class="text-xl font-bold text-slate-900">Project Activity</h3> | |
| <p class="mt-1 text-sm text-slate-500">Active vs completed tasks per month.</p> | |
| </div> | |
| <div class="text-sm text-slate-500">@ActivityRangeLabel</div> | |
| </div> | |
| <div class="mt-6"> | |
| <div class="relative"> | |
| <svg viewBox="0 0 760 340" class="h-[340px] w-full"> | |
| <defs> | |
| <linearGradient id="activityFill" x1="0" x2="0" y1="0" y2="1"> | |
| <stop offset="0%" stop-color="#0f766e" stop-opacity="0.18" /> | |
| <stop offset="100%" stop-color="#0f766e" stop-opacity="0" /> | |
| </linearGradient> | |
| </defs> | |
| @for (var i = 0; i < 5; i++) | |
| { | |
| var y = 60 + (i * 55); | |
| <line x1="48" y1="@y" x2="730" y2="@y" stroke="#e2e8f0" stroke-width="1" /> | |
| } | |
| @for (var i = 0; i < 6; i++) | |
| { | |
| var x = 60 + (i * 125); | |
| <line x1="@x" y1="40" x2="@x" y2="280" stroke="#e2e8f0" stroke-width="1" /> | |
| } | |
| <path d="@ActivityAreaPath" fill="url(#activityFill)" /> | |
| <path d="@ActivityLinePath" fill="none" stroke="#0f766e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> | |
| @for (var i = 0; i < ActivityMonths.Count; i++) | |
| { | |
| var index = i; | |
| var point = ActivityPoints[i]; | |
| <circle cx="@point.X" cy="@point.Y" r="5" | |
| fill="#0f766e" | |
| stroke="#ccfbf1" | |
| stroke-width="3" | |
| @onmouseenter="() => SetHoveredActivity(index)" | |
| @onmouseleave="ClearHoveredActivity" /> | |
| } | |
| </svg> | |
| @if (hoveredActivity is not null) | |
| { | |
| <div class="pointer-events-none absolute z-20 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-lg shadow-slate-200/70" | |
| style="@HoveredActivityStyle"> | |
| <div class="text-sm font-semibold text-slate-900">@hoveredActivity.Value.Label</div> | |
| <div class="mt-2 text-sm text-emerald-600">Completed: @hoveredActivity.Value.Completed</div> | |
| <div class="mt-1 text-sm text-violet-600">Active: @hoveredActivity.Value.Active</div> | |
| </div> | |
| } | |
| </div> | |
| <div class="mt-4 flex items-center justify-between text-xs text-slate-500"> | |
| @foreach (var label in ActivityLabels) | |
| { | |
| <span class="w-1/6 text-center">@label</span> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <ChartCard Title="Task Distribution" Subtitle="@($"{TotalTasksCount} total tasks")"> | |
| <ChildContent> | |
| <DonutChart Items="DistributionChartItems" /> | |
| </ChildContent> | |
| </ChartCard> | |
| </div> | |
| <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(280px,0.75fr)]"> | |
| <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900"> | |
| <div class="flex items-center justify-between gap-4"> | |
| <div> | |
| <h3 class="text-xl font-bold text-slate-900">All Projects</h3> | |
| <p class="mt-1 text-sm text-slate-500">A quick snapshot of the current project list.</p> | |
| </div> | |
| <a href="/projects/all" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-teal-700 shadow-sm transition-colors hover:bg-teal-50 dark:border-slate-700 dark:bg-slate-900 dark:text-teal-300 dark:hover:bg-slate-800"> | |
| View all | |
| </a> | |
| </div> | |
| <div class="mt-5 max-h-[560px] space-y-3 overflow-y-auto pr-1"> | |
| @foreach (var project in ProjectListItems) | |
| { | |
| var progress = GetProjectProgress(project.Id); | |
| <div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4"> | |
| <div class="flex items-start justify-between gap-4"> | |
| <div class="min-w-0"> | |
| <div class="flex flex-wrap items-center gap-2"> | |
| <p class="truncate text-sm font-semibold text-slate-900">@project.Name</p> | |
| <span class="@GetProjectBadgeClasses(project)">@GetProjectBadgeLabel(project)</span> | |
| </div> | |
| <p class="mt-1 text-xs text-slate-500">@DisplayFormats.Date(project.StartDate) to @DisplayFormats.Date(project.EndDate)</p> | |
| </div> | |
| <div class="text-right"> | |
| <div class="text-sm font-semibold text-slate-900">@progress.CompletedTasksCount/@progress.TotalTasksCount tasks</div> | |
| <div class="text-xs text-slate-500">@progress.CompletionPercentage%</div> | |
| </div> | |
| </div> | |
| <div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-200"> | |
| <div class="h-full rounded-full bg-teal-700 dark:bg-teal-500" style="width: @progress.CompletionPercentage%"></div> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| <div class="rounded-xl border border-slate-200 bg-white p-6 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> | |
| <div class="flex items-start justify-between gap-4"> | |
| <div> | |
| <h3 class="text-xl font-bold text-slate-900">Calendar</h3> | |
| <p class="mt-1 text-sm text-slate-500">Today and upcoming project starts.</p> | |
| </div> | |
| <div class="text-right"> | |
| <div class="text-sm font-semibold text-violet-600">@CalendarMonthLabel</div> | |
| </div> | |
| </div> | |
| <div class="mt-5 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-wider text-slate-400"> | |
| @foreach (var day in WeekdayLabels) | |
| { | |
| <span>@day</span> | |
| } | |
| </div> | |
| <div class="mt-3 grid grid-cols-7 gap-2"> | |
| @foreach (var cell in CalendarCells) | |
| { | |
| <div class="@GetCalendarCellClasses(cell)"> | |
| <div class="flex h-full flex-col justify-between"> | |
| <div class="text-sm font-semibold @(cell.IsCurrentMonth ? "text-slate-900" : "text-slate-400")">@cell.Date.Day</div> | |
| <div class="flex items-center gap-1"> | |
| @foreach (var dot in cell.ProjectDots) | |
| { | |
| <span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| <div class="mt-6 max-h-[360px] space-y-3 overflow-y-auto pr-1"> | |
| <div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Events</div> | |
| @foreach (var item in UpcomingProjectStarts) | |
| { | |
| <div class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"> | |
| <span class="mt-1 h-8 w-1.5 rounded-full" style="background-color: @item.Color"></span> | |
| <div class="min-w-0"> | |
| <div class="truncate text-sm font-semibold text-slate-900">@item.Name</div> | |
| <div class="text-xs text-slate-500">@item.DateLabel</div> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <div class="rounded-xl border border-slate-200 bg-white p-5 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> | |
| <div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"> | |
| <div> | |
| <h3 class="text-xl font-bold text-slate-900">Project Timeline</h3> | |
| <p class="mt-1 text-sm text-slate-500">Browse every project in a gantt view and adjust the month window below.</p> | |
| </div> | |
| <a href="/projects/all" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-teal-700 shadow-sm transition-colors hover:bg-teal-50 dark:border-slate-700 dark:bg-slate-900 dark:text-teal-300 dark:hover:bg-slate-800"> | |
| View all | |
| </a> | |
| </div> | |
| <div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3"> | |
| <div class="flex flex-col gap-1"> | |
| <label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</label> | |
| <input type="month" | |
| class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100" | |
| value="@timelineStartMonthValue" | |
| @onchange="OnTimelineStartMonthChanged" /> | |
| </div> | |
| <div class="flex flex-col gap-1"> | |
| <label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</label> | |
| <input type="month" | |
| class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100" | |
| value="@timelineEndMonthValue" | |
| @onchange="OnTimelineEndMonthChanged" /> | |
| </div> | |
| <div class="pb-1 text-sm font-medium text-slate-500"> | |
| @TimelineRangeLabel | |
| </div> | |
| </div> | |
| @if (TimelineProjects.Count > 0) | |
| { | |
| <div class="mt-4 max-h-[620px] overflow-auto pr-1"> | |
| <div id="project-timeline-chart" class="min-w-[1100px] w-full"></div> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="flex items-center justify-center py-16 text-slate-400 text-sm">No projects to display.</div> | |
| } | |
| </div> | |
| </div> | |
| @code { | |
| private DashboardSummaryDto summary = new(); | |
| private List<TaskStatusOverviewDto> taskStatuses = new(); | |
| private List<ProjectProgressDto> projectProgress = new(); | |
| private List<TaskDto> taskList = new(); | |
| private List<UserDto> usersList = new(); | |
| private List<ProjectDto> projectsList = new(); | |
| private string currentUserName = "User"; | |
| private string currentUserFullName = "User"; | |
| private bool canCreateTask = false; | |
| private string timelineStartMonthValue = string.Empty; | |
| private string timelineEndMonthValue = string.Empty; | |
| private bool _dataLoaded = false; | |
| private bool _shouldRenderCharts = false; | |
| private int? hoveredActivityIndex; | |
| private int? hoveredSliceIndex; | |
| private readonly string[] WeekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; | |
| private List<ActivityMonthStat> ActivityMonths => BuildActivityMonths(); | |
| private List<string> ActivityLabels => ActivityMonths.Select(month => month.Label).ToList(); | |
| private string ActivityRangeLabel => $"{ActivityMonths.First().Label} - {ActivityMonths.Last().Label}"; | |
| private string ActivityLinePath => BuildActivityPath(ActivityMonths); | |
| private string ActivityAreaPath => BuildActivityAreaPath(ActivityMonths); | |
| private List<ActivityPoint> ActivityPoints => BuildActivityPoints(); | |
| private List<DistributionSlice> DonutSlices => BuildDonutSlices(); | |
| private List<DistributionLegendItem> DistributionLegend => BuildDistributionLegend(); | |
| private List<ChartDataPoint> DistributionChartItems => DistributionLegend | |
| .Select(item => new ChartDataPoint(item.Label, item.Count, item.Color)) | |
| .ToList(); | |
| private int TotalTasksCount => taskList.Count; | |
| private int ActiveTasksCount => taskList.Count(t => t.StatusId != AppTaskStatus.Done); | |
| private int CompletedTasksCount => taskList.Count(t => t.StatusId == AppTaskStatus.Done); | |
| private int CompletionRate => TotalTasksCount > 0 ? (int)Math.Round((double)CompletedTasksCount / TotalTasksCount * 100) : 0; | |
| private string UsersDeltaText => BuildDeltaText(GetUsersThisMonth() - GetUsersPreviousMonth()); | |
| private string ProjectsDeltaText => BuildDeltaText(GetProjectsThisMonth() - GetProjectsPreviousMonth()); | |
| private string TasksDeltaText => BuildDeltaText(GetActiveTasksThisMonth() - GetActiveTasksPreviousMonth()); | |
| private string CompletionDeltaText => BuildCompletionDeltaText(); | |
| private List<DashboardMetric> SummaryMetrics => new() | |
| { | |
| new("users", UsersDeltaText, summary.TotalUsers.ToString(), "Total users", "flex h-12 w-12 items-center justify-center rounded-lg bg-teal-700 text-white dark:bg-teal-600", "text-sm font-semibold text-emerald-600 dark:text-emerald-300"), | |
| new("folder-kanban", ProjectsDeltaText, projectsList.Count.ToString(), "Total projects", "flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-500 text-white", "text-sm font-semibold text-emerald-500"), | |
| new("check-square", TasksDeltaText, ActiveTasksCount.ToString(), "Active tasks", "flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500 text-white", "text-sm font-semibold text-emerald-500"), | |
| new("trending-up", CompletionDeltaText, $"{CompletionRate}%", "Completion rate", "flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-white", "text-sm font-semibold text-emerald-500") | |
| }; | |
| private string CalendarMonthLabel => DateTime.Today.ToString("MMMM yyyy"); | |
| private List<CalendarCell> CalendarCells => BuildCalendarCells(); | |
| private List<CalendarEventItem> UpcomingProjectStarts => BuildUpcomingProjectStarts(); | |
| private List<ProjectDto> FeaturedProjects => TimelineProjects.Take(5).ToList(); | |
| private List<ProjectDto> ProjectListItems => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList(); | |
| private List<ProjectDto> TimelineProjects => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList(); | |
| private List<DateTime> TimelineMonths => BuildTimelineMonths(); | |
| private string TimelineRangeLabel => TryGetTimelineRange(out var range) ? $"{range.Start:MMM yyyy} - {range.End:MMM yyyy}" : string.Empty; | |
| protected override async Task OnInitializedAsync() | |
| { | |
| var authState = await AuthStateProvider.GetAuthenticationStateAsync(); | |
| var user = authState.User; | |
| var codes = await MenuAuthorization.GetCurrentAccessCodesAsync(user); | |
| if (codes.Contains("Projects_Create") && !codes.Contains("Roles_List")) | |
| { | |
| NavigationManager.NavigateTo("/dashboard/manager"); | |
| return; | |
| } | |
| else if (!codes.Contains("Projects_Create") && !codes.Contains("Roles_List")) | |
| { | |
| NavigationManager.NavigateTo("/dashboard/employee"); | |
| return; | |
| } | |
| canCreateTask = codes.Contains("Tasks_Create"); | |
| await LoadDashboardData(); | |
| } | |
| private async Task LoadDashboardData() | |
| { | |
| var client = ApiClient.CreateClient(); | |
| try | |
| { | |
| var authState = await AuthStateProvider.GetAuthenticationStateAsync(); | |
| var user = authState.User; | |
| currentUserName = user.Identity?.Name ?? "User"; | |
| var summaryResult = await client.GetFromJsonAsync<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive); | |
| if (summaryResult?.IsSuccess == true && summaryResult.Value != null) | |
| { | |
| summary = summaryResult.Value; | |
| } | |
| var tasksOverviewResult = await client.GetFromJsonAsync<Result<List<TaskStatusOverviewDto>>>("Dashboard/tasks-overview", Serialization.CaseInsensitive); | |
| if (tasksOverviewResult?.IsSuccess == true && tasksOverviewResult.Value != null) | |
| { | |
| taskStatuses = tasksOverviewResult.Value; | |
| } | |
| var progressResult = await client.GetFromJsonAsync<Result<List<ProjectProgressDto>>>("Dashboard/project-progress", Serialization.CaseInsensitive); | |
| if (progressResult?.IsSuccess == true && progressResult.Value != null) | |
| { | |
| projectProgress = progressResult.Value; | |
| } | |
| taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new(); | |
| usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new(); | |
| projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new(); | |
| InitializeTimelineRange(); | |
| currentUserFullName = ResolveCurrentUserFullName(); | |
| _dataLoaded = true; | |
| _shouldRenderCharts = true; | |
| StateHasChanged(); | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Dashboard load error: {ex.Message}"); | |
| } | |
| } | |
| protected override async Task OnAfterRenderAsync(bool firstRender) | |
| { | |
| await JS.InvokeVoidAsync("initIcons"); | |
| if (_shouldRenderCharts && _dataLoaded) | |
| { | |
| _shouldRenderCharts = false; | |
| await RenderChartsAsync(); | |
| } | |
| } | |
| private async Task RenderChartsAsync() | |
| { | |
| try | |
| { | |
| var ganttData = TimelineProjects.Select(p => { | |
| var progress = GetProjectProgress(p.Id); | |
| return new { | |
| id = p.Id.ToString(), | |
| name = p.Name, | |
| start = new DateTimeOffset(DateTime.SpecifyKind(p.StartDate.Date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(), | |
| end = new DateTimeOffset(DateTime.SpecifyKind(p.EndDate.Date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(), | |
| completed = progress.CompletionPercentage / 100.0, | |
| color = GetProjectColor(p.Id) | |
| }; | |
| }).ToList(); | |
| if (ganttData.Count > 0 && TryGetTimelineRange(out var timelineRange)) | |
| { | |
| await JS.InvokeVoidAsync("renderGanttChart", "project-timeline-chart", ganttData, new | |
| { | |
| height = 420, | |
| minWidth = Math.Max(900, GetTimelineMonthSpan(timelineRange.Start, timelineRange.End) * 110), | |
| xAxisMin = ToUnixMilliseconds(timelineRange.Start), | |
| xAxisMax = ToUnixMilliseconds(timelineRange.End) | |
| }); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Error rendering Highcharts: {ex.Message}"); | |
| } | |
| } | |
| private string ResolveCurrentUserFullName() | |
| { | |
| var user = usersList.FirstOrDefault(u => string.Equals(u.Username, currentUserName, StringComparison.OrdinalIgnoreCase)); | |
| if (user != null) | |
| { | |
| return $"{user.FirstName} {user.LastName}".Trim(); | |
| } | |
| return currentUserName; | |
| } | |
| private List<ActivityMonthStat> BuildActivityMonths() | |
| { | |
| var now = DateTime.Today; | |
| var start = new DateTime(now.Year, now.Month, 1).AddMonths(-5); | |
| var months = new List<ActivityMonthStat>(); | |
| for (var i = 0; i < 6; i++) | |
| { | |
| var monthStart = start.AddMonths(i); | |
| var nextMonth = monthStart.AddMonths(1); | |
| var active = taskList.Count(task => | |
| task.CreatedAt >= monthStart && | |
| task.CreatedAt < nextMonth && | |
| task.StatusId != AppTaskStatus.Done); | |
| var completed = taskList.Count(task => | |
| task.CompletedAt.HasValue && | |
| task.CompletedAt.Value >= monthStart && | |
| task.CompletedAt.Value < nextMonth); | |
| months.Add(new ActivityMonthStat(GetMonthLabel(monthStart), active, completed)); | |
| } | |
| return months; | |
| } | |
| private string BuildActivityPath(IReadOnlyList<ActivityMonthStat> months) | |
| { | |
| if (months.Count == 0) | |
| { | |
| return string.Empty; | |
| } | |
| const double left = 48; | |
| const double right = 730; | |
| const double top = 48; | |
| const double bottom = 280; | |
| const double maxValue = 20; | |
| double X(int i) => left + (i * ((right - left) / (months.Count - 1))); | |
| double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top); | |
| var points = new List<string>(); | |
| for (var i = 0; i < months.Count; i++) | |
| { | |
| points.Add($"{X(i):0},{Y(months[i].Active):0}"); | |
| } | |
| var path = $"M {points[0]}"; | |
| for (var i = 1; i < points.Count; i++) | |
| { | |
| path += $" L {points[i]}"; | |
| } | |
| return path; | |
| } | |
| private string BuildActivityAreaPath(IReadOnlyList<ActivityMonthStat> months) | |
| { | |
| if (months.Count == 0) | |
| { | |
| return string.Empty; | |
| } | |
| const double left = 48; | |
| const double right = 730; | |
| const double top = 48; | |
| const double bottom = 280; | |
| const double maxValue = 20; | |
| double X(int i) => left + (i * ((right - left) / (months.Count - 1))); | |
| double Y(double value) => bottom - Math.Min(value, maxValue) / maxValue * (bottom - top); | |
| var points = new List<string>(); | |
| for (var i = 0; i < months.Count; i++) | |
| { | |
| points.Add($"{X(i):0},{Y(months[i].Active):0}"); | |
| } | |
| var path = $"M {points[0]}"; | |
| for (var i = 1; i < points.Count; i++) | |
| { | |
| path += $" L {points[i]}"; | |
| } | |
| return $"{path} L {X(months.Count - 1):0},{bottom:0} L {X(0):0},{bottom:0} Z"; | |
| } | |
| private List<ActivityPoint> BuildActivityPoints() | |
| { | |
| var months = ActivityMonths; | |
| var points = new List<ActivityPoint>(); | |
| if (months.Count == 0) | |
| { | |
| return points; | |
| } | |
| const double left = 48; | |
| const double right = 730; | |
| const double top = 48; | |
| const double bottom = 280; | |
| var maxValue = Math.Max(months.Max(m => m.Active), 1); | |
| double X(int i) => left + (i * ((right - left) / (months.Count - 1))); | |
| double Y(double value) => bottom - Math.Min(value, maxValue) / (double)maxValue * (bottom - top); | |
| for (var i = 0; i < months.Count; i++) | |
| { | |
| points.Add(new ActivityPoint(months[i].Label, X(i), Y(months[i].Active), months[i].Active, months[i].Completed)); | |
| } | |
| return points; | |
| } | |
| private List<DistributionSlice> BuildDonutSlices() | |
| { | |
| // 1. Calculate counts entirely from the same taskList to fix data mismatches | |
| var todoCount = taskList.Count(t => t.StatusId == AppTaskStatus.Todo); | |
| var inProgressCount = taskList.Count(t => t.StatusId == AppTaskStatus.InProgress); | |
| var overdueCount = taskList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done); | |
| var rawCounts = new[] | |
| { | |
| CompletedTasksCount, // Already calculated from taskList | |
| inProgressCount, | |
| todoCount, | |
| overdueCount | |
| }; | |
| var colors = new[] { "#0f766e", "#38bdf8", "#94a3b8", "#dc2626" }; | |
| // 2. Adjust total so it maps 100% cleanly to the math displayed | |
| var total = Math.Max(TotalTasksCount, 1); | |
| var circumference = 2 * Math.PI * 74; // 2πr for r=74 | |
| var slices = new List<DistributionSlice>(); | |
| var cumulativeArc = 0.0; | |
| for (var i = 0; i < rawCounts.Length; i++) | |
| { | |
| if (rawCounts[i] <= 0) | |
| { | |
| // Arc length 0 with a full-circumference gap → invisible. | |
| // Avoids stroke-linecap="round" rendering a phantom dot. | |
| slices.Add(new DistributionSlice(GetDistributionLabel(i), 0, colors[i], $"0 {circumference:0.##}", 0)); | |
| continue; | |
| } | |
| var fraction = rawCounts[i] / (double)total; | |
| var arcLength = circumference * fraction; | |
| // dasharray: "{draw} {gap}" where gap = circumference - arcLength so the | |
| // pattern period equals exactly one full revolution. This prevents any | |
| // slice from bleeding into the next revolution of the ring. | |
| var dash = $"{arcLength:0.##} {(circumference - arcLength):0.##}"; | |
| // dashOffset = -cumulativeArc: | |
| // A negative offset shifts the start of the dash pattern forward along the | |
| // path by cumulativeArc pixels, so this slice begins exactly where the | |
| // previous slice ended. (The circle starts at 12 o'clock via rotate=-90.) | |
| var dashOffset = -cumulativeArc; | |
| slices.Add(new DistributionSlice(GetDistributionLabel(i), rawCounts[i], colors[i], dash, dashOffset)); | |
| cumulativeArc += arcLength; | |
| } | |
| return slices; | |
| } | |
| private static string GetDistributionLabel(int index) => index switch | |
| { | |
| 0 => "Completed", | |
| 1 => "In Progress", | |
| 2 => "To Do", | |
| _ => "Overdue" | |
| }; | |
| private List<DistributionLegendItem> BuildDistributionLegend() | |
| { | |
| return DonutSlices.Select(slice => new DistributionLegendItem(slice.Label, slice.Count, slice.Color)).ToList(); | |
| } | |
| private List<CalendarCell> BuildCalendarCells() | |
| { | |
| var today = DateTime.Today; | |
| var firstOfMonth = new DateTime(today.Year, today.Month, 1); | |
| var start = firstOfMonth.AddDays(-(int)firstOfMonth.DayOfWeek); | |
| var projectStarts = projectsList | |
| .Select(project => new { project.StartDate, Color = GetProjectColor(project.Id) }) | |
| .Where(item => item.StartDate.Date >= start.Date && item.StartDate.Date < start.AddDays(42)) | |
| .ToList(); | |
| var cells = new List<CalendarCell>(); | |
| for (var i = 0; i < 42; i++) | |
| { | |
| var date = start.AddDays(i); | |
| var markers = projectStarts | |
| .Where(item => item.StartDate.Date == date.Date) | |
| .Select(item => item.Color) | |
| .ToList(); | |
| cells.Add(new CalendarCell(date, date.Month == today.Month, date.Date == today.Date, markers)); | |
| } | |
| return cells; | |
| } | |
| private List<CalendarEventItem> BuildUpcomingProjectStarts() | |
| { | |
| return projectsList | |
| .Where(project => project.StartDate.Date >= DateTime.Today) | |
| .OrderBy(project => project.StartDate) | |
| .Select(project => new CalendarEventItem(project.Name, project.StartDate.ToString("MMM d"), GetProjectColor(project.Id))) | |
| .ToList(); | |
| } | |
| private List<DateTime> BuildTimelineMonths() | |
| { | |
| var featured = FeaturedProjects; | |
| if (featured.Count == 0) | |
| { | |
| return new List<DateTime>(); | |
| } | |
| var start = new DateTime(featured.Min(p => p.StartDate).Year, featured.Min(p => p.StartDate).Month, 1); | |
| var end = new DateTime(featured.Max(p => p.EndDate).Year, featured.Max(p => p.EndDate).Month, 1); | |
| var months = new List<DateTime>(); | |
| var current = start; | |
| while (current <= end) | |
| { | |
| months.Add(current); | |
| current = current.AddMonths(1); | |
| } | |
| return months; | |
| } | |
| private List<TimelineRow> BuildTimelineRows() | |
| { | |
| var months = TimelineMonths; | |
| if (months.Count == 0) | |
| { | |
| return new List<TimelineRow>(); | |
| } | |
| var first = months.First(); | |
| var n = months.Count; // total columns ΓÇö each flex-1 month slot = 1/n of bar width | |
| return FeaturedProjects.Select(project => | |
| { | |
| var startIndex = ((project.StartDate.Year - first.Year) * 12) + project.StartDate.Month - first.Month; | |
| var endIndex = ((project.EndDate.Year - first.Year) * 12) + project.EndDate.Month - first.Month; | |
| startIndex = Math.Clamp(startIndex, 0, n - 1); | |
| endIndex = Math.Clamp(endIndex, startIndex, n - 1); | |
| // Use n as divisor so positions match the flex-1 month columns. | |
| var left = (startIndex / (double)n) * 100; | |
| var width = ((endIndex - startIndex + 1) / (double)n) * 100; | |
| width = Math.Max(2, width); | |
| var progress = GetProjectProgress(project.Id); | |
| return new TimelineRow( | |
| project.Name, | |
| $"{DisplayFormats.Date(project.StartDate)} ΓÇô {DisplayFormats.Date(project.EndDate)}", | |
| GetProjectColor(project.Id), | |
| left, | |
| width, | |
| progress.CompletionPercentage); | |
| }).ToList(); | |
| } | |
| private string GetTimelineTodayMarkerLeft() | |
| { | |
| var months = TimelineMonths; | |
| if (months.Count == 0) return "0%"; | |
| var first = months.First(); | |
| var n = months.Count; | |
| var monthIndex = ((DateTime.Today.Year - first.Year) * 12) + DateTime.Today.Month - first.Month; | |
| // Clamp to visible range; if today is outside the timeline keep it at the edge. | |
| monthIndex = Math.Clamp(monthIndex, 0, n - 1); | |
| // Add fractional day-of-month so the line sits at the correct intra-month position. | |
| var dayFraction = (DateTime.Today.Day - 1.0) / DateTime.DaysInMonth(DateTime.Today.Year, DateTime.Today.Month); | |
| return $"{((monthIndex + dayFraction) / n * 100):0.##}%"; | |
| } | |
| private void InitializeTimelineRange() | |
| { | |
| if (TimelineProjects.Count == 0) | |
| { | |
| var currentMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); | |
| timelineStartMonthValue = currentMonth.ToString("yyyy-MM"); | |
| timelineEndMonthValue = currentMonth.ToString("yyyy-MM"); | |
| return; | |
| } | |
| var start = new DateTime(TimelineProjects.Min(p => p.StartDate).Year, TimelineProjects.Min(p => p.StartDate).Month, 1); | |
| var end = new DateTime(TimelineProjects.Max(p => p.EndDate).Year, TimelineProjects.Max(p => p.EndDate).Month, 1); | |
| timelineStartMonthValue = start.ToString("yyyy-MM"); | |
| timelineEndMonthValue = end.ToString("yyyy-MM"); | |
| } | |
| private Task OnTimelineStartMonthChanged(ChangeEventArgs args) | |
| { | |
| timelineStartMonthValue = args.Value?.ToString() ?? timelineStartMonthValue; | |
| NormalizeTimelineRange(); | |
| _shouldRenderCharts = true; | |
| StateHasChanged(); | |
| return Task.CompletedTask; | |
| } | |
| private Task OnTimelineEndMonthChanged(ChangeEventArgs args) | |
| { | |
| timelineEndMonthValue = args.Value?.ToString() ?? timelineEndMonthValue; | |
| NormalizeTimelineRange(); | |
| _shouldRenderCharts = true; | |
| StateHasChanged(); | |
| return Task.CompletedTask; | |
| } | |
| private void NormalizeTimelineRange() | |
| { | |
| if (!TryParseMonthValue(timelineStartMonthValue, out var start) || | |
| !TryParseMonthValue(timelineEndMonthValue, out var end)) | |
| { | |
| return; | |
| } | |
| if (start > end) | |
| { | |
| (start, end) = (end, start); | |
| } | |
| timelineStartMonthValue = start.ToString("yyyy-MM"); | |
| timelineEndMonthValue = end.ToString("yyyy-MM"); | |
| } | |
| private bool TryGetTimelineRange(out (DateTime Start, DateTime End) range) | |
| { | |
| if (!TryParseMonthValue(timelineStartMonthValue, out var start) || | |
| !TryParseMonthValue(timelineEndMonthValue, out var end)) | |
| { | |
| range = default; | |
| return false; | |
| } | |
| if (start > end) | |
| { | |
| (start, end) = (end, start); | |
| } | |
| var startOfMonth = new DateTime(start.Year, start.Month, 1); | |
| var endOfMonth = new DateTime(end.Year, end.Month, DateTime.DaysInMonth(end.Year, end.Month)); | |
| range = (startOfMonth, endOfMonth); | |
| return true; | |
| } | |
| private static bool TryParseMonthValue(string? value, out DateTime month) | |
| { | |
| month = default; | |
| if (string.IsNullOrWhiteSpace(value)) | |
| { | |
| return false; | |
| } | |
| return DateTime.TryParseExact($"{value}-01", "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out month); | |
| } | |
| private static long ToUnixMilliseconds(DateTime value) | |
| { | |
| var utc = DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); | |
| return new DateTimeOffset(utc).ToUnixTimeMilliseconds(); | |
| } | |
| private static int GetTimelineMonthSpan(DateTime start, DateTime end) | |
| { | |
| var span = ((end.Year - start.Year) * 12) + end.Month - start.Month + 1; | |
| return Math.Max(span, 1); | |
| } | |
| private ProjectProgressDto GetProjectProgress(long projectId) | |
| { | |
| return projectProgress.FirstOrDefault(item => item.ProjectId == projectId) | |
| ?? new ProjectProgressDto | |
| { | |
| ProjectId = projectId, | |
| ProjectName = string.Empty, | |
| TotalTasksCount = taskList.Count(task => task.ProjectId == projectId), | |
| CompletedTasksCount = taskList.Count(task => task.ProjectId == projectId && task.StatusId == AppTaskStatus.Done), | |
| CompletionPercentage = 0 | |
| }; | |
| } | |
| private string GetProjectBadgeLabel(ProjectDto project) | |
| { | |
| return project.StartDate.Date > DateTime.Today ? "Upcoming" : "Active"; | |
| } | |
| private string GetProjectBadgeClasses(ProjectDto project) | |
| { | |
| return project.StartDate.Date > DateTime.Today | |
| ? "rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700" | |
| : "rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700"; | |
| } | |
| private string GetProjectColor(long projectId) | |
| { | |
| var palette = new[] | |
| { | |
| "#0f766e", | |
| "#38bdf8", | |
| "#10b981", | |
| "#b45309", | |
| "#64748b" | |
| }; | |
| var index = (int)(projectId % palette.Length); | |
| return palette[index]; | |
| } | |
| private string GetCalendarCellClasses(CalendarCell cell) | |
| { | |
| var classes = new List<string> | |
| { | |
| "min-h-[62px] rounded-2xl border p-2" | |
| }; | |
| if (cell.IsToday) | |
| { | |
| classes.Add("border-violet-200 bg-violet-50"); | |
| } | |
| else if (cell.IsCurrentMonth) | |
| { | |
| classes.Add("border-slate-200 bg-white"); | |
| } | |
| else | |
| { | |
| classes.Add("border-slate-100 bg-slate-50/70"); | |
| } | |
| return string.Join(' ', classes); | |
| } | |
| private void SetHoveredActivity(int index) | |
| { | |
| hoveredActivityIndex = index; | |
| } | |
| private void ClearHoveredActivity() | |
| { | |
| hoveredActivityIndex = null; | |
| } | |
| private void SetHoveredSlice(int index) | |
| { | |
| hoveredSliceIndex = index; | |
| } | |
| private void ClearHoveredSlice() | |
| { | |
| hoveredSliceIndex = null; | |
| } | |
| private ActivityMonthStat? hoveredActivity => | |
| hoveredActivityIndex.HasValue && hoveredActivityIndex.Value < ActivityMonths.Count | |
| ? ActivityMonths[hoveredActivityIndex.Value] | |
| : null; | |
| private DistributionSlice? hoveredSlice => | |
| hoveredSliceIndex.HasValue && hoveredSliceIndex.Value < DonutSlices.Count | |
| ? DonutSlices[hoveredSliceIndex.Value] | |
| : null; | |
| private string HoveredActivityStyle | |
| { | |
| get | |
| { | |
| if (!hoveredActivityIndex.HasValue || hoveredActivityIndex.Value >= ActivityPoints.Count) | |
| { | |
| return string.Empty; | |
| } | |
| var point = ActivityPoints[hoveredActivityIndex.Value]; | |
| return $"left: {point.X / 760d * 100:0.##}%; top: {point.Y / 340d * 100:0.##}%; transform: translate(-50%, -110%);"; | |
| } | |
| } | |
| private string BuildDeltaText(int delta) | |
| { | |
| if (delta > 0) | |
| { | |
| return $"+{delta} this month"; | |
| } | |
| if (delta < 0) | |
| { | |
| return $"{delta} this month"; | |
| } | |
| return "0 this month"; | |
| } | |
| private string BuildCompletionDeltaText() | |
| { | |
| var current = GetCompletionRateForMonth(DateTime.Today); | |
| var previous = GetCompletionRateForMonth(DateTime.Today.AddMonths(-1)); | |
| var delta = current - previous; | |
| if (delta > 0) | |
| { | |
| return $"+{delta}% vs last mo"; | |
| } | |
| if (delta < 0) | |
| { | |
| return $"{delta}% vs last mo"; | |
| } | |
| return "0% vs last mo"; | |
| } | |
| private int GetUsersThisMonth() => | |
| usersList.Count(user => user.CreatedAt.Year == DateTime.Today.Year && user.CreatedAt.Month == DateTime.Today.Month); | |
| private int GetUsersPreviousMonth() | |
| { | |
| var previous = DateTime.Today.AddMonths(-1); | |
| return usersList.Count(user => user.CreatedAt.Year == previous.Year && user.CreatedAt.Month == previous.Month); | |
| } | |
| private int GetProjectsThisMonth() => | |
| projectsList.Count(project => project.CreatedAt.Year == DateTime.Today.Year && project.CreatedAt.Month == DateTime.Today.Month); | |
| private int GetProjectsPreviousMonth() | |
| { | |
| var previous = DateTime.Today.AddMonths(-1); | |
| return projectsList.Count(project => project.CreatedAt.Year == previous.Year && project.CreatedAt.Month == previous.Month); | |
| } | |
| private int GetActiveTasksThisMonth() => | |
| taskList.Count(task => task.CreatedAt.Year == DateTime.Today.Year && task.CreatedAt.Month == DateTime.Today.Month && task.StatusId != AppTaskStatus.Done); | |
| private int GetActiveTasksPreviousMonth() | |
| { | |
| var previous = DateTime.Today.AddMonths(-1); | |
| return taskList.Count(task => task.CreatedAt.Year == previous.Year && task.CreatedAt.Month == previous.Month && task.StatusId != AppTaskStatus.Done); | |
| } | |
| private int GetCompletionRateForMonth(DateTime date) | |
| { | |
| var monthTasks = taskList.Where(task => task.CreatedAt.Year == date.Year && task.CreatedAt.Month == date.Month).ToList(); | |
| if (monthTasks.Count == 0) | |
| { | |
| return 0; | |
| } | |
| return (int)Math.Round(monthTasks.Count(task => task.StatusId == AppTaskStatus.Done) / (double)monthTasks.Count * 100); | |
| } | |
| private string GetMonthLabel(DateTime date) => date.ToString("MMM"); | |
| private readonly record struct ActivityMonthStat(string Label, int Active, int Completed); | |
| private readonly record struct ActivityPoint(string Label, double X, double Y, int Active, int Completed); | |
| private readonly record struct DistributionSlice(string Label, int Count, string Color, string DashArray, double DashOffset); | |
| private readonly record struct DistributionLegendItem(string Label, int Count, string Color); | |
| private readonly record struct CalendarCell(DateTime Date, bool IsCurrentMonth, bool IsToday, List<string> ProjectDots); | |
| private readonly record struct CalendarEventItem(string Name, string DateLabel, string Color); | |
| private readonly record struct TimelineRow(string Name, string DateLabel, string Color, double Left, double Width, double CompletionPercentage); | |
| } | |