Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14620,6 +14620,18 @@
},
"isAllDay": {
"type": "boolean"
},
"id": {
"type": [
"null",
"string"
]
},
"calendarName": {
"type": [
"null",
"string"
]
}
}
},
Expand Down Expand Up @@ -15640,6 +15652,24 @@
"surfaceId": {
"type": "string",
"default": "today"
},
"rows": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/HabitListCardItem"
}
},
"totalRows": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "int32"
}
}
},
Expand Down
11 changes: 8 additions & 3 deletions src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,11 @@ private async Task<ResponseCards> BuildResponseCardsAsync(
&& executionResults.LastSuccessfulPayload<DailySummaryResponse>("get_daily_summary") is not null)
{
day = await TryBuildDaySummaryCardAsync(request.UserId, context.UserToday,
context.User?.CurrentStreak ?? 0, context.User?.TimeZone, cancellationToken);
context.User?.CurrentStreak ?? 0, context.User?.TimeZone,
HabitListCardBuilder.Build(context.TodayHabits, context.UserToday,
HabitListCardBuilder.ScopeToday, context.TodayFacts,
request.ClientContext.SupportsHabitListDoneStatus == true).Items,
cancellationToken);
}

var streak = request.ClientContext?.SupportsStreakCard == true
Expand All @@ -672,7 +676,8 @@ private async Task<ResponseCards> BuildResponseCardsAsync(
}

private async Task<DaySummaryCard?> TryBuildDaySummaryCardAsync(
Guid userId, DateOnly userToday, int currentStreak, string? timeZone, CancellationToken cancellationToken)
Guid userId, DateOnly userToday, int currentStreak, string? timeZone,
IReadOnlyList<HabitListCardItem> rows, CancellationToken cancellationToken)
{
try
{
Expand All @@ -684,7 +689,7 @@ private async Task<ResponseCards> BuildResponseCardsAsync(
habits.ToList(), userToday, userToday, currentStreak, currentStreak,
TimeZoneHelper.FindTimeZone(timeZone), weekStartDay);
var overdue = habits.Count(h => !h.IsGeneral && !h.IsCompleted && h.DueDate < userToday);
return StatusCardBuilder.BuildDay(userToday, metrics, overdue);
return StatusCardBuilder.BuildDay(userToday, metrics, overdue, rows);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Expand Down
17 changes: 12 additions & 5 deletions src/Orbit.Application/Chat/StatusCards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ namespace Orbit.Application.Chat;

public record DaySummaryCard(
DateOnly Date, int Due, int Done, int? CompletionRate,
int OverdueCount, int CurrentStreak, string SurfaceId = "today");
int OverdueCount, int CurrentStreak, string SurfaceId = "today",
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<HabitListCardItem>? Rows = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? TotalRows = null);

public record StreakAchievement(string Id, string IconKey, DateTime EarnedAt);

Expand All @@ -20,7 +22,9 @@ public record StreakCard(
string SurfaceId = "progress",
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<StreakAchievementDisc>? AchievementDiscs = null);

public record CalendarCardEvent(string Title, string Start, string? End, bool IsAllDay);
public record CalendarCardEvent(string Title, string Start, string? End, bool IsAllDay,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Id = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? CalendarName = null);

public record CalendarCardSync(bool Enabled, string Status, DateTime? LastSyncedAt);

Expand All @@ -38,11 +42,12 @@ public static class StatusCardBuilder
public const string StreakPrompt = "After get_gamification_overview succeeds, write one short line without figures, then emit [[orbit:streak]] last.";
public const string CalendarPrompt = "After get_calendar_overview succeeds, write one short line without listing events, then emit [[orbit:calendar]] last.";

public static DaySummaryCard BuildDay(DateOnly date, RetrospectiveMetrics metrics, int overdueCount) =>
public static DaySummaryCard BuildDay(
DateOnly date, RetrospectiveMetrics metrics, int overdueCount, IReadOnlyList<HabitListCardItem>? rows = null) =>
new(date, metrics.TotalScheduled,
metrics.CompletionSeries?.Points.SingleOrDefault()?.Completed ?? 0,
metrics.TotalScheduled == 0 ? null : metrics.CompletionRate,
overdueCount, metrics.CurrentStreak);
overdueCount, metrics.CurrentStreak, Rows: rows?.Take(20).ToList() ?? [], TotalRows: rows?.Count ?? 0);

