Advertisement
  1. Computer Skills

Track Project Time With Alfred Timekeeper

Scroll to top
Read Time: 26 min
Final product imageFinal product imageFinal product image
What You'll Be Creating

Alfred Time Keeper

Many people juggle multiple projects with different time schedules. From web apps to dedicated programs, there’s already a number of ways to keep track of time spent on a project. Most require net service and/or a program to stay running all the time. Often this isn’t practical.

Alfred Timekeeper works similar to a punch card time keeping system: you save a timestamp when you start and stop working. Since it just records timestamps, a program or web service isn’t always open.

In this tutorial, I'll assume that you’re already familiar with writing workflows in Alfred. If not, please check out these tutorials: Alfred for Beginners, Intermediates, Advanced users and Alfred Debugging.

Overall Design

Many people work with different computers in different locations. That makes keeping track of projects and time management that much harder. By using Dropbox, all of the project and time management information is synchronized automatically.

This approach necessitates the keeping of all information in files and not in computer memory. This restriction requires the use of two data storage arrangements: local directory of information used by that system’s program only, and a synchronized directory that’s shared with every computer connected to it.

The local storage contains the reference to the synchronized directory and the editor to edit timesheets. These files are in Alfred’s data directory location.

The synchronized directory keeps all the timesheets, lists of projects, last known state of recording information, and the timezone information. That will be in a Dropbox location. Any file system synchronization system can be used, I just happen to have Dropbox already.

Creating the Workflow

In Alfred, create a new workflow called Alfred Timekeeper.

Creating the WorkflowCreating the WorkflowCreating the Workflow
Creating the Workflow

In this new workflow, you need to create a File Filter that will only list directories.

Set Timesheet Directory CommandSet Timesheet Directory CommandSet Timesheet Directory Command
Set Timesheet Directory Command

You can set the File Types by dragging a directory from Finder to the File Types area. Set the Search Scope to the directory that contains your DropBox account.

Add a Run Script block after this block, unselecting all the escaping options. To that block, add this script:

1
#########################

2
# Contants.

3
#########################

4
VPREFS="${HOME}/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/"
5
NVPREFS="${HOME}/Library/Application Support/Alfred 2/Workflow Data/"
6
7
##############################################################################

8
# Read the bundleid from the workflow's info.plist

9
##############################################################################

10
getBundleId() {
11
  /usr/libexec/PlistBuddy  -c "Print :bundleid" "info.plist"
12
}
13
14
##############################################################################

15
# Get the workflow data dir

16
##############################################################################

17
getDataDir() {
18
  local BUNDLEID=$(getBundleId)
19
  echo "${NVPREFS}${BUNDLEID}"
20
}
21
22
if [ ! -d "$(getDataDir)" ]; then

23
   mkdir -p "$(getDataDir)";
24
   touch "$(getDataDir)/dir.txt";
25
   touch "$(getDataDir)/editor.txt";
26
fi
27
28
# Store the directory information.

29
echo "{query}" > "$(getDataDir)/dir.txt";
30
31
# Tell the user.

32
echo "Set directory to '{query}'.";

This script checks for the data directory to be there. If not there, it creates the directory and the files used for the workflow. It takes the input in the {query} macro and places it in the dir.txt file in the data directory. It then notifies the user of the action.

Connect the Run Script block to a notification block. All Run Script blocks from here on should connect to this notification block. Set its output to be the {query} macro and set the Only show if passed in argument has content.

Once saved, set the timesheet directory. Inside the Dropbox directory, create a directory for timesheets and use the atk:setdir command to set that directory. You’re now running a multiple system timekeeping program!

That's the basic setup routine for the workflow. Before tackling the main program, you need to setup the working environment.

Setting Up the Go Environment

The easiest way to install the go programming language is by Homebrew. If you haven’t installed Homebrew yet, the tutorial Homebrew Demystified: OS X’s Ultimate Package Manager will show you how.

In a terminal, type:

1
brew install go

In the home directory, create the directory go. The go language will store all downloaded libraries there. Add to the .bashrc file and/or .zshrc file this line:

