wellbeing: add Digital Wellbeing, with a built-in dash plugin

This commit is contained in:
bbedward
2026-10-01 15:15:37 -04:00
parent a348d8bc5e
commit d77c4f6298
31 changed files with 2439 additions and 8 deletions
+9
View File
@@ -28,6 +28,7 @@ import (
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wellbeing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/dankgo/ipc"
)
@@ -213,6 +214,14 @@ func newRequestMux() *ipc.Mux {
location.HandleRequest(conn, req, locationManager)
}))
mux.HandlePrefix("wellbeing.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) {
if wellbeingManager == nil {
models.RespondError(conn, req.ID, "wellbeing manager not initialized")
return
}
wellbeing.HandleRequest(conn, req, wellbeingManager)
}))
mux.HandlePrefix("notify.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) {
if notifyActionsManager == nil {
models.RespondError(conn, req.ID, "notification action manager not initialized")
+1
View File
@@ -37,6 +37,7 @@ func TestRouteRequestUnavailableManagers(t *testing.T) {
{"dbus.subscribe", "dbus manager not initialized"},
{"clipboard.getState", "clipboard manager not initialized"},
{"location.getState", "location manager not initialized"},
{"wellbeing.getState", "wellbeing manager not initialized"},
{"notify.invoke", "notification action manager not initialized"},
{"sysupdate.getState", "sysupdate manager not initialized"},
{"files.list", "files service not initialized"},
+39 -1
View File
@@ -31,6 +31,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wellbeing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
@@ -38,7 +39,7 @@ import (
"github.com/AvengeMedia/dankgo/syncmap"
)
const APIVersion = 36
const APIVersion = 37
var CLIVersion = "dev"
@@ -77,6 +78,7 @@ var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager
var notifyActionsManager *notifyactions.Manager
var wellbeingManager *wellbeing.Manager
var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
@@ -349,6 +351,16 @@ func InitializeTrayRecoveryManager() error {
return nil
}
func InitializeWellbeingManager() error {
manager, err := wellbeing.NewManager()
if err != nil {
return err
}
wellbeingManager = manager
log.Info("Wellbeing manager initialized")
return nil
}
func InitializeLocationManager(geoClient geolocation.Client) error {
manager, err := location.NewManager(geoClient)
if err != nil {
@@ -472,6 +484,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "location")
}
if wellbeingManager != nil {
caps = append(caps, "wellbeing")
}
if dbusManager != nil {
caps = append(caps, "dbus")
}
@@ -730,6 +746,15 @@ func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request)
}, mgr.GetState)
}
if shouldSubscribe("wellbeing") && wellbeingManager != nil {
mgr := wellbeingManager
id := clientID + "-wellbeing"
source := mgr.Subscribe(id)
forwardSubscription(&wg, eventChan, stopChan, "wellbeing", source, func() {
mgr.Unsubscribe(id)
}, mgr.Snapshot)
}
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
mgr := sysUpdateManager
id := clientID + "-sysupdate"
@@ -873,6 +898,9 @@ func cleanupManagers() {
if sysUpdateManager != nil {
sysUpdateManager.Close()
}
if wellbeingManager != nil {
wellbeingManager.Close()
}
if geoClientInstance != nil {
geoClientInstance.Close()
}
@@ -1088,6 +1116,12 @@ func (s *Server) Serve(printDocs bool) error {
log.Info("Location:")
log.Info(" location.getState - Get current location state")
log.Info(" location.subscribe - Subscribe to location changes (streaming)")
log.Info("Wellbeing:")
log.Info(" wellbeing.getState - Today's screen time per app")
log.Info(" wellbeing.setState - Report the focused app and whether the session is active (params: appId?, active, seq?)")
log.Info(" wellbeing.setLimits - Set daily and per-app limits in seconds (params: daily?, apps?)")
log.Info(" wellbeing.summary - Per-day screen time ending today (params: days?)")
log.Info(" wellbeing.clear - Delete the screen time history")
log.Info("")
}
log.Info("Initializing managers...")
@@ -1190,6 +1224,10 @@ func (s *Server) Serve(printDocs bool) error {
}()
}
if err := InitializeWellbeingManager(); err != nil {
log.Warnf("Wellbeing manager unavailable: %v", err)
}
if err := InitializeWallpaperManager(); err != nil {
log.Warnf("Wallpaper scheduler unavailable: %v", err)
} else {
@@ -0,0 +1,53 @@
package wellbeing
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/dankgo/ipc"
"github.com/AvengeMedia/dankgo/ipc/params"
)
func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) {
switch req.Method {
case "wellbeing.getState":
models.Respond(conn, req.ID, manager.GetState())
case "wellbeing.setState":
handleSetState(conn, req, manager)
case "wellbeing.setLimits":
handleSetLimits(conn, req, manager)
case "wellbeing.summary":
days := min(max(params.IntOpt(req.Params, "days", 7), 1), retentionDays)
models.Respond(conn, req.ID, map[string]any{"days": manager.Summary(days)})
case "wellbeing.clear":
manager.Clear()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wellbeing history cleared"})
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleSetState(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) {
active, err := params.Bool(req.Params, "active")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
manager.SetState(params.StringOpt(req.Params, "appId", ""), active, int64(params.FloatOpt(req.Params, "seq", 0)))
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wellbeing state set"})
}
func handleSetLimits(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) {
limits := Limits{Daily: int64(params.FloatOpt(req.Params, "daily", 0)), Apps: map[string]int64{}}
if apps, ok := params.AnyMap(req.Params, "apps"); ok {
for appID, raw := range apps {
seconds, ok := raw.(float64)
if !ok || seconds <= 0 || appID == "" {
continue
}
limits.Apps[appID] = int64(seconds)
}
}
manager.SetLimits(limits)
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wellbeing limits set"})
}
+351
View File
@@ -0,0 +1,351 @@
package wellbeing
import (
"maps"
"slices"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
bolt "go.etcd.io/bbolt"
)
func NewManager() (*Manager, error) {
return newManager(DBPath(), time.Now)
}
func newManager(path string, now func() time.Time) (*Manager, error) {
db, err := openDB(path)
if err != nil {
return nil, err
}
m := &Manager{
db: db,
now: now,
wake: make(chan struct{}, 1),
stopChan: make(chan struct{}),
limits: Limits{Apps: map[string]int64{}},
}
m.cur = segment{Since: now().Unix()}
err = m.dbView(func(tx *bolt.Tx) error {
readMeta(tx.Bucket(bucketMeta), keyAlerted, &m.alerted)
return nil
})
if err != nil {
db.Close()
return nil, err
}
m.wg.Go(m.run)
return m, nil
}
func (m *Manager) Close() {
close(m.stopChan)
m.wg.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
m.db.Close()
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
}
// The shell is the only source of focus and idle state, so when its last
// subscription drops the open segment ends instead of counting forever.
func (m *Manager) Unsubscribe(id string) {
ch, ok := m.subscribers.LoadAndDelete(id)
if !ok {
return
}
close(ch)
if m.subscriberCount() > 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.setSegmentLocked("", false)
}
func (m *Manager) subscriberCount() int {
count := 0
m.subscribers.Range(func(string, chan State) bool {
count++
return true
})
return count
}
// Snapshot opens every subscription; Resync asks the shell to push its state again.
func (m *Manager) Snapshot() State {
state := m.GetState()
state.Resync = true
return state
}
func (m *Manager) GetState() State {
m.mu.Lock()
defer m.mu.Unlock()
return State{Today: m.summaryLocked(1)[0]}
}
func (m *Manager) Summary(days int) []DayUsage {
m.mu.Lock()
defer m.mu.Unlock()
return m.summaryLocked(days)
}
func (m *Manager) SetState(appID string, active bool, seq int64) {
m.mu.Lock()
defer m.mu.Unlock()
if seq != 0 && seq <= m.seq {
return
}
if seq != 0 {
m.seq = seq
}
m.setSegmentLocked(appID, active)
}
func (m *Manager) setSegmentLocked(appID string, active bool) {
if m.cur.AppID == appID && m.cur.Active == active {
return
}
now := m.now()
m.creditLocked(now)
m.cur = segment{AppID: appID, Active: active, Since: now.Unix()}
select {
case m.wake <- struct{}{}:
default:
}
}
func (m *Manager) SetLimits(limits Limits) {
m.mu.Lock()
defer m.mu.Unlock()
if limits.Apps == nil {
limits.Apps = map[string]int64{}
}
previous, hadLimits := m.limits, m.limitsSet
m.limits, m.limitsSet = limits, true
if !hadLimits {
return
}
var keep []string
for _, key := range m.alerted.Keys {
if limitFor(key, previous) == limitFor(key, limits) {
keep = append(keep, key)
}
}
if len(keep) == len(m.alerted.Keys) {
return
}
m.alerted.Keys = keep
m.writeAlertedLocked()
}
func limitFor(key string, limits Limits) int64 {
if key == "daily" {
return limits.Daily
}
return limits.Apps[key[len("app:"):]]
}
func (m *Manager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.cur.Since = m.now().Unix()
m.alerted = alertRecord{}
err := m.dbUpdate(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket(bucketDays); err != nil {
return err
}
if _, err := tx.CreateBucket(bucketDays); err != nil {
return err
}
return tx.Bucket(bucketMeta).Delete(keyAlerted)
})
if err != nil {
log.Warnf("Wellbeing: clear failed: %v", err)
}
m.publish(State{Today: m.summaryLocked(1)[0]})
}
func (m *Manager) Flush() {
m.mu.Lock()
defer m.mu.Unlock()
m.creditLocked(m.now())
}
func (m *Manager) run() {
timer := time.NewTimer(flushInterval)
timer.Stop()
running := false
for {
m.mu.Lock()
active := m.cur.Active
m.mu.Unlock()
switch {
case active && !running:
timer.Reset(flushInterval)
running = true
case !active && running:
timer.Stop()
running = false
}
select {
case <-m.stopChan:
timer.Stop()
m.Flush()
return
case <-m.wake:
case <-timer.C:
running = false
m.Flush()
}
}
}
// splitCredit hands out the segment's seconds per local calendar day.
func splitCredit(seg segment, now time.Time, fn func(date string, seconds int64)) {
if !seg.Active {
return
}
gap := now.Unix() - seg.Since
if gap <= 0 || gap > int64(maxCreditGap/time.Second) {
return
}
cursor := time.Unix(seg.Since, 0).In(now.Location())
for {
dayEnd := time.Date(cursor.Year(), cursor.Month(), cursor.Day()+1, 0, 0, 0, 0, cursor.Location())
if !dayEnd.Before(now) {
fn(cursor.Format(dateLayout), now.Unix()-cursor.Unix())
return
}
fn(cursor.Format(dateLayout), dayEnd.Unix()-cursor.Unix())
cursor = dayEnd
}
}
func (m *Manager) creditLocked(now time.Time) {
seg := m.cur
m.cur.Since = now.Unix()
var today DayUsage
err := m.dbUpdate(func(tx *bolt.Tx) error {
days := tx.Bucket(bucketDays)
var err error
splitCredit(seg, now, func(date string, seconds int64) {
if err != nil {
return
}
day := readDay(days, date)
day.Active += seconds
if seg.AppID != "" {
day.Apps[seg.AppID] += seconds
}
err = writeDay(days, day)
today = day
})
if err != nil || today.Date == "" {
return err
}
return pruneDays(days, now.AddDate(0, 0, -retentionDays).Format(dateLayout))
})
if err != nil {
log.Warnf("Wellbeing: credit failed: %v", err)
return
}
if today.Date == "" {
return
}
events := m.crossedLimitsLocked(today)
if len(events) == 0 {
m.publish(State{Today: today})
return
}
for i := range events {
m.publish(State{Today: today, Limit: &events[i]})
}
}
func (m *Manager) crossedLimitsLocked(today DayUsage) []LimitEvent {
if m.alerted.Date != today.Date {
m.alerted = alertRecord{Date: today.Date}
}
var events []LimitEvent
if m.limits.Daily > 0 && today.Active >= m.limits.Daily && !slices.Contains(m.alerted.Keys, "daily") {
m.alerted.Keys = append(m.alerted.Keys, "daily")
events = append(events, LimitEvent{Kind: "daily", Used: today.Active, Limit: m.limits.Daily})
}
for _, appID := range slices.Sorted(maps.Keys(m.limits.Apps)) {
limit := m.limits.Apps[appID]
used := today.Apps[appID]
key := "app:" + appID
if limit <= 0 || used < limit || slices.Contains(m.alerted.Keys, key) {
continue
}
m.alerted.Keys = append(m.alerted.Keys, key)
events = append(events, LimitEvent{Kind: "app", AppID: appID, Used: used, Limit: limit})
}
if len(events) > 0 {
m.writeAlertedLocked()
}
return events
}
func (m *Manager) writeAlertedLocked() {
err := m.dbUpdate(func(tx *bolt.Tx) error {
return writeMeta(tx.Bucket(bucketMeta), keyAlerted, m.alerted)
})
if err != nil {
log.Warnf("Wellbeing: alert record write failed: %v", err)
}
}
func (m *Manager) summaryLocked(days int) []DayUsage {
now := m.now()
days = max(1, days)
result := make([]DayUsage, days)
index := map[string]int{}
for i := range days {
date := now.AddDate(0, 0, i-days+1).Format(dateLayout)
result[i] = DayUsage{Date: date, Apps: map[string]int64{}}
index[date] = i
}
err := m.dbView(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketDays)
for date, i := range index {
result[i] = readDay(bucket, date)
}
return nil
})
if err != nil {
log.Warnf("Wellbeing: summary read failed: %v", err)
}
splitCredit(m.cur, now, func(date string, seconds int64) {
i, ok := index[date]
if !ok {
return
}
result[i].Active += seconds
if m.cur.AppID != "" {
result[i].Apps[m.cur.AppID] += seconds
}
})
return result
}
func (m *Manager) publish(state State) {
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- state:
default:
log.Warn("Wellbeing: subscriber channel full, dropping update")
}
return true
})
}
@@ -0,0 +1,196 @@
package wellbeing
import (
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type clock struct{ now time.Time }
func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) }
func newTestManager(t *testing.T, start time.Time) (*Manager, *clock) {
t.Helper()
c := &clock{now: start}
m, err := newManager(filepath.Join(t.TempDir(), "db"), func() time.Time { return c.now })
require.NoError(t, err)
t.Cleanup(m.Close)
return m, c
}
func drain(ch chan State) []State {
var out []State
for {
select {
case s := <-ch:
out = append(out, s)
default:
return out
}
}
}
func TestFocusTimeSplitsAtMidnightPerApp(t *testing.T) {
start := time.Date(2026, 10, 1, 23, 59, 30, 0, time.UTC)
m, c := newTestManager(t, start)
m.SetState("firefox", true, 1)
c.advance(50 * time.Second)
m.SetState("kitty", true, 2)
c.advance(10 * time.Second)
m.SetState("", false, 3)
days := m.Summary(2)
assert.Equal(t, "2026-10-01", days[0].Date)
assert.Equal(t, int64(30), days[0].Active)
assert.Equal(t, map[string]int64{"firefox": 30}, days[0].Apps)
assert.Equal(t, "2026-10-02", days[1].Date)
assert.Equal(t, int64(30), days[1].Active)
assert.Equal(t, map[string]int64{"firefox": 20, "kitty": 10}, days[1].Apps)
}
func TestInactiveAndFrozenGapsCountNothing(t *testing.T) {
start := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
m, c := newTestManager(t, start)
m.SetState("firefox", false, 1)
c.advance(40 * time.Second)
m.SetState("firefox", true, 2)
c.advance(maxCreditGap + time.Second)
m.Flush()
c.advance(20 * time.Second)
m.SetState("", false, 3)
today := m.Summary(1)[0]
assert.Equal(t, int64(20), today.Active)
assert.Equal(t, map[string]int64{"firefox": 20}, today.Apps)
}
func TestStaleSequenceIsIgnored(t *testing.T) {
m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC))
m.SetState("kitty", true, 10)
m.SetState("firefox", true, 9)
c.advance(15 * time.Second)
m.SetState("", false, 11)
assert.Equal(t, map[string]int64{"kitty": 15}, m.Summary(1)[0].Apps)
}
func TestOpenSegmentShowsInSummaryWithoutBeingStored(t *testing.T) {
m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC))
m.SetState("firefox", true, 1)
c.advance(25 * time.Second)
assert.Equal(t, int64(25), m.Summary(1)[0].Apps["firefox"])
assert.Equal(t, int64(25), m.GetState().Today.Active)
assert.Equal(t, int64(25), m.Summary(1)[0].Apps["firefox"], "a read must not credit twice")
}
func TestLimitsAlertOncePerDayAndAgainAfterRaise(t *testing.T) {
m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC))
ch := m.Subscribe("test")
m.SetLimits(Limits{Daily: 60, Apps: map[string]int64{"firefox": 30}})
m.SetState("firefox", true, 1)
c.advance(30 * time.Second)
m.Flush()
c.advance(30 * time.Second)
m.Flush()
c.advance(30 * time.Second)
m.Flush()
var kinds []string
for _, s := range drain(ch) {
if s.Limit != nil {
kinds = append(kinds, s.Limit.Kind+":"+s.Limit.AppID)
}
}
assert.Equal(t, []string{"app:firefox", "daily:"}, kinds)
m.SetLimits(Limits{Daily: 60, Apps: map[string]int64{"firefox": 80}})
c.advance(30 * time.Second)
m.Flush()
limits := 0
for _, s := range drain(ch) {
if s.Limit != nil {
limits++
assert.Equal(t, int64(120), s.Limit.Used)
}
}
assert.Equal(t, 1, limits, "a raised app limit alerts again, the unchanged daily limit does not")
c.advance(12 * time.Hour)
m.Flush()
c.advance(30 * time.Second)
m.Flush()
for _, s := range drain(ch) {
assert.Nil(t, s.Limit, "nothing crosses on a frozen gap or a fresh day under the limits")
}
assert.Equal(t, int64(30), m.Summary(1)[0].Active)
}
func TestLastUnsubscribeEndsTheOpenSegment(t *testing.T) {
m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC))
m.Subscribe("shell")
m.SetState("firefox", true, 1)
c.advance(10 * time.Second)
m.Unsubscribe("shell")
c.advance(50 * time.Second)
assert.Equal(t, int64(10), m.Summary(1)[0].Active)
assert.True(t, m.Snapshot().Resync)
}
func TestClearDropsHistoryAndRearmsAlerts(t *testing.T) {
m, c := newTestManager(t, time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC))
ch := m.Subscribe("test")
m.SetLimits(Limits{Daily: 10})
m.SetState("firefox", true, 1)
c.advance(10 * time.Second)
m.Flush()
require.NotNil(t, drain(ch)[0].Limit)
m.Clear()
assert.Equal(t, int64(0), m.Summary(1)[0].Active)
c.advance(10 * time.Second)
m.Flush()
alerts := 0
for _, s := range drain(ch) {
if s.Limit != nil {
alerts++
}
}
assert.Equal(t, 1, alerts)
}
func TestAlertRecordSurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
c := &clock{now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)}
now := func() time.Time { return c.now }
m, err := newManager(path, now)
require.NoError(t, err)
m.SetLimits(Limits{Daily: 10})
m.SetState("firefox", true, 1)
c.advance(10 * time.Second)
m.Close()
m, err = newManager(path, now)
require.NoError(t, err)
defer m.Close()
ch := m.Subscribe("test")
m.SetLimits(Limits{Daily: 10})
m.SetState("firefox", true, 2)
c.advance(10 * time.Second)
m.Flush()
assert.Equal(t, int64(20), m.Summary(1)[0].Active)
for _, s := range drain(ch) {
assert.Nil(t, s.Limit, "the alert already fired before the restart")
}
}
+156
View File
@@ -0,0 +1,156 @@
package wellbeing
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime/debug"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
bolt "go.etcd.io/bbolt"
)
var (
bucketDays = []byte("days")
bucketMeta = []byte("meta")
keyAlerted = []byte("alerted")
)
func DBPath() string {
return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "wellbeing", "db")
}
func openDB(path string) (*bolt.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
db, err := tryOpenDB(path)
if err == nil {
return db, nil
}
log.Errorf("Wellbeing db unreadable, starting over: %v", err)
corruptPath := path + ".corrupt"
os.Remove(corruptPath)
if renameErr := os.Rename(path, corruptPath); renameErr != nil {
return nil, err
}
return tryOpenDB(path)
}
// bbolt reports corrupted pages by panicking (internal/common/verify.go), not
// by returning errors, so every db touchpoint recovers and converts to error.
func recoverDBPanic(err *error) {
r := recover()
if r == nil {
return
}
*err = fmt.Errorf("wellbeing db panic: %v", r)
}
// a pgid past the mmap end faults instead of panicking; only recoverable while armed
func armDBFaultPanics() func() {
prev := debug.SetPanicOnFault(true)
return func() { debug.SetPanicOnFault(prev) }
}
func tryOpenDB(path string) (db *bolt.DB, err error) {
defer func() {
r := recover()
if r == nil {
return
}
if db != nil {
db.Close()
}
db, err = nil, fmt.Errorf("wellbeing db panic: %v", r)
}()
defer armDBFaultPanics()()
db, err = bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second})
if err != nil {
return nil, err
}
err = db.Update(func(tx *bolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists(bucketDays); err != nil {
return err
}
_, err := tx.CreateBucketIfNotExists(bucketMeta)
return err
})
if err == nil {
err = db.View(func(tx *bolt.Tx) error {
return tx.Bucket(bucketDays).ForEach(func(k, v []byte) error { return nil })
})
}
if err != nil {
db.Close()
return nil, err
}
return db, nil
}
func (m *Manager) dbUpdate(fn func(tx *bolt.Tx) error) (err error) {
defer recoverDBPanic(&err)
defer armDBFaultPanics()()
return m.db.Update(fn)
}
func (m *Manager) dbView(fn func(tx *bolt.Tx) error) (err error) {
defer recoverDBPanic(&err)
defer armDBFaultPanics()()
return m.db.View(fn)
}
func readDay(b *bolt.Bucket, date string) DayUsage {
day := DayUsage{Date: date, Apps: map[string]int64{}}
raw := b.Get([]byte(date))
if raw == nil {
return day
}
if err := json.Unmarshal(raw, &day); err != nil || day.Apps == nil {
day.Apps = map[string]int64{}
}
day.Date = date
return day
}
func writeDay(b *bolt.Bucket, day DayUsage) error {
raw, err := json.Marshal(day)
if err != nil {
return err
}
return b.Put([]byte(day.Date), raw)
}
func readMeta(b *bolt.Bucket, key []byte, out any) bool {
raw := b.Get(key)
if raw == nil {
return false
}
return json.Unmarshal(raw, out) == nil
}
func writeMeta(b *bolt.Bucket, key []byte, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return b.Put(key, raw)
}
func pruneDays(b *bolt.Bucket, oldest string) error {
c := b.Cursor()
var stale [][]byte
for k, _ := c.First(); k != nil && string(k) < oldest; k, _ = c.Next() {
stale = append(stale, append([]byte(nil), k...))
}
for _, k := range stale {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package wellbeing
import (
"sync"
"time"
"github.com/AvengeMedia/dankgo/syncmap"
bolt "go.etcd.io/bbolt"
)
const (
flushInterval = time.Minute
// a gap longer than this between credits means the process was frozen (suspend); nothing is counted for it
maxCreditGap = 2 * flushInterval
retentionDays = 92
dateLayout = "2006-01-02"
)
type DayUsage struct {
Date string `json:"date"`
Active int64 `json:"active"`
Apps map[string]int64 `json:"apps"`
}
type Limits struct {
Daily int64 `json:"daily"`
Apps map[string]int64 `json:"apps"`
}
type LimitEvent struct {
Kind string `json:"kind"`
AppID string `json:"appId,omitempty"`
Used int64 `json:"used"`
Limit int64 `json:"limit"`
}
type State struct {
Today DayUsage `json:"today"`
Limit *LimitEvent `json:"limit,omitempty"`
Resync bool `json:"resync,omitempty"`
}
type segment struct {
AppID string `json:"appId"`
Active bool `json:"active"`
Since int64 `json:"since"`
}
type alertRecord struct {
Date string `json:"date"`
Keys []string `json:"keys"`
}
type Manager struct {
mu sync.Mutex
db *bolt.DB
now func() time.Time
cur segment
seq int64
limits Limits
limitsSet bool
alerted alertRecord
subscribers syncmap.Map[string, chan State]
wake chan struct{}
stopChan chan struct{}
wg sync.WaitGroup
}
+7
View File
@@ -487,6 +487,10 @@ Singleton {
"id": "weather",
"enabled": true
},
{
"id": "wellbeing",
"enabled": true
},
{
"id": "notifications",
"enabled": false
@@ -528,6 +532,9 @@ Singleton {
onDashCardsChanged: saveSettings()
property var dashOptions: Spec.SPEC.dashOptions.def
onDashOptionsChanged: saveSettings()
property bool wellbeingEnabled: Spec.SPEC.wellbeingEnabled.def
property int wellbeingDailyLimit: Spec.SPEC.wellbeingDailyLimit.def
property var wellbeingAppLimits: Spec.SPEC.wellbeingAppLimits.def
function getDashTabs() {
const stored = Array.isArray(dashTabs) ? dashTabs : [];
+8
View File
@@ -248,6 +248,14 @@ Singleton {
"tabIndex": 18,
"hint": I18n.tr("Volume, brightness, caps lock, position")
},
{
"id": "wellbeing",
"text": I18n.tr("Digital wellbeing"),
"icon": "digital_wellbeing",
"tabIndex": 67,
"aliases": ["screen_time"],
"hint": I18n.tr("Screen time and app limits")
},
{
"id": "sound_media",
"text": I18n.tr("Sound & media"),
@@ -735,6 +735,10 @@ var LOCAL_SPEC = {
id: "weather",
enabled: true
},
{
id: "wellbeing",
enabled: true
},
{
id: "notifications",
enabled: false
@@ -773,6 +777,15 @@ var LOCAL_SPEC = {
dashOptions: {
def: {}
},
wellbeingEnabled: {
def: true
},
wellbeingDailyLimit: {
def: 0
},
wellbeingAppLimits: {
def: {}
},
networkPreference: {
def: "auto"
},
+1
View File
@@ -257,6 +257,7 @@ Item {
TrashService.count;
WallpaperCyclingService.cyclingActive;
ThemeAutoService.active;
WellbeingService.tracking;
}
Loader {
@@ -243,6 +243,7 @@ FocusScope {
"autostart": "AutoStartTab.qml",
"battery": "BatteryTab.qml",
"dank_dash": "DankDashTab.qml",
"wellbeing": "DigitalWellbeingTab.qml",
"mouse_touchpad": "MouseTouchpadTab.qml",
"keyboard": "KeyboardTab.qml",
"plugins_manage": "PluginsManageTab.qml"
@@ -172,6 +172,25 @@ Singleton {
},
"options": [cardOnly(toggle("city", I18n.tr("Show city"), false)), cardOnly(toggle("readings", I18n.tr("Show readings"), true)), cardOnly(toneOption())]
},
{
"id": "wellbeing",
"text": I18n.tr("Digital wellbeing"),
"icon": "digital_wellbeing",
"description": SettingsData.wellbeingEnabled ? I18n.tr("Screen time and app limits") : I18n.tr("Hidden until screen time tracking is enabled"),
"available": SettingsData.wellbeingEnabled,
"tab": {
"component": wellbeingTab,
"async": true
},
"card": {
"component": wellbeingCard,
"w": 2,
"h": 1,
"minW": 1,
"minH": 1,
"maxH": 3
}
},
{
"id": "notifications",
"text": I18n.tr("Notifications"),
@@ -716,6 +735,16 @@ Singleton {
NotificationsOverviewCard {}
}
Component {
id: wellbeingTab
WellbeingTab {}
}
Component {
id: wellbeingCard
WellbeingOverviewCard {}
}
Component {
id: clockCard
ClockCard {}
@@ -13,6 +13,11 @@ CcSheetDialog {
property bool tabScope: false
readonly property var entry: DashRegistry.entry(entryId)
readonly property var settingsPages: ({
"weather": "weather",
"media": "media_player",
"wellbeing": "wellbeing"
})
readonly property var specs: DashRegistry.sheetOptionSpecs(entryId, tabScope)
function presentFor(id) {
@@ -44,14 +49,14 @@ CcSheetDialog {
}
SettingsNavRow {
visible: root.entryId === "weather" || root.entryId === "media"
visible: root.entryId in root.settingsPages
width: parent.width
title: root.entryId === "media" ? I18n.tr("Media player") : I18n.tr("Weather")
title: root.entryId === "media" ? I18n.tr("Media player") : root.entry?.text ?? ""
iconName: "settings"
onClicked: {
root.dismiss();
PopoutService.closeDankDash();
PopoutService.openSettingsWithTab(root.entryId === "media" ? "media_player" : "weather");
PopoutService.openSettingsWithTab(root.settingsPages[root.entryId]);
}
}
@@ -0,0 +1,122 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.DankDash
import qs.Modules.DankDash.Wellbeing
import qs.Modules.ControlCenter.Widgets
import "../Wellbeing/Wellbeing.js" as Wellbeing
Card {
id: root
property bool live: Window.window?.visible ?? false
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
readonly property bool tall: height >= DashMetrics.gridRowUnit * 2
readonly property var today: WellbeingService.today
readonly property real appRowHeight: Theme.iconSizeMedium + Theme.spacingXS
readonly property int appRows: tall ? Math.min(WellbeingMetrics.cardApps, Math.floor((height - pad * 2 - header.height - value.height - Theme.spacingS * 2) / (appRowHeight + Theme.spacingXS))) : 0
readonly property var apps: appRows > 0 ? Wellbeing.topApps([today], appRows) : []
readonly property real limitSeconds: SettingsData.wellbeingDailyLimit * 60
readonly property bool overLimit: limitSeconds > 0 && today.active >= limitSeconds
entryId: "wellbeing"
clipContent: true
pad: Theme.spacingM
Accessible.name: I18n.tr("Screen time")
Row {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "digital_wellbeing"
size: Theme.iconSizeMedium
color: root.accentColor
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Theme.iconSizeMedium - parent.spacing
text: I18n.tr("Screen time")
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: root.contentColor
elide: Text.ElideRight
}
}
StyledText {
id: value
anchors.left: parent.left
anchors.right: parent.right
y: root.apps.length > 0 ? header.height + Theme.spacingXS : parent.height - height
text: WellbeingService.formatDuration(root.today.active)
font.pixelSize: root.wide ? Theme.fontSizeXXLarge : DashMetrics.tileValueSizeCompact
font.weight: Theme.fontWeightMedium
font.features: ({
"tnum": 1
})
color: root.overLimit ? Theme.error : root.contentColor
elide: Text.ElideRight
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: value.bottom
anchors.topMargin: Theme.spacingS
spacing: Theme.spacingXS
visible: root.apps.length > 0
Repeater {
model: root.apps
Item {
id: appRow
required property var modelData
width: parent.width
height: root.appRowHeight
CcAppIcon {
id: icon
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
appId: appRow.modelData.appId
iconSize: Theme.iconSizeMedium
}
StyledText {
anchors.left: icon.right
anchors.leftMargin: Theme.spacingS
anchors.right: time.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: WellbeingService.appName(appRow.modelData.appId)
font.pixelSize: Theme.fontSizeSmall
color: root.contentColor
elide: Text.ElideRight
}
StyledText {
id: time
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: WellbeingService.formatDuration(appRow.modelData.seconds)
font.pixelSize: Theme.fontSizeSmall
font.features: ({
"tnum": 1
})
color: root.mutedColor
}
}
}
}
}
@@ -0,0 +1,41 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
import qs.Modules.ControlCenter.Widgets
import qs.Modules.DankDash
CcSheetDialog {
id: root
property string appId: ""
readonly property int minutes: WellbeingService.appLimitMinutes(appId)
function presentFor(id) {
appId = id;
present();
}
panelWidth: DashMetrics.optionSheetWidth
iconName: "timer"
title: WellbeingService.appName(appId)
subtitle: I18n.tr("Daily limit")
showScrollBar: false
SettingsGroup {
width: parent.width
slotColor: Theme.chipSurface
SettingsRow {
title: I18n.tr("Daily limit")
subtitle: root.minutes > 0 ? I18n.tr("Notifies when today's use of this app passes the limit") : I18n.tr("Off")
DurationSteppers {
anchors.verticalCenter: parent.verticalCenter
minutes: root.minutes
onCommitted: next => WellbeingService.setAppLimit(root.appId, next)
}
}
}
}
@@ -0,0 +1,223 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.DankDash
import qs.Modules.ControlCenter.Widgets
import "Wellbeing.js" as Wellbeing
DankCard {
id: root
property var days: []
property int firstDayOfWeek: 1
property string period: "week"
property bool showTitle: true
property bool fillHeight: true
readonly property var periods: [
{
"value": "day",
"text": I18n.tr("Day")
},
{
"value": "week",
"text": I18n.tr("Week")
},
{
"value": "month",
"text": I18n.tr("Month")
}
]
readonly property var apps: Wellbeing.topApps(Wellbeing.periodDays(days, new Date(), firstDayOfWeek, period), WellbeingMetrics.listedApps)
readonly property real longest: apps.length > 0 ? apps[0].seconds : 0
readonly property real listHeight: Math.max(1, apps.length) * WellbeingMetrics.appRowHeight + Math.max(0, apps.length - 1) * Theme.groupedListGap
signal limitRequested(string appId)
restRadius: DashMetrics.cardRadius
pad: Theme.spacingL
implicitHeight: pad * 2 + header.height + Theme.spacingM + listHeight
Item {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: Theme.buttonHeightXS
StyledText {
anchors.left: parent.left
anchors.right: periodGroup.left
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
visible: root.showTitle
text: I18n.tr("Most used apps")
font.pixelSize: Theme.fontSizeLarge
font.weight: Theme.fontWeightMedium
color: root.contentColor
elide: Text.ElideRight
}
DankButtonGroup {
id: periodGroup
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
size: "small"
checkEnabled: false
model: root.periods.map(p => p.text)
currentIndex: root.periods.findIndex(p => p.value === root.period)
onSelectionChanged: (index, selected) => {
if (selected)
root.period = root.periods[index].value;
}
}
}
StyledText {
anchors.centerIn: listHost
width: listHost.width - Theme.spacingL * 2
visible: root.apps.length === 0
text: I18n.tr("Nothing tracked yet", "empty state of the screen time app list")
font.pixelSize: Theme.fontSizeMedium
color: root.mutedColor
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Loader {
id: listHost
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Theme.spacingM
height: root.fillHeight ? parent.height - y : root.listHeight
sourceComponent: root.fillHeight ? scrolling : stacked
}
// a nested list view would take the wheel from the settings page
Component {
id: stacked
Column {
spacing: Theme.groupedListGap
Repeater {
model: root.apps
AppRow {
width: parent.width
}
}
}
}
Component {
id: scrolling
DankListView {
clip: true
spacing: Theme.groupedListGap
showScrollBar: false
model: root.apps
delegate: AppRow {
width: ListView.view.width
}
}
}
component AppRow: DankListItem {
id: row
required property var modelData
required property int index
readonly property string appId: modelData.appId
readonly property int limitMinutes: WellbeingService.appLimitMinutes(appId)
readonly property bool overLimit: limitMinutes > 0 && modelData.seconds >= limitMinutes * 60
height: WellbeingMetrics.appRowHeight
surfaceColor: root.chipColor
firstInGroup: index === 0
lastInGroup: index === root.apps.length - 1
Accessible.name: WellbeingService.appName(appId)
onClicked: root.limitRequested(appId)
CcAppIcon {
id: icon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
appId: row.appId
iconSize: Theme.iconSizeLarge
}
StyledText {
id: name
anchors.left: icon.right
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: WellbeingMetrics.appNameWidth
text: WellbeingService.appName(row.appId)
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: row.contentColor
elide: Text.ElideRight
}
Rectangle {
anchors.left: name.right
anchors.right: duration.left
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
height: DashMetrics.meterBarThickness
radius: Theme.fullRadius(width, height)
color: Theme.withAlpha(row.contentColor, Theme.stateLayerFocus)
Rectangle {
width: root.longest > 0 ? parent.width * row.modelData.seconds / root.longest : 0
height: parent.height
radius: parent.radius
color: row.overLimit ? Theme.error : Theme.primary
Behavior on width {
enabled: DashMetrics.animationsEnabled
NumberAnimation {
duration: DashMetrics.meterDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
}
}
}
}
NumericText {
id: duration
anchors.right: limitButton.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
width: reservedWidth
horizontalAlignment: Text.AlignRight
reserveText: WellbeingService.formatDuration(10 * Wellbeing.secondsPerHour)
text: WellbeingService.formatDuration(row.modelData.seconds)
font.pixelSize: Theme.fontSizeMedium
color: row.overLimit ? Theme.error : row.contentColor
}
DankActionButton {
id: limitButton
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
buttonSize: Theme.buttonHeightXS
iconName: row.limitMinutes > 0 ? "timer" : "timer_off"
iconColor: row.limitMinutes > 0 ? Theme.primary : row.supportingContentColor
tooltipText: row.limitMinutes > 0 ? I18n.tr("%1 min").arg(row.limitMinutes) : I18n.tr("Set limit", "button that opens the per-app screen time limit editor")
onClicked: root.limitRequested(row.appId)
}
}
}
@@ -0,0 +1,36 @@
import QtQuick
import qs.Common
import qs.Widgets
Row {
id: root
property int minutes: 0
readonly property int hours: Math.floor(minutes / 60)
readonly property int remainder: minutes % 60
readonly property int maxMinutes: 24 * 60 - 1
signal committed(int minutes)
function commit(next) {
committed(Math.max(0, Math.min(maxMinutes, next)));
}
spacing: Theme.spacingS
DankNumberStepper {
text: I18n.tr("%1h").arg(root.hours)
incrementEnabled: root.minutes + 60 <= root.maxMinutes
decrementEnabled: root.hours > 0
onIncrement: () => root.commit(root.minutes + 60)
onDecrement: () => root.commit(root.minutes - 60)
}
DankNumberStepper {
text: I18n.tr("%1m").arg(root.remainder)
incrementEnabled: root.minutes < root.maxMinutes
decrementEnabled: root.minutes > 0
onIncrement: () => root.commit(root.minutes + 1)
onDecrement: () => root.commit(root.minutes - 1)
}
}
@@ -0,0 +1,234 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.DankDash
import "Wellbeing.js" as Wellbeing
DankCard {
id: root
property var week: []
property real limitSeconds: 0
property bool showTitle: true
property int hoverIndex: -1
readonly property real total: Wellbeing.totalSeconds(week)
readonly property real peak: Math.max(limitSeconds, ...week.map(day => day.active))
readonly property var axis: Wellbeing.axis(peak)
readonly property var hoverDay: hoverIndex >= 0 && hoverIndex < week.length ? week[hoverIndex] : null
readonly property real columnWidth: week.length > 0 ? plot.width / week.length : 0
readonly property real barWidth: columnWidth * WellbeingMetrics.barWidthRatio
readonly property color pastColor: Theme.primaryContainer
readonly property color todayColor: Theme.primary
readonly property color overColor: Theme.error
function barColor(day) {
if (limitSeconds > 0 && day.active > limitSeconds)
return overColor;
return day.today ? todayColor : pastColor;
}
function yFor(seconds) {
return plot.height * (1 - Math.min(1, seconds / axis.top));
}
function dayLabel(day) {
return Qt.locale().dayName(day.weekday, Locale.ShortFormat);
}
restRadius: DashMetrics.cardRadius
pad: Theme.spacingL
Accessible.role: Accessible.Chart
Accessible.name: I18n.tr("Weekly screen time")
Column {
anchors.fill: parent
spacing: Theme.spacingS
StyledText {
width: parent.width
visible: root.showTitle
text: I18n.tr("Weekly screen time")
font.pixelSize: Theme.fontSizeLarge
font.weight: Theme.fontWeightMedium
color: root.contentColor
elide: Text.ElideRight
}
Row {
spacing: Theme.spacingS
StyledText {
anchors.baseline: caption.baseline
text: WellbeingService.formatDuration(root.hoverDay ? root.hoverDay.active : root.total)
font.pixelSize: Theme.fontSizeXLarge
font.weight: Theme.fontWeightBold
font.features: ({
"tnum": 1
})
color: root.accentColor
}
StyledText {
id: caption
anchors.bottom: parent.bottom
anchors.bottomMargin: Theme.spacingXXS
text: root.hoverDay ? root.dayLabel(root.hoverDay) : I18n.tr("Total")
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
font.capitalization: Font.AllUppercase
color: root.mutedColor
}
}
Item {
width: parent.width
height: parent.height - y
LayoutMirroring.enabled: false
LayoutMirroring.childrenInherit: true
Item {
id: plot
anchors.left: parent.left
anchors.right: axisLabels.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.topMargin: Theme.spacingS
anchors.bottom: dayLabels.top
anchors.bottomMargin: Theme.spacingXS
Repeater {
model: root.axis.ticks
Rectangle {
required property int modelData
width: plot.width
height: Theme.dividerWidth
y: root.yFor(modelData * Wellbeing.secondsPerHour) - height / 2
color: Theme.outlineVariant
}
}
Repeater {
model: root.week
Rectangle {
id: bar
required property var modelData
required property int index
readonly property real targetHeight: Math.max(0, plot.height - root.yFor(modelData.active))
x: index * root.columnWidth + (root.columnWidth - width) / 2
y: plot.height - height
width: root.barWidth
height: targetHeight
visible: !modelData.future && modelData.active > 0
topLeftRadius: Theme.cornerRadiusS
topRightRadius: Theme.cornerRadiusS
color: root.barColor(modelData)
opacity: root.hoverIndex === -1 || root.hoverIndex === index ? 1 : WellbeingMetrics.dimmedBarOpacity
Behavior on height {
enabled: DashMetrics.animationsEnabled
NumberAnimation {
duration: DashMetrics.meterDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
}
}
}
}
Shape {
anchors.fill: parent
visible: root.limitSeconds > 0
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.overColor
strokeWidth: Theme.outlineWidthFocused
strokeStyle: ShapePath.DashLine
dashPattern: [3, 3]
fillColor: "transparent"
capStyle: ShapePath.FlatCap
startX: 0
startY: root.yFor(root.limitSeconds)
PathLine {
x: plot.width
y: root.yFor(root.limitSeconds)
}
}
}
StyledText {
anchors.right: parent.right
y: root.yFor(root.limitSeconds) - height - Theme.spacingXXS
visible: root.limitSeconds > 0
text: I18n.tr("Limit", "label on the daily screen time limit line of a chart")
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightBold
font.capitalization: Font.AllUppercase
color: root.overColor
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
onPositionChanged: mouse => root.hoverIndex = root.columnWidth > 0 ? Math.floor(mouse.x / root.columnWidth) : -1
onExited: root.hoverIndex = -1
}
}
Item {
id: axisLabels
anchors.right: parent.right
anchors.top: plot.top
anchors.bottom: plot.bottom
width: WellbeingMetrics.axisLabelWidth
Repeater {
model: root.axis.ticks
StyledText {
required property int modelData
anchors.right: axisLabels.right
y: root.yFor(modelData * Wellbeing.secondsPerHour) - height / 2
text: I18n.tr("%1h").arg(modelData)
font.pixelSize: Theme.fontSizeSmall
font.features: ({
"tnum": 1
})
color: root.mutedColor
}
}
}
Row {
id: dayLabels
anchors.left: plot.left
anchors.right: plot.right
anchors.bottom: parent.bottom
height: Theme.fontSizeSmall + Theme.spacingXS
Repeater {
model: root.week
StyledText {
required property var modelData
width: root.columnWidth
horizontalAlignment: Text.AlignHCenter
text: root.dayLabel(modelData)
font.pixelSize: Theme.fontSizeSmall
font.weight: modelData.today ? Theme.fontWeightBold : Theme.fontWeightMedium
color: modelData.today ? root.accentColor : root.mutedColor
}
}
}
}
}
}
@@ -0,0 +1,113 @@
.pragma library
const secondsPerHour = 3600;
const weekLength = 7;
const monthLength = 30;
function dateKey(date) {
const pad = n => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function parseKey(key) {
const [year, month, day] = String(key).split("-").map(Number);
return new Date(year, month - 1, day);
}
function emptyDay(key) {
return {
date: key,
active: 0,
apps: {}
};
}
function byDate(days) {
const map = {};
for (const day of Array.isArray(days) ? days : []) {
if (day && typeof day.date === "string")
map[day.date] = day;
}
return map;
}
function weekDays(days, today, firstDayOfWeek) {
const map = byDate(days);
const todayKey = dateKey(today);
const offset = (today.getDay() - firstDayOfWeek + weekLength) % weekLength;
const result = [];
for (let i = 0; i < weekLength; i++) {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset + i);
const key = dateKey(date);
result.push({
date: key,
weekday: date.getDay(),
active: map[key]?.active ?? 0,
apps: map[key]?.apps ?? {},
today: key === todayKey,
future: key > todayKey
});
}
return result;
}
function periodDays(days, today, firstDayOfWeek, period) {
switch (period) {
case "day":
return weekDays(days, today, firstDayOfWeek).filter(day => day.today);
case "week":
return weekDays(days, today, firstDayOfWeek).filter(day => !day.future);
default:
return Array.isArray(days) ? days.slice(-monthLength) : [];
}
}
function totalSeconds(days) {
return days.reduce((sum, day) => sum + (day?.active ?? 0), 0);
}
function topApps(days, limit) {
const totals = {};
for (const day of days) {
const apps = day?.apps ?? {};
for (const appId in apps) {
if (apps[appId] > 0)
totals[appId] = (totals[appId] ?? 0) + apps[appId];
}
}
return Object.keys(totals).map(appId => ({
appId,
seconds: totals[appId]
})).sort((a, b) => b.seconds - a.seconds || a.appId.localeCompare(b.appId)).slice(0, limit ?? Infinity);
}
function axisStepHours(hours) {
if (hours <= 4)
return 1;
if (hours <= 8)
return 2;
if (hours <= 16)
return 4;
return 6;
}
function axis(peakSeconds) {
const hours = Math.max(1, Math.ceil(peakSeconds / secondsPerHour));
const step = axisStepHours(hours);
const top = Math.ceil(hours / step) * step;
const ticks = [];
for (let hour = 0; hour <= top; hour += step)
ticks.push(hour);
return {
top: top * secondsPerHour,
ticks
};
}
function splitDuration(seconds) {
const total = Math.max(0, Math.round(seconds));
return {
hours: Math.floor(total / secondsPerHour),
minutes: Math.floor((total % secondsPerHour) / 60)
};
}
@@ -0,0 +1,19 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Common
import qs.Modules.DankDash
Singleton {
readonly property real chartHeight: DashMetrics.gridRowUnit * 2.5
readonly property real barWidthRatio: 0.72
readonly property real dimmedBarOpacity: 0.55
readonly property real axisLabelWidth: 28
readonly property real appNameWidth: 120
readonly property real appRowHeight: Theme.listItemHeight
readonly property int listedApps: 12
readonly property int cardApps: 3
readonly property int defaultLimitMinutes: 60
readonly property int retentionDays: 92
}
@@ -0,0 +1,95 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.DankDash
import qs.Modules.DankDash.Wellbeing
import qs.Modules.ControlCenter.Widgets
import "Wellbeing/Wellbeing.js" as Wellbeing
FocusScope {
id: root
property string entryId: "wellbeing"
property bool live: Window.window?.visible ?? false
property var transientSurfaceTracker: null
property var days: []
readonly property int firstDayOfWeek: SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0 ? Qt.locale().firstDayOfWeek : SettingsData.firstDayOfWeek
readonly property var week: Wellbeing.weekDays(days, new Date(), firstDayOfWeek)
readonly property int revision: WellbeingService.revision
readonly property Item focusTarget: root
readonly property var menuActions: [
{
label: I18n.tr("Settings"),
iconName: "settings",
action: () => {
PopoutService.closeDankDash();
PopoutService.openSettingsWithTab("wellbeing");
}
}
]
implicitHeight: DashMetrics.tabMinHeight
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
onLiveChanged: {
if (live)
refresh();
else
limitSheet.dismiss();
}
onRevisionChanged: {
if (live)
refresh();
}
function refresh() {
WellbeingService.summary(result => root.days = result);
}
function handleKeyEvent(event) {
if (event.key !== Qt.Key_Escape || !limitSheet.shown)
return false;
limitSheet.dismiss();
return true;
}
CcEmptyState {
anchors.centerIn: parent
visible: !WellbeingService.available
iconName: "digital_wellbeing"
title: I18n.tr("DMS out of date")
subtitle: I18n.tr("Update the dms package with your package manager, then restart the shell.")
}
Column {
id: content
anchors.fill: parent
spacing: DashMetrics.contentGap
visible: WellbeingService.available
WeeklyChart {
width: parent.width
height: WellbeingMetrics.chartHeight
week: root.week
limitSeconds: SettingsData.wellbeingDailyLimit * 60
}
AppUsageList {
width: parent.width
height: parent.height - y
days: root.days
firstDayOfWeek: root.firstDayOfWeek
onLimitRequested: appId => limitSheet.presentFor(appId)
}
}
AppLimitSheet {
id: limitSheet
backdrop: content
}
}
@@ -213,7 +213,7 @@ PanelWindow {
readonly property bool contentAnchorsTop: isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left
readonly property real renderedContentOffsetY: contentAnchorsTop ? 0 : Math.max(0, allocatedAlignedHeight - renderedAlignedHeight)
readonly property real contentTop: Theme.snap(windowShadowPad + renderedContentOffsetY, dpr)
implicitWidth: contentImplicitWidth + (windowShadowPad * 2)
implicitWidth: contentImplicitWidth + (windowShadowPad * 2) + edgeBleed
implicitHeight: allocatedAlignedHeight + (windowShadowPad * 2)
function syncInlineTargetHeight() {
@@ -330,6 +330,14 @@ PanelWindow {
readonly property real maxPopupShadowOffsetYPx: Math.max(Math.abs(Theme.elevationOffsetY(Theme.elevationLevel3, 6)), Math.abs(Theme.elevationOffsetY(Theme.elevationLevel4, 8)))
readonly property bool popupWindowShadowActive: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled && !connectedFrameMode
readonly property real windowShadowPad: popupWindowShadowActive ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0
readonly property bool bleedsLeft: _frameEdgeSwipeDirection() < 0
readonly property real edgeBleed: {
if (isCenterPosition || !CompositorService.frameWindowVisibleForScreen(screen))
return 0;
const sideMargin = bleedsLeft ? getLeftMargin() : getRightMargin();
return Math.max(0, Theme.snap(sideMargin, dpr) - windowShadowPad);
}
readonly property real contentWindowX: Theme.snap(windowShadowPad + (bleedsLeft ? edgeBleed : 0), dpr)
anchors.top: true
anchors.left: true
@@ -492,7 +500,7 @@ PanelWindow {
function getWindowLeftMargin() {
if (!screen)
return 0;
return Theme.snap(getContentX() - windowShadowPad, dpr);
return Theme.snap(getContentX() - contentWindowX, dpr);
}
function getWindowTopMargin() {
@@ -526,7 +534,7 @@ PanelWindow {
readonly property bool screenValid: win.screen && !_isDestroying
readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1
readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2)), dpr)
readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2) - edgeBleed), dpr)
readonly property real alignedHeight: renderedAlignedHeight
onScreenYChanged: if (connectedFrameMode)
popupChromeGeometryChanged()
@@ -564,7 +572,7 @@ PanelWindow {
id: content
parent: slideClip
x: Theme.snap(windowShadowPad, dpr)
x: win.contentWindowX
y: win.contentTop - slideClip.y
width: alignedWidth
height: alignedHeight
@@ -44,6 +44,19 @@ FocusScope {
}
}
SettingsCard {
title: I18n.tr("Digital wellbeing")
SettingsNavRow {
tab: "dank_dash"
settingKey: "dashWellbeingSettings"
title: I18n.tr("Digital wellbeing")
hint: I18n.tr("Screen time and app limits")
iconName: "digital_wellbeing"
onClicked: root.parentModal?.navigateTo("wellbeing")
}
}
SettingsCard {
title: I18n.tr("Tabs", "noun, card title for dashboard tabs")
settingKey: "dashTabs"
@@ -0,0 +1,198 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modals.Common
import qs.Modules.Settings.Widgets
import qs.Modules.ControlCenter.Widgets
import qs.Modules.DankDash.Wellbeing
import "../DankDash/Wellbeing/Wellbeing.js" as Wellbeing
Item {
id: root
property var parentModal: null
property var days: []
readonly property var limits: SettingsData.wellbeingAppLimits ?? {}
readonly property var limitedApps: Object.keys(limits).filter(appId => Number(limits[appId]) > 0).sort((a, b) => WellbeingService.appName(a).localeCompare(WellbeingService.appName(b)))
readonly property bool available: WellbeingService.available
readonly property int revision: WellbeingService.revision
readonly property int firstDayOfWeek: SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0 ? Qt.locale().firstDayOfWeek : SettingsData.firstDayOfWeek
onAvailableChanged: refresh()
onRevisionChanged: refresh()
Component.onCompleted: refresh()
function toggleLimit(appId) {
WellbeingService.setAppLimit(appId, WellbeingService.appLimitMinutes(appId) > 0 ? 0 : WellbeingMetrics.defaultLimitMinutes);
}
function refresh() {
if (!available)
return;
WellbeingService.summary(result => root.days = result);
}
ConfirmModal {
id: clearConfirm
}
SettingsPage {
SettingsCard {
width: parent.width
visible: root.available && SettingsData.wellbeingEnabled
iconName: "show_chart"
title: I18n.tr("Weekly screen time")
settingKey: "wellbeingChart"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "chart", "week"]
WeeklyChart {
height: WellbeingMetrics.chartHeight
color: "transparent"
pad: 0
showTitle: false
week: Wellbeing.weekDays(root.days, new Date(), root.firstDayOfWeek)
limitSeconds: SettingsData.wellbeingDailyLimit * 60
}
}
SettingsCard {
width: parent.width
visible: root.available && SettingsData.wellbeingEnabled
iconName: "apps"
title: I18n.tr("Most used apps")
settingKey: "wellbeingApps"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "apps", "usage"]
AppUsageList {
height: implicitHeight
color: "transparent"
pad: 0
showTitle: false
fillHeight: false
days: root.days
firstDayOfWeek: root.firstDayOfWeek
onLimitRequested: appId => root.toggleLimit(appId)
}
}
SettingsCard {
width: parent.width
iconName: "digital_wellbeing"
title: I18n.tr("Screen time")
settingKey: "wellbeing"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "usage", "limit"]
SettingsToggleRow {
settingKey: "wellbeingEnabled"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "tracking"]
text: I18n.tr("Track screen time")
description: I18n.tr("Counts time in the focused app while the session is active. Stays on this device.")
checked: SettingsData.wellbeingEnabled
onToggled: checked => SettingsData.set("wellbeingEnabled", checked)
}
SettingsRow {
settingKey: "wellbeingDailyLimit"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "limit", "daily"]
title: I18n.tr("Daily limit")
subtitle: SettingsData.wellbeingDailyLimit > 0 ? I18n.tr("Notifies when today's screen time passes the limit") : I18n.tr("Off")
DurationSteppers {
anchors.verticalCenter: parent.verticalCenter
minutes: SettingsData.wellbeingDailyLimit
onCommitted: next => SettingsData.set("wellbeingDailyLimit", next)
}
}
}
SettingsCard {
width: parent.width
iconName: "timer"
title: I18n.tr("App limits")
settingKey: "wellbeingAppLimits"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "limit", "app"]
SettingsRow {
visible: root.limitedApps.length === 0
title: I18n.tr("No app limits")
subtitle: I18n.tr("Pick an app from the list above or the dashboard tab")
}
Repeater {
model: root.limitedApps
SettingsRow {
id: limitRow
required property string modelData
settingKey: "wellbeingAppLimits:" + modelData
resetKeys: []
title: WellbeingService.appName(modelData)
leading: CcAppIcon {
appId: limitRow.modelData
iconSize: Theme.iconSizeLarge
}
DurationSteppers {
anchors.verticalCenter: parent.verticalCenter
minutes: WellbeingService.appLimitMinutes(limitRow.modelData)
onCommitted: next => WellbeingService.setAppLimit(limitRow.modelData, next)
}
DankActionButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "close"
iconColor: Theme.surfaceVariantText
Accessible.name: I18n.tr("Remove")
onClicked: WellbeingService.setAppLimit(limitRow.modelData, 0)
}
}
}
}
SettingsCard {
width: parent.width
iconName: "history"
title: I18n.tr("History")
settingKey: "wellbeingHistory"
tab: "wellbeing"
tags: ["wellbeing", "screen time", "history", "clear"]
SettingsRow {
title: I18n.tr("Screen time history")
subtitle: I18n.tr("Kept for %1 days on this device").arg(WellbeingMetrics.retentionDays)
DankButton {
anchors.verticalCenter: parent.verticalCenter
buttonHeight: Theme.buttonHeightXS
text: I18n.tr("Clear")
backgroundColor: Theme.errorContainer
textColor: Theme.error
enabled: root.available
onClicked: clearConfirm.showWithOptions({
"title": I18n.tr("Clear History?"),
"message": I18n.tr("Deletes all stored screen time"),
"confirmText": I18n.tr("Clear"),
"confirmColor": Theme.error,
"onConfirm": () => {
WellbeingService.clearHistory();
root.days = [];
}
})
}
}
}
}
}
+3
View File
@@ -74,6 +74,7 @@ Singleton {
signal locationStateUpdate(var data)
signal sysupdateStateUpdate(var data)
signal tailscaleStateUpdate(var data)
signal wellbeingStateUpdate(var data)
signal filesEvent(var data)
property bool capsLockState: false
@@ -351,6 +352,8 @@ Singleton {
sysupdateStateUpdate(data);
} else if (service === "tailscale") {
tailscaleStateUpdate(data);
} else if (service === "wellbeing") {
wellbeingStateUpdate(data);
} else if (service === "files") {
filesEvent(data);
}
+191
View File
@@ -0,0 +1,191 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import "../Modules/DankDash/Wellbeing/Wellbeing.js" as Wellbeing
Singleton {
id: root
readonly property int idleTimeoutSeconds: 300
readonly property int summaryDays: Wellbeing.monthLength
readonly property var ownAppIds: ["com.danklinux.dms", "org.quickshell", "quickshell"]
readonly property bool available: DMSService.isConnected && DMSService.capabilities.includes("wellbeing")
readonly property bool enabled: SettingsData.wellbeingEnabled
readonly property bool tracking: available && enabled
readonly property bool sessionActive: !idleMonitor.isIdle && !IdleService.isShellLocked && !SessionService.locked
readonly property string pushedState: tracking ? focusedAppId + "\n" + sessionActive : ""
readonly property var limitParams: {
const apps = {};
const stored = SettingsData.wellbeingAppLimits ?? {};
for (const appId in stored) {
const minutes = Number(stored[appId]);
if (minutes > 0)
apps[appId] = minutes * 60;
}
return {
"daily": Math.max(0, Number(SettingsData.wellbeingDailyLimit) || 0) * 60,
"apps": apps
};
}
readonly property Toplevel managerActiveToplevel: ToplevelManager.activeToplevel
property string focusedAppId: ""
property var today: Wellbeing.emptyDay("")
property int revision: 0
property real seq: 0
IdleMonitor {
id: idleMonitor
timeout: root.idleTimeoutSeconds
respectInhibitors: true
enabled: root.tracking
}
onManagerActiveToplevelChanged: updateFocusedApp()
onPushedStateChanged: pushState()
onTrackingChanged: ensureSubscription()
onLimitParamsChanged: pushLimits()
Component.onCompleted: {
updateFocusedApp();
ensureSubscription();
}
Connections {
target: CompositorService
function onToplevelsChanged() {
root.updateFocusedApp();
}
function onWorkspaceStateChanged() {
root.updateFocusedApp();
}
}
Connections {
target: DMSService
function onConnectionStateChanged() {
if (!DMSService.isConnected)
return;
root.ensureSubscription();
root.pushLimits();
root.pushState();
}
function onWellbeingStateUpdate(data) {
root.handleStateUpdate(data);
}
}
function updateFocusedApp() {
const window = CompositorService.activeWindowForScreen(null, null, false);
const appId = window?.appId ?? "";
focusedAppId = ownAppIds.includes(appId) ? "" : appId;
}
function ensureSubscription() {
const subscribed = DMSService.activeSubscriptions.includes("wellbeing");
if (!enabled) {
if (subscribed)
DMSService.removeSubscription("wellbeing");
return;
}
if (!available || subscribed || DMSService.activeSubscriptions.includes("all"))
return;
DMSService.addSubscription("wellbeing");
}
function pushState() {
if (!available)
return;
seq = Math.max(Date.now(), seq + 1);
DMSService.sendRequest("wellbeing.setState", {
"appId": tracking ? focusedAppId : "",
"active": tracking && sessionActive,
"seq": seq
}, null);
}
function pushLimits() {
if (!available)
return;
DMSService.sendRequest("wellbeing.setLimits", limitParams, null);
}
function handleStateUpdate(data) {
if (!data)
return;
if (data.today) {
today = data.today;
revision++;
}
if (data.resync) {
pushLimits();
pushState();
}
if (data.limit)
notifyLimit(data.limit);
}
function notifyLimit(limit) {
const summary = limit.kind === "daily" ? I18n.tr("Daily screen time limit reached") : I18n.tr("%1 limit reached", "screen time notification title, %1 is an app name").arg(appName(limit.appId));
DMSService.notifySend({
"summary": summary,
"body": formatDuration(limit.used) + " · " + I18n.tr("Today"),
"icon": "preferences-system-time"
}, null);
}
function summary(callback) {
if (!available) {
callback([]);
return;
}
DMSService.sendRequest("wellbeing.summary", {
"days": summaryDays
}, response => callback(response?.result?.days ?? []));
}
function clearHistory() {
if (!available)
return;
DMSService.sendRequest("wellbeing.clear", null, null);
}
function appLimitMinutes(appId) {
return Math.max(0, Number((SettingsData.wellbeingAppLimits ?? {})[appId]) || 0);
}
function setAppLimit(appId, minutes) {
if (!appId)
return;
const limits = Object.assign({}, SettingsData.wellbeingAppLimits ?? {});
if (minutes > 0)
limits[appId] = minutes;
else
delete limits[appId];
SettingsData.set("wellbeingAppLimits", limits);
}
function desktopEntry(appId) {
return appId ? DesktopEntries.heuristicLookup(Paths.moddedAppId(appId)) : null;
}
function appName(appId) {
return Paths.getAppName(appId, desktopEntry(appId));
}
function formatDuration(seconds) {
const split = Wellbeing.splitDuration(seconds);
if (split.hours === 0)
return I18n.tr("%1m").arg(split.minutes);
return I18n.tr("%1h %2m").arg(split.hours).arg(split.minutes);
}
}
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadScript } from "./qml-script.mjs";
const model = loadScript(new URL("../Modules/DankDash/Wellbeing/Wellbeing.js", import.meta.url));
const plain = value => JSON.parse(JSON.stringify(value));
const days = [
{ date: "2026-09-27", active: 100, apps: { firefox: 100 } },
{ date: "2026-09-29", active: 200, apps: { firefox: 50, kitty: 150 } },
{ date: "2026-10-01", active: 300, apps: { kitty: 300 } }
];
const thursday = new Date(2026, 9, 1, 15, 0, 0);
test("week starts on the configured weekday and marks today and future days", () => {
const week = plain(model.weekDays(days, thursday, 1));
assert.deepEqual(week.map(day => day.date), ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04"]);
assert.deepEqual(week.map(day => day.active), [0, 200, 0, 300, 0, 0, 0]);
assert.deepEqual(week.map(day => day.today), [false, false, false, true, false, false, false]);
assert.deepEqual(week.map(day => day.future), [false, false, false, false, true, true, true]);
const sundayStart = plain(model.weekDays(days, thursday, 0));
assert.equal(sundayStart[0].date, "2026-09-27");
assert.equal(sundayStart[0].active, 100);
});
test("periods pick today, the week so far, or the whole month", () => {
assert.deepEqual(plain(model.periodDays(days, thursday, 1, "day")).map(day => day.date), ["2026-10-01"]);
assert.deepEqual(plain(model.periodDays(days, thursday, 1, "week")).map(day => day.date), ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]);
assert.deepEqual(plain(model.periodDays(days, thursday, 1, "month")).map(day => day.date), ["2026-09-27", "2026-09-29", "2026-10-01"]);
});
test("top apps aggregate across days, sort by time, and honour the limit", () => {
assert.deepEqual(plain(model.topApps(days)), [{ appId: "kitty", seconds: 450 }, { appId: "firefox", seconds: 150 }]);
assert.deepEqual(plain(model.topApps(days, 1)), [{ appId: "kitty", seconds: 450 }]);
assert.deepEqual(plain(model.topApps([{ date: "2026-10-01", active: 5, apps: { idle: 0 } }])), []);
});
test("axis rounds the peak up to whole-hour ticks", () => {
assert.deepEqual(plain(model.axis(0)), { top: 3600, ticks: [0, 1] });
assert.deepEqual(plain(model.axis(3 * 3600 + 1)), { top: 4 * 3600, ticks: [0, 1, 2, 3, 4] });
assert.deepEqual(plain(model.axis(5 * 3600)), { top: 6 * 3600, ticks: [0, 2, 4, 6] });
assert.deepEqual(plain(model.axis(13 * 3600)), { top: 16 * 3600, ticks: [0, 4, 8, 12, 16] });
});
test("durations split into hours and whole minutes", () => {
assert.deepEqual(plain(model.splitDuration(3725)), { hours: 1, minutes: 2 });
assert.deepEqual(plain(model.splitDuration(59)), { hours: 0, minutes: 0 });
assert.deepEqual(plain(model.splitDuration(-5)), { hours: 0, minutes: 0 });
});
@@ -101,6 +101,7 @@ TAB_INDEX_MAP = {
"KeybindsTab.qml": 2,
"DankBarTab.qml": 3,
"DankDashTab.qml": 43,
"DigitalWellbeingTab.qml": 67,
"CompositorLayoutTab.qml": 37,
"WindowRulesTab.qml": 38,
"DockGeneralTab.qml": 5,
@@ -13458,6 +13458,18 @@
"icon": "space_dashboard",
"description": "Tabs and weather"
},
{
"section": "dashWellbeingSettings",
"label": "Digital wellbeing",
"tabIndex": 43,
"category": "Dashboard",
"keywords": [
"dashboard",
"digital",
"wellbeing"
],
"icon": "digital_wellbeing"
},
{
"section": "dashTabsEvenlySpaced",
"label": "Evenly space tabs",
@@ -17401,5 +17413,141 @@
],
"parentLabel": "System & integrations",
"icon": "cloud_off"
},
{
"section": "wellbeingAppLimits",
"label": "App limits",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"app",
"digital",
"limit",
"limits",
"screen time",
"wellbeing"
],
"icon": "timer"
},
{
"section": "wellbeingDailyLimit",
"label": "Daily limit",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"daily",
"digital",
"limit",
"screen time",
"wellbeing"
]
},
{
"section": "_tab_67",
"label": "Digital wellbeing",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"digital",
"limits",
"screen",
"time",
"wellbeing"
],
"icon": "digital_wellbeing",
"description": "Screen time and app limits"
},
{
"section": "wellbeingHistory",
"label": "History",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"clear",
"digital",
"history",
"screen time",
"wellbeing"
],
"icon": "history"
},
{
"section": "wellbeingApps",
"label": "Most used apps",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"apps",
"digital",
"most",
"screen time",
"usage",
"wellbeing"
],
"icon": "apps"
},
{
"section": "wellbeing",
"label": "Screen time",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"active",
"counts",
"device",
"digital",
"focused",
"limit",
"screen",
"screen time",
"session",
"stays",
"time",
"usage",
"wellbeing",
"while"
],
"icon": "digital_wellbeing",
"description": "Counts time in the focused app while the session is active. Stays on this device."
},
{
"section": "wellbeingEnabled",
"label": "Track screen time",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"active",
"counts",
"device",
"digital",
"focused",
"screen",
"screen time",
"session",
"stays",
"time",
"track",
"tracking",
"wellbeing",
"while"
],
"description": "Counts time in the focused app while the session is active. Stays on this device."
},
{
"section": "wellbeingChart",
"label": "Weekly screen time",
"tabIndex": 67,
"category": "Digital wellbeing",
"keywords": [
"chart",
"digital",
"screen",
"screen time",
"time",
"week",
"weekly",
"wellbeing"
],
"icon": "show_chart"
}
]