Skip to content

Commit 2381f06

Browse files
rafmaster7claude
andcommitted
feat(dashboard): daily activity for the last 14 days, linking into the activity log
Adds a card to the admin dashboard with one row per day (today first) and the number of questions, answers, comments, review-queue items, badge awards and page views. Every number opens the activity log in a new tab, already filtered to that day and those actions; the date opens the whole day. Days are bucketed in the admin's time zone (tz_offset). Backed by GET /answer/admin/api/activity-log/daily?days=&tz_offset= (aggregated in Go from created_at + action of the activity_log rows). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0181uZ2px83HmPo3HKSEzWQR Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181uZ2px83HmPo3HKSEzWQR
1 parent 255d3a3 commit 2381f06

12 files changed

Lines changed: 252 additions & 2 deletions

File tree

‎i18n/en_US.yaml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,6 +1865,17 @@ ui:
18651865
dashboard:
18661866
title: Dashboard
18671867
welcome: Welcome to Admin!
1868+
daily_activity:
1869+
title: Activity in the last 14 days
1870+
day: Day
1871+
today: today
1872+
questions: Questions
1873+
answers: Answers
1874+
comments: Comments
1875+
reviews: To review
1876+
badges: Badges
1877+
views: Page views
1878+
open_log: Open in the activity log
18681879
site_statistics: Site statistics
18691880
questions: "Questions:"
18701881
resolved: "Resolved:"

‎internal/controller_admin/activity_log_controller.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ func (c *ActivityLogController) GetActivityLogActions(ctx *gin.Context) {
8585
handler.HandleResponse(ctx, err, resp)
8686
}
8787

88+
// GetActivityLogDaily daily counts for the dashboard
89+
// @Summary activity log daily counts
90+
// @Tags admin
91+
// @Produce json
92+
// @Security ApiKeyAuth
93+
// @Param days query int false "days back incl. today (default 14)"
94+
// @Param tz_offset query int false "browser UTC offset in minutes"
95+
// @Success 200 {object} handler.RespBody{data=[]schema.ActivityLogDailyRow}
96+
// @Router /answer/admin/api/activity-log/daily [get]
97+
func (c *ActivityLogController) GetActivityLogDaily(ctx *gin.Context) {
98+
req := &schema.ActivityLogDailyReq{}
99+
if handler.BindAndCheck(ctx, req) {
100+
return
101+
}
102+
resp, err := c.activityLogAdminService.Daily(ctx, req)
103+
handler.HandleResponse(ctx, err, resp)
104+
}
105+
88106
// ExportActivityLog TSV export with the same filters as the page
89107
// @Summary activity log export (TSV)
90108
// @Tags admin

‎internal/repo/activity_log/activity_log_repo.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,13 @@ func (r *activityLogRepo) SearchUserIDs(ctx context.Context, text string, limit
182182
}
183183
return ids, nil
184184
}
185+
186+
// ListActionsSince created_at + action only, for the dashboard's daily buckets
187+
func (r *activityLogRepo) ListActionsSince(ctx context.Context, t time.Time) ([]*entity.ActivityLog, error) {
188+
rows := make([]*entity.ActivityLog, 0)
189+
err := r.data.DB.Context(ctx).Cols("created_at", "action").Where("created_at >= ?", t).Find(&rows)
190+
if err != nil {
191+
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
192+
}
193+
return rows, nil
194+
}

‎internal/router/answer_api_router.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) {
425425
// community activity log
426426
r.GET("/activity-log", a.adminActivityLogController.GetActivityLogPage)
427427
r.GET("/activity-log/actions", a.adminActivityLogController.GetActivityLogActions)
428+
r.GET("/activity-log/daily", a.adminActivityLogController.GetActivityLogDaily)
428429
r.GET("/activity-log/export", a.adminActivityLogController.ExportActivityLog)
429430
r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus)
430431