1
export GOPATH="/Users/<your user name>/go"

If you’re using fish, add this to the config.fish file:

1
set -xg GOPATH "/Users/<your user name>/go"

After reloading shell, you can install the goAlfred library by typing:

1
go get github.com/raguay/goAlfred

This library makes using the go language with Alfred much easier. It will create the directories to the data storage area for Alfred workflows and make the XML listing needed in Script Filters.

Timekeeper.go

To create the main program file, you have to go to the directory for the workflow. Open the Run Script block.

Opening the Workflow DirectoryOpening the Workflow DirectoryOpening the Workflow Directory
Opening the Workflow Directory

Click the Open workflow folder button at the bottom of the Script: area. This opens the workflow directory in Finder (or Pathfinder if you have it). To open the directory in a terminal shell, you can use the Alfred Workflow TerminalFinder to open a Finder or Path Finder directory in a terminal session (or iTerm).

Creating the Program File in TerminalCreating the Program File in TerminalCreating the Program File in Terminal
Creating the Program File in Terminal

Once in the terminal program, type:

1
touch Timekeeper.go

Open that file in the editor of choice. Once open, put this code in:

1
package main
2
3
//

4
// Program:              TimeKeeper.go

5
//

6
// Description:         This program runs the feedback logic for selecting  the on/off state for

7
//                              the currently timed project.

8
//

9
10
//

11
// Import the libraries we use for this program.

12
//

13
import (
14
    "fmt"
15
    "github.com/raguay/goAlfred"
16
    "io"
17
    "io/ioutil"
18
    "os"
19
    "regexp"
20
    "strconv"
21
    "strings"
22
    "time"
23
)
24
25
//

26
// Setup and constants that are used.

27
//

28
// MAXPROJECTS            This is the maximum number of projects allowed.

29
// TSDir                         This keeps the directory name for the time sheets. It is a complete path.

30
//

31
const (
32
    MAXPROJECTS int = 20
33
)
34
35
var TSDir = ""
36
37
//

38
// Function:           main

39
//

40
// Description:       This is the main function for the TimeKeeper program. It takes the command line

41
//                          and parses it for the proper functionality.

42
//

43
func main() {
44
    if len(os.Args) > 1 {
45
        switch os.Args[1][0] {
46
        case 'm':
47
            //

48
            // atk:month

49
            //

50
            SystemViewMonth()
51
        case 'w':
52
            //

53
            // atk:week

54
            //

55
            SystemViewWeek()
56
        case 't':
57
            //

58
            // atk:current

59
            //

60
            SystemViewDate()
61
        case 'r':
62
            //

63
            // atk:remove

64
            //

65
            RemoveProject()
66
        case 'c':
67
            //

68
            // atk:project

69
            //

70
            ChangeProject()
71
        case 'b':
72
            //

73
            // atk:current

74
            //

75
            SystemViewDay()
76
        case 'a':
77
            //

78
            // atk:addproject

79
            //

80
            AddProject()
81
        case 'o':
82
            //

83
            // atk:state

84
            //

85
            StopStart()
86
        case 'p':
87
            //

88
            // Used for atk:project script fileter

89
            //

90
            project()
91
        case 'T':
92
            //

93
            // atk:time

94
            //

95
            SystemAllProjects()
96
        case 's':
97
            fallthrough
98
        default:
99
            //

100
            // Used for the script filter on atk:state

101
            //

102
            state()
103
        }
104
    }
105
}
106
107
//

108
// Function:        getTimeSheetDir

109
//

110
// Description:     This function is used to cache a copy of the time

111
//                  sheet directory and give it in the return.

112
//

113
func getTimeSheetDir() string {
114
    if strings.Contains("", TSDir) {
115
        Filename := goAlfred.Data() + "/dir.txt"
116
        buf, err := ioutil.ReadFile(Filename)
117
        if err == nil {
118
            //

119
            // Convert the directory path to a string and trim it.

120
            //

121
            TSDir = strings.TrimSpace(string(buf))
122
        }
123
    }
124
125
    //

126
    // Return the directory to the time sheets.

127
    //

128
    return (TSDir)
129
}
130
131
//

