houston-roadmaps 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/Gemfile +24 -0
  4. data/Gemfile.lock +358 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.md +31 -0
  7. data/Rakefile +25 -0
  8. data/app/assets/images/houston/roadmaps/.gitkeep +0 -0
  9. data/app/assets/javascripts/houston/roadmaps/app.coffee +2 -0
  10. data/app/assets/javascripts/houston/roadmaps/application.js +14 -0
  11. data/app/assets/javascripts/houston/roadmaps/handlebars_helpers.coffee +22 -0
  12. data/app/assets/javascripts/houston/roadmaps/jquery_extensions.coffee +4 -0
  13. data/app/assets/javascripts/houston/roadmaps/models/goal.coffee +11 -0
  14. data/app/assets/javascripts/houston/roadmaps/models/milestone.coffee +124 -0
  15. data/app/assets/javascripts/houston/roadmaps/models/roadmap.coffee +16 -0
  16. data/app/assets/javascripts/houston/roadmaps/models/viewport.coffee +3 -0
  17. data/app/assets/javascripts/houston/roadmaps/resize_listener.js +67 -0
  18. data/app/assets/javascripts/houston/roadmaps/views/_gantt_chart.coffee +224 -0
  19. data/app/assets/javascripts/houston/roadmaps/views/_show_milestone_view.coffee +204 -0
  20. data/app/assets/javascripts/houston/roadmaps/views/edit_gantt_chart.coffee +405 -0
  21. data/app/assets/javascripts/houston/roadmaps/views/edit_milestone_view.coffee +97 -0
  22. data/app/assets/javascripts/houston/roadmaps/views/edit_roadmap_view.coffee +80 -0
  23. data/app/assets/javascripts/houston/roadmaps/views/gantt_thumbnail_view.coffee +173 -0
  24. data/app/assets/javascripts/houston/roadmaps/views/goal_view.coffee +18 -0
  25. data/app/assets/javascripts/houston/roadmaps/views/project_goals_view.coffee +30 -0
  26. data/app/assets/javascripts/houston/roadmaps/views/roadmap_history_view.coffee +67 -0
  27. data/app/assets/javascripts/houston/roadmaps/views/roadmap_view.coffee +12 -0
  28. data/app/assets/javascripts/houston/roadmaps/views/roadmaps_view.coffee +29 -0
  29. data/app/assets/stylesheets/houston/roadmaps/application.css +13 -0
  30. data/app/assets/stylesheets/houston/roadmaps/colors.scss.erb +41 -0
  31. data/app/assets/stylesheets/houston/roadmaps/goals.scss +20 -0
  32. data/app/assets/stylesheets/houston/roadmaps/milestone.scss +58 -0
  33. data/app/assets/stylesheets/houston/roadmaps/roadmap.scss +276 -0
  34. data/app/assets/stylesheets/houston/roadmaps/roadmap_history.scss +42 -0
  35. data/app/assets/stylesheets/houston/roadmaps/roadmaps.scss +89 -0
  36. data/app/assets/templates/houston/roadmaps/goals/edit.hbs +6 -0
  37. data/app/assets/templates/houston/roadmaps/goals/index.hbs +9 -0
  38. data/app/assets/templates/houston/roadmaps/goals/new.hbs +27 -0
  39. data/app/assets/templates/houston/roadmaps/goals/show.hbs +6 -0
  40. data/app/assets/templates/houston/roadmaps/milestone/edit.hbs +67 -0
  41. data/app/assets/templates/houston/roadmaps/milestone/show.hbs +53 -0
  42. data/app/assets/templates/houston/roadmaps/milestone/ticket.hbs +28 -0
  43. data/app/assets/templates/houston/roadmaps/roadmap/goal.hbs +6 -0
  44. data/app/assets/templates/houston/roadmaps/roadmap/history.hbs +22 -0
  45. data/app/assets/templates/houston/roadmaps/roadmap/show.hbs +12 -0
  46. data/app/assets/templates/houston/roadmaps/roadmaps/index.hbs +2 -0
  47. data/app/assets/templates/houston/roadmaps/roadmaps/show.hbs +11 -0
  48. data/app/controllers/houston/roadmaps/api/v1/roadmap_controller.rb +20 -0
  49. data/app/controllers/houston/roadmaps/dashboard_controller.rb +34 -0
  50. data/app/controllers/houston/roadmaps/milestones_controller.rb +118 -0
  51. data/app/controllers/houston/roadmaps/project_goals_controller.rb +24 -0
  52. data/app/controllers/houston/roadmaps/roadmap_milestones_controller.rb +36 -0
  53. data/app/controllers/houston/roadmaps/roadmaps_controller.rb +77 -0
  54. data/app/helpers/houston/roadmaps/application_helper.rb +4 -0
  55. data/app/models/roadmap.rb +9 -0
  56. data/app/models/roadmap_commit.rb +35 -0
  57. data/app/models/roadmap_milestone.rb +29 -0
  58. data/app/models/roadmap_milestone_version.rb +8 -0
  59. data/app/presenters/houston/roadmaps/milestone_api_presenter.rb +27 -0
  60. data/app/presenters/houston/roadmaps/milestone_presenter.rb +74 -0
  61. data/app/presenters/houston/roadmaps/roadmap_milestone_presenter.rb +70 -0
  62. data/app/presenters/houston/roadmaps/roadmap_presenter.rb +37 -0
  63. data/app/presenters/houston/roadmaps/ticket_presenter.rb +20 -0
  64. data/app/views/houston/roadmaps/dashboard/show.html.erb +42 -0
  65. data/app/views/houston/roadmaps/milestones/show.html.erb +40 -0
  66. data/app/views/houston/roadmaps/project_goals/index.html.erb +42 -0
  67. data/app/views/houston/roadmaps/roadmaps/_form.html.erb +29 -0
  68. data/app/views/houston/roadmaps/roadmaps/edit.html.erb +8 -0
  69. data/app/views/houston/roadmaps/roadmaps/history.html.erb +41 -0
  70. data/app/views/houston/roadmaps/roadmaps/index.html.erb +24 -0
  71. data/app/views/houston/roadmaps/roadmaps/new.html.erb +7 -0
  72. data/app/views/houston/roadmaps/roadmaps/show.html.erb +27 -0
  73. data/app/views/houston/roadmaps/roadmaps/show_editable.html.erb +35 -0
  74. data/app/views/layouts/houston/roadmaps/application.html.erb +10 -0
  75. data/app/views/layouts/houston/roadmaps/dashboard.html.erb +9 -0
  76. data/bin/rails +8 -0
  77. data/config/database.yml +13 -0
  78. data/config/initializers/add_navigation_renderer.rb +13 -0
  79. data/config/routes.rb +37 -0
  80. data/db/.keep +0 -0
  81. data/db/migrate/20140831210254_add_band_to_milestones.rb +5 -0
  82. data/db/migrate/20140907212311_add_end_date_to_milestones.rb +19 -0
  83. data/db/migrate/20140916230539_add_locked_to_milestone.rb +5 -0
  84. data/db/migrate/20140927154728_add_closed_ticket_count_to_milestones.rb +11 -0
  85. data/db/migrate/20140929024130_create_roadmap_commits.rb +29 -0
  86. data/db/migrate/20141012023628_add_user_id_to_milestone_versions.rb +11 -0
  87. data/db/migrate/20150102192805_add_lanes_to_milestones.rb +5 -0
  88. data/db/migrate/20150119155145_add_goal_and_feedback_query_to_milestones.rb +6 -0
  89. data/db/migrate/20150524203903_add_project_id_to_roadmap_commits.rb +17 -0
  90. data/db/migrate/20150603203744_add_timestamps_to_roadmap_commits.rb +20 -0
  91. data/db/migrate/20160206214746_rename_roadmap_feature_to_milestones.rb +25 -0
  92. data/db/migrate/20160207154530_create_roadmaps.rb +125 -0
  93. data/db/structure.sql +2557 -0
  94. data/houston-roadmaps.gemspec +29 -0
  95. data/lib/houston-roadmaps.rb +1 -0
  96. data/lib/houston/roadmaps.rb +15 -0
  97. data/lib/houston/roadmaps/configuration.rb +14 -0
  98. data/lib/houston/roadmaps/engine.rb +27 -0
  99. data/lib/houston/roadmaps/milestone_ext.rb +14 -0
  100. data/lib/houston/roadmaps/project_ext.rb +20 -0
  101. data/lib/houston/roadmaps/railtie.rb +17 -0
  102. data/lib/houston/roadmaps/version.rb +5 -0
  103. data/lib/tasks/roadmap_tasks.rake +4 -0
  104. data/test/acceptance/houston_dummy_test.rb +17 -0
  105. data/test/dummy/houston.rb +23 -0
  106. data/test/fixtures/projects.yml +3 -0
  107. data/test/fixtures/users.yml +10 -0
  108. data/test/test_helper.rb +43 -0
  109. data/test/unit/fixtures_test.rb +11 -0
  110. metadata +227 -0
