Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ tick habit log "Read 30 min"

```bash
tick focus ls
tick focus ls --type 0 # pomodoro (default: 1=timer)
tick focus get <focus-id>
tick focus start "Deep work" --project Work
tick focus stop <focus-id>
tick focus get <focus-id> --type 0 # pomodoro (default: 1=timer)
```

### Configuration
Expand All @@ -165,7 +165,7 @@ tick config list
tick config get region
tick config set region ticktick # or dida365
tick config set output json # default output format
tick config set default_project Work # default for quick add / task add / focus start
tick config set default_project Work # default for quick add / task add
```

Switching regions after login requires re-authentication:
Expand Down
6 changes: 3 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ tick habit log "Read 30 min"

```bash
tick focus ls
tick focus ls --type 0 # 番茄钟(默认:1=正计时)
tick focus get <focus-id>
tick focus start "Deep work" --project Work
tick focus stop <focus-id>
tick focus get <focus-id> --type 0 # 番茄钟(默认:1=正计时)
```

### 配置
Expand All @@ -165,7 +165,7 @@ tick config list
tick config get region
tick config set region ticktick # 或 dida365
tick config set output json # 默认输出格式
tick config set default_project Work # quick add / task add / focus start 的默认项目
tick config set default_project Work # quick add / task add 的默认项目
```

切换区域后需要重新登录:
Expand Down
85 changes: 8 additions & 77 deletions internal/app/focus.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import (
)

type FocusAPI interface {
GetFocus(context.Context, string, string) (domain.Focus, error)
ListFocus(context.Context, string, time.Time, time.Time) ([]domain.Focus, error)
StartFocus(context.Context, string, domain.StartFocusInput) (domain.Focus, error)
StopFocus(context.Context, string, string) error
GetFocus(context.Context, string, string, int) (domain.Focus, error)
ListFocus(context.Context, string, time.Time, time.Time, int) ([]domain.Focus, error)
ListProjects(context.Context, string) ([]domain.Project, error)
}

Expand All @@ -23,18 +21,9 @@ type FocusApp struct {
}

type ListFocusInput struct {
From string
To string
Project string
}

type StartFocusAppInput struct {
Title string
Content string
ProjectRef string
TaskID string
Mode domain.FocusMode
StartRaw string
From string
To string
Type int
}

func (a FocusApp) List(ctx context.Context, in ListFocusInput) ([]domain.Focus, map[string]string, error) {
Expand Down Expand Up @@ -64,7 +53,7 @@ func (a FocusApp) List(ctx context.Context, in ListFocusInput) ([]domain.Focus,
}
}

focuses, err := a.Client.ListFocus(ctx, token, startDate, endDate)
focuses, err := a.Client.ListFocus(ctx, token, startDate, endDate, in.Type)
if err != nil {
return nil, nil, err
}
Expand All @@ -79,73 +68,15 @@ func (a FocusApp) List(ctx context.Context, in ListFocusInput) ([]domain.Focus,
projectNames[p.ID] = p.Name
}

if in.Project != "" {
project, err := ResolveProject(in.Project, projects)
if err != nil {
return nil, nil, err
}
filtered := make([]domain.Focus, 0, len(focuses))
for _, f := range focuses {
if f.ProjectID == project.ID {
filtered = append(filtered, f)
}
}
focuses = filtered
}

return focuses, projectNames, nil
}

func (a FocusApp) Get(ctx context.Context, focusID string) (domain.Focus, error) {
token, err := a.Auth.AccessToken(ctx)
if err != nil {
return domain.Focus{}, err
}
return a.Client.GetFocus(ctx, token, focusID)
}