132
// Function:              SystemAllProjects

133
//

134
// Description:          This function will display to the terminal the time for all projects on

135
//                             the day given on the command line next.

136
//

137
func SystemAllProjects() {
138
    //

139
    // Get the current date in case there isn't one on the command line.

140
    //

141
    tm := time.Now()
142
    if len(os.Args) > 2 {
143
        if strings.Contains("today", os.Args[2]) {
144
            //

145
            // Today's date.

146
            //

147
            tm = time.Now()
148
        } else if strings.Contains("yesterday", os.Args[2]) {
149
            //

150
            // Yesterday is today minus one day.

151
            //

152
            tm = time.Now()
153
            tm = tm.AddDate(0, 0, -1)
154
        } else {
155
            //

156
            // Parse the date string given.

157
            //

158
            tm, _ = time.Parse("2006-Jan-02", os.Args[2])
159
        }
160
    }
161
162
    //

163
    // Get the list of project names.

164
    //

165
    proj := GetListOfProjects()
166
167
    //

168
    // For each project, get the time spent on it for the given day.

169
    //

170
    numproj := len(proj) - 1
171
    for i := 0; i < numproj; i++ {
172
        fmt.Printf("%s: %s\n", proj[i], formatTimeString(GetTimeAtDate(proj[i], tm)))
173
    }
174
}
175
176
//

177
// Function:           SystemViewMonth

178
//

179
// Description:       This function will calculate the time the current month for all the projects.

180
//

181
func SystemViewMonth() {
182
    //

183
    // Get the current project.

184
    //

185
    currentProject := GetCurrentProject()
186
187
    //

188
    // Get the time on that project for this month. The current time gives the current month.

189
    //

190
    tm := GetTimeAtMonth(currentProject, time.Now())
191
192
    //

193
    // format the time string and print it out.

194
    //

195
    fmt.Print(formatTimeString(tm))
196
}
197
198
//

199
// Function:           GetTimeAtDate

200
//

201
// Description:       This function will take a project and calculate the time spent

202
//                          on that project for a particular date.

203
//

204
func GetTimeAtMonth(project string, date time.Time) int64 {
205
    tm := int64(0)
206
    dateStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, time.UTC)
207
208
    //

209
    // Get the time added up for the whole week.

210
    //

211
    for i := 0; i <= date.Day(); i++ {
212
        tm += GetTimeAtDate(project, dateStart.AddDate(0, 0, i))
213
    }
214
215
    //

216
    // Return the amount of time calculated.

217
    //

218
    return (tm)
219
}
220
221
//

222
// Function:           SystemViewWeek

223
//

224
// Description:       This function will calculate the time the current week for all the projects.

225
//

226
// Inputs:

227
//      variable    description

228
//

229
func SystemViewWeek() {
230
    currentProject := GetCurrentProject()
231
    tm := GetTimeAtWeek(currentProject, time.Now())
232
    fmt.Print(formatTimeString(tm))
233
}
234
235
//

236
// Function:           GetTimeAtDate

237
//

238
// Description:       This function will take a project and calculate the time spent

239
//                          on that project for a particular date.

240
//

241
func GetTimeAtWeek(project string, date time.Time) int64 {
242
    tm := int64(0)
243
    dateStart := date
244
    dateEnd := date
245
    switch date.Weekday() {
246
    case 0:
247
        {
248
            dateEnd = dateEnd.AddDate(0, 0, 6)
249
        }
250
    case 1:
251
        {
252
            dateStart = dateStart.AddDate(0, 0, -1)
253
            dateEnd = dateEnd.AddDate(0, 0, 5)
254
        }
255
    case 2:
256
        {
257
            dateStart = dateStart.AddDate(0, 0, -2)
258
            dateEnd = dateEnd.AddDate(0, 0, 4)
259
        }
260
    case 3:
261
        {
262
            dateStart = dateStart.AddDate(0, 0, -3)
263
            dateEnd = dateEnd.AddDate(0, 0, 3)
264
        }
265
    case 4:
266
        {
267
            dateStart = dateStart.AddDate(0, 0, -4)
268
            dateEnd = dateEnd.AddDate(0, 0, 2)
269
        }
270
    case 5:
271
        {
272
            dateStart = dateStart.AddDate(0, 0, -5)
273
            dateEnd = dateEnd.AddDate(0, 0, 1)
274
        }
275
    case 6:
276
        {
277
            dateStart = dateStart.AddDate(0, 0, -6)
278
        }
279
    }
280
    //

281
    // Get the time added up for th whole week.

282
    //

283
    for i := 0; i < 7; i++ {
284
        tm += GetTimeAtDate(project, dateStart.AddDate(0, 0, i))
285
    }
286
    return (tm)
287
}
288
289
//

