Skip to content

Commit f74a5dd

Browse files
committed
Merge origin/main into paper/1019-provenance
Resolve conflicts from #1032 (streak), #1033 (side-effects), and #1038 (similar-past) merges into main. - AutomationProposalsController: keep provenance endpoint from this branch alongside side-effects and similar-past endpoints from main; inject both IProvenanceQueryService and ISideEffectAnalyzer. - ApplicationServiceRegistration: register both IProvenanceQueryService and ISideEffectAnalyzer in the DI container. - smoke.spec.ts: take main's improved addColumn helper that waits for the POST response and dialog close.
2 parents 7e21ce0 + caa9f2b commit f74a5dd

49 files changed

Lines changed: 4208 additions & 14 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,26 @@ public class AutomationProposalsController : AuthenticatedControllerBase
2626

2727
private readonly IAutomationProposalService _proposalService;
2828
private readonly IAutomationExecutorService _executorService;
29+
private readonly ISimilarDecisionService _similarDecisionService;
2930
private readonly BoardAuthorizationService _authorizationService;
3031
private readonly IProvenanceQueryService _provenanceQueryService;
32+
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;
3133

3234
public AutomationProposalsController(
3335
IAutomationProposalService proposalService,
3436
IAutomationExecutorService executorService,
37+
ISimilarDecisionService similarDecisionService,
3538
BoardAuthorizationService authorizationService,
3639
IProvenanceQueryService provenanceQueryService,
40+
ISideEffectAnalyzer sideEffectAnalyzer,
3741
IUserContext userContext) : base(userContext)
3842
{
3943
_proposalService = proposalService;
4044
_executorService = executorService;
45+
_similarDecisionService = similarDecisionService;
4146
_authorizationService = authorizationService;
4247
_provenanceQueryService = provenanceQueryService;
48+
_sideEffectAnalyzer = sideEffectAnalyzer;
4349
}
4450

4551
/// <summary>
@@ -264,6 +270,24 @@ public async Task<IActionResult> DismissProposals(
264270
: result.ToErrorActionResult();
265271
}
266272

273+
/// <summary>
274+
/// Gets the side-effect analysis for a proposal, including the 7-category breakdown
275+
/// and reversibility posture.
276+
/// </summary>
277+
[HttpGet("{id}/side-effects")]
278+
public async Task<IActionResult> GetProposalSideEffects(Guid id, CancellationToken cancellationToken = default)
279+
{
280+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
281+
return errorResult!;
282+
283+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
284+
if (auth.ErrorResult is not null)
285+
return auth.ErrorResult;
286+
287+
var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken);
288+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
289+
}
290+
267291
/// <summary>
268292
/// Gets a diff preview for a proposal showing what changes will be made.
269293
/// </summary>
@@ -299,6 +323,24 @@ public async Task<IActionResult> GetProposalProvenance(Guid id, CancellationToke
299323
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
300324
}
301325

