User
Deploy normal dashboard layout
481309b
Raw
History Blame Contribute Delete
43.2 kB
@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);
}