290
// Function:           SystemViewDate

291
//

292
// Description:       This function will calculate the time for projects at a certain date.

293
//

294
func SystemViewDate() {
295
    currentProject := GetCurrentProject()
296
    tm := GetTimeAtDate(currentProject, time.Now())
297
    fmt.Print(formatTimeString(tm))
298
}
299
300
//

301
// function:            SystemViewDay

302
//

303
// Description:        This function is for displaying a nice time for the current project.

304
//

305
func SystemViewDay() {
306
    currentProject := GetCurrentProject()
307
    tm := GetTimeAtDate(currentProject, time.Now())
308
    ctime := formatTimeString(tm)
309
    state := GetCurrentState()
310
    fmt.Printf("The current time on %s is %s. Current state is %s.", currentProject, ctime, state)
311
}
312
313
//

314
// Function:           GetTimeAtDate

315
//

316
// Description:       This function will take a project and calculate the time spent

317
//                          on that project for a particular date.

318
//

319
func GetTimeAtDate(project string, date time.Time) int64 {
320
    //

321
    // Get the current project.

322
    //

323
    filename := generateTimeLogFileName(project, date)
324
    tm := readDayTime(filename)
325
    return tm
326
}
327
328
//

329
// Function:             formatTimeString

330
//

331
// Description:         This function takes the number of seconds and returns a string

332
//                            in hour:minute:seconds format with zero padding.

333
//

334
// Input:

335
//                            tm          time in seconds (an int64)

336
//

337
func formatTimeString(tm int64) string {
338
    min := int(tm / 60)
339
    sec := tm - int64(min*60)
340
    hr := min / 60
341
    min = min - (hr * 60)
342
    return fmt.Sprintf("%02d:%02d:%02d", hr, min, sec)
343
}
344
345
//

346
// Function:             readDayTime

347
//

348
// Description:         This function reads a time sheet file and calculates the time

349
//                            represented in that file.

350
351
func readDayTime(filename string) int64 {
352
    buf, _ := ioutil.ReadFile(filename)
353
    times := regexp.MustCompile("\n|\r").Split(string(buf), -1)
354
355
    //

356
    // Loop through all the time lines.

357
    //

358
    tmwork := int64(0)
359
    firsttime := int64(0)
360
    first := false
361
    for i := 0; i < len(times); i++ {
362
        if !strings.Contains("", times[i]) {
363
            //

364
            // Split by colon to time and action.

365
            //

366
            parts := strings.Split(times[i], ":")
367
            if strings.Contains("start", parts[1]) {
368
                firsttime, _ = strconv.ParseInt(parts[0], 10, 64)
369
                first = true
370
            } else {
371
                tm, _ := strconv.ParseInt(parts[0], 10, 64)
372
                tmwork += tm - firsttime
373
                first = false
374
            }
375
        }
376
    }
377
378
    //

379
    // If a start was the last thing processed, that means it is still being timed. Get the

380
    // current time to see the overall time. firsttime is the time stamp when the start

381
    // was given.

382
    //

383
    if first {
384
        currentTime := time.Now()
385
        ctime := currentTime.Unix()
386
        tmwork += ctime - firsttime
387
    }
388
    //

389
    // Return the final Time.

390
    //

391
    return tmwork
392
}
393
394
//

395
// Function:           RemoveProject

396
//

397
// Description:       This function will remove a project from the list a valid projects.