@@ -0,0 +1,204 @@
1
+ class Roadmaps.ShowMilestoneView extends @TicketsView
2
+ supportsSorting: false
3
+ template: HandlebarsTemplates['houston/roadmaps/milestone/show']
4
+
5
+ initialize: ->
6
+ @milestone = @options.milestone
7
+ @id = @milestone.id
8
+ @projectTicketTracker = @options.projectTicketTracker
9
+ @usesFeedback = @options.usesFeedback
10
+ super
11
+
12
+ @$el.on 'click', '#show_completed_tickets', _.bind(@toggleShowCompleted, @)
13
+
14
+ render: ->
15
+ tickets = for ticket in @tickets.models
16
+ json = ticket.toJSON()
17
+ json.estimatedEffortCompleted = ticket.estimatedEffortCompleted()
18
+ json.estimatedEffort = ticket.estimatedEffort()
19
+ json
20
+
21
+ html = @template
22
+ projectSlug: @project
23
+ milestone: @milestone
24
+ usesFeedback: @usesFeedback
25
+ tickets: tickets
26
+ @$el.html html
27
+
28
+ complete = _.select(tickets, (ticket)-> !!ticket.closedAt).length / tickets.length
29
+ if _.isNaN(complete)
30
+ $('.milestone-progress').hide()
31
+ else
32
+ $('#milestone_progress').html(App.formatPercent complete)
33
+ $('.milestone-progress').show()
34
+
35
+ @renderBurndownChart(@tickets.models)
36
+ @
37
+
38
+
39
+
40
+ rerenderTickets: ->
41
+ template = HandlebarsTemplates['houston/roadmaps/milestone/ticket']
42
+ $tickets = @$el.find('#tickets')
43
+ return @render() if $tickets.length is 0
44
+ $tickets.empty()
45
+ for ticket in @tickets.models
46
+ json = ticket.toJSON()
47
+ json.estimatedEffortCompleted = ticket.estimatedEffortCompleted()
48
+ json.estimatedEffort = ticket.estimatedEffort()
49
+ $tickets.append template(json)
50
+
51
+
52
+
53
+ renderBurndownChart: (tickets)->
54
+
55
+ # Sum progress by week;
56
+ # Find the total amount of effort to accomplish
57
+ progressBySprint = {}
58
+ totalEffort = 0
59
+ mostRecentDataPoint = 0
60
+ for ticket in tickets
61
+ effort = +ticket.estimatedEffort()
62
+ if effort and ticket.get('firstReleaseAt')
63
+ firstReleaseAt = App.parseDate(ticket.get('firstReleaseAt'))
64
+ mostRecentDataPoint = +firstReleaseAt if mostRecentDataPoint < firstReleaseAt
65
+ sprint = @getEndOfSprint(firstReleaseAt)
66
+ progressBySprint[sprint] = (progressBySprint[sprint] || 0) + effort
67
+ totalEffort += effort
68
+
69
+ [firstSprint, lastSprint] = d3.extent(+date for date in _.keys(progressBySprint))
70
+
71
+ # Start 1 week before the first progress was made
72
+ # to show the original total effort of the milestone
73
+ firstSprint = @prevSprint(firstSprint)
74
+
75
+ # Transform into remaining effort by week:
76
+ # Iterate by week in case there are some weeks
77
+ # where no progress was made
78
+ remainingEffort = totalEffort
79
+ sprint = firstSprint
80
+ data = []
81
+ while sprint <= lastSprint
82
+ remainingEffort -= (progressBySprint[sprint] || 0)
83
+ data.push
84
+ day: new Date(sprint)
85
+ effort: Math.ceil(remainingEffort)
86
+ sprint = @nextSprint(sprint)
87
+
88
+ # If the most recent data point is for an incomplete
89
+ # sprint, disregard it when calculating the regressions
90
+ lastCompleteSprint = @getEndOfSprint(1.week().before(new Date()))
91
+ if @truncateDate(mostRecentDataPoint) > lastCompleteSprint
92
+ regAll = @computeRegression(data.slice( 0, -1)) if data.length >= 6 # all time
93
+ regLast3 = @computeRegression(data.slice(-5, -1)) if data.length >= 5 # last 3 weeks only
94
+ regLast2 = @computeRegression(data.slice(-4, -1)) if data.length >= 4 # last 2 weeks only
95
+ else
96
+ regAll = @computeRegression(data) if data.length >= 5 # all time
97
+ regLast3 = @computeRegression(data.slice(-4)) if data.length >= 4 # last 3 weeks only
98
+ regLast2 = @computeRegression(data.slice(-3)) if data.length >= 3 # last 2 weeks only
99
+
100
+ width = mostRecentDataPoint - firstSprint
101
+ maxDate = @getEndOfSprint(mostRecentDataPoint + width)
102
+ console.log 'earliestDataPoint', new Date(firstSprint)
103
+ console.log 'mostRecentDataPoint', new Date(mostRecentDataPoint)
104
+ console.log 'lastCompleteSprint', new Date(lastCompleteSprint)
105
+ console.log "width: #{(width / Duration.DAY).toFixed(1)} days"
106
+
107
+ console.log 'regAll', new Date(regAll.x2) if regAll
108
+ console.log 'regLast3', new Date(regLast3.x2) if regLast3
109
+ console.log 'regLast2', new Date(regLast2.x2) if regLast2
110
+
111
+ # Widen the graph so that it includes the X intercept
112
+ projections = []
113
+ projections.push regAll.x2 if regAll
114
+ projections.push regLast2.x2 if regLast2
115
+ projections.push regLast3.x2 if regLast3
116
+ sprints = (d.day for d in data)
117
+ if projectedEnd = projections.max()
118
+ lastSprint = @getEndOfSprint(projectedEnd)
119
+ lastSprint = maxDate if lastSprint > maxDate
120
+ sprint = _.last(sprints)
121
+ while sprint < lastSprint
122
+ sprint = @nextSprint(sprint)
123
+ sprints.push(sprint)
124
+
125
+ chart = new Houston.BurndownChart()
126
+ .days((new Date(sprint) for sprint in sprints))
127
+ .dateFormat(d3.time.format('%b %e'))
128
+ .totalEffort(totalEffort)
129
+ .addLine('completed', data)
130
+ chart.addRegression('all', regAll) if regAll
131
+ chart.addRegression('last-3', regLast3) if regLast3
132
+ chart.addRegression('last-2', regLast2) if regLast2
133
+ chart.render()
134
+
135
+ insertLinebreaks = (d)->
136
+ el = d3.select(this)
137
+ words = el.text().split(/\s+/)
138
+ el.text('')
139
+
140
+ el.append('tspan').text(words[0]).attr('class', 'month')
141
+ el.append('tspan').text(words[1]).attr('x', 0).attr('dy', '11').attr('class', 'day')
142
+
143
+ svg = d3.select('#graph').select('svg')
144
+ svg.selectAll('.x.axis text').each(insertLinebreaks)
145
+
146
+ prevSprint: (timestamp)->
147
+ 1.week().before(new Date(timestamp)).getTime()
148
+
149
+ nextSprint: (timestamp)->
150
+ 1.week().after(new Date(timestamp)).getTime()
151
+
152
+ getEndOfSprint: (timestamp)->
153
+ +@getNextFriday(new Date(timestamp))
154
+
155
+ getNextFriday: (date)->
156
+ wday = date.getDay() # 0-6 (0=Sunday)
157
+ daysUntilFriday = 5 - wday # 5=Friday
158
+ daysUntilFriday += 7 if daysUntilFriday < 0
159
+ daysUntilFriday.days().after(date)
160
+
161
+ truncateDate: (date)->
162
+ date = new Date(date)
163
+ date.setHours(0)
164
+ date.setMinutes(0)
165
+ date.setSeconds(0)
166
+ date.setMilliseconds(0)
167
+ +date
168
+
169
+ computeRegression: (data)->
170
+ # Compute the linear regression of the points
171
+ # http://trentrichardson.com/2010/04/06/compute-linear-regressions-in-javascript/
172
+ # http://dracoblue.net/dev/linear-least-squares-in-javascript/159/
173
+ [sum_x, sum_y, sum_xx, sum_xy, n] = [0, 0, 0, 0, data.length]
174
+ for d in data
175
+ [_x, _y] = [+d.day, d.effort]
176
+ sum_x += _x
177
+ sum_y += _y
178
+ sum_xx += _x * _x
179
+ sum_xy += _x * _y
180
+ m = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
181
+ b = (sum_y - m*sum_x) / n
182
+
183
+ # No progress is being made
184
+ return null if m == 0
185
+
186
+ # Find the X intercept
187
+ [x0, y0] = [((0 - b) / m), 0]
188
+
189
+ # Calculate the regression line
190
+ x1: new Date(+data[0].day)
191
+ x2: x0
192
+ y1: new Date(b + m * +data[0].day)
193
+ y2: y0
194
+
195
+
196
+
197
+ toggleShowCompleted: (e)->
198
+ $button = $(e.target)
199
+ if $button.hasClass('active')
200
+ $button.removeClass('btn-success')
201
+ @$el.addClass('hide-completed')
202
+ else
203
+ $button.addClass('btn-success')
204
+ @$el.removeClass('hide-completed')
@@ -0,0 +1,405 @@
1
+ class Roadmaps.EditGanttChart extends Roadmaps.GanttChart
2
+ MAX_BANDS = 4
3
+ $newMilestone: null
4
+ newMilestoneX: null
5
+ supportsCreate: false
6
+
7
+
8
+ constructor: (milestones, options={})->
9
+ super(milestones, options)
10
+
11
+ if options.removeMilestoneByDroppingOn
12
+ $(options.removeMilestoneByDroppingOn).droppable
13
+ tolerance: 'pointer'
14
+ drop: (e, ui) =>
15
+ return unless @drag?.milestone
16
+ @drag.milestone.markRemoved()
17
+
18
+ $(document.body).on 'keyup', (e)=>
19
+ if e.keyCode is 27
20
+ @cancelCreate() if @$newMilestone and @supportsCreate
21
+ if @drag
22
+ @update()
23
+ @drag = null
24
+
25
+
26
+ createMilestone: (callback)->
27
+ @supportsCreate = !!callback
28
+ @_createMilestoneCallback = callback
29
+ @
30
+
31
+
32
+ render: ->
33
+ super
34
+ @$el.on 'mouseleave', => @cancelCreate()
35
+
36
+ @$el.on 'mouseup', (e)=>
37
+ return unless @$newMilestone and @supportsCreate
38
+ newMilestoneWidth = @$newMilestone.outerWidth()
39
+ if newMilestoneWidth > 10
40
+ band = +@$newMilestone.closest('.roadmap-band').attr('data-band')
41
+ left = @$newMilestone.position().left
42
+ startDate = @x.invert(left)
43
+ endDate = @x.invert(left + newMilestoneWidth)
44
+ attributes =
45
+ band: band
46
+ lanes: 1
47
+ startDate: d3.time.monday.round(startDate)
48
+ endDate: d3.time.saturday.round(endDate)
49
+ @$newMilestone.addClass('creating').text('Saving...')
50
+ @$el.removeClass('drag-create')
51
+ [$newMilestone, @$newMilestone] = [@$newMilestone, null]
52
+ @_createMilestoneCallback attributes, -> $newMilestone.remove()
53
+ else
54
+ @cancelCreate()
55
+
56
+ @$el.on 'mousemove', (e)=>
57
+ return unless @$newMilestone and @supportsCreate
58
+ newMilestoneWidth = e.screenX - @newMilestoneX
59
+ newMilestoneWidth = 0 if newMilestoneWidth < 0
60
+
61
+ left = @$newMilestone.position().left
62
+ startDate = @x.invert(left)
63
+ endDate = d3.time.saturday.round(@x.invert(@$newMilestone.position().left + newMilestoneWidth))
64
+ newMilestoneWidth = @x(endDate) - left
65
+ milliseconds = endDate.getTime() - startDate.getTime() + 2 * Duration.DAY
66
+ weeks = Math.floor(milliseconds / Duration.WEEK)
67
+
68
+ @$newMilestone.css(width: newMilestoneWidth)
69
+ if weeks < 2
70
+ @$newMilestone.html("&nbsp;")
71
+ else
72
+ @$newMilestone.html("<span>#{weeks}&nbsp;weeks</span>")
73
+
74
+
75
+ cancelCreate: ->
76
+ return unless @$newMilestone and @supportsCreate
77
+ @$el.removeClass('drag-create')
78
+ @$newMilestone.remove()
79
+ @$newMilestone = null
80
+
81
+
82
+ initializeBand: (band)->
83
+ view = @
84
+
85
+ $(band).droppable
86
+ hoverClass: 'sort-active'
87
+ tolerance: 'pointer'
88
+ over: (e, ui)->
89
+ return unless view.drag
90
+ band = +$(@).attr('data-band')
91
+
92
+ # When we start dragging a multi-lane milestone,
93
+ # note which of its lanes we grabbed.
94
+ unless view.drag.bandOver
95
+ if view.drag.milestone
96
+ view.drag.bandOffset = band - view.drag.milestone.get('band')
97
+ else
98
+ view.drag.bandOffset = 0
99
+
100
+ view.drag.bandOver = band - view.drag.bandOffset
101
+
102
+ drop: (e, ui) =>
103
+ return unless @drag
104
+
105
+ startDate = d3.time.monday.round(@x.invert(ui.position.left))
106
+ ui.draggable.css left: @x(startDate)
107
+
108
+ if @drag.milestone
109
+ milestone = @drag.milestone
110
+ milestone.set
111
+ band: @drag.bandOver or milestone.get('band')
112
+ startDate: startDate
113
+ endDate: d3.time.saturday.round(milestone.duration().after(startDate))
114
+
115
+ @saveRepositionedMilestones()
116
+
117
+ if @drag.goal
118
+ milestone = new Roadmaps.Milestone
119
+ milestoneId: @drag.goal.id
120
+ name: @drag.goal.name
121
+ projectId: @drag.goal.projectId
122
+ projectColor: @drag.goal.projectColor
123
+ band: @drag.bandOver or 1
124
+ removed: false
125
+ lanes: 1
126
+ startDate: startDate
127
+ endDate: d3.time.saturday.round(26.days().after(startDate))
128
+ @milestones.add milestone
129
+
130
+ .on 'mousedown', (e)->
131
+ return unless view.supportsCreate
132
+ return if e.target isnt @
133
+ view.newMilestoneX = e.screenX
134
+ view.$el.addClass('drag-create')
135
+
136
+ startDate = d3.time.monday.round(view.x.invert(e.offsetX))
137
+
138
+ view.$newMilestone = $('<div class="roadmap-milestone-placeholder">&nbsp;</div>')
139
+ .css(left: view.x(startDate))
140
+ .appendTo(@)
141
+
142
+ initializeMilestone: (milestone)->
143
+ view = @
144
+ $milestone = $(milestone)
145
+ $menu = null
146
+
147
+ $milestone.pseudoHover()
148
+ $milestone.attr "tabindex", -1
149
+ $milestone.blur (e)=>
150
+ if $menu
151
+ # Don't remove the menu before the click event
152
+ # propagates to the menu item that was clicked!
153
+ window.setTimeout (=>
154
+ $milestone.removeClass "dropdown-open"
155
+ $menu.remove()
156
+ $menu = null), 100
157
+ $milestone.on 'contextmenu', (e)=>
158
+ return false if $menu
159
+ $milestone.focus()
160
+ $milestone.addClass "dropdown-open"
161
+ id = $milestone.attr('data-milestone-id')
162
+ html = """
163
+ <ul class="dropdown-menu" role="menu">
164
+ """
165
+ html += """
166
+ <li role="presentation"><a role="menuitem" tabindex="-1" href="/roadmap/milestones/#{id}" target="_blank">Open</a></li>
167
+ """ if id
168
+ html += """
169
+ <li role="presentation"><a role="menuitem" tabindex="-1" class="rename-milestone-action">Rename</a></li>
170
+ <li role="presentation"><a role="menuitem" tabindex="-1" class="remove-milestone-action">Remove</a></li>
171
+ </ul>
172
+ """
173
+ $menu = $(html)
174
+ .appendTo(document.body)
175
+ .css
176
+ display: "block"
177
+ left: $milestone.offset().left
178
+ top: $milestone.offset().top + $milestone.outerHeight() + 2
179
+
180
+ .on "click", ".rename-milestone-action", (e)=>
181
+ id = $milestone.attr('data-cid')
182
+ milestone = @milestones.get(id)
183
+ return false unless milestone
184
+
185
+ if name = prompt('Name:', milestone.get('name'))
186
+ milestone.set(name: name)
187
+
188
+ .on "click", ".remove-milestone-action", (e)=>
189
+ id = $milestone.attr('data-cid')
190
+ milestone = @milestones.get(id)
191
+ return false unless milestone
192
+
193
+ milestone.markRemoved()
194
+ $milestone.remove()
195
+ false
196
+
197
+ $(milestone).resizable
198
+ handles: 'e, s, se'
199
+ animate: false
200
+ start: _.bind(@onStartDrag, @)
201
+
202
+ resize: (e, ui)=>
203
+ return unless @drag?.milestone
204
+
205
+ # If we're _only_ changing the number of lanes
206
+ # this milestone covers, update its duration as
207
+ # its height is changed.
208
+ if @drag.handle is 'ui-resizable-s'
209
+ lanes = @lanesSpanned(ui.size.height)
210
+ if lanes isnt @drag.currentLanes
211
+ @drag.currentLanes = lanes
212
+ originalLanes = @lanesSpanned(ui.originalSize.height)
213
+ ui.size.width = ui.originalSize.width * Math.pow(2, originalLanes - lanes)
214
+ $(ui.element).css(width: ui.size.width)
215
+ @initializeDrag()
216
+
217
+ @repositionMilestones ui.size.width - ui.originalSize.width
218
+
219
+ stop: (e, ui)=>
220
+ ui.element.resizable 'option', 'grid', false
221
+ return unless @drag?.milestone
222
+
223
+ milestone = @drag.milestone
224
+ endDate = d3.time.saturday.round(@x.invert(ui.position.left + ui.size.width))
225
+ ui.element.css width: @x(endDate) - ui.element.position().left
226
+ lanes = @lanesSpanned(ui.size.height)
227
+ milestone.set
228
+ endDate: endDate
229
+ lanes: lanes
230
+
231
+ @saveRepositionedMilestones()
232
+ @drag = null
233
+
234
+ .draggable
235
+ snap: '.roadmap-band'
236
+ snapMode: 'inner'
237
+ zIndex: 10
238
+ revertDuration: 150
239
+ start: _.bind(@onStartDrag, @)
240
+
241
+ drag: (e, ui)=>
242
+ return false unless @drag?.milestone
243
+ @initializeDrag() unless @drag.band is @drag.bandOver
244
+
245
+ ui.position.left = Math.max(ui.position.left, @drag.minLeft)
246
+
247
+ if e.shiftKey
248
+ if @drag.maxRight
249
+ width = $(e.target).outerWidth()
250
+ ui.position.left = Math.min(ui.position.left, @drag.maxRight - width)
251
+ delta = 0
252
+ else
253
+ delta = ui.position.left - ui.originalPosition.left
254
+
255
+ @repositionMilestones(delta)
256
+
257
+ stop: (e, ui)=>
258
+ @drag = null
259
+ $(e.target).resizable 'option', 'grid', false
260
+
261
+ revert: ($target)->
262
+ !$target or !$target.is('.roadmap-band')
263
+
264
+
265
+ dragFrom: ($selector, goals) ->
266
+ $selector.draggable
267
+ snap: '.roadmap-band'
268
+ snapMode: 'inner'
269
+ zIndex: 10
270
+
271
+ # Position of the cursor over the drag helper
272
+ cursorAt:
273
+ top: 15
274
+ left: 50
275
+
276
+ # Append the helper to `body` so that it isn't clipped
277
+ # by its container (like if its container is scrollable)
278
+ appendTo: document.body
279
+
280
+ revertDuration: 150
281
+
282
+ helper: (e) =>
283
+ $goal = $(e.target).closest('.goal')
284
+ id = $goal.data('id')
285
+ goal = goals.get(id)
286
+ @drag = {goal: goal.toJSON()}
287
+
288
+ $ """
289
+ <div class="roadmap-milestone unlocked uncompleted unhovered #{goal.get('projectColor')}" tabindex="-1">
290
+ <span class="roadmap-milestone-name">#{goal.get('name')}</span>
291
+ </div>
292
+ """
293
+
294
+ stop: (e, ui) =>
295
+ @drag = null
296
+
297
+
298
+ onStartDrag: (e, ui)->
299
+ $milestone = $(e.target)
300
+ return false if $milestone.hasClass('locked')
301
+
302
+ handle = $(e.originalEvent.target).attr('class')
303
+ handle = handle.split(' ')[1] if handle
304
+
305
+ id = $milestone.attr('data-cid')
306
+ milestone = @milestones.get(id)
307
+
308
+ grid = [@weekWidth(), @bandHeight + @bandMargin]
309
+ $milestone.resizable 'option', 'grid', grid
310
+
311
+ $('.roadmap-milestone').each ->
312
+ $milestone = $(@)
313
+ $milestone.data('original-left', $milestone.position().left)
314
+
315
+ @drag =
316
+ milestone: milestone
317
+ handle: handle
318
+
319
+ @initializeDrag()
320
+
321
+
322
+ initializeDrag: ->
323
+ milestone = @drag.milestone
324
+ return unless milestone
325
+
326
+ band = @drag.bandOver || milestone.get('band')
327
+ lanes = @drag.currentLanes || milestone.get('lanes')
328
+ bands = (band + i for i in [0...lanes])
329
+
330
+ milestonesInBand = @milestones.overlappingBands(bands)
331
+ minStartDate = 2.days().after(milestonesInBand.lastMilestoneBefore(milestone)?.get('endDate'))
332
+ maxEndDate = 2.days().before(milestonesInBand.firstMilestoneAfter(milestone)?.get('startDate'))
333
+
334
+ milestonesAfter = @milestones.downstreamOf(milestone.get('endDate'), bands)
335
+ $milestonesAfter = @$el.find(milestonesAfter
336
+ .map (m)-> ".roadmap-milestone[data-cid=#{m.cid}]"
337
+ .join(', '))
338
+
339
+ @drag = @drag extends
340
+ band: band
341
+ minLeft: if minStartDate then @x(minStartDate) else 0
342
+ maxRight: maxEndDate && @x(maxEndDate)
343
+ $milestonesAfter: $milestonesAfter
344
+
345
+
346
+ repositionMilestones: (delta)->
347
+ return unless @drag?.$milestonesAfter
348
+
349
+ # !todo: check for unmoveable milestones
350
+ @drag.$milestonesAfter.each ->
351
+ $milestone = $(@)
352
+ originalLeft = +$milestone.data('original-left')
353
+ $milestone.css(left: originalLeft + delta)
354
+
355
+ saveRepositionedMilestones: ->
356
+ return unless @drag?.$milestonesAfter
357
+
358
+ @drag.$milestonesAfter.each (i, el)=>
359
+ $milestone = $(el)
360
+ milestone = @milestones.get $milestone.data('cid')
361
+
362
+ unless milestone
363
+ App.debug 'There is no milestone for', $milestone
364
+ return
365
+
366
+ if milestone.get('locked')
367
+ App.debug "#{milestone.get('name')} is locked and cannot be repositioned"
368
+ return
369
+
370
+ newPosition = $milestone.position().left
371
+ startDate = d3.time.monday.round(@x.invert(newPosition))
372
+ endDate = d3.time.saturday.round(milestone.duration().after(startDate))
373
+ milestone.set
374
+ startDate: startDate
375
+ endDate: endDate
376
+
377
+
378
+
379
+ groupMilestonesIntoBands: ->
380
+ milestoneBands = super
381
+
382
+ # Make sure there is at least one empty band in the Roadmap
383
+ # (up to MAX_BANDS); then don't add an empty band.
384
+ nextBand = +d3.max(milestoneBands, (band)-> band.number) + 1
385
+ nextBand = 1 if _.isNaN(nextBand)
386
+ if _.keys(milestoneBands).length <= MAX_BANDS
387
+ milestoneBands.push
388
+ key: nextBand
389
+ number: nextBand
390
+ milestones: []
391
+
392
+ milestoneBands
393
+
394
+ weekWidth: ->
395
+ date = d3.time.saturdays(@x.domain()...)[0]
396
+ @x(7.days().after(date)) - @x(date)
397
+
398
+ lanesSpanned: (height)->
399
+ # space between lanes is 8; height if lane + space is 38
400
+ (height + 8) / 38
401
+
402
+ toJSON: (milestone)->
403
+ json = milestone.toJSON()
404
+ json.cid = milestone.cid # Use `cid` because some milestones may not be saved yet
405
+ json