feat(reports): align first response distribution range

This commit is contained in:
2026-06-07 01:49:30 +08:00
parent 2453a84098
commit dd23de4e53
5 changed files with 66 additions and 6 deletions
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 outgoing messages count builder parity checkpoint, prepared as `feat(reports): align outgoing message counts`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align outgoing message counts`.
- Latest documentation/tooling checkpoint: this tracker update records P3.52 outgoing messages count builder parity plus the committed P3.51/P3.50/P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Current tracking checkpoint: 2026-06-07 first-response distribution range parity checkpoint, prepared as `feat(reports): align first response distribution range`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align first response distribution range`.
- Latest documentation/tooling checkpoint: this tracker update records P3.53 first-response distribution range parity plus the committed P3.52/P3.51/P3.50/P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: v2 reports outgoing-message counts now match inspected Chatwoot `V2::Reports::OutgoingMessagesCountBuilder` more closely by returning empty `422` for invalid or blank `group_by` and by grouping label counts by tag name before resolving the account label id, instead of leaking the raw tagging id. P3.51 inbox-label matrix filter parity, P3.50 bot metrics builder count parity, P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: v2 reports first-response distribution now matches inspected Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` range semantics by allowing missing or partial `since/until` to omit the date filter while preserving range filtering when both params are present. P3.52 outgoing-message count builder parity, P3.51 inbox-label matrix filter parity, P3.50 bot metrics builder count parity, P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence.
- `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.53 first-response distribution range parity | Implemented for reused v2 reports distribution clients: GoChat now follows inspected Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` and `DateRangeHelper#range` by treating missing or partial `since/until` as no date filter, while still applying the half-open created-at range when both values are present. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for exact SQL aggregation behavior, invalid timestamp exceptions, or channel type bucketing drift. | Focused Analytics handler and service first-response distribution tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.52 outgoing messages count builder parity | Implemented for reused v2 reports outgoing-message clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder` by returning empty `422` for invalid or blank `group_by`, and by grouping label counts by tag name before resolving the current account's label id. This prevents local tag ids from leaking when taggings reference a same-named label outside the account. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for missing-date `range` behavior, output ordering, nil inbox id serialization, or ActsAsTaggableOn context drift. | Focused Analytics handler and service outgoing-message tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.51 inbox-label matrix filter parity | Implemented for reused v2 reports matrix clients: GoChat now follows inspected Chatwoot `V2::Reports::InboxLabelMatrixBuilder` by accepting optional `since/until`, `inbox_ids`, and `label_ids`; filtering the inbox and label axes before sorting by name/title; and counting only matching conversation labels whose conversations fall in the optional range and selected inboxes. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActsAsTaggableOn title/name divergence, array param encoding drift, or optional invalid-date controller behavior. | Focused Analytics handler and service matrix tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.50 bot metrics builder count parity | Implemented for reused v2 reports bot metric clients: GoChat now follows inspected Chatwoot `V2::Reports::BotMetricsBuilder`. `message_count` is scoped to outgoing messages whose `conversation_id` belongs to bot conversations created in the requested range, `resolution_rate` uses distinct resolved conversations after excluding any conversation with a handoff in range, and `handoff_rate` uses distinct non-null handoff conversations. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for `active_bot?` inbox activation drift, message sender-type filtering drift, or provider/bot-inbox status semantics beyond the inspected builder. | Focused Analytics bot metrics service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -291,6 +292,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): align first response distribution range` | Advances P3.53 first-response distribution range parity by matching Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` and `DateRangeHelper#range`. GoChat now allows `/reports/first_response_time_distribution` without `since/until`, treats partial ranges as no range filter, and still filters reporting events by `created_at >= since AND created_at < until` when both values are present. | `go test ./internal/service -run FirstResponseTimeDistribution -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.53 moves to Review for current distribution builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align outgoing message counts` | Advances P3.52 outgoing messages count builder parity by matching Chatwoot `Api::V2::Accounts::ReportsController#outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder`. GoChat now returns empty `422` for invalid or blank `group_by`, and label grouping now counts by tag name then resolves the current account's label id, instead of returning the raw joined tag id. | `go test ./internal/service -run OutgoingMessagesCount -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.52 moves to Review for current outgoing-message builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align inbox label matrix filters` | Advances P3.51 inbox-label matrix filter parity by matching Chatwoot `V2::Reports::InboxLabelMatrixBuilder`. GoChat now parses optional `since/until`, `inbox_ids`, and `label_ids` on `/reports/inbox_label_matrix`, filters the inbox and label axes before sorting, and counts only labels attached to account conversations in the selected inbox/date range. | `go test ./internal/service -run InboxLabelMatrix -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.51 moves to Review for current matrix builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align bot metrics builder counts` | Advances P3.50 bot metrics builder parity by matching Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat now derives `message_count` from outgoing messages attached to in-range bot conversations, not every outgoing message in an active-bot inbox; `resolution_rate` uses distinct resolved conversations after excluding handoff conversations; and `handoff_rate` uses distinct non-null handoff conversations. | `go test ./internal/service -run 'AnalyticsBotMetrics\|AnalyticsReportsUsePersisted' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.50 moves to Review for current bot metrics builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2566,3 +2568,4 @@ Verification milestone gates:
- 2026-06-07: P3.50 bot metrics builder count checkpoint prepared as `feat(reports): align bot metrics builder counts`; audited Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat `/reports/bot_metrics` now counts outgoing bot messages only through in-range bot conversation IDs, computes bot resolutions as distinct non-null conversations after excluding handoff conversations, and computes handoffs as distinct non-null conversations, matching the builder rate denominators and avoiding active-bot inbox leakage from old conversations. Focused Analytics bot metrics tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.51 inbox-label matrix filter checkpoint prepared as `feat(reports): align inbox label matrix filters`; audited Chatwoot `V2::Reports::InboxLabelMatrixBuilder`. GoChat `/reports/inbox_label_matrix` now parses optional frontend `since/until`, `inbox_ids`, and `label_ids`, filters the returned inbox/label axes, and counts only selected account conversations in the requested date and inbox scope. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.52 outgoing messages count builder checkpoint prepared as `feat(reports): align outgoing message counts`; audited Chatwoot v2 reports controller `outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder`. GoChat invalid or blank `group_by` now returns empty `422`, and label grouping now follows Chatwoot by grouping counts on tag name and resolving the current account label id rather than returning the raw joined tag id. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.53 first-response distribution range checkpoint prepared as `feat(reports): align first response distribution range`; audited Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` and `DateRangeHelper#range`. GoChat `/reports/first_response_time_distribution` now accepts missing or partial `since/until` as an all-time distribution and only applies the created-at range when both params are present. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
+20 -1
View File
@@ -566,7 +566,7 @@ func (h *AnalyticsHandler) FirstResponseTimeDistribution(c *gin.Context) {
if !ok {
return
}
since, until, ok := parseDateRange(c)
since, until, ok := parseOptionalReportDateRange(c)
if !ok {
return
}
@@ -581,6 +581,25 @@ func (h *AnalyticsHandler) FirstResponseTimeDistribution(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
func parseOptionalReportDateRange(c *gin.Context) (since, until time.Time, ok bool) {
sinceRaw := c.Query("since")
untilRaw := c.Query("until")
if sinceRaw == "" || untilRaw == "" {
return time.Time{}, time.Time{}, true
}
since, err := parseChatwootReportTime(sinceRaw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid since date format")
return time.Time{}, time.Time{}, false
}
until, err = parseChatwootReportTime(untilRaw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid until date format")
return time.Time{}, time.Time{}, false
}
return since, until, true
}
// OutgoingMessagesCount returns outgoing message count metrics.
// GET /api/v1/accounts/:account_id/reports/outgoing_messages_count
// Reference: Chatwoot reports#outgoing_messages_count
@@ -74,6 +74,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic)
accounts.GET("/reports/conversations", s.handler.Conversations)
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
accounts.GET("/reports/first_response_time_distribution", s.handler.FirstResponseTimeDistribution)
accounts.GET("/reports/inbox_label_matrix", s.handler.InboxLabelMatrix)
accounts.GET("/reports/outgoing_messages_count", s.handler.OutgoingMessagesCount)
s.router = r
@@ -514,6 +515,21 @@ func (s *AnalyticsHandlerTestSuite) TestOutgoingMessagesCount_InvalidGroupByRetu
s.Empty(w.Body.String())
}
func (s *AnalyticsHandlerTestSuite) TestFirstResponseTimeDistribution_AllowsMissingRange() {
inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
s.Require().NoError(s.db.Create(&inbox).Error)
s.Require().NoError(s.db.Create(&model.ReportingEvent{AccountID: s.accountID, Name: model.MetricNameFirstResponse, Value: 4000, InboxID: &inbox.ID}).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/first_response_time_distribution", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var payload map[string]map[string]int64
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Equal(int64(1), payload["web_widget"]["1-4h"])
}
// ========== Nil service guard ==========
func (s *AnalyticsHandlerTestSuite) TestNilService() {
+18
View File
@@ -237,6 +237,24 @@ func TestAnalyticsOutgoingMessagesCountLabelUsesAccountLabelName(t *testing.T) {
assert.Equal(t, int64(1), rows[0]["outgoing_messages_count"])
}
func TestAnalyticsFirstResponseTimeDistributionAllowsMissingRange(t *testing.T) {
db, svc, account, inbox, _, user, _ := setupAnalyticsP513Test(t)
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
recent := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: old}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 120, InboxID: &inbox.ID, UserID: &user.ID}).Error)
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: recent}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 90000, InboxID: &inbox.ID, UserID: &user.ID}).Error)
allDistribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, time.Time{}, time.Time{})
require.NoError(t, err)
assert.Equal(t, int64(1), allDistribution.(map[string]map[string]int64)["web_widget"]["0-1h"])
assert.Equal(t, int64(1), allDistribution.(map[string]map[string]int64)["web_widget"]["24h+"])
rangedDistribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, recent.Add(-time.Hour), recent.Add(time.Hour))
require.NoError(t, err)
assert.Equal(t, int64(0), rangedDistribution.(map[string]map[string]int64)["web_widget"]["0-1h"])
assert.Equal(t, int64(1), rangedDistribution.(map[string]map[string]int64)["web_widget"]["24h+"])
}
func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) {
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
+5 -1
View File
@@ -853,7 +853,11 @@ func (s *AnalyticsService) firstResponseTimeDistribution(ctx context.Context, ac
return nil, err
}
var events []model.ReportingEvent
if err := db.WithContext(ctx).Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, since, until).Find(&events).Error; err != nil {
eventQ := db.WithContext(ctx).Where("account_id = ? AND name = ?", accountID, model.MetricNameFirstResponse)
if !since.IsZero() && !until.IsZero() {
eventQ = eventQ.Where("created_at >= ? AND created_at < ?", since, until)
}
if err := eventQ.Find(&events).Error; err != nil {
return nil, err
}
inboxIDs := make([]uint, 0, len(events))