Add GitHub-style activity contribution graph
CI / build-and-push (push) Successful in 52s
CI / deploy (push) Successful in 9s

Record create/update/delete events across novel content (chapters, beats,
characters, arc stages, tags, locations, questions) into an append-only
ActivityEvent log, aggregate by UTC day, and surface as a heatmap on the
novels list and each novel's dashboard. Backfills history from existing
CreatedAt timestamps on first boot after the migration.
This commit is contained in:
James Wampler
2026-08-19 17:39:13 -07:00
parent 51f3176bd0
commit 71953220aa
28 changed files with 2113 additions and 20 deletions
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -13,6 +14,7 @@ public class ChapterService(
NovelAccessService access,
TagService tags,
LocationService locations,
ActivityLog activity,
ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
@@ -90,6 +92,7 @@ public class ChapterService(
}
db.Chapters.Add(chapter);
activity.Record(novelId, ActivityEntityKind.Chapter, ActivityAction.Created, chapter.Id, chapter.WordCount);
await db.SaveChangesAsync(ct);
return (await FindAsync(chapter.Id, ct))!;
@@ -118,6 +121,8 @@ public class ChapterService(
chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
var wordCountBeforeEdit = chapter.WordCount;
if (request.Prose is not null)
{
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
@@ -136,6 +141,7 @@ public class ChapterService(
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
}
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Updated, chapter.Id, chapter.WordCount - wordCountBeforeEdit);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -155,6 +161,7 @@ public class ChapterService(
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
db.Chapters.Remove(chapter);
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
await db.SaveChangesAsync(ct);
return true;
}