398
//

399
func RemoveProject() {
400
    //

401
    // Get the project name from the command line.

402
    //

403
    proj := GetCommandLineString()
404
405
    //

406
    // Get the list of project names.

407
    //

408
    projects := GetListOfProjects()
409
410
    //

411
    // Open the projects file in truncation mode to remove all the old stuff.

412
    //

413
    Filename := getTimeSheetDir() + "/projects.txt"
414
    Fh, err := os.OpenFile(Filename, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0666)
415
    if err != nil {
416
        //

417
        // The file would not open. Error out.

418
        //

419
        fmt.Print("Could not open the  projects file: ", Filename, "\n")
420
        os.Exit(1)
421
    }
422
423
    //

424
    // Loop through all the projects.

425
    //

426
    for i := 0; i < len(projects); i++ {
427
        if !strings.Contains(proj, projects[i]) {
428
            //

429
            // It is not the project to be removed. Put it into the file.

430
            //

431
            Fh.WriteString(projects[i] + "\n")
432
        }
433
    }
434
435
    //

436
    // Close the file.

437
    //

438
    Fh.Close()
439
440
    //

441
    // Tell the user that the project has been removed.

442
    //

443
    fmt.Print(proj + " has been removed!")
444
}
445
446
//

447
// Function:           ChangeProject

448
//

449
// Description:       This function will change the currently active project. If the old

450
//                            project was started, it will stop it first, then set the new project

451
//                            and start it.

452
//

453
func ChangeProject() {
454
    //

455
    // Get the project name from the command line.

456
    //

457
    proj := GetCommandLineString()
458
459
    //

460
    // Get the current project.

461
    //

462
    currentProject := GetCurrentProject()
463
464
    //

465
    // Stop the current project.

466
    //

467
    StopStartProject(currentProject, "stop")
468
469
    //

470
    // Save the new project to the data file.

471
    //

472
    SaveProject(proj)
473
474
    //

475
    // Start the new project.

476
    //

477
    StopStartProject(proj, "start")
478
479
    //

480
    // Tell the user it is started.

481
    //

482
    fmt.Print("The current project is now " + proj + " and is  started.")
483
}
484
485
//

486
// Function:           GetCommandLineString

487
//

488
// Description:       This function is used to get the after the function if there is one.

489
//                             If not, then just return nothing.

490
//

491
func GetCommandLineString() string {
492
    //

493
    // See if we have any input other then the command.

494
    //

495
    clstring := ""
496
    if len(os.Args) > 2 {
497
        clstring = strings.TrimSpace(os.Args[2])
498
    }
499
500
    //

501
    // Return the the string.

502
    //

503
    return (clstring)
504
}
505
506
//

507
// Function:           AddProject

508
//

509
// Description:       This function will add a new project to the list of current projects.

510
//

511
func AddProject() {
512
    //

513
    // Get the project name from the command line.

514
    //

515
    proj := GetCommandLineString()
516
517
    //

518
    // Create the file name that contains all the projects.

519
    //

520
    projectFile := getTimeSheetDir() + "/projects.txt"
521
    Fh, err := os.OpenFile(projectFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666)
522
    if err != nil {
523
        Fh, err = os.Create(projectFile)
524
        if err != nil {
525
            //

526
            // The file would not open. Error out.

527
            //

528
            fmt.Print("Could not open the projects file: ", projectFile, "\n")
529
            os.Exit(1)
530
        }
531
    }
532
533
    //

534
    // Write the new command with the time stamp to the buffer.

535
    //

536
    _, err = io.WriteString(Fh, proj+"\n")
537
538
    //

539
    // Lose the file.

540
    //

541
    Fh.Close()
542
543
    //

544
    // Tell the user that the project is added.

545
    //

546
    fmt.Print("Added project " + proj + " to the list.")
547
}
548
549
//

550
// Function:           state

551
//

552
// Description:       This function gives the proper output for changing the state. The state

553
//                            first is the one opposite from the current state.

554
//

