Skip to content

Commit 465de97

Browse files
feat: implement initial project structure with core models, sqlite migrations, and UI theme styling
1 parent bd906c1 commit 465de97

16 files changed

Lines changed: 893 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.5.0]
9+
10+
### Added
11+
- **Command Center Dashboard**: A next-generation interactive stats dashboard accessible via `s`.
12+
- **Productivity DNA**: Visualizes task distribution across peak hours with theme-aware color mapping.
13+
- **Momentum Engine**: Real-time momentum score calculation based on task completions and focus sessions over the last 3 days.
14+
- **Behavioral Insights**: Data-driven, dynamic insights that automatically adapt to your task completion patterns and focus trends.
15+
- **Activity Timeline**: Adaptive graph showing task completion trends, with intelligent rendering that cleans up labels when data is low.
16+
- **Tag Intelligence**: Heatmap-style visualization of tag clusters, identifying your primary areas of focus.
17+
- **Deep Analytics Data Model**:
18+
- Added `completed_at` tracking to tasks for precise velocity metrics.
19+
- Introduced `sessions` and `events` tables for granular focus and interaction logging.
20+
- **Premium UI Polish**: Keyboard-driven navigation, thick borders, and adaptive centered layouts that sync seamlessly with global theme colors.
21+
822
## [1.4.3]
923
### Added
1024
- **Enhanced Data Portability**: All export and import formats (JSON, CSV, Markdown, Text) now fully support nested task hierarchies and folders.

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ Tasks reappear automatically on a schedule. Weekly (`mon,wed,fri`) or monthly (`
8686
### 🔒 Your Data, Locally
8787
SQLite with WAL mode. Fully offline. Optional Git-backed sync — no backend, no account, no lock-in. Export to JSON, CSV, Markdown, or plain text on demand.
8888

89+
### 🧭 Interactive Stats Dashboard
90+
Press `s` to open a next-gen "Command Center". Visualize your **Productivity DNA**, track real-time momentum, and get behavioral insights like "You complete 73% more tasks at night". Fully animated, keyboard-driven, and deeply insightful.
91+
8992
### 🤖 AI — Optional, Never Intrusive
9093
Gemini integration (`gemini-3.1-flash-lite-preview` / `gemini-2.5-flash-lite` / `gemini-2.0-flash-lite`). Toggle with `ctrl+a`. Create and manage complex recurring tasks with natural language. Invisible until you need it.
9194

@@ -106,6 +109,7 @@ A Lua plugin system hooks into task events. A headless CLI API enables full scri
106109
| `z` | Complete task |
107110
| `d` | Delete task |
108111
| `Space` | Collapse / expand subtasks |
112+
| `s` | Stats dashboard |
109113
| `f` | Filter by tag |
110114
| `t` | Switch theme |
111115
| `ctrl+p` | Command palette / Markdown preview |
@@ -119,10 +123,8 @@ A Lua plugin system hooks into task events. A headless CLI API enables full scri
119123
<img src="screenshots/filter_tags.png" width="30%" />
120124
<img src="screenshots/help_menu.png" width="30%" />
121125
<img src="screenshots/settings_menu.png" width="30%" />
122-
</div>
123-
124-
<div align="center">
125126
<img src="screenshots/theme_menu.png" width="30%" />
127+
<img src="screenshots/dashboard.png" width="30%" />
126128
</div>
127129

128130
---

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.4.3
1+
1.5.0

internal/app/model.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/programmersd21/kairo/internal/plugins"
2626
"github.com/programmersd21/kairo/internal/search"
2727
"github.com/programmersd21/kairo/internal/service"
28+
istats "github.com/programmersd21/kairo/internal/stats"
2829
ksync "github.com/programmersd21/kairo/internal/sync"
2930
"github.com/programmersd21/kairo/internal/ui/ai_panel"
3031
"github.com/programmersd21/kairo/internal/ui/detail"
@@ -37,6 +38,7 @@ import (
3738
"github.com/programmersd21/kairo/internal/ui/plugin_menu"
3839
"github.com/programmersd21/kairo/internal/ui/render"
3940
"github.com/programmersd21/kairo/internal/ui/settings"
41+
"github.com/programmersd21/kairo/internal/ui/stats"
4042
"github.com/programmersd21/kairo/internal/ui/styles"
4143
"github.com/programmersd21/kairo/internal/ui/tasklist"
4244
"github.com/programmersd21/kairo/internal/ui/theme"
@@ -96,6 +98,7 @@ const (
9698
ModeSettings
9799
ModeImportExport
98100
ModeOnboarding
101+
ModeStats
99102
)
100103

101104
type Model struct {
@@ -129,6 +132,7 @@ type Model struct {
129132
pm plugin_menu.Model
130133
set settings.Model
131134
iem import_export_menu.Model
135+
stats stats.Model
132136
aiPanel ai_panel.Model
133137
aiClient *ai.Client
134138
aiKey string
@@ -184,6 +188,20 @@ type Model struct {
184188
animationGen int
185189
}
186190

191+
type statsLoadedMsg struct {
192+
Data istats.DashboardData
193+
}
194+
195+
func (m *Model) loadStatsCmd() tea.Cmd {
196+
return func() tea.Msg {
197+
tasks, _ := m.svc.ListAll(m.ctx)
198+
sessions, _ := m.svc.ListSessions(m.ctx)
199+
events, _ := m.svc.ListEvents(m.ctx)
200+
data := istats.ComputeDashboard(tasks, sessions, events)
201+
return statsLoadedMsg{Data: data}
202+
}
203+
}
204+
187205
func (m *Model) rainbowTickCmd() tea.Cmd {
188206
return tea.Tick(150*time.Millisecond, func(time.Time) tea.Msg {
189207
return rainbowTickMsg{}
@@ -230,6 +248,7 @@ func New(ctx context.Context, cfg config.Config, svc service.TaskService) (tea.M
230248
m.pm = plugin_menu.New(m.s)
231249
m.set = settings.New(m.s, cfg)
232250
m.iem = import_export_menu.New(m.s)
251+
m.stats = stats.New(m.s)
233252
m.aiPanel = ai_panel.New(m.s)
234253
m.aiChan = make(chan ai_panel.AIChunkMsg, 100)
235254
m.aiKey = cfg.App.GeminiAPIKey
@@ -443,6 +462,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
443462
m.rebuildPaletteIndex()
444463
return m, nil
445464

465+
case statsLoadedMsg:
466+
m.stats.SetData(x.Data)
467+
return m, nil
468+
446469
case allTasksLoadedMsg:
447470
m.all = x.Tasks
448471
m.list.SetAllTasks(m.all)
@@ -1147,6 +1170,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11471170
}
11481171
return m, nil
11491172
}
1173+
if keymapMatch(m.km.Stats, km) {
1174+
m.mode = ModeStats
1175+
var animCmd tea.Cmd
1176+
if m.cfg.App.Animations {
1177+
m.transitioning = m.cfg.App.Animations
1178+
m.transitionStarted = time.Now()
1179+
animCmd = m.viewTransitionTickCmd()
1180+
}
1181+
return m, tea.Batch(m.loadStatsCmd(), m.stats.Init(), animCmd)
1182+
}
11501183
}
11511184

11521185
if m.mode == ModeList {
@@ -1239,6 +1272,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12391272
t := item.Task
12401273
m.animationGen++
12411274
m.animationReverse = (t.Status == core.StatusDone)
1275+
m.animatingTaskID = t.ID
1276+
m.animationStarted = time.Now()
1277+
m.animationDuration = 600 * time.Millisecond
12421278
if m.cfg.App.Animations {
12431279
return m, m.strikeAnimationTickCmd(t.ID)
12441280
}
@@ -1277,6 +1313,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12771313
t := m.det.Task()
12781314
m.animationGen++
12791315
m.animationReverse = (t.Status == core.StatusDone)
1316+
m.animatingTaskID = t.ID
1317+
m.animationStarted = time.Now()
1318+
m.animationDuration = 600 * time.Millisecond
12801319
if m.cfg.App.Animations {
12811320
return m, m.strikeAnimationTickCmd(t.ID)
12821321
}
@@ -1374,6 +1413,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
13741413
var cmd tea.Cmd
13751414
m.onb, cmd = m.onb.Update(msg)
13761415
return m, cmd
1416+
case ModeStats:
1417+
if km, ok := msg.(tea.KeyMsg); ok {
1418+
if keymapMatch(m.km.Back, km) || km.String() == "q" {
1419+
m.mode = ModeList
1420+
if m.cfg.App.Animations {
1421+
m.transitioning = m.cfg.App.Animations
1422+
m.transitionStarted = time.Now()
1423+
return m, m.viewTransitionTickCmd()
1424+
}
1425+
return m, nil
1426+
}
1427+
}
1428+
var cmd tea.Cmd
1429+
m.stats, cmd = m.stats.Update(msg)
1430+
return m, cmd
13771431
}
13781432

13791433
return m, nil
@@ -1447,6 +1501,7 @@ func (m *Model) renderMainUI() string {
14471501
m.hlp.AIEnabled = m.aiKey != ""
14481502
m.tm.SetSize(mainW, availableHeight)
14491503
m.iem.SetSize(mainW, availableHeight)
1504+
m.stats.SetSize(mainW, availableHeight)
14501505
if m.edit != nil {
14511506
m.edit.SetSize(mainW, availableHeight)
14521507
}
@@ -1488,6 +1543,8 @@ func (m *Model) renderMainUI() string {
14881543
}
14891544
case ModeImportExport:
14901545
body = m.iem.View()
1546+
case ModeStats:
1547+
body = m.stats.View()
14911548
case ModeOnboarding:
14921549
body = m.list.View()
14931550
default:
@@ -1874,6 +1931,7 @@ func (m *Model) renderFooter() string {
18741931
makePill(fk(m.km.NewTask) + " " + styles.IconNew + "new"),
18751932
makePill("f " + styles.IconTag + "tag"),
18761933
makePill(fk(m.km.ToggleStrike) + " " + styles.IconStrike + "done"),
1934+
makePill(fk(m.km.Stats) + " stats"),
18771935
makePill(fk(m.km.DeleteTask) + " " + styles.IconDelete + "delete"),
18781936
makePill(fk(m.km.Settings) + " settings"),
18791937
}

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ type KeymapConfig struct {
9393
Settings string `toml:"settings"`
9494
ImportExport string `toml:"import_export"`
9595
AIPanelToggle string `toml:"ai_panel_toggle"`
96+
Stats string `toml:"stats"`
9697
}
9798

9899
func Default() Config {
@@ -161,6 +162,7 @@ func Default() Config {
161162
Settings: "ctrl+s",
162163
ImportExport: "x",
163164
AIPanelToggle: "ctrl+a",
165+
Stats: "s",
164166
},
165167
}
166168
}
@@ -302,6 +304,9 @@ func Load() (Config, error) {
302304
if cfg.Keymap.ImportExport == "" {
303305
cfg.Keymap.ImportExport = defaults.Keymap.ImportExport
304306
}
307+
if cfg.Keymap.Stats == "" {
308+
cfg.Keymap.Stats = defaults.Keymap.Stats
309+
}
305310

306311
appDir, _ := util.AppDataDir(appName)
307312

internal/core/stats.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package core
2+
3+
import "time"
4+
5+
type Session struct {
6+
ID string
7+
StartTime time.Time
8+
EndTime *time.Time
9+
FocusScore int
10+
}
11+
12+
type Event struct {
13+
ID int64
14+
Type string
15+
TaskID string
16+
Timestamp time.Time
17+
Metadata string
18+
}
19+
20+
const (
21+
EventTypeTaskCreated = "task_created"
22+
EventTypeTaskCompleted = "task_completed"
23+
EventTypeTaskDeleted = "task_deleted"
24+
EventTypeAppOpened = "app_opened"
25+
EventTypeStatsOpened = "stats_opened"
26+
)

internal/core/task.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ type Task struct {
7777
Collapsed bool
7878
CreatedAt time.Time
7979
UpdatedAt time.Time
80+
CompletedAt *time.Time
8081
}
8182

8283
func (t Task) NormalizedTags() []string {
@@ -144,6 +145,7 @@ type TaskPatch struct {
144145
RecurrenceMonthly *int
145146
ParentID *string
146147
Collapsed *bool
148+
CompletedAt **time.Time
147149
}
148150

149151
func (p TaskPatch) ApplyTo(t Task) Task {
@@ -180,6 +182,9 @@ func (p TaskPatch) ApplyTo(t Task) Task {
180182
if p.Collapsed != nil {
181183
t.Collapsed = *p.Collapsed
182184
}
185+
if p.CompletedAt != nil {
186+
t.CompletedAt = *p.CompletedAt
187+
}
183188
return t
184189
}
185190

@@ -199,12 +204,18 @@ func (t Task) MarshalJSON() ([]byte, error) {
199204
Collapsed bool `json:"collapsed,omitempty"`
200205
CreatedAt time.Time `json:"created_at"`
201206
UpdatedAt time.Time `json:"updated_at"`
207+
CompletedAt *string `json:"completed_at,omitempty"`
202208
}
203209
var d *string
204210
if t.Deadline != nil {
205211
s := t.Deadline.UTC().Format(time.RFC3339Nano)
206212
d = &s
207213
}
214+
var c *string
215+
if t.CompletedAt != nil {
216+
s := t.CompletedAt.UTC().Format(time.RFC3339Nano)
217+
c = &s
218+
}
208219
return json.Marshal(wire{
209220
ID: t.ID,
210221
Title: t.Title,
@@ -220,5 +231,6 @@ func (t Task) MarshalJSON() ([]byte, error) {
220231
Collapsed: t.Collapsed,
221232
CreatedAt: t.CreatedAt.UTC(),
222233
UpdatedAt: t.UpdatedAt.UTC(),
234+
CompletedAt: c,
223235
})
224236
}

internal/lua/engine.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (e *Engine) SetupKairoAPI(L *lua.LState) {
6161
L.SetField(kairo, "notify", L.NewFunction(e.luaNotify))
6262

6363
// Meta
64-
L.SetField(kairo, "version", lua.LString("1.4.3"))
64+
L.SetField(kairo, "version", lua.LString("1.5.0"))
6565

6666
// Set as global
6767
L.SetGlobal("kairo", kairo)

internal/service/service.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ type TaskService interface {
5757
// Prune performs a hard delete of soft-deleted tasks and optimizes the database.
5858
Prune(ctx context.Context) error
5959

60+
// Sessions
61+
CreateSession(ctx context.Context, session core.Session) error
62+
UpdateSession(ctx context.Context, id string, endTime time.Time, focusScore int) error
63+
ListSessions(ctx context.Context) ([]core.Session, error)
64+
65+
// Events
66+
LogEvent(ctx context.Context, event core.Event) error
67+
ListEvents(ctx context.Context) ([]core.Event, error)
68+
6069
// Hooks returns the event manager for this service.
6170
Hooks() *hooks.Manager
6271

@@ -239,3 +248,26 @@ func (s *taskService) Prune(ctx context.Context) error {
239248
}
240249
return nil
241250
}
251+
252+
func (s *taskService) CreateSession(ctx context.Context, session core.Session) error {
253+
return s.repo.CreateSession(ctx, session)
254+
}
255+
256+
func (s *taskService) UpdateSession(ctx context.Context, id string, endTime time.Time, focusScore int) error {
257+
return s.repo.UpdateSession(ctx, id, endTime, focusScore)
258+
}
259+
260+
func (s *taskService) ListSessions(ctx context.Context) ([]core.Session, error) {
261+
return s.repo.ListSessions(ctx)
262+
}
263+
264+
func (s *taskService) LogEvent(ctx context.Context, event core.Event) error {
265+
if event.Timestamp.IsZero() {
266+
event.Timestamp = time.Now()
267+
}
268+
return s.repo.CreateEvent(ctx, event)
269+
}
270+
271+
func (s *taskService) ListEvents(ctx context.Context) ([]core.Event, error) {
272+
return s.repo.ListEvents(ctx)
273+
}

0 commit comments

Comments
 (0)