feat(reports): align v2 timeseries timezones
This commit is contained in:
@@ -49,11 +49,11 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 API v2 reports CSV/download parity checkpoint, prepared as `feat(reports): align v2 csv downloads`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align v2 csv downloads`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records P3.41 API v2 report CSV/download parity plus the committed P3.40/P3.39 baseline and landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Current tracking checkpoint: 2026-06-06 API v2 reports timeseries timezone parity checkpoint, prepared as `feat(reports): align v2 timeseries timezones`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align v2 timeseries timezones`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records P3.42 API v2 report timeseries timezone parity plus the committed P3.41/P3.40 baseline and landed parity tracker history; this document is the active follow-up plan and 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: API v2 reports from `reference/chatwoot/config/routes.rb:481-509`, `Api::V2::Accounts::ReportsController`, CSV templates under `app/views/api/v2/accounts/reports`, `ReportsHelper`, `HeatmapHelper`, and reused dashboard `reports.js` are aligned for frontend report downloads. `agents`, `inboxes`, `labels`, `teams`, `conversations_summary`, and `conversation_traffic` now return `text/csv` attachments with Chatwoot filenames and headers; standard report CSVs include the Chatwoot reporting-period row and frontend Unix-second `since/until` support; `conversation_traffic` accepts frontend `days_before`/`timezone_offset` without requiring `since/until` while preserving `since/until` compatibility. P3.40 raw JSON/date parity for non-download v2 report, live report, and summary report endpoints remains 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: API v2 report timeseries from `reference/chatwoot/config/routes.rb:488`, `Api::V2::Accounts::ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, `TimezoneHelper`, `DateRangeHelper`, and reused dashboard `reports.js` are aligned for frontend `timezone_offset` grouping. `GET /api/v2/accounts/:account_id/reports` now parses `timezone_offset`, groups conversations/messages/reporting-events by local hour/day/week/month/year bucket boundaries, returns bucket timestamps at the local bucket start like Chatwoot `group_by_period(time_zone: ...)`, and includes zero-valued buckets across the requested range. 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 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.
|
||||
@@ -140,6 +140,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.42 API v2 reports timeseries timezone parity | Implemented for reused dashboard chart clients: `GET /api/v2/accounts/:account_id/reports` now follows inspected Chatwoot `ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, and `TimezoneHelper` behavior by honoring frontend `timezone_offset` for bucket boundaries and returned timestamps. Conversation, message, and reporting-event timeseries now seed zero buckets across the requested range, group by local hour/day/week/month/year starts, preserve data totals while redistributing values across timezone-local buckets, and keep summary-report totals timezone-boundary driven by the frontend `since/until` values. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport named timezone/DST drift, exact Groupdate range edge cases, rollup-data-source behavior, or metric-specific builder differences beyond the inspected raw timeseries contract. | Focused Analytics handler/service timeseries tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.41 API v2 reports CSV/download parity | Implemented for reused dashboard report download clients: `GET /api/v2/accounts/:account_id/reports/agents`, `/inboxes`, `/labels`, `/teams`, `/conversations_summary`, and `/conversation_traffic` now follow the inspected Chatwoot v2 reports controller, CSV templates, `ReportsHelper`, `HeatmapHelper`, and `reports.js` download actions by returning `text/csv` attachments instead of JSON. Standard download reports emit Chatwoot filenames, reporting-period row, localized English header text from the reference locale, readable duration formatting, and account-scoped agent/inbox/team/label/conversation-summary rows. `conversation_traffic` accepts frontend `days_before` plus `timezone_offset` without requiring `since/until`, emits `conversation_traffic_reports.csv`, a timezone row, and hourly heatmap rows; explicit `since/until` remains compatible for local callers. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for exact metric-builder aggregation drift, ActiveSupport timezone display-name drift, business-hours summary drift, channel display-name drift, or CSV row ordering/body differences beyond the inspected download contract. | Focused Analytics handler CSV tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.40 API v2 reports envelope/date parity | Implemented for reused dashboard reports clients: `GET /api/v2/accounts/:account_id/reports*`, `/summary_reports/*`, and `/live_reports/*` now follow the inspected Chatwoot v2 controllers by accepting Unix-second `since/until` params from `reports.js`/`summaryReports.js`, preserving existing RFC3339 compatibility for local callers, and returning raw JSON arrays/objects for reports, live reports, and summary reports instead of the local `{ success, data }` envelope. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for CSV body column drift, deeper metric-builder shape drift, authorization payload drift, or report CSV download content differences beyond this raw JSON/date-parameter slice. | Focused Analytics/SummaryReport/LiveReport handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.39 profile MFA route/payload parity | Implemented for reused dashboard profile MFA settings: `GET/PATCH/PUT /api/v1/profile`, profile action routes, and no-trailing `GET/POST/DELETE /api/v1/profile/mfa` plus `POST /profile/mfa/verify` and `POST /profile/mfa/backup_codes` are registered and tracked from `routes.rb:421-435`; profile MFA status returns raw `{ feature_available, enabled, backup_codes_generated }`; setup returns raw `{ provisioning_url, secret }` while persisting a pending TOTP secret; verify accepts frontend `otp_code`, enables MFA, and returns raw `{ enabled: true, backup_codes }`; backup-code regeneration validates the active OTP; destroy validates the current password plus OTP/backup-code and returns raw `{ enabled: false }`; legacy `/api/v1/auth/mfa/*` routes remain compatibility endpoints. | Keep in Review; reopen only if live profile security settings smoke exposes MFA feature-flag gating drift, backup-code storage shape drift, Devise password validation drift, or profile serializer fields beyond the inspected controller/Jbuilder/frontend contract. | Focused MFA handler/router/route-parity tests, route dump/parity regeneration (`967` routes; `421 exact`, `18 parameter-compatible`, `0 missing out of 439`), full `go test ./...`, and `git diff --check` must pass. |
|
||||
@@ -264,6 +265,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(reports): align v2 timeseries timezones` | Advances P3.42 API v2 reports timeseries timezone parity by matching Chatwoot `Api::V2::Accounts::ReportsController#index`, `V2::Reports::Timeseries::BaseTimeseriesBuilder`, `Reports::RawDataSource`, `TimezoneHelper`, `DateRangeHelper`, controller timezone specs, and reused dashboard `reports.js`. GoChat now parses frontend `timezone_offset` on `/reports`, seeds zero buckets across the range, groups conversation/message/reporting-event timeseries by local hour/day/week/month/year starts, and returns timestamps for the local bucket boundary so timezone offsets redistribute values while preserving totals. | `go test ./internal/handler/api/v1 ./internal/service -run 'Analytics\|Timeseries' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.42 moves to Review for current timeseries timezone evidence; continue Phase 2/3 drift audit for Groupdate/DST/rollup builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(reports): align v2 csv downloads` | Advances P3.41 API v2 reports CSV/download parity by matching Chatwoot `Api::V2::Accounts::ReportsController`, CSV templates `agents/inboxes/labels/teams/conversations_summary/conversation_traffic`, `ReportsHelper`, `HeatmapHelper`, locale CSV labels, and reused dashboard `reports.js` download actions. GoChat now returns `text/csv` attachments for report downloads, emits Chatwoot filenames and headers, keeps Unix-second `since/until` report period support, formats durations like Chatwoot's time presenter, and lets `conversation_traffic` run from frontend `days_before`/`timezone_offset` without requiring `since/until`. | `go test ./internal/handler/api/v1 -run Analytics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.41 moves to Review for current CSV/download evidence; continue Phase 2/3 drift audit for metric-builder/timezone/body-depth drift, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(reports): align v2 report envelopes` | Advances P3.40 API v2 reports envelope/date-parameter parity by matching Chatwoot `Api::V2::Accounts::ReportsController`, `SummaryReportsController`, `LiveReportsController`, routes `481-509`, and reused dashboard `reports.js`, `summaryReports.js`, and `liveReports.js`. GoChat now accepts Unix-second `since/until` report params from the frontend while preserving RFC3339 compatibility, and returns raw Chatwoot-style JSON arrays/objects for reports, summary reports, and live reports instead of the local `{ success, data }` envelope. | `go test ./internal/handler/api/v1 -run 'Analytics\|SummaryReport\|LiveReport' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.40 moves to Review for the current v2 report JSON/date evidence; continue Phase 2/3 drift audit for CSV/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(profile): align mfa payloads` | Advances P3.39 profile MFA route/payload parity by matching Chatwoot `Api::V1::ProfilesController`, `Api::V1::Profile::MfaController`, MFA Jbuilder views, routes `421-435`, and reused dashboard `mfa.js`/profile MFA settings flow. GoChat now exposes no-trailing profile MFA routes, tracks profile route parity, returns raw MFA status/create/verify/destroy/backup-code payloads, persists pending setup secrets, verifies frontend `otp_code`, returns and stores backup-code hashes, and validates password plus OTP/backup-code for disable while preserving legacy `/auth/mfa` compatibility endpoints. | `go test ./internal/auth ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'MFA\|Profile\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 967`; tracked route parity is `421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 439`. | P3.39 moves to Review for current profile MFA evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
@@ -2515,3 +2517,4 @@ Verification milestone gates:
|
||||
- 2026-06-06: Documentation checkpoint prepared as `docs: land parity execution tracker`; confirms the clean committed baseline at `21421c6 feat(profile): align mfa payloads`, makes `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` the self-contained active tracker for the user-confirmed scope, marks `.hermes/plans/*` as source notes only, replaces stale next-slice wording with the Phase 2/3 audit, Phase 6 placeholder audit, and B12 live-smoke queue, and records the missing P3.35-P3.39 progress entries. Verification for this docs-only checkpoint: `git diff --check`.
|
||||
- 2026-06-06: P3.40 API v2 reports checkpoint prepared as `feat(reports): align v2 report envelopes`; audited Chatwoot v2 reports, summary reports, and live reports controllers plus reused dashboard `reports.js`, `summaryReports.js`, and `liveReports.js`. GoChat report handlers now accept frontend Unix-second `since/until` params while preserving RFC3339 compatibility, and v2 reports/live_reports/summary_reports return raw Chatwoot JSON arrays/objects instead of the local `{ success, data }` envelope. Focused Analytics/SummaryReport/LiveReport handler tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-06: P3.41 API v2 reports CSV/download checkpoint prepared as `feat(reports): align v2 csv downloads`; audited Chatwoot v2 reports CSV actions, templates, ReportsHelper/HeatmapHelper, locale headers, and reused dashboard report download clients. GoChat now returns `text/csv` attachments for agents/inboxes/labels/teams/conversations_summary/conversation_traffic, emits Chatwoot filenames/reporting-period/header rows, renders readable durations, and accepts `conversation_traffic` frontend `days_before`/`timezone_offset` without requiring `since/until`. Focused Analytics CSV tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-06: P3.42 API v2 reports timeseries timezone checkpoint prepared as `feat(reports): align v2 timeseries timezones`; audited Chatwoot report index controller, Timeseries builder, RawDataSource, TimezoneHelper, DateRangeHelper, controller specs, and reused dashboard reports client. GoChat `/reports` timeseries now honors frontend `timezone_offset` for local hour/day/week/month/year bucket starts, returns local bucket-boundary timestamps, and seeds zero-value buckets across the requested range while preserving totals. Focused Analytics/Timeseries tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
|
||||
|
||||
@@ -49,7 +49,11 @@ func (h *AnalyticsHandler) Index(c *gin.Context) {
|
||||
id = uint(parsed)
|
||||
}
|
||||
businessHours := c.Query("business_hours") == "true" || c.Query("business_hours") == "1"
|
||||
result, err := h.svc.GetTimeseries(c.Request.Context(), accountID, metric, since, until, c.DefaultQuery("type", "account"), id, c.Query("group_by"), businessHours)
|
||||
timezoneOffset, ok := parseReportTimezoneOffset(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.svc.GetTimeseries(c.Request.Context(), accountID, metric, since, until, c.DefaultQuery("type", "account"), id, c.Query("group_by"), timezoneOffset, businessHours)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Timeseries report: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate report")
|
||||
@@ -105,15 +109,22 @@ func parseReportBusinessHours(c *gin.Context) bool {
|
||||
return c.Query("business_hours") == "true" || c.Query("business_hours") == "1"
|
||||
}
|
||||
|
||||
func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) {
|
||||
timezoneOffset := 0.0
|
||||
func parseReportTimezoneOffset(c *gin.Context) (float64, bool) {
|
||||
if raw := c.Query("timezone_offset"); raw != "" {
|
||||
parsed, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid timezone_offset")
|
||||
return time.Time{}, time.Time{}, 0, false
|
||||
return 0, false
|
||||
}
|
||||
timezoneOffset = parsed
|
||||
return parsed, true
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) {
|
||||
timezoneOffset, ok := parseReportTimezoneOffset(c)
|
||||
if !ok {
|
||||
return time.Time{}, time.Time{}, 0, false
|
||||
}
|
||||
|
||||
if c.Query("since") != "" || c.Query("until") != "" {
|
||||
|
||||
@@ -179,8 +179,27 @@ func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesWithData() {
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var data []interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &data))
|
||||
s.Len(data, 1)
|
||||
s.Equal(float64(1), data[0].(map[string]interface{})["value"])
|
||||
s.Len(data, 31)
|
||||
s.Equal(float64(1), data[14].(map[string]interface{})["value"])
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesHonorsTimezoneOffset() {
|
||||
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-02T01:00:00Z")}}
|
||||
s.Require().NoError(s.db.Create(&conv).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/accounts/1/reports?metric=conversations_count&since=1735689600&until=1735862400&type=account&group_by=day&timezone_offset=-8", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var data []map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &data))
|
||||
s.Len(data, 3)
|
||||
loc := time.FixedZone("report", -8*3600)
|
||||
s.Equal(float64(time.Date(2024, 12, 31, 0, 0, 0, 0, loc).Unix()), data[0]["timestamp"])
|
||||
s.Equal(float64(time.Date(2025, 1, 1, 0, 0, 0, 0, loc).Unix()), data[1]["timestamp"])
|
||||
s.Equal(float64(0), data[0]["value"])
|
||||
s.Equal(float64(1), data[1]["value"])
|
||||
}
|
||||
|
||||
// ========== AgentMetrics ==========
|
||||
|
||||
@@ -159,17 +159,27 @@ func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) {
|
||||
require.NoError(t, db.Model(conv).Updates(map[string]interface{}{"created_at": since.Add(2 * time.Hour)}).Error)
|
||||
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(3 * time.Hour)}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 120, ConversationID: &conv.ID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(2 * time.Minute)}).Error)
|
||||
|
||||
points, err := svc.GetTimeseries(context.Background(), account.ID, "conversations_count", since, until, "account", 0, "day", false)
|
||||
points, err := svc.GetTimeseries(context.Background(), account.ID, "conversations_count", since, until, "account", 0, "day", 0, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, points, 1)
|
||||
require.Len(t, points, 2)
|
||||
assert.Equal(t, float64(1), points[0].Value)
|
||||
assert.Equal(t, since.Unix(), points[0].Timestamp)
|
||||
assert.Equal(t, float64(0), points[1].Value)
|
||||
assert.Equal(t, since.Add(24*time.Hour).Unix(), points[1].Timestamp)
|
||||
|
||||
avgPoints, err := svc.GetTimeseries(context.Background(), account.ID, "avg_first_response_time", since, until, "agent", user.ID, "day", false)
|
||||
avgPoints, err := svc.GetTimeseries(context.Background(), account.ID, "avg_first_response_time", since, until, "agent", user.ID, "day", 0, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, avgPoints, 1)
|
||||
require.Len(t, avgPoints, 2)
|
||||
assert.Equal(t, 120.0, avgPoints[0].Value)
|
||||
assert.Equal(t, int64(1), avgPoints[0].Count)
|
||||
assert.Equal(t, 0.0, avgPoints[1].Value)
|
||||
assert.Equal(t, int64(0), avgPoints[1].Count)
|
||||
|
||||
tzPoints, err := svc.GetTimeseries(context.Background(), account.ID, "conversations_count", since, until, "account", 0, "day", -8, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tzPoints, 3)
|
||||
assert.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, time.FixedZone("report", -8*3600)).Unix(), tzPoints[0].Timestamp)
|
||||
assert.Equal(t, float64(1), tzPoints[0].Value)
|
||||
|
||||
wp := worker.NewWorkerPool(db)
|
||||
svc.SetWorkerPool(wp)
|
||||
|
||||
@@ -50,35 +50,36 @@ func (s *AnalyticsService) EnsureRollupsForRange(ctx context.Context, accountID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetTimeseries(ctx context.Context, accountID uint, metric string, since, until time.Time, reportType string, id uint, groupBy string, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
|
||||
func (s *AnalyticsService) GetTimeseries(ctx context.Context, accountID uint, metric string, since, until time.Time, reportType string, id uint, groupBy string, timezoneOffset float64, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
|
||||
if strings.TrimSpace(groupBy) == "" {
|
||||
groupBy = "day"
|
||||
}
|
||||
if reportType == "" {
|
||||
reportType = "account"
|
||||
}
|
||||
loc := timeseriesLocation(timezoneOffset)
|
||||
if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch metric {
|
||||
case "conversations_count":
|
||||
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, false)
|
||||
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, false)
|
||||
case "resolutions_count":
|
||||
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, true)
|
||||
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, true)
|
||||
case "incoming_messages_count":
|
||||
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, model.MessageTypeIncoming)
|
||||
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeIncoming)
|
||||
case "outgoing_messages_count":
|
||||
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, model.MessageTypeOutgoing)
|
||||
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeOutgoing)
|
||||
case "avg_first_response_time":
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{model.MetricNameFirstResponse}, true, businessHours)
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameFirstResponse}, true, businessHours)
|
||||
case "avg_resolution_time":
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours)
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours)
|
||||
case "reply_time":
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{model.MetricNameReplyTime}, true, businessHours)
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameReplyTime}, true, businessHours)
|
||||
case "bot_resolutions_count":
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours)
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours)
|
||||
case "bot_handoffs_count":
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours)
|
||||
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported report metric %q", metric)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ func (s *AnalyticsService) liveConversationMetrics(ctx context.Context, accountI
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) conversationCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, resolved bool) ([]AnalyticsTimeseriesPoint, error) {
|
||||
func (s *AnalyticsService) conversationCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, loc *time.Location, resolved bool) ([]AnalyticsTimeseriesPoint, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -128,20 +129,20 @@ func (s *AnalyticsService) conversationCountTimeseries(ctx context.Context, acco
|
||||
if err := q.Find(&conversations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
||||
buckets := seedTimeseriesBuckets(since, until, groupBy, loc)
|
||||
for _, conversation := range conversations {
|
||||
t := conversation.CreatedAt
|
||||
if resolved && conversation.ResolvedAt != nil {
|
||||
t = *conversation.ResolvedAt
|
||||
}
|
||||
bucket := bucketStart(t, groupBy)
|
||||
bucket := bucketStart(t, groupBy, loc)
|
||||
point := ensureTimeseriesBucket(buckets, bucket)
|
||||
point.Value++
|
||||
}
|
||||
return sortedTimeseries(buckets), nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) messageCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, messageType model.MessageType) ([]AnalyticsTimeseriesPoint, error) {
|
||||
func (s *AnalyticsService) messageCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, loc *time.Location, messageType model.MessageType) ([]AnalyticsTimeseriesPoint, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -152,15 +153,15 @@ func (s *AnalyticsService) messageCountTimeseries(ctx context.Context, accountID
|
||||
if err := q.Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
||||
buckets := seedTimeseriesBuckets(since, until, groupBy, loc)
|
||||
for _, message := range messages {
|
||||
point := ensureTimeseriesBucket(buckets, bucketStart(message.CreatedAt, groupBy))
|
||||
point := ensureTimeseriesBucket(buckets, bucketStart(message.CreatedAt, groupBy, loc))
|
||||
point.Value++
|
||||
}
|
||||
return sortedTimeseries(buckets), nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, names []string, average bool, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
|
||||
func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, loc *time.Location, names []string, average bool, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -171,9 +172,9 @@ func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint,
|
||||
if err := q.Find(&events).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
||||
buckets := seedTimeseriesBuckets(since, until, groupBy, loc)
|
||||
for _, event := range events {
|
||||
point := ensureTimeseriesBucket(buckets, bucketStart(event.CreatedAt, groupBy))
|
||||
point := ensureTimeseriesBucket(buckets, bucketStart(event.CreatedAt, groupBy, loc))
|
||||
value := event.Value
|
||||
if businessHours {
|
||||
value = event.ValueInBusinessHours
|
||||
@@ -265,20 +266,53 @@ func dayStart(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func bucketStart(t time.Time, groupBy string) time.Time {
|
||||
t = t.UTC()
|
||||
func bucketStart(t time.Time, groupBy string, loc *time.Location) time.Time {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
t = t.In(loc)
|
||||
switch groupBy {
|
||||
case "hour":
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC)
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc)
|
||||
case "week":
|
||||
start := dayStart(t)
|
||||
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
return start.AddDate(0, 0, -int(start.Weekday()))
|
||||
case "month":
|
||||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc)
|
||||
case "year":
|
||||
return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, loc)
|
||||
default:
|
||||
return dayStart(t)
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
}
|
||||
|
||||
func timeseriesLocation(offsetHours float64) *time.Location {
|
||||
return time.FixedZone("report", int(offsetHours*3600))
|
||||
}
|
||||
|
||||
func seedTimeseriesBuckets(since, until time.Time, groupBy string, loc *time.Location) map[time.Time]*AnalyticsTimeseriesPoint {
|
||||
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
||||
if since.IsZero() || until.IsZero() || !since.Before(until) {
|
||||
return buckets
|
||||
}
|
||||
for bucket := bucketStart(since, groupBy, loc); bucket.Before(until.In(loc)); bucket = nextBucketStart(bucket, groupBy) {
|
||||
ensureTimeseriesBucket(buckets, bucket)
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
func nextBucketStart(bucket time.Time, groupBy string) time.Time {
|
||||
switch groupBy {
|
||||
case "hour":
|
||||
return bucket.Add(time.Hour)
|
||||
case "week":
|
||||
return bucket.AddDate(0, 0, 7)
|
||||
case "month":
|
||||
return bucket.AddDate(0, 1, 0)
|
||||
case "year":
|
||||
return bucket.AddDate(1, 0, 0)
|
||||
default:
|
||||
return bucket.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user