555
func state() {
556
    //

557
    // Get the last state of the current project.

558
    //

559
    stateFile := getTimeSheetDir() + "/laststate.txt"
560
    buf, _ := ioutil.ReadFile(stateFile)
561
    curState := string(buf)
562
563
    //

564
    // Set the first command to the opposite of the current state. That way

565
    // the user simply pushes return to toggle states.

566
    //

567
    if strings.Contains(curState, "start") {
568
        goAlfred.AddResult("stop", "stop", "stop", "", "icon.png", "yes", "", "")
569
        goAlfred.AddResult("start", "start", "start", "", "icon.png", "yes", "", "")
570
    } else {
571
        goAlfred.AddResult("start", "start", "start", "", "icon.png", "yes", "", "")
572
        goAlfred.AddResult("stop", "stop", "stop", "", "icon.png", "yes", "", "")
573
    }
574
575
    //

576
    // Print out the xml string.

577
    //

578
    fmt.Print(goAlfred.ToXML())
579
}
580
581
//

582
// Function:           project

583
//

584
// Description:       This function creates a list of the projects and displays the ones

585
//                            similar to the input.

586
//

587
func project() {
588
    //

589
    // Get the project name from the command line.

590
    //

591
    proj := GetCommandLineString()
592
593
    //

594
    // Set our default string.

595
    //

596
    goAlfred.SetDefaultString("Alfred Time Keeper:  Sorry, no match...")
597
598
    //

599
    // Get the latest project.

600
    //

601
    latestproject := GetCurrentProject()
602
603
    //

604
    // Get the list of projects.

605
    //

606
    projects := make([]string, MAXPROJECTS)
607
    projects = GetListOfProjects()
608
609
    //

610
    // The regexp split statement gives one string more than was split out. The last

611
    // string is a catchall. It does not need to be included.

612
    //

613
    numproj := len(projects) - 1
614
615
    //

616
    // For each project, create a result line. Show all put the current project.

617
    //

618
    for i := 0; i < numproj; i++ {
619
        if !strings.Contains(projects[i], latestproject) {
620
            goAlfred.AddResultsSimilar(proj, projects[i], projects[i], projects[i], "", "icon.png", "yes", "", "")
621
        }
622
    }
623
624
    //

625
    // Print out the xml string.

626
    //

627
    fmt.Print(goAlfred.ToXML())
628
}
629
630
//

631
// Function:           GetListOfProjects

632
//

633
// Description:       This function will return an array of string with the names of the project.

634
//

635
func GetListOfProjects() []string {
636
    //

637
    // Create the projects array and populate it.

638
    //

639
    projectFile := getTimeSheetDir() + "/projects.txt"
640
    buf, _ := ioutil.ReadFile(projectFile)
641
642
    //

643
    // Split out the different project names into separate strings.

644
    //

645
    return (regexp.MustCompile("\n|\r").Split(string(buf), -1))
646
}
647
648
//

649
// Function:           StopStart

650
//

651
// Description:       This will place a start or stop time stamp for the current project and

652
//                            current date.

653
//

654
func StopStart() {
655
    //

656
    // See if we have any input other then the command.  If not, assume a stop command.

657
    //

658
    cmd := "stop"
659
    if len(os.Args) > 2 {
660
        cmd = strings.ToLower(os.Args[2])
661
    }
662
663
    //

664
    // Get the current project.

665
    //

666
    currentProject := GetCurrentProject()
667
668
    //

669
    // Run the appropriate function and print the results.

670
    //

671
    fmt.Print(StopStartProject(currentProject, cmd))
672
}
673
674
//

675
// Function:           GetCurrentProject

676
//

677
// Description:       This function will retrieve the current project from the

678
//                            state file.

679
//

680
func GetCurrentProject() string {
681
    //

682
    // Get the current project.

683
    //

684
    Filename := getTimeSheetDir() + "/project.txt"
685
    buf, _ := ioutil.ReadFile(Filename)
686
687
    //

688
    // Convert the current project to a string, trim it, and return it.

689
    //

690
    return (strings.TrimSpace(string(buf)))
691
}
692
693
//

694
// Function:           SaveProject

695
//

696
// Description:       This function will save the given project name to the

