diff --git a/Content.Client/Info/PlaytimeStats/PlaytimeStatsEntry.cs b/Content.Client/Info/PlaytimeStats/PlaytimeStatsEntry.cs
new file mode 100644
index 0000000000..aff01800f9
--- /dev/null
+++ b/Content.Client/Info/PlaytimeStats/PlaytimeStatsEntry.cs
@@ -0,0 +1,39 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Info.PlaytimeStats;
+
+[GenerateTypedNameReferences]
+public sealed partial class PlaytimeStatsEntry : ContainerButton
+{
+ public TimeSpan Playtime { get; private set; } // new TimeSpan property
+
+ public PlaytimeStatsEntry(string role, TimeSpan playtime, StyleBox styleBox)
+ {
+ RobustXamlLoader.Load(this);
+
+ RoleLabel.Text = role;
+ Playtime = playtime; // store the TimeSpan value directly
+ PlaytimeLabel.Text = ConvertTimeSpanToHoursMinutes(playtime); // convert to string for display
+ BackgroundColorPanel.PanelOverride = styleBox;
+ }
+
+ private static string ConvertTimeSpanToHoursMinutes(TimeSpan timeSpan)
+ {
+ var hours = (int)timeSpan.TotalHours;
+ var minutes = timeSpan.Minutes;
+
+ var formattedTimeLoc = Loc.GetString("ui-playtime-time-format", ("hours", hours), ("minutes", minutes));
+ return formattedTimeLoc;
+ }
+
+ public void UpdateShading(StyleBoxFlat styleBox)
+ {
+ BackgroundColorPanel.PanelOverride = styleBox;
+ }
+ public string? PlaytimeText => PlaytimeLabel.Text;
+
+ public string? RoleText => RoleLabel.Text;
+}
diff --git a/Content.Client/Info/PlaytimeStats/PlaytimeStatsEntry.xaml b/Content.Client/Info/PlaytimeStats/PlaytimeStatsEntry.xaml
new file mode 100644
index 0000000000..97a66e5cc2
--- /dev/null
+++ b/Content.Client/Info/PlaytimeStats/PlaytimeStatsEntry.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Info/PlaytimeStats/PlaytimeStatsHeader.cs b/Content.Client/Info/PlaytimeStats/PlaytimeStatsHeader.cs
new file mode 100644
index 0000000000..b005c641f7
--- /dev/null
+++ b/Content.Client/Info/PlaytimeStats/PlaytimeStatsHeader.cs
@@ -0,0 +1,86 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Input;
+
+namespace Content.Client.Info.PlaytimeStats;
+
+[GenerateTypedNameReferences]
+public sealed partial class PlaytimeStatsHeader : ContainerButton
+{
+ public event Action? OnHeaderClicked;
+ private SortDirection _roleDirection = SortDirection.Ascending;
+ private SortDirection _playtimeDirection = SortDirection.Descending;
+
+ public PlaytimeStatsHeader()
+ {
+ RobustXamlLoader.Load(this);
+
+ RoleLabel.OnKeyBindDown += RoleClicked;
+ PlaytimeLabel.OnKeyBindDown += PlaytimeClicked;
+
+ UpdateLabels();
+ }
+
+ public enum Header : byte
+ {
+ Role,
+ Playtime
+ }
+ public enum SortDirection : byte
+ {
+ Ascending,
+ Descending
+ }
+
+ private void HeaderClicked(GUIBoundKeyEventArgs args, Header header)
+ {
+ if (args.Function != EngineKeyFunctions.UIClick)
+ {
+ return;
+ }
+
+ switch (header)
+ {
+ case Header.Role:
+ _roleDirection = _roleDirection == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending;
+ break;
+ case Header.Playtime:
+ _playtimeDirection = _playtimeDirection == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending;
+ break;
+ }
+
+ UpdateLabels();
+ OnHeaderClicked?.Invoke(header, header == Header.Role ? _roleDirection : _playtimeDirection);
+ args.Handle();
+ }
+ private void UpdateLabels()
+ {
+ RoleLabel.Text = Loc.GetString("ui-playtime-header-role-type") +
+ (_roleDirection == SortDirection.Ascending ? " ↓" : " ↑");
+ PlaytimeLabel.Text = Loc.GetString("ui-playtime-header-role-time") +
+ (_playtimeDirection == SortDirection.Ascending ? " ↓" : " ↑");
+ }
+
+ private void RoleClicked(GUIBoundKeyEventArgs args)
+ {
+ HeaderClicked(args, Header.Role);
+ }
+
+ private void PlaytimeClicked(GUIBoundKeyEventArgs args)
+ {
+ HeaderClicked(args, Header.Playtime);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ RoleLabel.OnKeyBindDown -= RoleClicked;
+ PlaytimeLabel.OnKeyBindDown -= PlaytimeClicked;
+ }
+ }
+}
diff --git a/Content.Client/Info/PlaytimeStats/PlaytimeStatsHeader.xaml b/Content.Client/Info/PlaytimeStats/PlaytimeStatsHeader.xaml
new file mode 100644
index 0000000000..4cf4d8e2cc
--- /dev/null
+++ b/Content.Client/Info/PlaytimeStats/PlaytimeStatsHeader.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Info/PlaytimeStats/PlaytimeStatsWindow.cs b/Content.Client/Info/PlaytimeStats/PlaytimeStatsWindow.cs
new file mode 100644
index 0000000000..3b54bf82da
--- /dev/null
+++ b/Content.Client/Info/PlaytimeStats/PlaytimeStatsWindow.cs
@@ -0,0 +1,146 @@
+using System.Linq;
+using System.Text.RegularExpressions;
+using Content.Client.Players.PlayTimeTracking;
+using Content.Client.UserInterface.Controls;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Info.PlaytimeStats;
+
+[GenerateTypedNameReferences]
+public sealed partial class PlaytimeStatsWindow : FancyWindow
+{
+ [Dependency] private readonly JobRequirementsManager _jobRequirementsManager = default!;
+ private ISawmill _sawmill = Logger.GetSawmill("PlaytimeStatsWindow");
+ private readonly Color _altColor = Color.FromHex("#292B38");
+ private readonly Color _defaultColor = Color.FromHex("#2F2F3B");
+ private bool _useAltColor;
+
+ public PlaytimeStatsWindow()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ PopulatePlaytimeHeader();
+ PopulatePlaytimeData();
+ }
+
+ private void PopulatePlaytimeHeader()
+ {
+ var header = new PlaytimeStatsHeader();
+ header.OnHeaderClicked += HeaderClicked;
+ header.BackgroundColorPlaytimePanel.PanelOverride = new StyleBoxFlat(_altColor);
+ RolesPlaytimeList.AddChild(header);
+ }
+
+ private void HeaderClicked(PlaytimeStatsHeader.Header header, PlaytimeStatsHeader.SortDirection direction)
+ {
+ switch (header)
+ {
+ case PlaytimeStatsHeader.Header.Role:
+ SortByRole(direction);
+ break;
+ case PlaytimeStatsHeader.Header.Playtime:
+ SortByPlaytime(direction);
+ break;
+ }
+ }
+
+ private void SortByRole(PlaytimeStatsHeader.SortDirection direction)
+ {
+ var header = RolesPlaytimeList.GetChild(0) as PlaytimeStatsHeader;
+
+ var entries = RolesPlaytimeList.Children.OfType().ToList();
+
+ RolesPlaytimeList.RemoveAllChildren();
+
+ if (header != null)
+ RolesPlaytimeList.AddChild(header);
+
+ var sortedEntries = (direction == PlaytimeStatsHeader.SortDirection.Ascending)
+ ? entries.OrderBy(entry => entry.RoleText).ToList()
+ : entries.OrderByDescending(entry => entry.RoleText).ToList();
+
+ _useAltColor = false;
+
+ foreach (var entry in sortedEntries)
+ {
+ var styleBox = new StyleBoxFlat { BackgroundColor = _useAltColor ? _altColor : _defaultColor };
+ entry.UpdateShading(styleBox);
+ RolesPlaytimeList.AddChild(entry);
+ _useAltColor ^= true;
+ }
+ }
+
+ private void SortByPlaytime(PlaytimeStatsHeader.SortDirection direction)
+ {
+ var header = RolesPlaytimeList.GetChild(0) as PlaytimeStatsHeader;
+
+ var entries = RolesPlaytimeList.Children.OfType().ToList();
+
+ RolesPlaytimeList.RemoveAllChildren();
+
+ if (header != null)
+ RolesPlaytimeList.AddChild(header);
+
+ var sortedEntries = (direction == PlaytimeStatsHeader.SortDirection.Ascending)
+ ? entries.OrderBy(entry => entry.Playtime).ToList()
+ : entries.OrderByDescending(entry => entry.Playtime).ToList();
+
+ _useAltColor = false;
+
+ foreach (var entry in sortedEntries)
+ {
+ var styleBox = new StyleBoxFlat { BackgroundColor = _useAltColor ? _altColor : _defaultColor };
+ entry.UpdateShading(styleBox);
+ RolesPlaytimeList.AddChild(entry);
+ _useAltColor ^= true;
+ }
+ }
+
+
+ private void PopulatePlaytimeData()
+ {
+ var overallPlaytime = _jobRequirementsManager.FetchOverallPlaytime();
+
+ var formattedPlaytime = ConvertTimeSpanToHoursMinutes(overallPlaytime);
+ OverallPlaytimeLabel.Text = Loc.GetString("ui-playtime-overall", ("time", formattedPlaytime));
+
+ var rolePlaytimes = _jobRequirementsManager.FetchPlaytimeByRoles();
+
+ RolesPlaytimeList.RemoveAllChildren();
+ PopulatePlaytimeHeader();
+
+ foreach (var rolePlaytime in rolePlaytimes)
+ {
+ var role = rolePlaytime.Key;
+ var playtime = rolePlaytime.Value;
+ AddRolePlaytimeEntryToTable(Loc.GetString(role), playtime.ToString());
+ }
+ }
+
+ private void AddRolePlaytimeEntryToTable(string role, string playtimeString)
+ {
+ if (TimeSpan.TryParse(playtimeString, out var playtime))
+ {
+ var entry = new PlaytimeStatsEntry(role, playtime,
+ new StyleBoxFlat(_useAltColor ? _altColor : _defaultColor));
+ RolesPlaytimeList.AddChild(entry);
+ _useAltColor ^= true;
+ }
+ else
+ {
+ _sawmill.Error($"The provided playtime string '{playtimeString}' is not in the correct format.");
+ }
+ }
+
+ private static string ConvertTimeSpanToHoursMinutes(TimeSpan timeSpan)
+ {
+ var hours = (int) timeSpan.TotalHours;
+ var minutes = timeSpan.Minutes;
+
+ var formattedTimeLoc = Loc.GetString("ui-playtime-time-format", ("hours", hours), ("minutes", minutes));
+ return formattedTimeLoc;
+ }
+}
diff --git a/Content.Client/Info/PlaytimeStats/PlaytimeStatsWindow.xaml b/Content.Client/Info/PlaytimeStats/PlaytimeStatsWindow.xaml
new file mode 100644
index 0000000000..b38394d9a3
--- /dev/null
+++ b/Content.Client/Info/PlaytimeStats/PlaytimeStatsWindow.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs b/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs
index 0e559d0f8c..5027f77663 100644
--- a/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs
+++ b/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs
@@ -1,6 +1,4 @@
using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using System.Text;
using Content.Shared.CCVar;
using Content.Shared.Players;
using Content.Shared.Players.PlayTimeTracking;
@@ -123,4 +121,24 @@ public sealed partial class JobRequirementsManager
reason = reasons.Count == 0 ? null : FormattedMessage.FromMarkup(string.Join('\n', reasons));
return reason == null;
}
+
+ public TimeSpan FetchOverallPlaytime()
+ {
+ return _roles.TryGetValue("Overall", out var overallPlaytime) ? overallPlaytime : TimeSpan.Zero;
+ }
+
+ public IEnumerable> FetchPlaytimeByRoles()
+ {
+ var jobsToMap = _prototypes.EnumeratePrototypes();
+
+ foreach (var job in jobsToMap)
+ {
+ if (_roles.TryGetValue(job.PlayTimeTracker, out var locJobName))
+ {
+ yield return new KeyValuePair(job.Name, locJobName);
+ }
+ }
+ }
+
+
}
diff --git a/Content.Client/Preferences/UI/CharacterSetupGui.xaml b/Content.Client/Preferences/UI/CharacterSetupGui.xaml
index 5db8610475..9a76029ce0 100644
--- a/Content.Client/Preferences/UI/CharacterSetupGui.xaml
+++ b/Content.Client/Preferences/UI/CharacterSetupGui.xaml
@@ -10,10 +10,13 @@
-
+