Skip to content

Commit 9382c4c

Browse files
committed
Merge origin/main into paper/1018-line-for-tomorrow
Combines streak (#1032), side-effects (#1033), and similar-past (#1038) from main with the tomorrow-note endpoints on this branch. Resolved smoke.spec.ts conflict by taking main's version (addColumn fix).
2 parents 75ac7c8 + caa9f2b commit 9382c4c

40 files changed

Lines changed: 3325 additions & 1 deletion

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,23 @@ 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;
31+
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;
3032

3133
public AutomationProposalsController(
3234
IAutomationProposalService proposalService,
3335
IAutomationExecutorService executorService,
36+
ISimilarDecisionService similarDecisionService,
3437
BoardAuthorizationService authorizationService,
38+
ISideEffectAnalyzer sideEffectAnalyzer,
3539
IUserContext userContext) : base(userContext)
3640
{
3741
_proposalService = proposalService;
3842
_executorService = executorService;
43+
_similarDecisionService = similarDecisionService;
3944
_authorizationService = authorizationService;
45+
_sideEffectAnalyzer = sideEffectAnalyzer;
4046
}
4147

4248
/// <summary>
@@ -261,6 +267,24 @@ public async Task<IActionResult> DismissProposals(
261267
: result.ToErrorActionResult();
262268
}
263269

270+
/// <summary>
271+
/// Gets the side-effect analysis for a proposal, including the 7-category breakdown
272+
/// and reversibility posture.
273+
/// </summary>
274+
[HttpGet("{id}/side-effects")]
275+
public async Task<IActionResult> GetProposalSideEffects(Guid id, CancellationToken cancellationToken = default)
276+
{
277+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
278+
return errorResult!;
279+
280+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
281+
if (auth.ErrorResult is not null)
282+
return auth.ErrorResult;
283+
284+
var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken);
285+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
286+
}
287+
264288
/// <summary>
265289
/// Gets a diff preview for a proposal showing what changes will be made.
266290
/// </summary>
@@ -278,6 +302,24 @@ public async Task<IActionResult> GetProposalDiff(Guid id, CancellationToken canc
278302
return result.IsSuccess ? Ok(new { diff = result.Value }) : result.ToErrorActionResult();
279303
}
280304

