feat(time-tracking): broadcast timer changes over websocket

This commit is contained in:
kolaente 2026-06-08 15:11:07 +02:00 committed by kolaente
parent e197b1912f
commit cf22f08974
3 changed files with 159 additions and 0 deletions

View File

@ -21,7 +21,9 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/notifications"
"github.com/ThreeDotsLabs/watermill/message"
@ -67,10 +69,50 @@ func (n *NotificationListener) Handle(msg *message.Message) error {
return nil
}
// TimeEntryListener pushes a user's own timer changes to their WebSocket
// connections. wsEvent is "timer.created", "timer.updated" or "timer.deleted";
// the payload is the full entry, so the running-elsewhere badge reads end_time
// to know whether a timer is active (and the id to drop a deleted one). Not
// emitted on unlicensed instances.
type TimeEntryListener struct {
wsEvent string
}
func (l *TimeEntryListener) Name() string { return "websocket.push." + l.wsEvent }
func (l *TimeEntryListener) Handle(msg *message.Message) error {
if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
return nil
}
// All TimeEntry events share the {time_entry, doer} shape; only the entry is needed.
var event struct {
TimeEntry *models.TimeEntry `json:"time_entry"`
}
if err := json.Unmarshal(msg.Payload, &event); err != nil {
return err
}
if event.TimeEntry == nil {
return nil
}
hub := GetHub()
if hub == nil {
log.Warningf("WebSocket: hub not initialized, skipping timer push")
return nil
}
hub.PublishForUser(event.TimeEntry.UserID, l.wsEvent, event.TimeEntry)
return nil
}
// RegisterListeners registers WebSocket event listeners.
func RegisterListeners() {
events.RegisterListener(
(&notifications.NotificationCreatedEvent{}).Name(),
&NotificationListener{},
)
events.RegisterListener((&models.TimeEntryCreatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.created"})
events.RegisterListener((&models.TimeEntryUpdatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.updated"})
events.RegisterListener((&models.TimeEntryDeletedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.deleted"})
}

View File

@ -20,10 +20,14 @@ import (
"os"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
)
func TestMain(m *testing.M) {
log.InitLogger()
config.InitDefaultConfig()
keyvalue.InitStorage() // license.SetForTests persists state through keyvalue
os.Exit(m.Run())
}

View File

@ -0,0 +1,113 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package websocket
import (
"testing"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func timerConn(userID int64) *Connection {
return &Connection{
userID: userID,
subscriptions: map[string]bool{"timer.created": true, "timer.updated": true, "timer.deleted": true},
send: make(chan OutgoingMessage, 16),
}
}
func TestTimeEntryListener(t *testing.T) {
t.Run("a create pushes timer.created with the entry to its owner", func(t *testing.T) {
InitHub()
conn := timerConn(1)
GetHub().Register(conn)
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
defer license.ResetForTests()
ev := &models.TimeEntryCreatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.created"})
require.Len(t, conn.send, 1)
msg := <-conn.send
assert.Equal(t, "timer.created", msg.Event)
te, ok := msg.Data.(*models.TimeEntry)
require.True(t, ok, "payload must be the time entry itself")
assert.Equal(t, int64(4), te.ID)
})
t.Run("an update pushes timer.updated", func(t *testing.T) {
InitHub()
conn := timerConn(1)
GetHub().Register(conn)
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
defer license.ResetForTests()
ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"})
require.Len(t, conn.send, 1)
assert.Equal(t, "timer.updated", (<-conn.send).Event)
})
t.Run("a delete pushes timer.deleted so other tabs drop it", func(t *testing.T) {
InitHub()
conn := timerConn(1)
GetHub().Register(conn)
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
defer license.ResetForTests()
ev := &models.TimeEntryDeletedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.deleted"})
require.Len(t, conn.send, 1)
msg := <-conn.send
assert.Equal(t, "timer.deleted", msg.Event)
te, ok := msg.Data.(*models.TimeEntry)
require.True(t, ok)
assert.Equal(t, int64(4), te.ID)
})
t.Run("does not push when the feature is disabled", func(t *testing.T) {
InitHub()
conn := timerConn(1)
GetHub().Register(conn)
license.ResetForTests() // free mode
ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"})
assert.Empty(t, conn.send)
})
t.Run("only pushes to the entry owner", func(t *testing.T) {
InitHub()
other := timerConn(2)
GetHub().Register(other)
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
defer license.ResetForTests()
ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}}
events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"})
assert.Empty(t, other.send, "a different user must not receive the timer update")
})
}