326+
/// <summary>
327+
/// Gets similar past decisions for a proposal, including the latest 3 decisions
328+
/// with the same action class and an aggregate apply rate.
329+
/// </summary>
330+
[HttpGet("{id}/similar-past")]
331+
public async Task<IActionResult> GetSimilarPast(Guid id, CancellationToken cancellationToken = default)
332+
{
333+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
334+
return errorResult!;
335+
336+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
337+
if (auth.ErrorResult is not null)
338+
return auth.ErrorResult;
339+
340+
var result = await _similarDecisionService.GetSimilarPastAsync(id, callerUserId, cancellationToken);
341+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
342+
}
343+
302344
private async Task<(ProposalDto? Proposal, IActionResult? ErrorResult)> AuthorizeProposalAsync(
303345
Guid proposalId,
304346
Guid callerUserId,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Taskdeck.Api.Contracts;
4+
using Taskdeck.Api.Extensions;
5+
using Taskdeck.Application.DTOs;
6+
using Taskdeck.Application.Interfaces;
7+
using Taskdeck.Application.Services;
8+
9+
namespace Taskdeck.Api.Controllers;
10+
11+
/// <summary>
12+
/// Today-view endpoints: streak data, cadence aggregation, and daily dossier data.
13+
/// </summary>
14+
[ApiController]
15+
[Authorize]
16+
[Route("api/[controller]")]
17+
[Produces("application/json")]
18+
public class TodayController : AuthenticatedControllerBase
19+
{
20+
private readonly IStreakService _streakService;
21+
private readonly ICadenceService _cadenceService;
22+
23+
public TodayController(
24+
IStreakService streakService,
25+
ICadenceService cadenceService,
26+
IUserContext userContext)
27+
: base(userContext)
28+
{
29+
_streakService = streakService;
30+
_cadenceService = cadenceService;
31+
}
32+
33+
/// <summary>
34+
/// Get streak data (daily activity intensity and sealed status) for the authenticated user.
35+
/// </summary>
36+
/// <param name="days">Number of days to include (1-365, default 90).</param>
37+
/// <param name="cancellationToken">Cancellation token.</param>
38+
/// <returns>Streak data with daily intensity buckets and streak lengths.</returns>
39+
/// <response code="200">Streak data returned successfully.</response>
40+
/// <response code="400">Invalid days parameter.</response>
41+
/// <response code="401">Authentication required.</response>
42+
[HttpGet("streak")]
43+
[ProducesResponseType(typeof(StreakResponse), StatusCodes.Status200OK)]
44+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
45+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
46+
public async Task<IActionResult> GetStreak(
47+
[FromQuery] int days = 90,
48+
CancellationToken cancellationToken = default)
49+
{
50+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
51+
return errorResult!;
52+
53+
var result = await _streakService.GetStreakAsync(userId, days, cancellationToken);
54+
55+
if (!result.IsSuccess)
56+
return result.ToErrorActionResult();
57+
58+
var streakResult = result.Value;
59+
var response = new StreakResponse(
60+
streakResult.Days.Select(d => new StreakDayResponse(d.Date, d.IsSealed, d.IntensityBucket)).ToList(),
61+
streakResult.CurrentStreakLength,
62+
streakResult.LongestStreakLength,
63+
streakResult.Days.Count);
64+
65+
return Ok(response);
66+
}
67+
68+
/// <summary>
69+
/// Get the per-hour cadence snapshot for the authenticated user on the specified date.
70+
/// Returns 24 hourly buckets with event counts plus first/peak/last action timestamps.
71+
/// </summary>
72+
/// <param name="date">Date to aggregate (ISO 8601). Defaults to today (UTC).</param>
73+
/// <param name="cancellationToken">Cancellation token.</param>
74+
/// <returns>Cadence snapshot for the day.</returns>
75+
/// <response code="200">Cadence snapshot returned successfully.</response>
76+
/// <response code="400">Invalid date parameter.</response>
77+
/// <response code="401">Authentication required.</response>
78+
[HttpGet("cadence")]
79+
[ProducesResponseType(typeof(CadenceResponse), StatusCodes.Status200OK)]
80+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
81+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
82+
public async Task<IActionResult> GetCadence(
83+
[FromQuery] DateTimeOffset? date,
84+
CancellationToken cancellationToken)
85+
{
86+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
87+
return errorResult!;
88+
89+
var targetDate = date ?? DateTimeOffset.UtcNow;
90+
91+
var result = await _cadenceService.GetDailyCadenceAsync(userId, targetDate, cancellationToken);
92+
93+
if (!result.IsSuccess)
94+
return result.ToErrorActionResult();
95+
96+
var snapshot = result.Value;
97+
var response = new CadenceResponse(
98+
Buckets: snapshot.Buckets.Select(b => new CadenceBucketDto(b.Hour, b.EventCount)).ToList(),
99+
FirstActionAt: snapshot.FirstActionAt,
100+
PeakHour: snapshot.PeakHour,
101+
LastActionAt: snapshot.LastActionAt);
102+
103+
return Ok(response);
104+
}
105+
}

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
4848
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
4949
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
5050
services.AddScoped<IProvenanceQueryService, ProvenanceQueryService>();
51+
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();
5152
services.AddScoped<IProposalRevisionService, ProposalRevisionService>();
53+
services.AddScoped<ISimilarDecisionService, SimilarDecisionService>();
5254
services.AddScoped<IAutomationPolicyEngine, AutomationPolicyEngine>();
5355
services.AddScoped<IAutomationPlannerService, AutomationPlannerService>();
5456
services.AddScoped<IAutomationExecutorService, AutomationExecutorService>();
@@ -57,6 +59,8 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
5759
services.AddScoped<IBoardContextBuilder, BoardContextBuilder>();
5860
services.AddScoped<IChatService, ChatService>();
5961
services.AddScoped<ILogQueryService, LogQueryService>();
62+
services.AddScoped<ICadenceService>(sp =>
63+
new CadenceService(sp.GetRequiredService<IUnitOfWork>().AuditLogs));
6064
services.AddScoped<INotificationService, NotificationService>();
6165
services.AddScoped<IKnowledgeService, KnowledgeService>();
6266
services.AddScoped<IWorkspaceService, WorkspaceService>();
@@ -74,6 +78,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
7478
new BoardMetricsService(
7579
sp.GetRequiredService<IUnitOfWork>(),
7680
sp.GetRequiredService<IAuthorizationService>()));
81+
services.AddScoped<IStreakService, StreakService>();
7782
services.AddScoped<ApiKeyService>();
7883
services.AddScoped<IForecastingService>(sp =>
7984
new ForecastingService(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
/// <summary>
4+
/// A single hour bucket in the cadence response.
5+
/// </summary>
6+
public sealed record CadenceBucketDto(int Hour, int EventCount);
7+
8+
/// <summary>
9+
/// API response DTO for daily cadence aggregation.
10+
/// Contains 24 per-hour buckets and first/peak/last action metadata.
11+
/// </summary>
12+
public sealed record CadenceResponse(
13+
IReadOnlyList<CadenceBucketDto> Buckets,
14+
DateTimeOffset? FirstActionAt,
15+
int? PeakHour,
16+
DateTimeOffset? LastActionAt);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
public record SideEffectRowDto(
4+
string Key,
5+
string Value,
6+
string Tone // "active" | "passive"
7+
);
8+
9+
public record ReversibilityDto(
10+
string Summary,
11+
string Description,
12+
long WindowMs
13+
);
14+
15+
public record ProposalSideEffectsDto(
16+
IReadOnlyList<SideEffectRowDto> Rows,
17+
ReversibilityDto Reversibility
18+
);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
/// <summary>
4+
/// DTO for a single similar past decision.
5+
/// </summary>
6+
public record SimilarPastDecisionDto(
7+
string Serial,
8+
string Title,
9+
string Verdict,
10+
string Date);
11+
12+
/// <summary>
13+
/// DTO for the aggregated similar past result.
14+
/// </summary>
15+
public record SimilarPastResultDto(
16+
IReadOnlyList<SimilarPastDecisionDto> Decisions,
17+
double ApplyRate);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
/// <summary>
4+
/// Response DTO for a single day in the streak grid.
5+
/// </summary>
6+
public sealed record StreakDayResponse(
7+
DateOnly Date,
8+
bool IsSealed,
9+
int IntensityBucket);
10+
11+
/// <summary>
12+
/// Response DTO for the full streak query result.
13+
/// </summary>
14+
public sealed record StreakResponse(
15+
IReadOnlyList<StreakDayResponse> Days,
16+
int CurrentStreakLength,
17+
int LongestStreakLength,
18+
int DayCount);

backend/src/Taskdeck.Application/Interfaces/IAuditLogRepository.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33

44
namespace Taskdeck.Application.Interfaces;
55

6+
/// <summary>
7+
/// Lightweight projection record for per-day audit entry counts.
8+
/// Used by <see cref="IAuditLogRepository.CountByDateAsync"/> to avoid loading full entities.
9+
/// </summary>
10+
public sealed record DailyAuditCount(DateOnly Date, int Count);
11+
612
public interface IAuditLogRepository : IRepository<AuditLog>
713
{
814
Task<IEnumerable<AuditLog>> GetByEntityAsync(string entityType, Guid entityId, int limit = 100, CancellationToken cancellationToken = default);
@@ -18,6 +24,21 @@ Task<IEnumerable<AuditLog>> QueryAsync(
1824
int limit = 100,
1925
CancellationToken cancellationToken = default);
2026

27+
/// <summary>
28+
/// Returns per-day entry counts for a user within a time range.
29+
/// Uses a server-side GROUP BY projection to avoid loading full entities into memory.
30+
/// </summary>
31+
/// <param name="from">Start of the time range (inclusive).</param>
32+
/// <param name="to">End of the time range (inclusive).</param>
33+
/// <param name="userId">The user whose entries to count.</param>
34+
/// <param name="cancellationToken">Cancellation token.</param>
35+
/// <returns>A list of (Date, Count) pairs for days with at least one entry.</returns>
36+
Task<IReadOnlyList<DailyAuditCount>> CountByDateAsync(
37+
DateTimeOffset from,
38+
DateTimeOffset to,
39+
Guid userId,
40+
CancellationToken cancellationToken = default);
41+
2142
/// <summary>
2243
/// Deletes audit log entries older than the specified cutoff date in batches.
2344
/// Uses direct SQL DELETE for efficiency (does not load entities into memory).

backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,16 @@ public interface IAutomationProposalRepository : IRepository<AutomationProposal>
2121
ProposalSourceType sourceType,
2222
CancellationToken cancellationToken = default);
2323
Task<IEnumerable<AutomationProposal>> GetExpiredAsync(CancellationToken cancellationToken = default);
24+
25+
/// <summary>
26+
/// Gets proposals that have at least one operation matching the given action type
27+
/// and are in a terminal state (Applied or Rejected), ordered by most recent first.
28+
/// Optionally scoped to a specific board. Limited to a lookback window for performance.
29+
/// </summary>
30+
Task<IReadOnlyList<AutomationProposal>> GetTerminalByActionTypeAsync(
31+
string actionType,
32+
Guid? boardId,
33+
Guid userId,
34+
int limit = 100,
35+
CancellationToken cancellationToken = default);
2436
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Taskdeck.Domain.Common;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
/// <summary>
6+
/// Provides daily cadence aggregation: per-hour activity buckets
7+
/// derived from audit log entries for a given user and day.
8+
/// </summary>
9+
public interface ICadenceService
10+
{
11+
/// <summary>
12+
/// Compute the per-hour cadence snapshot for the given user on the specified date.
13+
/// Returns 24 buckets (hours 0-23) with event counts, plus first/peak/last timestamps.
14+
/// </summary>
15+
/// <param name="userId">The user whose activity to aggregate.</param>
16+
/// <param name="date">The date to aggregate (only the date portion is used).</param>
17+
/// <param name="cancellationToken">Cancellation token.</param>
18+
/// <returns>A cadence snapshot for the day, or an empty snapshot if no activity.</returns>
19+
Task<Result<CadenceSnapshot>> GetDailyCadenceAsync(
20+
Guid userId,
21+
DateTimeOffset date,
22+
CancellationToken cancellationToken = default);
23+
}

0 commit comments

Comments
 (0)