305+
/// <summary>
306+
/// Gets similar past decisions for a proposal, including the latest 3 decisions
307+
/// with the same action class and an aggregate apply rate.
308+
/// </summary>
309+
[HttpGet("{id}/similar-past")]
310+
public async Task<IActionResult> GetSimilarPast(Guid id, CancellationToken cancellationToken = default)
311+
{
312+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
313+
return errorResult!;
314+
315+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
316+
if (auth.ErrorResult is not null)
317+
return auth.ErrorResult;
318+
319+
var result = await _similarDecisionService.GetSimilarPastAsync(id, callerUserId, cancellationToken);
320+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
321+
}
322+
281323
private async Task<(ProposalDto? Proposal, IActionResult? ErrorResult)> AuthorizeProposalAsync(
282324
Guid proposalId,
283325
Guid callerUserId,

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,65 @@
99
namespace Taskdeck.Api.Controllers;
1010

1111
/// <summary>
12-
/// Today-view endpoints: cadence aggregation and daily dossier data.
12+
/// Today-view endpoints: streak data, cadence aggregation, and daily dossier data.
1313
/// </summary>
1414
[ApiController]
1515
[Authorize]
1616
[Route("api/[controller]")]
1717
[Produces("application/json")]
1818
public class TodayController : AuthenticatedControllerBase
1919
{
20+
private readonly IStreakService _streakService;
2021
private readonly ICadenceService _cadenceService;
2122
private readonly ITomorrowNoteService _tomorrowNoteService;
2223

2324
public TodayController(
25+
IStreakService streakService,
2426
ICadenceService cadenceService,
2527
ITomorrowNoteService tomorrowNoteService,
2628
IUserContext userContext)
2729
: base(userContext)
2830
{
31+
_streakService = streakService;
2932
_cadenceService = cadenceService;
3033
_tomorrowNoteService = tomorrowNoteService;
3134
}
3235

36+
/// <summary>
37+
/// Get streak data (daily activity intensity and sealed status) for the authenticated user.
38+
/// </summary>
39+
/// <param name="days">Number of days to include (1-365, default 90).</param>
40+
/// <param name="cancellationToken">Cancellation token.</param>
41+
/// <returns>Streak data with daily intensity buckets and streak lengths.</returns>
42+
/// <response code="200">Streak data returned successfully.</response>
43+
/// <response code="400">Invalid days parameter.</response>
44+
/// <response code="401">Authentication required.</response>
45+
[HttpGet("streak")]
46+
[ProducesResponseType(typeof(StreakResponse), StatusCodes.Status200OK)]
47+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
48+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
49+
public async Task<IActionResult> GetStreak(
50+
[FromQuery] int days = 90,
51+
CancellationToken cancellationToken = default)
52+
{
53+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
54+
return errorResult!;
55+
56+
var result = await _streakService.GetStreakAsync(userId, days, cancellationToken);
57+
58+
if (!result.IsSuccess)
59+
return result.ToErrorActionResult();
60+
61+
var streakResult = result.Value;
62+
var response = new StreakResponse(
63+
streakResult.Days.Select(d => new StreakDayResponse(d.Date, d.IsSealed, d.IntensityBucket)).ToList(),
64+
streakResult.CurrentStreakLength,
65+
streakResult.LongestStreakLength,
66+
streakResult.Days.Count);
67+
68+
return Ok(response);
69+
}
70+
3371
/// <summary>
3472
/// Get the per-hour cadence snapshot for the authenticated user on the specified date.
3573
/// Returns 24 hourly buckets with event counts plus first/peak/last action timestamps.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
4747
services.AddScoped<HistoryService>();
4848
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
4949
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
50+
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();
5051
services.AddScoped<IProposalRevisionService, ProposalRevisionService>();
52+
services.AddScoped<ISimilarDecisionService, SimilarDecisionService>();
5153
services.AddScoped<IAutomationPolicyEngine, AutomationPolicyEngine>();
5254
services.AddScoped<IAutomationPlannerService, AutomationPlannerService>();
5355
services.AddScoped<IAutomationExecutorService, AutomationExecutorService>();
@@ -76,6 +78,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
7678
new BoardMetricsService(
7779
sp.GetRequiredService<IUnitOfWork>(),
7880
sp.GetRequiredService<IAuthorizationService>()));
81+
services.AddScoped<IStreakService, StreakService>();
7982
services.AddScoped<ApiKeyService>();
8083
services.AddScoped<IForecastingService>(sp =>
8184
new ForecastingService(
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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
/// <summary>
7+
/// Analyzes a proposal's operations to produce a 7-category side-effect breakdown
8+
/// and a reversibility posture.
9+
/// </summary>
10+
public interface ISideEffectAnalyzer
11+
{
12+
/// <summary>
13+
/// Analyzes the side effects of the specified proposal.
14+
/// </summary>
15+
Task<Result<ProposalSideEffectsDto>> AnalyzeAsync(Guid proposalId, CancellationToken cancellationToken = default);
16+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
/// <summary>
7+
/// Surfaces similar past proposal decisions for a given proposal,
8+
/// providing reviewers with a historical base rate.
9+
/// </summary>
10+
public interface ISimilarDecisionService
11+
{
12+
/// <summary>
13+
/// Gets the most recent similar past decisions for a proposal,
14+
/// matching on the proposal's primary action class.
15+
/// </summary>
16+
Task<Result<SimilarPastResultDto>> GetSimilarPastAsync(
17+
Guid proposalId,
18+
Guid userId,
19+
CancellationToken cancellationToken = default);
20+
}

0 commit comments

Comments
 (0)