‎internal/schema/activity_log_schema.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,26 @@ type ActivityLogPageViewReq struct {
7373
Title string `validate:"omitempty,lte=300" json:"title"`
7474
UserID string `json:"-"`
7575
}
76+
77+
// ActivityLogDailyReq daily activity counts for the dashboard
78+
type ActivityLogDailyReq struct {
79+
// Days how many days back (including today), default 14
80+
Days int `validate:"omitempty,min=1,max=90" form:"days"`
81+
// TzOffset browser offset from UTC in minutes (Date.getTimezoneOffset() negated, e.g. 120 for CEST)
82+
TzOffset int `validate:"omitempty,min=-840,max=840" form:"tz_offset"`
83+
}
84+
85+
// ActivityLogDailyRow counts of one local day
86+
type ActivityLogDailyRow struct {
87+
Date string `json:"date"` // YYYY-MM-DD in the browser's zone
88+
From int64 `json:"from"` // unix start of that day
89+
To int64 `json:"to"` // unix start of the next day
90+
Questions int64 `json:"questions"`
91+
Answers int64 `json:"answers"`
92+
Comments int64 `json:"comments"`
93+
Reviews int64 `json:"reviews"`
94+
Badges int64 `json:"badges"`
95+
Views int64 `json:"views"`
96+
Logins int64 `json:"logins"`
97+
Actions map[string]int64 `json:"actions"` // every action key → count (for future columns)
98+
}

‎internal/service/activity_log/activity_log_admin_service.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,53 @@ func (s *ActivityLogAdminService) PageView(ctx context.Context, req *schema.Acti
142142
s.logService.LogPageView(ctx, req.UserID, req.Path, req.Title)
143143
}
144144