public static StreakCard? BuildStreak(GamificationOverviewPayload? payload)
{
Expand Down Expand Up @@ -84,7 +89,9 @@ item.StartTime is null
item.StartTime is null
? null
: item.EndUtc?.ToString("O", CultureInfo.InvariantCulture) ?? item.EndTime,
item.StartTime is null))
item.StartTime is null,
item.Id,
item.CalendarName))
.ToList();
var sync = payload.AutoSyncState is { } state
? new CalendarCardSync(state.Enabled, state.Status.ToString(), state.LastSyncedAt)
Expand Down
139 changes: 137 additions & 2 deletions tests/Orbit.Application.Tests/Chat/StatusCardBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentAssertions;
using System.Text.Json;
using System.Text.Json.Nodes;
using Orbit.Application.Calendar.Queries;
using Orbit.Application.Chat;
using Orbit.Application.Chat.Tools.Implementations;
Expand All @@ -22,7 +23,7 @@ public void Day_ThreeDueOneDone_HasThirtyThreePercent()
habits[0].Log(today, advanceDueDate: false);

var metrics = RetrospectiveMetricsCalculator.Compute(habits, today, today, 2, 4);
var card = StatusCardBuilder.BuildDay(today, metrics, 0);
var card = StatusCardBuilder.BuildDay(today, metrics, 0, []);

card.Due.Should().Be(3);
card.Done.Should().Be(1);
Expand All @@ -35,7 +36,140 @@ public void Day_NothingDue_HasNullRate()
var today = new DateOnly(2026, 9, 25);
var metrics = RetrospectiveMetricsCalculator.Compute([], today, today, 0, 0);

StatusCardBuilder.BuildDay(today, metrics, 0).CompletionRate.Should().BeNull();
var card = StatusCardBuilder.BuildDay(today, metrics, 0, []);

card.Rows.Should().BeEmpty();
card.TotalRows.Should().Be(0);
card.Due.Should().Be(0);
card.Done.Should().Be(0);
card.OverdueCount.Should().Be(0);
card.CompletionRate.Should().BeNull();
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void Day_RowsFollowHabitListOrderAndStatusCapability(bool supportsDoneStatus)
{
var today = new DateOnly(2026, 9, 25);
var userId = Guid.NewGuid();
var done = Habit.Create(new HabitCreateParams(userId, "Done", null, null, today,
Position: 0, Emoji: "✅")).Value;
done.Log(today).IsSuccess.Should().BeTrue();
var due = Habit.Create(new HabitCreateParams(userId, "Due", null, null, today, Position: 1)).Value;
var child = Habit.Create(new HabitCreateParams(userId, "Child", null, null, today,
ParentHabitId: due.Id, Position: 0)).Value;
var overdue = Habit.Create(new HabitCreateParams(userId, "Overdue", null, null,
today.AddDays(-1), Position: 2)).Value;
var bad = Habit.Create(new HabitCreateParams(userId, "Bad", FrequencyUnit.Day, 1, today,
IsBadHabit: true, Position: 3)).Value;
List<Habit> habits = [bad, overdue, child, due, done];
var rows = HabitListCardBuilder.Build(habits, today, HabitListCardBuilder.ScopeToday,
supportsDoneStatus).Items;
var metrics = RetrospectiveMetricsCalculator.Compute(habits, today, today, 0, 0);

var card = StatusCardBuilder.BuildDay(today, metrics, 1, rows);

card.Rows.Should().Equal(rows);
card.Rows!.Select(row => row.Id).Should().Equal(
done.Id.ToString(), due.Id.ToString(), child.Id.ToString(), overdue.Id.ToString(), bad.Id.ToString());
card.Rows.Select(row => row.Status).Should().Equal(
supportsDoneStatus ? "done" : "none", "today", "today", "overdue", "today");
card.Rows[0].Emoji.Should().Be("✅");
card.Rows[2].Depth.Should().Be(1);
card.Rows[4].IsBadHabit.Should().BeTrue();
card.TotalRows.Should().Be(5);
}

