Split the person card into overview, clothes, carry and now tabs.

Today's history is a paged HTTP log on the worker, not on the card or in people.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 04:00:38 +03:00
co-authored by Cursor
parent 69106cb48b
commit 6be4fa75ab
27 changed files with 1551 additions and 109 deletions
+179
View File
@@ -0,0 +1,179 @@
using System.Globalization;
using Arch.Core;
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Log row types the person card pages. Captions for morning issue are ready even while phase 34
/// is the one that appends that row.
/// </summary>
public static class PersonLogTypes
{
public const string ActionStarted = "action-started";
public const string ActionEnded = "action-ended";
public const string ApparelReplaced = "apparel-replaced";
public const string ApparelChanged = "apparel-changed";
}
/// <summary>
/// One thing that happened to a person today. Stored on the school worker, not in <c>people.json</c>.
/// The day boundary is six in the morning — the same hour skip lands on.
/// </summary>
public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, string? ThingDef)
{
public string Caption(DefCatalog catalog, string locale)
{
ArgumentNullException.ThrowIfNull(catalog);
if (ThingDef is null)
{
return Type;
}
if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal))
{
var name = catalog.Things.TryGetValue(ThingDef, out var def)
? catalog.Label(locale, def)
: ThingDef;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal))
{
var name = catalog.Actions.TryGetValue(ThingDef, out var action)
? catalog.Label(locale, action)
: ThingDef;
var key = Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
? "ActionStarted"
: "ActionEnded";
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name);
}
return Type;
}
}
public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize);
public sealed record PersonLogPage(int Total, int Page, int PageSize, IReadOnlyList<PersonLogRow> Entries);
public sealed record PersonLogRow(DateTime Time, string Type, string Label, string? ThingDef);
/// <summary>Filters, sorts and pages today's log for one person. Does not touch a World.</summary>
public static class PersonLogBrowser
{
public const int DefaultPageSize = 20;
public const int MaxPageSize = 100;
public static PersonLogPage Apply(
IReadOnlyList<PersonLogEvent> log,
string personId,
PersonLogQuery query,
DefCatalog? catalog,
string locale)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize);
var search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search.Trim();
var rows = new List<PersonLogRow>();
foreach (var row in log)
{
if (!row.PersonId.Equals(personId, StringComparison.Ordinal))
{
continue;
}
var label = catalog is null ? row.Type : row.Caption(catalog, locale);
if (search is not null
&& !label.Contains(search, StringComparison.OrdinalIgnoreCase)
&& !row.Type.Contains(search, StringComparison.OrdinalIgnoreCase)
&& (row.ThingDef is null || !row.ThingDef.Contains(search, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
rows.Add(new PersonLogRow(row.Time, row.Type, label, row.ThingDef));
}
rows.Sort((left, right) =>
{
var byTime = left.Time.CompareTo(right.Time);
return query.Descending ? -byTime : byTime;
});
var skip = (page - 1) * pageSize;
var slice = skip >= rows.Count ? Array.Empty<PersonLogRow>() : rows.Skip(skip).Take(pageSize).ToArray();
return new PersonLogPage(rows.Count, page, pageSize, slice);
}
}
/// <summary>Records action start/end and clears yesterday at six in the morning.</summary>
internal static class PersonDayLog
{
private static readonly QueryDescription IdentityAndActivity =
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
internal static bool CrossedDayStart(DateTime before, DateTime after)
{
if (after <= before)
{
return false;
}
var cursor = DateTime.SpecifyKind(before.Date, DateTimeKind.Utc).Add(SchoolDay.DayStart.ToTimeSpan());
if (before >= cursor)
{
cursor = cursor.AddDays(1);
}
return after >= cursor;
}
internal static void Sync(School school)
{
var world = school.World;
var seen = new HashSet<string>(StringComparer.Ordinal);
var query = IdentityAndActivity;
world.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
seen.Add(identity.Id);
Record(school, identity.Id, activity.ActionId);
});
foreach (var id in school.LoggedActivity.Keys.ToArray())
{
if (!seen.Contains(id))
{
Record(school, id, current: null);
school.LoggedActivity.Remove(id);
}
}
}
private static void Record(School school, string personId, string? current)
{
school.LoggedActivity.TryGetValue(personId, out var last);
if (string.Equals(last, current, StringComparison.Ordinal))
{
school.LoggedActivity[personId] = current;
return;
}
if (last is not null)
{
school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionEnded, last));
}
if (current is not null)
{
school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionStarted, current));
}
school.LoggedActivity[personId] = current;
}
}