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
@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Activity;
public static class ActivityBackfill
{
public static async Task RunAsync(INovelDbContext db, ILogger logger, CancellationToken ct = default)
{
if (await db.ActivityEvents.AnyAsync(ct))
{
return;
}
logger.LogInformation("Backfilling activity events from existing rows");
var events = new List<ActivityEvent>();
var novels = await db.Novels.AsNoTracking().Select(n => new { n.Id, n.CreatedAt }).ToListAsync(ct);
events.AddRange(novels.Select(n => Backfilled(n.Id, ActivityEntityKind.Novel, n.Id, n.CreatedAt)));
var chapters = await db.Chapters.AsNoTracking().Select(c => new { c.Id, c.NovelId, c.CreatedAt, c.WordCount }).ToListAsync(ct);
events.AddRange(chapters.Select(c => Backfilled(c.NovelId, ActivityEntityKind.Chapter, c.Id, c.CreatedAt, c.WordCount)));
var characters = await db.Characters.AsNoTracking().Select(c => new { c.Id, c.NovelId, c.CreatedAt }).ToListAsync(ct);
events.AddRange(characters.Select(c => Backfilled(c.NovelId, ActivityEntityKind.Character, c.Id, c.CreatedAt)));
var arcStages = await db.CharacterArcStages.AsNoTracking().Select(s => new { s.Id, s.CreatedAt, NovelId = s.Character!.NovelId }).ToListAsync(ct);
events.AddRange(arcStages.Select(s => Backfilled(s.NovelId, ActivityEntityKind.ArcStage, s.Id, s.CreatedAt)));
var beats = await db.Beats.AsNoTracking().Select(b => new { b.Id, b.CreatedAt, NovelId = b.Chapter!.NovelId }).ToListAsync(ct);
events.AddRange(beats.Select(b => Backfilled(b.NovelId, ActivityEntityKind.Beat, b.Id, b.CreatedAt)));
var tags = await db.Tags.AsNoTracking().Select(t => new { t.Id, t.NovelId, t.CreatedAt }).ToListAsync(ct);
events.AddRange(tags.Select(t => Backfilled(t.NovelId, ActivityEntityKind.Tag, t.Id, t.CreatedAt)));
var locations = await db.Locations.AsNoTracking().Select(l => new { l.Id, l.NovelId, l.CreatedAt }).ToListAsync(ct);
events.AddRange(locations.Select(l => Backfilled(l.NovelId, ActivityEntityKind.Location, l.Id, l.CreatedAt)));
var questions = await db.OpenQuestions.AsNoTracking().Select(q => new { q.Id, q.NovelId, q.CreatedAt }).ToListAsync(ct);
events.AddRange(questions.Select(q => Backfilled(q.NovelId, ActivityEntityKind.Question, q.Id, q.CreatedAt)));
if (events.Count == 0)
{
return;
}
db.ActivityEvents.AddRange(events);
await db.SaveChangesAsync(ct);
logger.LogInformation("Backfilled {Count} activity events", events.Count);
}
private static ActivityEvent Backfilled(Guid novelId, ActivityEntityKind kind, Guid entityId, DateTimeOffset occurredAt, int wordDelta = 0) =>
new()
{
NovelId = novelId,
UserId = null,
OccurredAt = occurredAt,
DayKey = ActivityDayKey.For(occurredAt),
EntityKind = kind,
Action = ActivityAction.Created,
EntityId = entityId,
WordDelta = wordDelta
};
}
@@ -0,0 +1,5 @@
namespace Novelly.Api.Activity;
public record ActivityDayResponse(DateOnly Date, int Words, int Edits);
public record ActivityCalendarResponse(DateOnly From, DateOnly To, int TotalWords, int TotalEdits, IReadOnlyList<ActivityDayResponse> Days);
@@ -0,0 +1,28 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Activity;
public static class ActivityEndpoints
{
public static IEndpointRouteBuilder MapActivityEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/activity").WithTags("Activity")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, int? days, ActivityService service, CancellationToken ct) =>
Results.Ok(await service.GetForNovelAsync(novelId, days, ct)))
.WithSummary("Get a novel's daily activity calendar.");
var mine = app.MapGroup("/api/activity").WithTags("Activity")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
mine.MapGet("/", async (int? days, ActivityService service, CancellationToken ct) =>
Results.Ok(await service.GetForCurrentUserAsync(days, ct)))
.WithSummary("Get the current user's daily activity calendar across every visible novel.");
return app;
}
}
+73
View File
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Novels;
using Novelly.Api.Users;
namespace Novelly.Api.Activity;
public enum ActivityEntityKind
{
Novel,
Chapter,
Beat,
Character,
ArcStage,
Tag,
Location,
Question
}
public enum ActivityAction
{
Created,
Updated,
Deleted
}
public class ActivityEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
public Novel? Novel { get; set; }
public Guid? UserId { get; set; }
public NovellyUser? User { get; set; }
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
public int DayKey { get; set; } = ActivityDayKey.For(DateTimeOffset.UtcNow);
public ActivityEntityKind EntityKind { get; set; }
public ActivityAction Action { get; set; }
public Guid EntityId { get; set; }
public int WordDelta { get; set; }
}
public static class ActivityDayKey
{
public static int For(DateTimeOffset occurredAt) =>
occurredAt.UtcDateTime.Year * 10000 + occurredAt.UtcDateTime.Month * 100 + occurredAt.UtcDateTime.Day;
public static int For(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
public static DateOnly ToDate(int dayKey) => new(dayKey / 10000, dayKey / 100 % 100, dayKey % 100);
}
public class ActivityEventEntityTypeConfiguration : IEntityTypeConfiguration<ActivityEvent>
{
public void Configure(EntityTypeBuilder<ActivityEvent> entity)
{
entity.Property(e => e.EntityKind).HasConversion<string>().HasMaxLength(32);
entity.Property(e => e.Action).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(e => new { e.NovelId, e.DayKey });
entity.HasIndex(e => new { e.UserId, e.DayKey });
entity.HasOne(e => e.Novel).WithMany()
.HasForeignKey(e => e.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.SetNull);
}
}
+28
View File
@@ -0,0 +1,28 @@
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Activity;
public class ActivityLog(INovelDbContext db, INovelUserContext userContext, ILogger<ActivityLog> logger)
{
public void Record(Guid novelId, ActivityEntityKind kind, ActivityAction action, Guid entityId, int wordDelta = 0)
{
var occurredAt = DateTimeOffset.UtcNow;
logger.LogDebug(
"Recording activity {Action} on {EntityKind} {EntityId} for novel {NovelId}, word delta {WordDelta}",
action, kind, entityId, novelId, wordDelta);
db.ActivityEvents.Add(new ActivityEvent
{
NovelId = novelId,
UserId = userContext.UserId,
OccurredAt = occurredAt,
DayKey = ActivityDayKey.For(occurredAt),
EntityKind = kind,
Action = action,
EntityId = entityId,
WordDelta = wordDelta
});
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Activity;
public class ActivityService(INovelDbContext db, NovelAccessService access, ILogger<ActivityService> logger)
{
private const int MinDays = 1;
private const int MaxDays = 400;
private const int DefaultDays = 365;
public async Task<ActivityCalendarResponse> GetForNovelAsync(Guid novelId, int? days, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Getting activity calendar for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await BuildCalendarAsync(db.ActivityEvents.Where(e => e.NovelId == novelId), days, ct);
}
public async Task<ActivityCalendarResponse> GetForCurrentUserAsync(int? days, CancellationToken ct = default)
{
logger.LogInformation("Getting activity calendar for current user");
var visibleNovelIds = await access.VisibleNovels().Select(n => n.Id).ToListAsync(ct);
return await BuildCalendarAsync(db.ActivityEvents.Where(e => visibleNovelIds.Contains(e.NovelId)), days, ct);
}
private static async Task<ActivityCalendarResponse> BuildCalendarAsync(
IQueryable<ActivityEvent> query, int? requestedDays, CancellationToken ct)
{
var days = Math.Clamp(requestedDays ?? DefaultDays, MinDays, MaxDays);
var to = DateOnly.FromDateTime(DateTime.UtcNow);
var from = to.AddDays(-(days - 1));
var fromKey = ActivityDayKey.For(from);
var grouped = await query
.Where(e => e.DayKey >= fromKey)
.GroupBy(e => e.DayKey)
.Select(g => new { DayKey = g.Key, Words = g.Sum(e => e.WordDelta), Edits = g.Count() })
.ToListAsync(ct);
var responseDays = grouped
.OrderBy(g => g.DayKey)
.Select(g => new ActivityDayResponse(ActivityDayKey.ToDate(g.DayKey), g.Words, g.Edits))
.ToList();
return new ActivityCalendarResponse(
from,
to,
responseDays.Sum(d => d.Words),
responseDays.Sum(d => d.Edits),
responseDays);
}
}
+9 -1
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
@@ -13,6 +14,7 @@ public class BeatService(
INovelDbContext db,
NovelAccessService access,
TagService tags,
ActivityLog activity,
ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator,
@@ -119,6 +121,7 @@ public class BeatService(
chapter.UpdatedAt = DateTimeOffset.UtcNow;
db.Beats.Add(beat);
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Created, beat.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(beat.Id, ct))!;
@@ -164,6 +167,7 @@ public class BeatService(
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
}
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Updated, beat.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -183,7 +187,11 @@ public class BeatService(
await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
if (chapter is not null)
{
chapter.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Deleted, beat.Id);
}
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
@@ -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;
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -9,6 +10,7 @@ namespace Novelly.Api.Characters;
public class CharacterArcService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator,
@@ -76,6 +78,7 @@ public class CharacterArcService(
};
db.CharacterArcStages.Add(stage);
activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Created, stage.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(stage.Id, ct))!;
@@ -112,6 +115,7 @@ public class CharacterArcService(
stage.ChapterId = request.ChapterId ?? stage.ChapterId;
stage.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Updated, stage.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -128,9 +132,11 @@ public class CharacterArcService(
return false;
}
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
var novelId = await db.Characters.Where(c => c.Id == stage.CharacterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct);
db.CharacterArcStages.Remove(stage);
activity.Record(novelId, ActivityEntityKind.ArcStage, ActivityAction.Deleted, stage.Id);
await db.SaveChangesAsync(ct);
return true;
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -11,6 +12,7 @@ public class CharacterService(
INovelDbContext db,
NovelAccessService access,
TagService tags,
ActivityLog activity,
ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator,
IModelValidator<UpdateCharacterRequest> updateValidator,
@@ -101,6 +103,7 @@ public class CharacterService(
}
db.Characters.Add(character);
activity.Record(novelId, ActivityEntityKind.Character, ActivityAction.Created, character.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(character.Id, ct))!;
@@ -147,6 +150,7 @@ public class CharacterService(
character.Aliases = [.. aliases];
}
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Updated, character.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -166,6 +170,7 @@ public class CharacterService(
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
db.Characters.Remove(character);
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
await db.SaveChangesAsync(ct);
return true;
}
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
@@ -52,6 +53,8 @@ public static class NovellyServiceRegistration
services.AddHttpContextAccessor();
services.AddScoped<INovelUserContext, NovelUserContext>();
services.AddScoped<NovelAccessService>();
services.AddScoped<ActivityLog>();
services.AddScoped<ActivityService>();
services.ConfigureApplicationCookie(options =>
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ActivityEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ActivityEvents",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
NovelId = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
OccurredAt = table.Column<long>(type: "INTEGER", nullable: false),
DayKey = table.Column<int>(type: "INTEGER", nullable: false),
EntityKind = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Action = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
EntityId = table.Column<Guid>(type: "TEXT", nullable: false),
WordDelta = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ActivityEvents", x => x.Id);
table.ForeignKey(
name: "FK_ActivityEvents_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ActivityEvents_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ActivityEvents_NovelId_DayKey",
table: "ActivityEvents",
columns: new[] { "NovelId", "DayKey" });
migrationBuilder.CreateIndex(
name: "IX_ActivityEvents_UserId_DayKey",
table: "ActivityEvents",
columns: new[] { "UserId", "DayKey" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ActivityEvents");
}
}
}
@@ -169,6 +169,49 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<int>("DayKey")
.HasColumnType("INTEGER");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT");
b.Property<string>("EntityKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<long>("OccurredAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<int>("WordDelta")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("NovelId", "DayKey");
b.HasIndex("UserId", "DayKey");
b.ToTable("ActivityEvents");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
@@ -1001,6 +1044,24 @@ namespace Novelly.Api.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b =>
{
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany()
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Users.NovellyUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Novel");
b.Navigation("User");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
+3
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
@@ -33,6 +34,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
public DbSet<ActivityEvent> ActivityEvents => Set<ActivityEvent>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
@@ -62,6 +64,7 @@ public interface INovelDbContext
DbSet<Genre> Genres { get; }
DbSet<NovellyUser> Users { get; }
DbSet<NovelMember> NovelMembers { get; }
DbSet<ActivityEvent> ActivityEvents { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -9,6 +10,7 @@ namespace Novelly.Api.Locations;
public class LocationService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
ILogger<LocationService> logger,
IModelValidator<CreateLocationRequest> createValidator,
IModelValidator<UpdateLocationRequest> updateValidator)
@@ -75,6 +77,7 @@ public class LocationService(
var location = new Location { NovelId = novelId, Name = name };
db.Locations.Add(location);
activity.Record(novelId, ActivityEntityKind.Location, ActivityAction.Created, location.Id);
await db.SaveChangesAsync(ct);
return location;
}
@@ -110,6 +113,7 @@ public class LocationService(
location.Name = name;
}
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Updated, location.Id);
await db.SaveChangesAsync(ct);
return location;
}
@@ -130,6 +134,7 @@ public class LocationService(
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
db.Locations.Remove(location);
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
await db.SaveChangesAsync(ct);
return true;
}
+4
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -10,6 +11,7 @@ public class NovelService(
INovelDbContext db,
NovelAccessService access,
INovelUserContext userContext,
ActivityLog activity,
ILogger<NovelService> logger,
IModelValidator<CreateNovelRequest> createValidator,
IModelValidator<UpdateNovelRequest> updateValidator)
@@ -69,6 +71,7 @@ public class NovelService(
};
db.Novels.Add(novel);
activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Created, novel.Id);
await db.SaveChangesAsync(ct);
return novel;
}
@@ -96,6 +99,7 @@ public class NovelService(
novel.Phase = request.Phase ?? novel.Phase;
novel.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Updated, novel.Id);
await db.SaveChangesAsync(ct);
return novel;
}
+4 -1
View File
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
@@ -66,6 +67,7 @@ using (var scope = app.Services.CreateScope())
}
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
await ActivityBackfill.RunAsync(db, app.Logger);
}
app.UseSerilogRequestLogging();
@@ -119,7 +121,8 @@ app.MapNovelEndpoints()
.MapGenreEndpoints()
.MapOpenQuestionEndpoints()
.MapAgentEndpoints()
.MapImportEndpoints();
.MapImportEndpoints()
.MapActivityEndpoints();
app.Run();
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
@@ -11,6 +12,7 @@ namespace Novelly.Api.Questions;
public class OpenQuestionService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
ILogger<OpenQuestionService> logger,
IModelValidator<CreateOpenQuestionRequest> createValidator,
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
@@ -102,6 +104,7 @@ public class OpenQuestionService(
};
db.OpenQuestions.Add(question);
activity.Record(novelId, ActivityEntityKind.Question, ActivityAction.Created, question.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(question.Id, ct))!;
@@ -131,6 +134,7 @@ public class OpenQuestionService(
question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId;
question.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -185,6 +189,7 @@ public class OpenQuestionService(
}
}
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -207,6 +212,7 @@ public class OpenQuestionService(
question.ResolvedAt = null;
question.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id);
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -226,6 +232,7 @@ public class OpenQuestionService(
await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
db.OpenQuestions.Remove(question);
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Deleted, question.Id);
await db.SaveChangesAsync(ct);
return true;
}
+5
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -9,6 +10,7 @@ namespace Novelly.Api.Tags;
public class TagService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
ILogger<TagService> logger,
IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator)
@@ -80,6 +82,7 @@ public class TagService(
var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color };
db.Tags.Add(tag);
activity.Record(novelId, ActivityEntityKind.Tag, ActivityAction.Created, tag.Id);
await db.SaveChangesAsync(ct);
return tag;
}
@@ -116,6 +119,7 @@ public class TagService(
}
tag.Color = Patch.Apply(tag.Color, request.Color);
activity.Record(tag.NovelId, ActivityEntityKind.Tag, ActivityAction.Updated, tag.Id);
await db.SaveChangesAsync(ct);
return tag;
}
@@ -136,6 +140,7 @@ public class TagService(
await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
db.Tags.Remove(tag);
activity.Record(tag.NovelId, ActivityEntityKind.Tag, ActivityAction.Deleted, tag.Id);
await db.SaveChangesAsync(ct);
return true;
}
+52 -6
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api, ApiError } from './client'
import type {
ActivityCalendar,
AgentTurn,
ArcStage,
Chapter,
@@ -46,6 +47,8 @@ export const keys = {
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const,
importJob: (id: string) => ['imports', id] as const,
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
myActivity: ['activity'] as const,
}
export const useUiSettings = () =>
@@ -126,7 +129,10 @@ export function useCreateNovel() {
return useMutation({
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
api.post<Novel>('/api/novels', body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.novels })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -137,6 +143,8 @@ export function useUpdateNovel(id: string) {
onSuccess: (updated) => {
qc.setQueryData(keys.novel(id), updated)
qc.invalidateQueries({ queryKey: keys.novels })
qc.invalidateQueries({ queryKey: keys.novelActivity(id) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -163,6 +171,8 @@ export function useCreateCharacter(novelId: string) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -175,6 +185,8 @@ export function useUpdateCharacter(novelId: string) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -183,7 +195,11 @@ export function useDeleteCharacter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -443,6 +459,8 @@ export function useCreateBeat(chapterId: string, novelId: string) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -458,15 +476,21 @@ export function useUpdateBeat(chapterId: string, novelId: string) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
export function useDeleteBeat(chapterId: string) {
export function useDeleteBeat(chapterId: string, novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/beats/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -519,7 +543,11 @@ export function useCreateChapter(novelId: string) {
mutationFn: (
body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
) => api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -534,6 +562,8 @@ export function useUpdateChapter(novelId: string) {
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -542,7 +572,11 @@ export function useDeleteChapter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/chapters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
},
})
}
@@ -576,6 +610,18 @@ export function useSendAgentMessage(novelId: string) {
})
}
export const useNovelActivity = (novelId: string) =>
useQuery({
queryKey: keys.novelActivity(novelId),
queryFn: () => api.get<ActivityCalendar>(`/api/novels/${novelId}/activity`),
})
export const useMyActivity = () =>
useQuery({
queryKey: keys.myActivity,
queryFn: () => api.get<ActivityCalendar>('/api/activity'),
})
export function useInspectImport() {
return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
+14
View File
@@ -317,3 +317,17 @@ export interface ImportInspection {
chaptersTotal: number
completedPasses: string[]
}
export interface ActivityDay {
date: string
words: number
edits: number
}
export interface ActivityCalendar {
from: string
to: string
totalWords: number
totalEdits: number
days: ActivityDay[]
}
@@ -0,0 +1,147 @@
import type { ActivityDay } from '../api/types'
const WEEKS = 53
const DAYS_PER_WEEK = 7
const EDIT_WEIGHT = 25
const LEVEL_COUNT = 4
const CELL_SIZE = 11
const CELL_GAP = 3
const MONTH_LABEL_HEIGHT = 16
const WEEKDAY_LABEL_WIDTH = 20
const WEEKDAY_LABELS: { row: number; label: string }[] = [
{ row: 1, label: 'Mon' },
{ row: 3, label: 'Wed' },
{ row: 5, label: 'Fri' },
]
const MONTH_NAMES = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
]
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10)
}
function startOfGrid(today: Date): Date {
const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
const start = new Date(end)
start.setUTCDate(start.getUTCDate() - (WEEKS * DAYS_PER_WEEK - 1))
start.setUTCDate(start.getUTCDate() - start.getUTCDay())
return start
}
function levelFor(score: number, sortedNonZero: number[]): number {
if (score <= 0) return 0
if (sortedNonZero.length === 0) return 0
const rank = sortedNonZero.filter((value) => value <= score).length
const quartile = Math.ceil((rank / sortedNonZero.length) * LEVEL_COUNT)
return Math.min(LEVEL_COUNT, Math.max(1, quartile))
}
function colorFor(level: number): string {
if (level === 0) return 'var(--surface-sunken)'
const percent = (level / LEVEL_COUNT) * 100
return `color-mix(in srgb, var(--accent) ${percent}%, var(--surface-sunken))`
}
export function ContributionGraph({ id, days, title }: { id: string; days: ActivityDay[]; title: string }) {
const today = new Date()
const gridStart = startOfGrid(today)
const byDate = new Map(days.map((d) => [d.date, d]))
const cells: { date: string; words: number; edits: number; score: number }[] = []
for (let i = 0; i < WEEKS * DAYS_PER_WEEK; i++) {
const date = new Date(gridStart)
date.setUTCDate(date.getUTCDate() + i)
const iso = toIsoDate(date)
const entry = byDate.get(iso)
const words = entry?.words ?? 0
const edits = entry?.edits ?? 0
cells.push({ date: iso, words, edits, score: words + edits * EDIT_WEIGHT })
}
const sortedNonZero = cells.map((c) => c.score).filter((s) => s > 0).sort((a, b) => a - b)
const monthLabels: { week: number; label: string }[] = []
let lastMonth = -1
for (let week = 0; week < WEEKS; week++) {
const date = new Date(gridStart)
date.setUTCDate(date.getUTCDate() + week * DAYS_PER_WEEK)
const month = date.getUTCMonth()
if (month !== lastMonth) {
monthLabels.push({ week, label: MONTH_NAMES[month] })
lastMonth = month
}
}
const gridWidth = WEEKS * (CELL_SIZE + CELL_GAP)
const gridHeight = DAYS_PER_WEEK * (CELL_SIZE + CELL_GAP)
const svgWidth = WEEKDAY_LABEL_WIDTH + gridWidth
const svgHeight = MONTH_LABEL_HEIGHT + gridHeight
return (
<div id={id} className="overflow-x-auto">
<svg width={svgWidth} height={svgHeight} role="img" aria-label={title}>
{monthLabels.map(({ week, label }) => (
<text
key={week}
x={WEEKDAY_LABEL_WIDTH + week * (CELL_SIZE + CELL_GAP)}
y={MONTH_LABEL_HEIGHT - 4}
fontSize={10}
fill="var(--ink-muted)"
>
{label}
</text>
))}
{WEEKDAY_LABELS.map(({ row, label }) => (
<text
key={label}
x={0}
y={MONTH_LABEL_HEIGHT + row * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 2}
fontSize={9}
fill="var(--ink-muted)"
>
{label}
</text>
))}
{cells.map((cell, i) => {
const week = Math.floor(i / DAYS_PER_WEEK)
const day = i % DAYS_PER_WEEK
const level = levelFor(cell.score, sortedNonZero)
const label = `${cell.date} · ${cell.words.toLocaleString()} words · ${cell.edits} edit${cell.edits === 1 ? '' : 's'}`
return (
<rect
key={cell.date}
id={`${id}-day-${cell.date}`}
x={WEEKDAY_LABEL_WIDTH + week * (CELL_SIZE + CELL_GAP)}
y={MONTH_LABEL_HEIGHT + day * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
fill={colorFor(level)}
>
<title>{label}</title>
</rect>
)
})}
</svg>
<div className="mt-2 flex items-center justify-end gap-1 text-xs muted">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<span
key={level}
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ background: colorFor(level) }}
/>
))}
<span>More</span>
</div>
</div>
)
}
+1 -1
View File
@@ -351,7 +351,7 @@ function BeatTable({
canDelete: boolean
}) {
const update = useUpdateBeat(chapter.id, novelId)
const remove = useDeleteBeat(chapter.id)
const remove = useDeleteBeat(chapter.id, novelId)
const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id)
const moveBeats = useMoveBeats(chapter.id)
+16 -1
View File
@@ -1,8 +1,9 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useNovel, useTags, useUpdateNovel } from '../api/hooks'
import { useChapters, useCharacters, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
import type { Novel, TagSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { ContributionGraph } from '../components/ContributionGraph'
const RECENT_COUNT = 5
const RECENT_CHAPTERS_COUNT = 10
@@ -49,6 +50,7 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
const recentCharacters = [...(characters ?? [])].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
@@ -59,6 +61,19 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
return (
<div className="grid gap-6">
<section id="dashboard-activity-graph" className="card p-5">
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
{activityError && <ErrorNote error={activityError} />}
{activityPending ? (
<Spinner label="Loading activity" />
) : !activity || activity.days.length === 0 ? (
<EmptyState title="No activity yet" hint="Write a chapter or add a beat to start the streak." />
) : (
<ContributionGraph id="novel-activity-graph" days={activity.days} title="Writing activity for this novel" />
)}
</section>
<div className="grid gap-6 lg:grid-cols-3">
<section className="card p-5 lg:col-span-2">
<div className="mb-4 flex items-center justify-between gap-4">
+16 -1
View File
@@ -1,14 +1,16 @@
import { useId, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useCreateNovel, useGenres, useLogout, useNovels } from '../api/hooks'
import { useCreateNovel, useGenres, useLogout, useMyActivity, useNovels } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { ContributionGraph } from '../components/ContributionGraph'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function NovelsPage() {
const { data: novels, isPending, error } = useNovels()
const { data: activity, isPending: activityPending, error: activityError } = useMyActivity()
const { user, can } = useAuth()
const logout = useLogout()
const [creating, setCreating] = useState(false)
@@ -61,6 +63,19 @@ export default function NovelsPage() {
Outlines, character dossiers, and a writing partner that knows the book.
</p>
<section id="novels-activity-graph" className="card mb-8 p-5">
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
{activityError && <ErrorNote error={activityError} />}
{activityPending ? (
<Spinner label="Loading activity" />
) : !activity || activity.days.length === 0 ? (
<EmptyState title="No activity yet" hint="Write a chapter or add a beat to start the streak." />
) : (
<ContributionGraph id="all-novels-activity-graph" days={activity.days} title="Writing activity across every novel" />
)}
</section>
{error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading novels" />}