[Fact]
public void Day_TwentyFiveRows_ReturnsFirstTwentyAndFullCount()
{
var today = new DateOnly(2026, 9, 25);
var habits = Enumerable.Range(0, 25).Select(index => Habit.Create(new HabitCreateParams(
Guid.NewGuid(), $"Habit {index}", null, null, today, Position: index)).Value).ToList();
var rows = HabitListCardBuilder.Build(habits, today, HabitListCardBuilder.ScopeToday).Items;
var metrics = RetrospectiveMetricsCalculator.Compute(habits, today, today, 0, 0);

var card = StatusCardBuilder.BuildDay(today, metrics, 0, rows);

card.Rows.Should().Equal(rows.Take(20));
card.TotalRows.Should().Be(25);
card.Due.Should().Be(25);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void Day_Serialization_PreservesExistingFields(bool includeRows)
{
var card = new DaySummaryCard(new DateOnly(2026, 9, 25), 3, 1, 33, 2, 4,
Rows: includeRows ? [new HabitListCardItem("habit", "Read", null, 0, false, "today")] : null,
TotalRows: includeRows ? 1 : null);
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var json = JsonSerializer.SerializeToNode(card, options)!.AsObject();
if (!includeRows)
{
json.ContainsKey("rows").Should().BeFalse();
json.ContainsKey("totalRows").Should().BeFalse();
}
json.Remove("rows");
json.Remove("totalRows");
var legacy = JsonNode.Parse("""{"date":"2026-09-25","due":3,"done":1,"completionRate":33,"overdueCount":2,"currentStreak":4,"surfaceId":"today"}""");

JsonNode.DeepEquals(json, legacy).Should().BeTrue();
var deserialized = JsonSerializer.Deserialize<DaySummaryCard>(json.ToJsonString(), options)!;
deserialized.Rows.Should().BeNull();
deserialized.TotalRows.Should().BeNull();
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void CalendarEvent_Serialization_PreservesExistingFields(bool includeMetadata)
{
var card = new CalendarCardEvent("Holiday", "2026-09-25", null, true,
includeMetadata ? "google-event" : null, includeMetadata ? "Work" : null);
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var json = JsonSerializer.SerializeToNode(card, options)!.AsObject();
if (!includeMetadata)
{
json.ContainsKey("id").Should().BeFalse();
json.ContainsKey("calendarName").Should().BeFalse();
}
json.Remove("id");
json.Remove("calendarName");
var legacy = JsonNode.Parse("""{"title":"Holiday","start":"2026-09-25","end":null,"isAllDay":true}""");

JsonNode.DeepEquals(json, legacy).Should().BeTrue();
var deserialized = JsonSerializer.Deserialize<CalendarCardEvent>(json.ToJsonString(), options)!;
deserialized.Id.Should().BeNull();
deserialized.CalendarName.Should().BeNull();
}

[Fact]
public void Calendar_TimedAndAllDayEvents_CopyMetadataAndKeepOrdering()
{
var start = new DateTime(2026, 9, 25, 12, 0, 0, DateTimeKind.Utc);
List<CalendarEventItem> events =
[
new("later", "Later", null, "2026-09-25", "14:00", "15:00", false, null, [],
StartUtc: start.AddHours(2), CalendarName: "Work", EndUtc: start.AddHours(3)),
new("timed", "Meeting", null, "2026-09-25", "12:00", "13:00", false, null, [],
StartUtc: start, CalendarName: "Work", EndUtc: start.AddHours(1)),
new("all-day", "Holiday", null, "2026-09-26", null, null, false, null, [], CalendarName: "Personal"),
new("earlier-all-day", "Day off", null, "2026-09-24", null, null, false, null, [], CalendarName: "Family")
];

var card = StatusCardBuilder.BuildCalendar(new CalendarOverviewPayload(events, null, []))!;

card.Events.Select(item => item.Id).Should().Equal("earlier-all-day", "all-day", "timed", "later");
card.Events.Select(item => item.CalendarName).Should().Equal("Family", "Personal", "Work", "Work");
card.Events[0].IsAllDay.Should().BeTrue();
card.Events[0].Start.Should().Be("2026-09-24");
card.Events[0].End.Should().BeNull();
card.Events[2].IsAllDay.Should().BeFalse();
card.Events[2].Start.Should().Be("2026-09-25T12:00:00.0000000Z");
card.Events[2].End.Should().Be("2026-09-25T13:00:00.0000000Z");
}

[Fact]
Expand All @@ -51,6 +185,7 @@ public void Calendar_TwentyFiveEvents_CapsAtTenAndOmitsGatedSync()
var card = StatusCardBuilder.BuildCalendar(payload)!;

card.Events.Should().HaveCount(10);
card.Events.Select(item => item.Id).Should().Equal(events.Take(10).Select(item => item.Id));
card.Sync.Should().BeNull();
card.Events[0].IsAllDay.Should().BeTrue();
JsonSerializer.Serialize(card, new JsonSerializerOptions(JsonSerializerDefaults.Web))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,70 @@ public async Task Handle_SuccessfulDailySummary_BuildsCardFromLoggedSchedule()
result.Value.DaySummary.CompletionRate.Should().Be(67);
}

[Theory]
[InlineData(true, "done")]
[InlineData(false, "none")]
[InlineData(null, "none")]
public async Task Handle_DaySummaryRows_MatchTodayHabitListAndScheduleFacts(
bool? supportsDoneStatus, string doneStatus)
{
SetupUserAndPayGate();
var done = Habit.Create(new HabitCreateParams(UserId, "Done", FrequencyUnit.Day, 1, Today,
Position: 0, CreatedAtUtc: new DateTime(2026, 4, 1, 12, 0, 0, DateTimeKind.Utc))).Value;
done.Log(Today).IsSuccess.Should().BeTrue();
var due = Habit.Create(new HabitCreateParams(UserId, "Due", null, null, Today, Position: 1)).Value;
var overdue = Habit.Create(new HabitCreateParams(UserId, "Overdue", null, null,
Today.AddDays(-1), Position: 2)).Value;
var future = Habit.Create(new HabitCreateParams(UserId, "Future", null, null,
Today.AddDays(1), Position: 3)).Value;
_habitRepo.FindAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(),
Arg.Any<CancellationToken>())
.Returns(new List<Habit> { future, overdue, due, done }.AsReadOnly());
_scheduleLogReader.ReadDaysAsync(
Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateOnly>(), Arg.Any<DateOnly>(),
Arg.Any<CancellationToken>())
.Returns([new HabitScheduleLogDay(done.Id, Today, 1, 0, true)]);
var tool = ReadTool("get_daily_summary", new ToolResult(true,
Payload: new DailySummaryResponse("Summary", string.Empty, false)));
SetupReadToolReply("get_daily_summary", "Today [[orbit:day]] [[orbit:habits:today]]");

var result = await CreateHandler(tool).Handle(new ProcessUserChatCommand(
UserId, "How is today?", ClientContext: new AgentClientContext(
SupportsDaySummaryCard: true, SupportsHabitListCard: true,
SupportsHabitListDoneStatus: supportsDoneStatus)), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.DaySummary.Should().NotBeNull();
result.Value.DaySummary!.Rows.Should().Equal(result.Value.HabitList!.Items);
result.Value.DaySummary.Rows!.Select(row => row.Id).Should().Equal(
done.Id.ToString(), due.Id.ToString(), overdue.Id.ToString());
result.Value.DaySummary.Rows.Select(row => row.Status).Should().Equal(doneStatus, "today", "overdue");
result.Value.DaySummary.TotalRows.Should().Be(3);
}

[Fact]
public async Task Handle_DaySummaryWithNothingDue_ReturnsEmptyRows()
{
SetupUserAndPayGate();
var tool = ReadTool("get_daily_summary", new ToolResult(true,
Payload: new DailySummaryResponse("Summary", string.Empty, false)));
SetupReadToolReply("get_daily_summary", "Today [[orbit:day]]");

var result = await CreateHandler(tool).Handle(new ProcessUserChatCommand(
UserId, "How is today?", ClientContext: new AgentClientContext(SupportsDaySummaryCard: true)),
CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.DaySummary!.Rows.Should().BeEmpty();
result.Value.DaySummary.TotalRows.Should().Be(0);
result.Value.DaySummary.Due.Should().Be(0);
result.Value.DaySummary.Done.Should().Be(0);
result.Value.DaySummary.OverdueCount.Should().Be(0);
result.Value.DaySummary.CompletionRate.Should().BeNull();
}

[Fact]
public async Task Handle_OverdueOffCadenceLog_BuildsCompletedDayCard()
{
Expand Down
Loading