145+
// Daily counts per local day for the dashboard (today first)
146+
func (s *ActivityLogAdminService) Daily(ctx context.Context, req *schema.ActivityLogDailyReq) ([]*schema.ActivityLogDailyRow, error) {
147+
days := req.Days
148+
if days <= 0 {
149+
days = 14
150+
}
151+
loc := time.FixedZone("browser", req.TzOffset*60)
152+
today := time.Now().In(loc)
153+
start := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -(days - 1))
154+
rows, err := s.repo.ListActionsSince(ctx, start)
155+
if err != nil {
156+
return nil, err
157+
}
158+
byDay := map[string]*schema.ActivityLogDailyRow{}
159+
out := make([]*schema.ActivityLogDailyRow, 0, days)
160+
for i := days - 1; i >= 0; i-- {
161+
d := start.AddDate(0, 0, i)
162+
row := &schema.ActivityLogDailyRow{Date: d.Format("2006-01-02"), From: d.Unix(), To: d.AddDate(0, 0, 1).Unix(), Actions: map[string]int64{}}
163+
byDay[row.Date] = row
164+
out = append(out, row)
165+
}
166+
for _, r := range rows {
167+
row, ok := byDay[r.CreatedAt.In(loc).Format("2006-01-02")]
168+
if !ok {
169+
continue
170+
}
171+
row.Actions[r.Action]++
172+
switch r.Action {
173+
case "question.create":
174+
row.Questions++
175+
case "answer.create":
176+
row.Answers++
177+
case "comment.create", ActionCommentReply:
178+
row.Comments++
179+
case ActionReviewQueued:
180+
row.Reviews++
181+
case ActionBadgeAward:
182+
row.Badges++
183+
case ActionPageView:
184+
row.Views++
185+
case ActionUserLogin:
186+
row.Logins++
187+
}
188+
}
189+
return out, nil
190+
}
191+
145192
// decorate resolves users and object titles for a batch of rows
146193
func (s *ActivityLogAdminService) decorate(ctx context.Context, rows []*entity.ActivityLog) []*schema.ActivityLogItem {
147194
userIDs := make([]string, 0, len(rows)*2)

‎internal/service/activity_log/activity_log_service.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ type ActivityLogRepo interface {
9797
Iterate(ctx context.Context, q *Query, limit int, fn func(rows []*entity.ActivityLog) bool) error
9898
ActionCounts(ctx context.Context, q *Query) (map[string]int64, error)
9999
DeletePageViewsBefore(ctx context.Context, t time.Time) (int64, error)
100+
// ListActionsSince created_at + action of rows since t (for daily aggregation)
101+
ListActionsSince(ctx context.Context, t time.Time) ([]*entity.ActivityLog, error)
100102
// SearchUserIDs ids of users whose username / display name / e-mail contains text
101103
SearchUserIDs(ctx context.Context, text string, limit int) ([]string, error)
102104
}

‎internal/service/activity_log/activity_log_service_test.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ func (f *fakeRepo) Iterate(context.Context, *Query, int, func([]*entity.Activity
4242
return nil
4343
}
4444
func (f *fakeRepo) ActionCounts(context.Context, *Query) (map[string]int64, error) { return nil, nil }
45-
func (f *fakeRepo) SearchUserIDs(context.Context, string, int) ([]string, error) { return nil, nil }
45+
func (f *fakeRepo) ListActionsSince(context.Context, time.Time) ([]*entity.ActivityLog, error) {
46+
return nil, nil
47+
}
48+
func (f *fakeRepo) SearchUserIDs(context.Context, string, int) ([]string, error) { return nil, nil }
4649
func (f *fakeRepo) DeletePageViewsBefore(context.Context, time.Time) (int64, error) {
4750
return 0, nil
4851
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { FC } from 'react';
21+
import { Card, Table } from 'react-bootstrap';
22+
import { useTranslation } from 'react-i18next';
23+
24+
import dayjs from 'dayjs';
25+
26+
import { useQueryActivityLogDaily } from '@/services';
27+
28+
// What happened on each of the last 14 days; every number opens the
29+
// activity log in a new tab, already filtered to that day and those actions
30+
const COLUMNS: { key: string; actions: string }[] = [
31+
{ key: 'questions', actions: 'question.create' },
32+
{ key: 'answers', actions: 'answer.create' },
33+
{ key: 'comments', actions: 'comment.create,comment.reply' },
34+
{ key: 'reviews', actions: 'review.queued' },
35+
{ key: 'badges', actions: 'badge.award' },
36+
{ key: 'views', actions: 'page.view' },
37+
];
38+
39+
const DailyActivity: FC = () => {
40+
const { t } = useTranslation('translation', {
41+
keyPrefix: 'admin.dashboard.daily_activity',
42+
});
43+
const { data } = useQueryActivityLogDaily(14);
44+
const today = dayjs().format('YYYY-MM-DD');
45+
46+
const logLink = (date: string, actions: string) =>
47+
`/admin/activity-log?range=custom&from=${date}&to=${date}&action=${encodeURIComponent(actions)}`;
48+
49+
return (
50+
<Card className="mb-4">
51+
<Card.Body>
52+
<h6 className="mb-3">{t('title')}</h6>
53+
<Table size="sm" responsive className="mb-0 align-middle">
54+
<thead>
55+
<tr>
56+
<th>{t('day')}</th>
57+
{COLUMNS.map((c) => (
58+
<th key={c.key} className="text-end">
59+
{t(c.key)}
60+
</th>
61+
))}
62+
</tr>
63+
</thead>
64+
<tbody>
65+
{(data || []).map((row) => (
66+
<tr key={row.date}>
67+
<td className="text-nowrap">
68+
<a
69+
href={logLink(row.date, '')}
70+
target="_blank"
71+
rel="noreferrer"
72+
className={
73+
row.date === today ? 'fw-bold' : 'link-secondary'
74+
}>
75+
{row.date}
76+
{row.date === today ? ` (${t('today')})` : ''}
77+
</a>
78+
</td>
79+
{COLUMNS.map((c) => {
80+
const n = Number(row[c.key] || 0);
81+
return (
82+
<td key={c.key} className="text-end">
83+
{n > 0 ? (
84+
<a
85+
href={logLink(row.date, c.actions)}
86+
target="_blank"
87+
rel="noreferrer"
88+
title={t('open_log')}>
89+
{n}
90+
</a>
91+
) : (
92+
<span className="text-secondary">0</span>
93+
)}
94+
</td>
95+
);
96+
})}
97+
</tr>
98+
))}
99+
</tbody>
100+
</Table>
101+
</Card.Body>
102+
</Card>
103+
);
104+
};
105+
106+
export default DailyActivity;

‎ui/src/pages/Admin/Dashboard/components/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ import SystemInfo from './SystemInfo';
2121
import Statistics from './Statistics';
2222
import AnswerLinks from './AnswerLinks';
2323
import HealthStatus from './HealthStatus';
24+
import DailyActivity from './DailyActivity';
2425

25-
export { SystemInfo, Statistics, AnswerLinks, HealthStatus };
26+
export { SystemInfo, Statistics, AnswerLinks, HealthStatus, DailyActivity };

0 commit comments

Comments
 (0)