697
//                            current project file.

698
//

699
// Inputs:

700
//      proj         Name of the new project

701
//

702
func SaveProject(proj string) {
703
    //

704
    // Write the new project.

705
    //

706
    Filename := getTimeSheetDir() + "/project.txt"
707
    err := ioutil.WriteFile(Filename, []byte(proj), 0666)
708
    if err != nil {
709
        fmt.Print("Can not write the project file: " + Filename)
710
        os.Exit(1)
711
    }
712
}
713
714
//

715
// Function:           StopStartProject

716
//

717
// Description:       This function is used to set the state for the given project.

718
//

719
// Inputs:

720
//      currentProject  The project to effect the state of.

721
//               cmd                    The start or stop command.

722
//

723
func StopStartProject(currentProject string, cmd string) string {
724
    //

725
    // Setup the result string.

726
    //

727
    resultStr := ""
728
729
    currentState := GetCurrentState()
730
731
    //

732
    // Is the current state the same as the new state?

733
    //

734
    if strings.Contains(cmd, currentState) {
735
        //

736
        // It is already in that state. Do nothing, but give a message.

737
        //

738
        resultStr = "Already " + cmd + "\n"
739
    } else {
740
        //

741
        // Okay, we can proceed with writing the new state into the

742
        // dated project file. Open the file for writing.

743
        //

744
        currentTime := time.Now()
745
        Filename := generateTimeLogFileName(currentProject, currentTime)
746
        Fh, err := os.OpenFile(Filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666)
747
        if err != nil {
748
            //

749
            // The file would not open. Error out.

750
            //

751
            fmt.Print("Could not open the dated project file: ", Filename, "\n")
752
            os.Exit(1)
753
        }
754
755
        //

756
        // Write the new command with the time stamp to the buffer.

757
        //

758
        str := fmt.Sprintf("%d:%s\n", currentTime.Unix(), cmd)
759
        _, err = io.WriteString(Fh, str)
760
761
        //

762
        // Lose the file.

763
        //

764
        Fh.Close()
765
766
        //

767
        // Write the laststate file with the new state.

768
        //

769
        ioutil.WriteFile(getTimeSheetDir()+"/laststate.txt", []byte(cmd), 0666)
770
771
        //

772
        // Tell the user it is set.

773
        //

774
        resultStr = currentProject + " is now " + cmd
775
    }
776
777
    //

778
    // Return the resulting string.

779
    //

780
    return (resultStr)
781
}
782
783
//

784
// function:                GetCurrentState

785
//

786
// Description:           This function gets the current state of the project.

787
//

788
func GetCurrentState() string {
789
    //

790
    // Get the current state.

791
    //

792
    Filename := getTimeSheetDir() + "/laststate.txt"
793
    buf, err := ioutil.ReadFile(Filename)
794
    currentState := "stop"
795
    if err == nil {
796
        //

797
        // Convert the current project to a string and trim it.

798
        //

799
        currentState = strings.TrimSpace(string(buf))
800
    }
801
    return currentState
802
}
803
804
//

805
// Function:           generateTimeLogFileName

806
//

807
// Description:       This functions creates the time log file based on the project name and

808
//                            date.

809
//

810
// Inputs:

811
//      proj         Name of the project

812
//               dt           Date in question

813
//

814
func generateTimeLogFileName(proj string, dt time.Time) string {
815
    //

816
    // Generate the proper file name based on the project name and date.

817
    //

818
    filename := getTimeSheetDir() + "/" + proj + "_" + dt.Format("2006-01-02") + ".txt"
819
    return (filename)
820
}

That code defines all the functionality of the Alfred Timekeeper. The design of the program is to do different functions based on a command letter and options parameters after it. It will get the timesheet directory from Alfred and create the project files and timesheets as needed.

Compiling

You have to compile the program before you can use it. The go language isn’t an interpreted language, but a compiled language. This allows the program to run much faster.

In the terminal program, type:

1
go build Timekeeper.go
Compiling TimekeepergoCompiling TimekeepergoCompiling Timekeepergo
Compiling Timekeeper.go