func (a FocusApp) Start(ctx context.Context, in StartFocusAppInput) (domain.Focus, error) {
func (a FocusApp) Get(ctx context.Context, focusID string, focusType int) (domain.Focus, error) {
token, err := a.Auth.AccessToken(ctx)
if err != nil {
return domain.Focus{}, err
}

projects, err := a.Client.ListProjects(ctx, token)
if err != nil {
return domain.Focus{}, err
}

project, err := ResolveProject(in.ProjectRef, projects)
if err != nil {
return domain.Focus{}, err
}

payload := domain.StartFocusInput{
Title: in.Title,
Content: in.Content,
ProjectID: project.ID,
TaskID: in.TaskID,
Mode: in.Mode,
}

if in.StartRaw != "" {
loc := time.Local
start, err := domain.ParseUserTime(in.StartRaw, loc)
if err != nil {
return domain.Focus{}, err
}
payload.StartDate = &start
}

return a.Client.StartFocus(ctx, token, payload)
}

func (a FocusApp) Stop(ctx context.Context, focusID string) error {
token, err := a.Auth.AccessToken(ctx)
if err != nil {
return err
}
return a.Client.StopFocus(ctx, token, focusID)
return a.Client.GetFocus(ctx, token, focusID, focusType)
}

func (a FocusApp) ListProjects(ctx context.Context) ([]domain.Project, error) {
Expand Down
132 changes: 16 additions & 116 deletions internal/app/focus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ type recordingFocusAPI struct {
focuses []domain.Focus
lastListStart time.Time
lastListEnd time.Time
startCalls []domain.StartFocusInput
stopCalls []string
lastListType int
lastGetType int
}

func (r *recordingFocusAPI) ListProjects(context.Context, string) ([]domain.Project, error) {
Expand All @@ -24,7 +24,8 @@ func (r *recordingFocusAPI) ListProjects(context.Context, string) ([]domain.Proj
return []domain.Project{{ID: "p1", Name: "Inbox"}}, nil
}

func (r *recordingFocusAPI) GetFocus(_ context.Context, _ string, focusID string) (domain.Focus, error) {
func (r *recordingFocusAPI) GetFocus(_ context.Context, _ string, focusID string, focusType int) (domain.Focus, error) {
r.lastGetType = focusType
for _, f := range r.focuses {
if f.ID == focusID {
return f, nil
Expand All @@ -33,22 +34,13 @@ func (r *recordingFocusAPI) GetFocus(_ context.Context, _ string, focusID string
return domain.Focus{}, nil
}

func (r *recordingFocusAPI) ListFocus(_ context.Context, _ string, startDate, endDate time.Time) ([]domain.Focus, error) {
func (r *recordingFocusAPI) ListFocus(_ context.Context, _ string, startDate, endDate time.Time, focusType int) ([]domain.Focus, error) {
r.lastListStart = startDate
r.lastListEnd = endDate
r.lastListType = focusType
return r.focuses, nil
}

func (r *recordingFocusAPI) StartFocus(_ context.Context, _ string, in domain.StartFocusInput) (domain.Focus, error) {
r.startCalls = append(r.startCalls, in)
return domain.Focus{ID: "f1", Title: in.Title, Mode: in.Mode, ProjectID: in.ProjectID}, nil
}

func (r *recordingFocusAPI) StopFocus(_ context.Context, _ string, focusID string) error {
r.stopCalls = append(r.stopCalls, focusID)
return nil
}

func TestFocusAppListDefaultTimeRange(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.Local)
client := &recordingFocusAPI{focuses: []domain.Focus{{ID: "f1", Title: "Deep work", ProjectID: "p1"}}}
Expand All @@ -60,7 +52,7 @@ func TestFocusAppListDefaultTimeRange(t *testing.T) {
},
}

focuses, _, err := focusApp.List(context.Background(), ListFocusInput{})
focuses, _, err := focusApp.List(context.Background(), ListFocusInput{Type: 1})
if err != nil {
t.Fatalf("List() error = %v", err)
}
Expand All @@ -75,6 +67,9 @@ func TestFocusAppListDefaultTimeRange(t *testing.T) {
if !client.lastListEnd.Equal(now) {
t.Fatalf("list end = %v, want %v", client.lastListEnd, now)
}
if client.lastListType != 1 {
t.Fatalf("list type = %d, want 1", client.lastListType)
}
}

func TestFocusAppListCustomTimeRange(t *testing.T) {
Expand All @@ -91,6 +86,7 @@ func TestFocusAppListCustomTimeRange(t *testing.T) {
_, _, err := focusApp.List(context.Background(), ListFocusInput{
From: "2026-05-01",
To: "2026-05-05",
Type: 0,
})
if err != nil {
t.Fatalf("List() error = %v", err)
Expand All @@ -104,62 +100,8 @@ func TestFocusAppListCustomTimeRange(t *testing.T) {
if !client.lastListEnd.Equal(wantEnd) {
t.Fatalf("list end = %v, want %v", client.lastListEnd, wantEnd)
}
}

func TestFocusAppListFiltersByProject(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.Local)
client := &recordingFocusAPI{
projects: []domain.Project{{ID: "p1", Name: "Inbox"}, {ID: "p2", Name: "Work"}},
focuses: []domain.Focus{
{ID: "f1", Title: "Personal", ProjectID: "p1"},
{ID: "f2", Title: "Work", ProjectID: "p2"},
},
}
focusApp := FocusApp{
Auth: stubTokenSource{},
Client: client,
Now: func() time.Time {
return now
},
}

focuses, _, err := focusApp.List(context.Background(), ListFocusInput{Project: "Work"})
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(focuses) != 1 {
t.Fatalf("len(focuses) = %d, want 1", len(focuses))
}
if focuses[0].ID != "f2" {
t.Fatalf("focuses[0].ID = %q, want f2", focuses[0].ID)
}
}

func TestFocusAppStartResolvesProject(t *testing.T) {
client := &recordingFocusAPI{
projects: []domain.Project{{ID: "p2", Name: "Work"}},
}
focusApp := FocusApp{
Auth: stubTokenSource{},
Client: client,
}

focus, err := focusApp.Start(context.Background(), StartFocusAppInput{
Title: "Deep work",
ProjectRef: "Work",
Mode: domain.FocusModeTimer,
})
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if focus.ProjectID != "p2" {
t.Fatalf("focus.ProjectID = %q, want p2", focus.ProjectID)
}
if len(client.startCalls) != 1 {
t.Fatalf("start calls = %d, want 1", len(client.startCalls))
}
if client.startCalls[0].ProjectID != "p2" {
t.Fatalf("start call ProjectID = %q, want p2", client.startCalls[0].ProjectID)
if client.lastListType != 0 {
t.Fatalf("list type = %d, want 0", client.lastListType)
}
}

Expand All @@ -172,7 +114,7 @@ func TestFocusAppGet(t *testing.T) {
Client: client,
}

focus, err := focusApp.Get(context.Background(), "f1")
focus, err := focusApp.Get(context.Background(), "f1", 0)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
Expand All @@ -182,49 +124,7 @@ func TestFocusAppGet(t *testing.T) {
if focus.Title != "Deep work" {
t.Fatalf("focus.Title = %q, want Deep work", focus.Title)
}
}

func TestFocusAppStartWithCustomTime(t *testing.T) {
client := &recordingFocusAPI{
projects: []domain.Project{{ID: "p2", Name: "Work"}},
}
focusApp := FocusApp{
Auth: stubTokenSource{},
Client: client,
}

_, err := focusApp.Start(context.Background(), StartFocusAppInput{
Title: "Deep work",
ProjectRef: "Work",
Mode: domain.FocusModeTimer,
StartRaw: "2026-05-01",
})
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if len(client.startCalls) != 1 {
t.Fatalf("start calls = %d, want 1", len(client.startCalls))
}
if client.startCalls[0].StartDate == nil {
t.Fatalf("start call StartDate = nil, want non-nil")
}
want := time.Date(2026, 5, 1, 0, 0, 0, 0, time.Local)
if !client.startCalls[0].StartDate.Equal(want) {
t.Fatalf("start call StartDate = %v, want %v", client.startCalls[0].StartDate, want)
}
}

func TestFocusAppStop(t *testing.T) {
client := &recordingFocusAPI{}
focusApp := FocusApp{
Auth: stubTokenSource{},
Client: client,
}

if err := focusApp.Stop(context.Background(), "f1"); err != nil {
t.Fatalf("Stop() error = %v", err)
}
if len(client.stopCalls) != 1 || client.stopCalls[0] != "f1" {
t.Fatalf("stop calls = %v, want [f1]", client.stopCalls)
if client.lastGetType != 0 {
t.Fatalf("get type = %d, want 0", client.lastGetType)
}
}
Loading
Loading