You are an experienced, pragmatic software engineering AI agent. Do not over-engineer a solution when a simple one is possible. Keep edits minimal. If you want an exception to ANY rule, you MUST stop and get permission first.
Chronicle is a game-play performance analysis tool for Classic World of Warcraft, specifically targeting the Turtle WoW server. It transforms combat logs into accessible insights for raid leaders, tracking damage, healing, and other raid metrics.
| Layer | Technology |
|---|---|
| Backend | Go 1.25+ |
| Frontend | React 19 + TypeScript + Vite + Tailwind CSS v4 |
| Database | PostgreSQL 17 (via pgx/v5) |
| ORM/Query | sqlc (code generation from SQL) |
| Router | go-chi/chi |
| Auth | OAuth (Discord) + JWT sessions |
| Queue | River (PostgreSQL-based job queue) |
| Package | pnpm (frontend) |
| Hosting | Railway |
cmd/
├── chronicle/ # CLI tool
├── chronicled/ # Main server daemon
└── wasm/ # WebAssembly build for browser parsing
api/
├── api.go # API router and initialization
├── chronauth/ # Authentication (OAuth, sessions, JWT)
├── chroniclesdk/ # SDK types (used for TypeScript generation)
├── httpapi/ # HTTP response helpers (Write, Read, InternalServerError)
├── httpmw/ # HTTP middleware (auth, recovery, prometheus)
└── db2sdk/ # Database model to SDK conversion
chronicle/ # Core business logic (log parsing queue, uploads)
combatlog/ # Combat log parsing engine (vanilla WoW format)
database/
├── migrations/ # SQL migration files (numbered)
├── queries/ # Raw SQL queries for sqlc
├── dbtestutil/ # Test helpers (Postgres in Docker)
├── pubsub/ # PostgreSQL LISTEN/NOTIFY wrapper
└── storage/ # Object storage interface
frontend/chronicle/ # React frontend (Vite project)
internal/ # Shared utilities (testutil, cryptorand, etc.)
api/api.go– API routes definition, middleware setupchronicle/chronicle.go– Core Chronicle service (uploads, parsing)database/sqlc.yaml– sqlc configurationdatabase/generate.sh– Custom sqlc output merging scriptMakefile– Primary build/dev commands
# Build full project (backend + frontend)
make build
# Build backend only
make build-backend
# Build frontend only
cd frontend/chronicle && pnpm build# Start local dependencies first (Postgres on :5433, SpiceDB, OCR)
make services-up
# Start full dev server (backend on :4000, builds frontend)
make develop
# Start backend only without requiring frontend/dist assets
make develop-backend
# Start frontend dev server with hot reload (proxies to backend)
cd frontend/chronicle && pnpm dev# Run all tests (requires Postgres, use docker or local)
make test
# Start Postgres in Docker for testing
make test-postgres-docker
# Create the chronicle database when using local Postgres client tools
make create-db# Go linting
make lint
# or directly:
golangci-lint run
# Frontend linting
cd frontend/chronicle && pnpm lint# Regenerate everything (database, WASM, TypeScript types)
make gen
# Database only (after changing migrations or queries)
make gen/db
# TypeScript API types from Go SDK
# (auto-runs via make gen)
go run -C ./scripts/apitypings main.go > frontend/chronicle/src/api/typesGenerated.tsMigrations are immutable once deployed. Never edit an existing migration file — always create a new one. If you are unsure whether a migration has been deployed, ask the user before modifying it.
# Create a new migration
./database/migrations/create_migration.sh "description of migration"
# After creating migration, regenerate:
make gen# Build WASM parser for browser
make wasm
# Serve WASM site locally
make serveUse httpapi.Write for JSON responses and httpapi.Read for parsing request bodies:
func (api *API) SomeHandler(w http.ResponseWriter, r *http.Request) {
var req chroniclesdk.SomeRequest
if !httpapi.Read(r.Context(), w, r, &req) {
return // Read already wrote the error response
}
// ... process request ...
httpapi.Write(r.Context(), w, http.StatusOK, chroniclesdk.SomeResponse{...})
}Retrieve authenticated user from context:
func (api *API) ProtectedHandler(w http.ResponseWriter, r *http.Request) {
claims := chronauth.MustAuthenticatedClaims(r.Context())
userID := claims.UserID
// ...
}- Write SQL in
database/queries/*.sql - Run
make gen/db - Use generated methods via
database.Storeinterface
Use dbtestutil.NewDB(t) for tests requiring Postgres:
func TestSomething(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, pubsub := dbtestutil.NewDB(t)
// db is a database.Store, pubsub is *pubsub.PGPubsub
}Use testutil.Context with duration constants:
ctx := testutil.Context(t, testutil.WaitShort) // 10s
ctx := testutil.Context(t, testutil.WaitMedium) // 15s
ctx := testutil.Context(t, testutil.WaitLong) // 25sFull documentation: See
frontend/chronicle/src/pages/Instance/EventsPanels/DESIGN.mdfor architecture details.Sync Mode: See
frontend/chronicle/src/pages/Instance/SYNC_MODE.mdfor the video synchronization feature (experimental,?exp=1).
EventsPanels process combat log event streams and aggregate them into displayable metrics. Processing runs in a Web Worker to keep UI responsive.
Processor (worker-safe) + Panel (React wrapper)
───────────────────────── ────────────────────────
processors/foo.processor.ts FooPanel/Foo.tsx
- id, streams, createState - label, icon
- processEvent() - render()
- spreads processor props
Data Flow:
usePanelAggregationfetches required streams fromInstanceEventsContext(cached)- Streams + context are sent to Web Worker via
postMessage - Worker iterates events, calls
processor.processEvent() - Worker returns serialized result (Maps become arrays with markers)
- Hook deserializes result and triggers re-render
-
Create the processor (
processors/myPanel.processor.tsorMyPanel/myPanel.processor.ts)- Export a
PanelProcessor<TResult, TEvent>object - Must be pure TypeScript (NO React, NO JSX) - runs in Web Worker
- Define
id,streams,createState(),processEvent()
- Export a
-
Register the processor in
processors/index.ts- Add to imports and exports
- Add to
processorRegistryobject
-
Create the panel component (
MyPanel/MyPanel.tsx)- Import and spread processor properties
- Add
label,icon, optionalsupportsPerSecond - Implement
render()function
-
Add to PANELS registry in
EventsPanel.tsx- This defines
EventsPanelType- TypeScript enforces steps 5 and 6
- This defines
-
Add to PANEL_CODES in
frontend/chronicle/src/hooks/useUrlState.ts- TypeScript will error if missing (enforced via
Record<PanelType, string>)
- TypeScript will error if missing (enforced via
-
Add to PANEL_CATEGORIES in
PanelSelector.tsx- Add to existing category or create new one
// Processor (worker-safe, no React)
interface PanelProcessor<TResult, TEvent = ProcessorEvent> {
id: string;
streams: StreamType[]; // "damage" | "heal" | "resource_change" | "slain" | "cast" | "aura" | "extra_attack"
createState: () => TResult;
processEvent: (state: TResult, event: TEvent, encounterID: string,
firstTimestamp: Date, streamType: StreamType, context: ProcessorContext) => void;
}
// Panel (React wrapper)
interface PanelDefinition<TResult> extends PanelProcessor<TResult> {
label: string;
icon: React.ReactNode;
supportsPerSecond?: boolean;
checkboxLabel?: string;
selfManagesAggregation?: boolean; // For custom pagination/loading
render: (props: PanelRenderProps<TResult>) => React.ReactNode;
}| What | Cached Where | Lifetime |
|---|---|---|
Raw stream data (Uint8Array) |
InstanceEventsContext |
Until instance changes |
| Decoded events | Never cached | Re-decoded per worker request |
| Aggregated results | React state | Until context/panel changes |
Stream caching: Multiple panels requesting the same stream type share cached data. Fetch happens once per stream per instance.
Result caching: Each panel's aggregated result is stored in React state. When context changes (encounters, entity selection), the worker re-processes all events. The onContextChange callback (if defined) can return 'rerender' to skip reprocessing for display-only changes.
// empty.processor.ts
export const emptyProcessor: PanelProcessor<EmptyResult> = {
id: "empty",
streams: [], // No streams needed
createState: () => ({}),
processEvent: () => {}, // No-op
};
// Empty.tsx
export function createEmptyPanel(): PanelDefinition<EmptyResult> {
return {
...emptyProcessor,
label: "Empty",
icon: <Square className="h-4 w-4" />,
render: () => <div>Select a panel type</div>,
};
}// damageDone.processor.ts
export const damageDoneProcessor: PanelProcessor<DamageDoneResult, DamageProcessorEvent> = {
id: "damage_done",
streams: ["damage"],
createState: () => ({
EncounterDamage: new Map(),
ByAbility: new Map(),
ByTarget: new Map(),
GuidCache: createGuidCache(),
}),
processEvent: (state, event, encounterID, _, _streamType, context) => {
// Filter by player GUIDs, accumulate damage...
// Use context.entitySelection for filtering
// Use context.players for name lookups
},
};
// DamageDone.tsx
export function createDamageDonePanel(sourceType: DamageSourceType): PanelDefinition<DamageDoneResult> {
return {
...damageDoneProcessor,
label: "Damage Done",
icon: <Swords className="h-4 w-4" />,
supportsPerSecond: true,
render: (props) => <DamageDoneContent {...props} sourceType={sourceType} />,
};
}type ProcessorEvent =
| DamageProcessorEvent // damage stream
| HealProcessorEvent // heal stream
| ResourceChangeProcessorEvent // resource_change stream
| ExtraAttackProcessorEvent // extra_attack stream
| SlainProcessorEvent // slain stream (deaths)
| CastProcessorEvent // cast stream
| AuraProcessorEvent; // aura stream (buffs/debuffs)- Don't store event references - the
eventobject is reused; copy values if needed - Use
GuidCachefor repeated GUID parsing (seeprocessors/guidCache.ts) - Filter early - check
context.entitySelectionandcontext.selectedEncounterIdsbefore expensive work - Breakout data is optional - only compute ability/target breakouts when entity is selected
- Don't bypass sqlc – All database queries should go through
database/queries/*.sqland be generated. Don't write raw SQL in Go code. - Don't hold pubsub locks while calling pgListener – See comments in
database/pubsub/pubsub.goabout deadlock risks. - Don't use
http.Errordirectly in handlers – Usehttpapi.Writeorhttpapi.InternalServerErrorto maintain consistent JSON responses. - Don't skip
t.Parallel()– Tests should be parallelizable unless there's a specific reason.
Some paths are excluded from linting (see .golangci.yml):
cmd/wasm/main.go– WASM-specific codedatabase/gen/dump/*– Generated dump logicdatabase/dbtestutil/*– Test utilitiescombatlog/parser/regexs/compiled/– Generated regex code
- Indentation: 2 spaces (see
.editorconfig), except Makefile uses tabs - Go: Standard
gofmtformatting; usesgoimports - Frontend: ESLint configured in
frontend/chronicle/eslint.config.js
- Lint:
make lintmust pass - Test:
make testmust pass (requires Postgres) - Generate: If you changed migrations, queries, or SDK types, run
make gen - Build:
make buildshould succeed
Follow lowercase type: message format (based on git history):
feat: add raid log parsing
fix: handle missing player data
chore: update dependencies
Types: feat, fix, chore, refactor, docs, test
- Target
mainordevelopbranch - CI runs lint and tests automatically
- Ensure all checks pass before requesting review