If everything is in place properly, you should now have the Timekeeper program in that directory. It is in red above. If something did not get copied right and the compile fails, lookup the line number given and compare it to the above. Or, get a fresh copy from the download given with this tutorial.

Adding Alfred Functions

With the program finished, you need to call it from Alfred. Create a Keyword block with the keyword set to atk:addproject. Connect it to a Run Script block and put this line of code:

1
./TimeKeeper a "{query}"

All Run Scripts blocks here on out should be set to run bash scripts and none of the escaping options set. The above script allows for creating new projects. Use this to create your different projects. I have the projects: Envato, CustomCT, and Missions created for the work I do.

Now that you have projects, you now need to set one as the current project. Create a Script Filter as below:

Script Filter for Selecting a ProjectScript Filter for Selecting a ProjectScript Filter for Selecting a Project
Script Filter for Selecting a Project

Add a Run Script block after it with the following script:

1
./Timekeeper c "{query}"
Setting the ProjectSetting the ProjectSetting the Project
Setting the Project

Now, you can set the project you want to work on. In the Alfred Prompt, type atk:project and a list of the created projects should appear. Select the project you want and it will automatically start timing for you.

Therefore, if you selected the Tutorials project, there is a file named Tutorials_2014–05–05.txt (that is, as long as you created it on May 5, 2014). Inside that file is a time stamp, a colon, and a start statement. You will also have the project.txt file with the currently selected project, laststate.txt file with the last state (ie: start or stop), and the projects.txt file with a list of created projects.

The timing has started, but now you need to stop it. Create a new Script Filter block with the keyword set to atk:state and the script set to:

1
./TimeKeeper s "{query}"

That should connect to a Run Script block with the script set to:

1
./TimeKeeper o "{query}"

The s command tells Timekeeper program to generate an XML output for the next state. It will automatically have the first option in the XML to be the opposite of the current state.

Changing Timing StatesChanging Timing StatesChanging Timing States
Changing Timing States

You can now toggle the state of timing a project with atk:state. You can also create hotkeys to start and stop the timing. Try to do that one on your own. You never learn until you try!

Adding an External TriggerAdding an External TriggerAdding an External Trigger
Adding an External Trigger

You might want an external program to set the state of time recording. Therefore, create a External Trigger block as shown. Connect this block to the previous Run Script block.

Adding an External Trigger - ConfigurationAdding an External Trigger - ConfigurationAdding an External Trigger - Configuration
Adding an External Trigger - Configuration

The Sample Code at the bottom can be ran by any AppleScript aware program to trigger start/stop actions on the current project. If you replace the text test with stop, it will stop timing. If you replace the text test with start, it will start timing. Used with ControlPlane, you can create a rule to turn off timing when the computer sleeps and resume timing when the computer wakes.

Now that you’re starting and stopping the time, you need to view the time spent. Create a Keyword block with atk:current. Connect it to a Run Script block with this script:

1
echo `./Timekeeper b`;

Connect it to the Notification block.

Current Time CommandCurrent Time CommandCurrent Time Command
Current Time Command

When you run atk:current in Alfred prompt, you will get the current time spent on the project and the current state.

Second Computer

If you have another computer, setup Alfred to share over Dropbox. Once set up, all of your workflows will automatically update from one computer to the other.

Setting Up Alfred SyncingSetting Up Alfred SyncingSetting Up Alfred Syncing
Setting Up Alfred Syncing

Select the Set sync folder to set up syncing via Dropbox and follow the directions. On the second computer, use the command atk:setdir to set the timesheet directory you created in Dropbox. The two systems can now start and stop project timings. Make sure Dropbox is fully synced in between state changes.

Conclusion

That’s the basics of the Alfred Timekeeper. The workflow packaged with the download is the full version with other features. You now understand how to create your own time management system that’s scalable to use on multiple computers using Dropbox and does not take up computer resources. 

Since I use this everyday, I will be adding more features such as a web interface to see the timesheets graphically. If you can come up with other features, try to add them yourself! That’s the fun of working with Alfred workflows.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Computer Skills tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.