habits 0.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+
2
+ Copyright (c) 2010 Antti Hakala
3
+ All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions
7
+ are met:
8
+ 1. Redistributions of source code must retain the above copyright
9
+ notice, this list of conditions and the following disclaimer.
10
+ 2. Redistributions in binary form must reproduce the above copyright
11
+ notice, this list of conditions and the following disclaimer in the
12
+ documentation and/or other materials provided with the distribution.
13
+ 3. The name of the author may not be used to endorse or promote products
14
+ derived from this software without specific prior written permission.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
17
+ IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18
+ OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19
+ IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21
+ NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25
+ THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
+
data/README ADDED
@@ -0,0 +1,8 @@
1
+ Habits
2
+ ======
3
+
4
+ An experimental habit tracker.
5
+
6
+ Tracks habits in weekly cycles (i.e. status of each habit is cleared weekly).
7
+ Each habit has a day or days associated with it. Habits expects activity on the habit on those days. If no activity is registered, habit goes first into yellow zone (e.g. 20 hours before deadline), then into red zone (e.g. 6 hours before), and finally into missed state. Transfer into each zone/state can be used to trigger commands.
8
+
data/bin/habits ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'habits/command_line'
4
+
@@ -0,0 +1,129 @@
1
+ require 'time'
2
+ require 'date'
3
+ require 'habits'
4
+ require 'habits/subcommand'
5
+ require 'fileutils'
6
+
7
+ if !File.exists?(Habits::Habit::HABITS_DIR)
8
+ FileUtils.mkdir Habits::Habit::HABITS_DIR
9
+ FileUtils.cp File.join(File.dirname(__FILE__), '..', '..', 'whip_config.rb'),
10
+ Habits::Habit::HABITS_DIR+"/"
11
+ puts "\n*** Copied default whip_config.rb to #{Habits::Habit::HABITS_DIR}"
12
+ puts "*** Please edit it to suit you."
13
+ puts "*** Also, consider adding line '0 * * * * /usr/bin/habits whip'"
14
+ puts "*** to your crontab.\n"
15
+ end
16
+
17
+ sub = Subcommand.new
18
+
19
+ sub.register('whip', [], 'Use the whip.') do
20
+ Habits::Whip.use
21
+ end
22
+
23
+ sub.register('delete', ['TITLE'], 'Delete habit.') do |title|
24
+ h = Habits::Habit.find(title)
25
+ h.destroy
26
+ puts "#{h.title} deleted."
27
+ end
28
+
29
+ sub.register('create', ['TITLE','DAYS'], 'Create a new habit.') do |title, days|
30
+ Habits::Habit.new(title, days.split(',')).save
31
+ puts "Habit \"#{title}\" created."
32
+ end
33
+
34
+ sub.register('list', [], 'List habits.') do
35
+ puts "\nHABIT DAYS STATUS YELLOW/RED"
36
+ puts "===============================================================================\n"
37
+ Habits::Habit.all_not_on_hold.each do |habit|
38
+ printf("%-25s %-30s %-10s [%dh / %dh]\n",
39
+ habit.title, habit.days.join(','),
40
+ habit.status.value.to_s.capitalize,
41
+ (habit.yellow_zone / (60*60)),
42
+ (habit.red_zone / (60*60)))
43
+ end
44
+ puts "===============================================================================\n"
45
+ on_hold = Habits::Habit.all_on_hold
46
+
47
+ if on_hold.size > 0
48
+ puts "\nOn Hold: #{on_hold.map{|h| h.title}.join(', ')}.\n"
49
+ end
50
+
51
+ puts "\nWeek #{Date.today.cweek} | Total #{Habits::Habit.all.size} habits.\n\n"
52
+ end
53
+
54
+ sub.register('zones', ['TITLE','YELLOW_ZONE','RED_ZONE'],
55
+ "Set habit's yellow and red zones.") do |title, yellow, red|
56
+ h = Habits::Habit.find(title)
57
+
58
+ h.yellow_zone = yellow.to_i * 60 * 60
59
+ h.red_zone = red.to_i * 60 * 60
60
+ h.save
61
+ puts "Zones set."
62
+ end
63
+
64
+ sub.register('do', ['TITLE', '[HOURS]'],
65
+ 'Add activity to habit, hours optional.') do |title, hours|
66
+ h = Habits::Habit.find(title)
67
+ h.add_event(Habits::Events::Activity.new(hours ? hours.to_f : nil))
68
+ puts "Activity added."
69
+ end
70
+
71
+ sub.register('rename', ['TITLE', 'NEW_TITLE'], 'Rename habit.') do |title, new_title|
72
+ h = Habits::Habit.find(title)
73
+ h.set_title(new_title)
74
+ puts "Renamed #{title} -> #{new_title}"
75
+ h.save
76
+ end
77
+
78
+ sub.register('show', ['TITLE'], 'Show activity of habit.') do |title|
79
+ h = Habits::Habit.find(title)
80
+
81
+ if h.events.empty?
82
+ puts "\nNo activity.\n\n"
83
+ else
84
+ puts "\n#{title} Activity:\n"+ ('='*(title.size)) + "==========\n"
85
+
86
+ h.events.each do |event|
87
+ str = " #{event.applied_at.strftime("%a %b %d %Y")}"
88
+ str += " #{event.duration} hours" if event.duration
89
+ puts str
90
+ end
91
+ puts
92
+ end
93
+ end
94
+
95
+ sub.register('split', ['TITLE'], 'Split a habit into days.') do |title|
96
+ h = Habits::Habit.find(title)
97
+ h.split!
98
+ puts "Habit \"#{title}\" split."
99
+ end
100
+
101
+ sub.register('join', ['TITLE', '[OTHER_TITLE]'],
102
+ 'Join habits. First one is kept.') do |title, other_title|
103
+ if other_title.nil?
104
+ Habits::Habit.join_all(title)
105
+ puts "#{title} joined."
106
+ else
107
+ habit = Habits::Habit.find(title)
108
+ other = Habits::Habit.find(other_title)
109
+ habit.join!(other)
110
+ puts "Habits \"#{title}\" and \"#{other_title}\" joined."
111
+ end
112
+ end
113
+
114
+ sub.register('hold', ['TITLE'], 'Put a habit on hold.') do |title|
115
+ h = Habits::Habit.find(title)
116
+ h.hold
117
+ puts "Habit \"#{title}\" is now on hold."
118
+ end
119
+
120
+ sub.register('unhold', ['TITLE'], 'Unhold a habit.') do |title|
121
+ h = Habits::Habit.find(title)
122
+ h.unhold
123
+ puts "Habit \"#{title}\" is not on hold anymore."
124
+ end
125
+
126
+
127
+ sub.default = 'list'
128
+
129
+ sub.parse
@@ -0,0 +1,11 @@
1
+ module Habits
2
+
3
+ module Event
4
+ attr_reader :applied_at
5
+
6
+ def apply(habit, time=Time.now)
7
+ @applied_at = time
8
+ end
9
+ end
10
+
11
+ end
@@ -0,0 +1,20 @@
1
+ require 'habits/event'
2
+
3
+ module Habits::Events
4
+
5
+ class Activity
6
+ include Habits::Event
7
+
8
+ attr_reader :duration
9
+
10
+ def initialize(duration=nil)
11
+ @duration = duration
12
+ end
13
+
14
+ def apply(habit, time)
15
+ super
16
+ end
17
+
18
+ end
19
+
20
+ end
@@ -0,0 +1,162 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ require 'habits/status'
5
+ require 'habits/events/activity'
6
+
7
+ module Habits
8
+
9
+ # The habit class. Attached events describe activities etc.
10
+ class Habit
11
+ HABITS_DIR = File.join(ENV['HOME'], '.habits')
12
+ YELLOW_ZONE = 16*60*60 # 16 hours before deadline,
13
+ RED_ZONE = 6*60*60 # 6 -"-
14
+
15
+ def self.all
16
+ @@all ||= begin
17
+ habits = []
18
+ Dir.glob(File.join(HABITS_DIR, '*.habit')).each do |habit_file|
19
+ habits << YAML.load(File.read(habit_file))
20
+ end
21
+ habits
22
+ end
23
+ end
24
+
25
+ def self.all_not_on_hold
26
+ all.select{|habit| habit.status != Status.on_hold}
27
+ end
28
+
29
+ def self.all_on_hold
30
+ all.select{|habit| habit.status == Status.on_hold}
31
+ end
32
+
33
+ def self.find(title)
34
+ h = Habit.all.detect {|h| h.title == title.strip}
35
+ raise "No such habit found." unless h
36
+ h
37
+ end
38
+
39
+ # Join all habits with title 'title':something.
40
+ def self.join_all(title)
41
+ joinables = Habit.all.select {|habit| habit.title =~ /^#{title}:.+/}
42
+ raise "Habits not found." if joinables.size == 0
43
+ habit = Habit.find(title) rescue nil
44
+ habit ||= Habit.new(title, [])
45
+ joinables.each{|joinable| habit.join!(joinable)}
46
+ end
47
+
48
+ attr_reader :title, :days, :status, :events
49
+ attr_accessor :yellow_zone, :red_zone
50
+
51
+ def initialize(title, days = ['Mon'],
52
+ yellow_zone = YELLOW_ZONE,
53
+ red_zone = RED_ZONE,
54
+ events = [])
55
+ set_title(title)
56
+ set_days(days)
57
+ @yellow_zone, @red_zone = yellow_zone, red_zone
58
+ @status = Status.green
59
+ @created_at = Time.now
60
+ @events = events
61
+ end
62
+
63
+ def add_event(event, time=Time.now)
64
+ event.apply(self, time)
65
+ @events << event
66
+ save
67
+ end
68
+
69
+ def file_path(title=@title)
70
+ File.join(HABITS_DIR, title.downcase + '.habit')
71
+ end
72
+
73
+ def save
74
+ @@all = nil
75
+ FileUtils.rm_f(file_path(@old_title)) if @old_title
76
+ FileUtils.mkdir_p HABITS_DIR
77
+ File.open(file_path, 'w') do |file|
78
+ file.write self.to_yaml
79
+ end
80
+ self
81
+ end
82
+
83
+ def update_status
84
+ old_status = @status
85
+ @status = Status.resolve(self)
86
+
87
+ if old_status != @status
88
+ yield if block_given?
89
+ save
90
+ end
91
+ end
92
+
93
+ def activities_on_week(week, day=nil)
94
+ activities = @events.select do |e|
95
+ e.is_a?(Events::Activity) and Date.new(e.applied_at.year,
96
+ e.applied_at.month,
97
+ e.applied_at.day).cweek == week
98
+ end
99
+ activities = activities.select{|a| a.applied_at.strftime('%a') == day} if day
100
+ activities
101
+ end
102
+
103
+ def destroy
104
+ FileUtils.rm_f file_path
105
+ @@all = nil
106
+ end
107
+
108
+ def set_title(title)
109
+ raise "No spaces or commas allowed in habit title" if title =~ /[\s,,]+/
110
+ @old_title = @title
111
+ @title = title
112
+ end
113
+
114
+ def set_days(days)
115
+ if days.detect{|d| Time::RFC2822_DAY_NAME.index(d).nil?}
116
+ raise "Valid days are #{Time::RFC2822_DAY_NAME.join(',')}"
117
+ else
118
+ @days = days
119
+ end
120
+ end
121
+
122
+ def split
123
+ @days.map do |day|
124
+ events = @events.select{|event| event.applied_at.strftime('%a') == day}
125
+ Habit.new("#{@title}:#{day}", [day], YELLOW_ZONE, RED_ZONE, events)
126
+ end
127
+ end
128
+
129
+ def split!
130
+ habits = split
131
+ habits.each{|habit| habit.save}
132
+ destroy
133
+ habits
134
+ end
135
+
136
+ def join(habit)
137
+ @events += habit.events
138
+ @days += habit.days
139
+ @days.uniq!
140
+ @days.sort!{|a,b| Time::RFC2822_DAY_NAME.index(a) <=> Time::RFC2822_DAY_NAME.index(b)}
141
+ end
142
+
143
+ def join!(habit)
144
+ join(habit)
145
+ save
146
+ habit.destroy
147
+ end
148
+
149
+ def hold
150
+ @status = Status.on_hold
151
+ save
152
+ end
153
+
154
+ def unhold
155
+ @status = Status.green
156
+ @status = Status.resolve(self)
157
+ save
158
+ end
159
+
160
+ end
161
+
162
+ end
@@ -0,0 +1,25 @@
1
+
2
+ module Habits
3
+
4
+ # a module to in Habit to mock data storing
5
+ class Habit
6
+
7
+ def self.all
8
+ @@all
9
+ end
10
+
11
+ def self.all=(habits)
12
+ @@all = habits
13
+ end
14
+
15
+ def save
16
+ # do nothing
17
+ end
18
+
19
+ def destroy
20
+ # do nothing
21
+ end
22
+
23
+ end
24
+
25
+ end
@@ -0,0 +1,60 @@
1
+ require 'date'
2
+ require 'time'
3
+
4
+ module Habits
5
+ class Status
6
+ include Comparable
7
+
8
+ VALUES = [:green, :yellow, :red, :missed, :on_hold]
9
+ VALUES.each do |val|
10
+ eval %Q(def self.#{val}; Status.new(#{val.inspect}) end)
11
+ end
12
+
13
+ attr_reader :value
14
+
15
+ def initialize(val)
16
+ raise "Invalid status value" unless VALUES.include?(val)
17
+ @value = val
18
+ end
19
+
20
+ def <=>(other)
21
+ VALUES.index(self.value) <=> VALUES.index(other.value)
22
+ end
23
+
24
+ # Resolves the status of a habit.
25
+ # Status starts fresh every week.
26
+ def self.resolve(habit, time=Time.now)
27
+ return Status.on_hold if habit.status == Status.on_hold
28
+
29
+ statuses = []
30
+ date = Date.new(time.year, time.month, time.day)
31
+
32
+ habit.days.each do |day|
33
+ activities = habit.activities_on_week(date.cweek, day)
34
+ day_diff = Time::RFC2822_DAY_NAME.index(day) - date.wday
35
+
36
+ if !activities.empty? or (day_diff > 0)
37
+ statuses << Status.green
38
+ else
39
+ dl_date = date + day_diff
40
+ deadline = Time.mktime(dl_date.year, dl_date.month,
41
+ dl_date.day, 23, 59)
42
+
43
+ if Date.new(deadline.year, deadline.month, deadline.day).cweek != date.cweek
44
+ statuses << Status.green
45
+ elsif time > deadline
46
+ statuses << Status.missed
47
+ elsif time > (deadline - habit.red_zone)
48
+ statuses << Status.red
49
+ elsif time > (deadline - habit.yellow_zone)
50
+ statuses << Status.yellow
51
+ else
52
+ statuses << Status.green
53
+ end
54
+ end
55
+ end
56
+ statuses.max
57
+ end
58
+
59
+ end
60
+ end
@@ -0,0 +1,96 @@
1
+ #
2
+ # A minimal command line parser for subcommands.
3
+ # CMD SUBCOMMAND ARG1 ARG2 ...
4
+ #
5
+ class Subcommand
6
+ attr_reader :subs
7
+
8
+ class Cmd
9
+ attr_reader :args, :desc, :blk
10
+
11
+ def initialize(args, desc, blk)
12
+ @args, @desc, @blk = args, desc, blk
13
+ @optional = @args.pop if @args.last =~ /^\[.*\]$/
14
+ end
15
+
16
+ def args_str
17
+ (@args + [@optional]).compact.join(' ')
18
+ end
19
+
20
+ # Parse @call_args for this command
21
+ def parse(args)
22
+ if (args.size == @args.size) or (@optional and args.size == @args.size+1)
23
+ @call_args = args
24
+ else
25
+ raise "Invalid arguments."
26
+ end
27
+ end
28
+
29
+ # Trigger this command
30
+ def call
31
+ if @optional # provide nil for the optional arg in block if needed
32
+ args = @call_args
33
+ args << nil if @call_args.size == @args.size
34
+ blk.call *args
35
+ else
36
+ blk.call *@call_args
37
+ end
38
+ end
39
+ end
40
+
41
+ def initialize
42
+ @default = nil
43
+ @subs = {}
44
+ end
45
+
46
+ # Register a command with name, arguments, description, and block (blk).
47
+ # The block is called with as many input args as you defined in 'args'
48
+ def register(cmd_name, args, desc, &blk)
49
+ @subs ||= {}
50
+ @subs[cmd_name] = Cmd.new(args, desc, blk)
51
+ end
52
+
53
+ # Set the default command to be called when just the executable is run.
54
+ def default(&blk)
55
+ raise "No block given" unless blk
56
+ @default = blk
57
+ end
58
+
59
+ # Set the default command to be one of the registered subcommands
60
+ def default=(cmd_name)
61
+ raise "No subcommand #{cmd_name} registerd" if @subs[cmd_name].nil?
62
+ @default = @subs[cmd_name].blk
63
+ end
64
+
65
+ # Parse command line arguments according to registered commands.
66
+ def parse(args=ARGV)
67
+ if args.size == 0
68
+ @default.call if @default
69
+ return
70
+ end
71
+
72
+ sub = @subs[args.shift]
73
+
74
+ help and return if sub.nil?
75
+
76
+ begin
77
+ sub.parse(args)
78
+ sub.call
79
+ rescue Exception => e
80
+ puts e.message
81
+ help
82
+ end
83
+ end
84
+
85
+ # Print help of available commands.
86
+ def help
87
+ puts "\nUsage: #{File.basename($0)} {command} arg1 arg2 ...\n\nCommand Args"+
88
+ ' '*32+"Description"
89
+ @subs.keys.sort.each do |name|
90
+ printf(" %-12s %-35s %s\n", name, @subs[name].args_str, @subs[name].desc)
91
+ end
92
+ puts
93
+ true
94
+ end
95
+
96
+ end
@@ -0,0 +1,39 @@
1
+ require 'habits/habit'
2
+
3
+ module Habits
4
+
5
+ module Whip
6
+ extend self
7
+ @@transitions = {}
8
+ @@ticks = []
9
+
10
+ # Add block to be executed when status changes to given status.
11
+ # A habit which changes to this status is passed to the block.
12
+ def on(status, &blk)
13
+ raise "No block given" if blk.nil?
14
+ @@transitions[status] = blk
15
+ end
16
+
17
+ # Add a block to be called each time before whip is used.
18
+ def on_tick(&blk)
19
+ raise "No block given" if blk.nil?
20
+ @@ticks << blk
21
+ end
22
+
23
+ # Use whip, i.e. update status of each habit and call
24
+ # blocks associated with state changes.
25
+ def use
26
+ @@ticks.each {|tick| tick.call}
27
+
28
+ Habit.all.each do |habit|
29
+ habit.update_status do
30
+ st = habit.status.value
31
+ @@transitions[st].call(habit) if @@transitions[st]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ config = File.join(Habits::Habit::HABITS_DIR, 'whip_config.rb')
39
+ require config if File.exists?(config)
data/lib/habits.rb ADDED
@@ -0,0 +1,3 @@
1
+
2
+ require 'habits/habit'
3
+ require 'habits/whip'
data/whip_config.rb ADDED
@@ -0,0 +1,24 @@
1
+ #
2
+ # a habits whip config file
3
+ #
4
+ # Store in $HOME/.habits/whip_config.rb
5
+ #
6
+
7
+ def dialog(title, txt)
8
+ system %Q(say '#{title}')
9
+ system %Q(osascript -e 'tell app "System Events" to display dialog "#{txt}"')
10
+ # or if you have 'ruby-growl' gem installed
11
+ # system "growl -h localhost -m '#{txt}'"
12
+ end
13
+
14
+ Habits::Whip.on(:yellow) do |habit|
15
+ dialog habit.title, "[HABITS] #{habit.title} is now in yellow."
16
+ end
17
+
18
+ Habits::Whip.on(:red) do |habit|
19
+ dialog habit.title, "[HABITS] #{habit.title} is now in RED!"
20
+ end
21
+
22
+ Habits::Whip.on(:missed) do |habit|
23
+ dialog habit.title, "[HABITS] #{habit.title} MISSED!"
24
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: habits
3
+ version: !ruby/object:Gem::Version
4
+ hash: 9
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ version: "0.1"
10
+ platform: ruby
11
+ authors:
12
+ - Antti Hakala
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-11-26 00:00:00 +02:00
18
+ default_executable:
19
+ dependencies: []
20
+
21
+ description: A habit tracker. Tracks habits in weekly cycles.Each habit has a day or days associated with it.Habits expects activity on the habit on those days. If no activity is registered, habit goes first into yellow zone (e.g. 20 hours before deadline) and then into red zone (e.g. 6 hours). And finally into missed state.Transfer into each state can be used to trigger commands.
22
+ email: antti.hakala@gmail.com
23
+ executables:
24
+ - habits
25
+ extensions: []
26
+
27
+ extra_rdoc_files: []
28
+
29
+ files:
30
+ - README
31
+ - LICENSE
32
+ - bin/habits
33
+ - whip_config.rb
34
+ - lib/habits/command_line.rb
35
+ - lib/habits/event.rb
36
+ - lib/habits/events/activity.rb
37
+ - lib/habits/habit.rb
38
+ - lib/habits/mock_data_store.rb
39
+ - lib/habits/status.rb
40
+ - lib/habits/subcommand.rb
41
+ - lib/habits/whip.rb
42
+ - lib/habits.rb
43
+ has_rdoc: true
44
+ homepage: http://github.com/ander/habits
45
+ licenses: []
46
+
47
+ post_install_message:
48
+ rdoc_options: []
49
+
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ hash: 3
58
+ segments:
59
+ - 0
60
+ version: "0"
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ hash: 3
67
+ segments:
68
+ - 0
69
+ version: "0"
70
+ requirements: []
71
+
72
+ rubyforge_project:
73
+ rubygems_version: 1.3.7
74
+ signing_key:
75
+ specification_version: 3
76
+ summary: A habit tracker.
77
+ test_files: []
78
+