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
+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" />}